rfcs/text/0008-preload-realm.md at main · electron/rfcs · GitHub
Skip to content

Latest commit

 

History

History
486 lines (379 loc) · 18.2 KB

File metadata and controls

486 lines (379 loc) · 18.2 KB

Preload Realm for Service Workers

Summary

Preload realms provide an isolated context to run JavaScript prior to code evaluating in another context. This expands functionality of Electron's preload scripts to provide new context targets. This proposal intends to target Service Worker contexts with potential plans for new contexts (web workers, shared workers) in future proposals.

Motivation

Electron's Chrome extension support adds a subset of APIs to support the use case of DevTools-related extensions. Extending this to support the full API would not only add a large maintenance burden, but might impose and restrict design of features such as extension action buttons.

Expanding preload scripts to allow targeting of new contexts such as Service Workers would allow the community to fill in the gaps for Chrome extension APIs as well as entirely new ideas. Preload "realms" would provide a basis for this functionality.

In addition, others have expressed desire for preload support in worker contexts:

  • electron/electron#28620

    there is no way to fix issues like Electron's broken atob/btoa implementation, or hiding APIs such as battery/keyboard/gpuinfo from being accessed [...] in workers

  • electron/electron#39848

    nodeIntegrationInWorker: true does not cause loading of preload script in workers

Although the initial goal of this RFC is to support Service Workers, I anticipate support of web workers can be added with relative ease following its completion.

Guide-level explanation

A preload realm is an isolated JavaScript context—without access to the DOM—which exists alongside a service worker context. It offers a way to safely interact with the service worker context in order to add new APIs which can communicate with Electron's main process.

This functionality can be used to transform network requests, prototype new extension APIs, or enable a new method of background data synchronization to name a few use cases.

To demonstrate the API, we'll take a look at how we can use Preload Realms to prototype a new extension API.

Creating a new extension API

Chrome's Manifest V3 extensions provide event routing and browser APIs through a background service worker. While leveraging the existing extension APIs provided by Chrome, we can add our own to extend its full set of functionality.

Given the recent rise of generative AI, we'll look at how we might offer extensions the ability to summarize text using one in Electron.

We can start by writing a script to run in a preload realm targeting extension service workers.

// extension-sw-preload.js
const { contextBridge, ipcRenderer } = require("electron");

// Check that our preload script was initiated by an extension service worker.
if (
  process.type === "service-worker" &&
  process.scope.startsWith("chrome-extension:")
) {
  // Expose our API to the service worker!
  exposeApi();
}

function exposeApi() {
  const api = {
    ai: {
      summarize: (text) => {
        // Send an IPC to Electron's main process to summarize the text.
        return ipcRenderer.invoke("SUMMARIZE_TEXT", text);
      },
    },
  };

  // Expose our API in the main JS context of the worker thread.
  contextBridge.exposeInMainWorld("myElectronApi", api);
}

With our preload realm script ready, we can write an extension service worker script to use it.

// extension-sw-script.js

// Listen for messages which request summarizing of text.
chrome.runtime.onMessage.addListener(async function (
  request,
  sender,
  sendResponse
) {
  if (request.type === "summarize") {
    // Invoke our new API.
    const summary = await myElectronApi.ai.summarize(request.payload);
    sendResponse({ text: summary });
  }
});

In the main process, we'll handle the IPC request with some additional checks to validate the sender.

// main.js
const { session, serviceWorkerMain } = require("electron");

session.defaultSession.serviceWorkers.on(
  "registration-completed",
  (_event, { scope }) => {
    // Listen for extension service worker registration
    if (!scope.startsWith("chrome-extension:")) return;

    // Get ServiceWorkerMain instance from proposed API
    const sw = serviceWorkerMain.fromScope(scope);
    if (!sw) return;

    // Handle the IPC sent from the service worker preload realm.
    sw.ipc.handle("SUMMARIZE_TEXT", (event, text) => {
      // Validate arguments
      if (typeof text !== "string") {
        throw new Error("Invalid request");
      }

      // Call out to an LLM to summarize the text :)
      return gptSummarize(text);
    });
  }
);

// Add our preload realm script to the session.
session.defaultSession.setPreloads([
  {
    // Our script should only run in service worker preload realms.
    type: "service-worker",
    // The absolute path to the script.
    script: path.join(__dirname, "extension-sw-preload.js"),
  },
]);

Finally, as an example, we can summarize the text of the web document as soon as it completes loading.

// content-script.js
window.addEventListener('load', () => {
  const response = await chrome.runtime.sendMessage({
    type: 'summarize',
    payload: document.body.innerText
  });
  console.log('summary', response.text);
});

Reference-level explanation

Preload realm

Chrome extension content scripts introduced the concept of running code in an isolated world where V8 globals are not shared between contexts. Electron's preload scripts build on the same technology to isolate Electron code from the main document's context. This explanation will expand on the ideas behind these technologies.

[!NOTE] For a primer on V8 isolates, contexts, and worlds, refer to the Design of V8 bindings guide in Chromium.

A preload realm is an isolated v8::Context which is created in response to another context being created in a renderer. content::ContentRendererClient exposes hooks for when a new context is created and ready for evaluation.

content::ContentRendererClient::WillEvaluateServiceWorkerOnWorkerThread is called when a service worker context will be evaluated. A preload realm will be created in response to this hook.

ShadowRealm proposal

ShadowRealms are an ECMAScript TC39 Stage 3 proposal which introduce the ability to create new isolated contexts from any existing JS context. Although not yet finalized, there's an implementation available in V8 and Chromium which can be used as a reference.

[!NOTE] It's possible to test ShadowRealms in Electron by adding a V8 flag:

app.commandLine.appendSwitch("js-flags", "--harmony-shadow-realm");

In Chromium, an implementation for a V8 hook named OnCreateShadowRealmV8Context is provided. In this hook, a new v8::Context is created with a ShadowRealmGlobalScope blink execution context. Using blink APIs would allow the context to interact with debugger agents to provide a DevTools debugging experience-although not currently exposed in Chrome.

The implementation of ShadowRealms provides a basis for the design of Preload Realms. The ShadowRealmGlobalScope can be reused for Preload Realms or copied and renamed with non-significant duplication.

Preload realm scripts

To make preload realms useful for app developers, they'll need to be able to run their own scripts in its context.

For Electron's existing preload scripts, this is handled in sandboxed renderers by using the synchronous BROWSER_SANDBOX_LOAD IPC to gather scripts.

The same approach can be used by Preload Realms. A new initialization script will be added in lib/preload_realm/ to invoke an IPC to collect scripts intended for service workers.

To register scripts, Session#setPreloads can be expanded to support multiple target contexts.

session.defaultSession.setPreloads([
  // Existing usage -- maintained for backwards compat
  "frame-preload.js",
  // New equivalent usage
  {
    type: "frame",
    script: "frame-preload.js",
  },
  // New context type
  {
    type: "service-worker",
    script: "sw-preload.js",
  },
]);

contextBridge

A service worker's preload script should have access to the main worker context from the preload realm context. The existing contextBridge.exposeInMainWorld can be reused with minor adjustments to be aware of contexts without DOM support.

In the case of render frames, webFrame.executeJavaScript exists as a way to evaluate JS in the main world. Workers will require the same functionality which could be added without introducing another top-level module.

interface ContextBridge {
  evaluateInMainWorld(code: string): any;
}

Receiving IPCs

IPCs in Electron have historically only been transmitted between the main process and render frames in renderer processes.

While web workers can exist in the same process as render frames, service workers live in their own process on a worker thread. This will require IPC messages in the main process to be aware of multiple kinds of senders.

Adapting ipcMain to support both would then require developers to filter between the two kinds of senders. However, it would be easiest to instead add an ipc property to a service worker object, similar to WebContents.ipc.

ServiceWorkerMain

WebContents-and recently WebFrameMain-has served as the primary class for transmitting IPC messages. Service Workers will require a new class in the main process to facilitate messages to their equivalent instance in the renderer process.

Chrome's extension system uses ServiceWorkerHost which will serve as inspiration.

/** JS wrapper */
class ServiceWorkerMain {
  /** Scope URL running in the SW */
  scope: string;
  /** Unique version ID */
  versionId: number;
  /** IPC interface */
  ipc: Electron.IpcMainImpl;

  /**
   * Get an existing instance from its scope.
   */
  static fromScope(scope: string): ServiceWorkerMain | null;

  /**
   * Get an existing instance from its unique version ID.
   * Similar to Session.serviceWorkers#getFromVersionID
   */
  static fromVersionID(versionId: number): ServiceWorkerMain | null;
}

const sw = serviceWorkerMain.fromVersionID("abc123");
sw.ipc.handle("ping", () => "pong");

ServiceWorkerMain instances would be created when a remote associated interface is binded for a service worker in the renderer. This can be handled using ContentBrowserClient::RegisterAssociatedInterfaceBindersForServiceWorker. When its associated renderer process terminates, it will be destroyed.

Drawbacks

Increased IPC complexity

Electron users may soon need to be aware of multiple types of senders. The approach we decide to take with ipcMain's TypeScript types will determine whether it requires breaking changes.

interface IpcMainEvent {
  // Breaking change
-  sender: WebContents;
+  sender: WebContents | ServiceWorkerMain;

  // Non-breaking change
+  senderType: 'frame' | 'service-worker';
+  senderServiceWorker: ServiceWorkerMain;
}

However, this potential breaking change can be avoided by only dispatching IPCs to a ServiceWorkerMain.ipc instance instead of on the global ipcMain object.

Internals will also need to abandon assuming a render frame is always the sender. ElectronApiIPCHandlerImpl assumes render frames are used which will require changes.

Blink internals usage

Some parts of Electron's code are already aware of Blink's ExecutionContexts.

// shell/renderer/electron_renderer_client.cc
auto* ec = blink::ExecutionContext::From(context);
if (ec->IsServiceWorkerGlobalScope() || ec->IsSharedWorkerGlobalScope() ||
    ec->IsMainThreadWorkletGlobalScope())
  return;

By introducing Preload Realms, we may need to add additional checks for IsShadowRealmGlobalScope() or IsPreloadRealmGlobalScope()-the latter depending on whether we patch Blink.

Additionally, the setup code ShadowRealm's V8 Context relies on Blink's Oilpan GC (see implementation). Whether we can switch to using V8/Gin's GC instead is yet to be tested.

Lack of DOM APIs

The ShadowRealm proposal is currently stalled due to lack of consensus on which DOM APIs should be made available in the ShadowRealmGlobalScope (tc39/proposal-shadowrealm#284). This might be a new concept for consumers of preload realms. Common APIs we typically think of as JavaScript-native are missing such as setTimeout and setInterval.

This could be addressed if necessary. Introducing our own PreloadRealmGlobalScope and exposing APIs via WebIDL's [Exposed] attribute is possible by patching Blink.

It's worth noting that there's already an effort to expose some DOM APIs to all contexts via [Exposed=*].

Rationale and alternatives

The design of preload realms provides an isolated and secure environment for Electron app developers to enable new use cases in service workers. It builds on developers' existing mental model for preload scripts.

In using ShadowRealm as a basis for the design of Preload Realm contexts, we can potentially minimize the maintenance burdon of adding new contexts to Electron's renderer code. This should prevent us from needing to patch Blink to add a new global scope. However, due to yet to be finalized nature of the ShadowRealm proposal, it might be best to initially mark Preload Realms as experimental.

By limiting the initial design and implementation to service workers, an iterative approach can be taken in supporting future contexts such as web workers.

Alternatives

RendererPreloadWorklet

Worklets are a relatively new component of the web. Now used for single-purpose processes in the renderer such as painting, layout, and audio computation.

Electron could provide its own worklet for deciding when and what to inject as a preload script.

// Allow multiple registrations for extensibility.
registerPreload(
  class {
    /**
     * Target one or more contexts.
     * Possible values could be:
     *    main-frame, sub-frame, web-worker, service-worker
     */
    static get targetContexts() {
      return ["service-worker"];
    }

    /**
     * Filter based on URL of the context.
     */
    static get urlPatterns() {
      return ["chrome-extension://*"];
    }

    /**
     * One or more relative path scripts to be injected.
     */
    static get scripts() {
      return ["setup-script.js", "relative-script.js"];
    }

    // OR could allow more flexibility and provide APIs similar to ShadowRealm
    // proposal.
    handleContext(context) {
      context.importScript("setup-script.js");
      context.importScript("relative-script.js");
    }
  }
);

A approach like above would allow us to eliminate all nodeIntegrationIn* APIs which have been a source of confusion for some users (see issues with nodeIntegrationInSubFrames).

Prior art

Preloads for Web Workers

Previously existed in Electron, however, never was introduced with context isolation support and thus never landed.

A PR was created without support for context isolation which ultimately led to its rejection.

ShadowRealms

See above in the reference-level guide.

Runtime.evaluate (Chrome DevTools Protocol)

Runtime.evaluate provides functionality to evaluate a script in any context. However, it doesn't seem to allow evaluation in a worker thread's context.

Unresolved questions

  • Should we use ShadowRealmGlobalScope or introduce a new PreloadRealmGlobalScope?
    • Adding a new global scope requires patching Blink.
  • The intended implementation of preload realms is only for sandboxed renderers. Out-of-process service workers cause crashes with node integration enabled. Should we still look into making this possible?
  • contextBridge.evaluateInMainWorld is intended to run synchronously. This can be important for applications which intend to expose globals prior to scripts being evaluated in the context. How should we handle the semantic differences with webFrame.executeJavaScript which is async?

Future possibilities

  • Preload realms for web workers and shared workers
    • After an initial implementation of support for service workers, this should be better understood.
  • Subframe option for preload scripts
    session.defaultSession.setPreloads([
      {
        type: "frame",
        script: "frame-preload.js",
        // Property for loading in all subframes, inspired by content scripts
        // https://developer.chrome.com/docs/extensions/develop/concepts/content-scripts
        allFrames: true,
      },
    ]);