Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/lint-build-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ jobs:
node-version: ${{ matrix.node-version }}
env:
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1
- run: yarn build
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In general I hate that we have to build before anything except the e2e tests but we already live in sin so this is fine for now. I'm of a mind to start caching builds in CI after this PR.

- run: yarn test:integration
- name: Require clean working directory
shell: bash
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"test:e2e": "yarn workspaces foreach --all run test:e2e",
"test:e2e:ci": "yarn workspaces foreach --all run test:e2e:ci",
"test:e2e:local": "yarn workspaces foreach --all run test:e2e:local",
"test:integration": "yarn workspaces foreach --all run test:integration",
"test:verbose": "yarn test --reporter verbose",
"test:watch": "vitest",
"why:batch": "./scripts/why-batch.sh"
Expand Down
4 changes: 1 addition & 3 deletions packages/extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,15 +42,13 @@
"test:e2e:debug": "playwright test --debug"
},
"dependencies": {
"@endo/eventual-send": "^1.3.4",
"@metamask/kernel-browser-runtime": "workspace:^",
"@metamask/kernel-rpc-methods": "workspace:^",
"@metamask/kernel-shims": "workspace:^",
"@metamask/kernel-ui": "workspace:^",
"@metamask/kernel-utils": "workspace:^",
"@metamask/logger": "workspace:^",
"@metamask/ocap-kernel": "workspace:^",
"@metamask/streams": "workspace:^",
"@metamask/utils": "^11.9.0",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"ses": "^1.14.0"
Expand Down
2 changes: 1 addition & 1 deletion packages/extension/scripts/build-constants.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export const kernelBrowserRuntimeSrcDir = path.resolve(
*/
export const trustedPreludes = {
background: {
path: path.resolve(sourceDir, 'env/background-trusted-prelude.js'),
content: "import './endoify.js';",
},
'kernel-worker': { content: "import './endoify.js';" },
};
147 changes: 86 additions & 61 deletions packages/extension/src/background.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,32 @@
import { E } from '@endo/eventual-send';
import {
connectToKernel,
rpcMethodSpecs,
makeBackgroundCapTP,
makeCapTPNotification,
isCapTPNotification,
getCapTPMessage,
} from '@metamask/kernel-browser-runtime';
import type {
KernelFacade,
CapTPMessage,
} from '@metamask/kernel-browser-runtime';
import defaultSubcluster from '@metamask/kernel-browser-runtime/default-cluster';
import { RpcClient } from '@metamask/kernel-rpc-methods';
import { delay } from '@metamask/kernel-utils';
import type { JsonRpcCall } from '@metamask/kernel-utils';
import { delay, isJsonRpcMessage, stringify } from '@metamask/kernel-utils';
import type { JsonRpcMessage } from '@metamask/kernel-utils';
import { Logger } from '@metamask/logger';
import { kernelMethodSpecs } from '@metamask/ocap-kernel/rpc';
import { ChromeRuntimeDuplexStream } from '@metamask/streams/browser';
import { isJsonRpcResponse } from '@metamask/utils';
import type { JsonRpcResponse } from '@metamask/utils';

defineGlobals();

const OFFSCREEN_DOCUMENT_PATH = '/offscreen.html';
const logger = new Logger('background');
let bootPromise: Promise<void> | null = null;
let kernelP: Promise<KernelFacade>;
let ping: () => Promise<void>;

// With this we can click the extension action button to wake up the service worker.
chrome.action.onClicked.addListener(() => {
ping?.().catch(logger.error);
});

// Install/update
chrome.runtime.onInstalled.addListener(() => {
Expand Down Expand Up @@ -79,85 +91,98 @@ async function main(): Promise<void> {
// Without this delay, sending messages via the chrome.runtime API can fail.
await delay(50);

// Create stream for CapTP messages
const offscreenStream = await ChromeRuntimeDuplexStream.make<
JsonRpcResponse,
JsonRpcCall
>(chrome.runtime, 'background', 'offscreen', isJsonRpcResponse);

const rpcClient = new RpcClient(
kernelMethodSpecs,
async (request) => {
await offscreenStream.write(request);
JsonRpcMessage,
JsonRpcMessage
>(chrome.runtime, 'background', 'offscreen', isJsonRpcMessage);

// Set up CapTP for E() based communication with the kernel
const backgroundCapTP = makeBackgroundCapTP({
send: (captpMessage: CapTPMessage) => {
const notification = makeCapTPNotification(captpMessage);
offscreenStream.write(notification).catch((error) => {
logger.error('Failed to send CapTP message:', error);
});
},
'background:',
);
});

// Get the kernel remote presence
kernelP = backgroundCapTP.getKernel();

const ping = async (): Promise<void> => {
const result = await rpcClient.call('ping', []);
ping = async () => {
const result = await E(kernelP).ping();
logger.info(result);
};

// globalThis.kernel will exist due to dev-console.js in background-trusted-prelude.js
Object.defineProperties(globalThis.kernel, {
ping: {
value: ping,
},
sendMessage: {
value: async (message: JsonRpcCall) =>
await offscreenStream.write(message),
},
});
harden(globalThis.kernel);

// With this we can click the extension action button to wake up the service worker.
chrome.action.onClicked.addListener(() => {
ping().catch(logger.error);
// Handle incoming CapTP messages from the kernel
const drainPromise = offscreenStream.drain((message) => {
if (isCapTPNotification(message)) {
const captpMessage = getCapTPMessage(message);
backgroundCapTP.dispatch(captpMessage);
} else {
throw new Error(`Unexpected message: ${stringify(message)}`);
}
});

// Pipe responses back to the RpcClient
const drainPromise = offscreenStream.drain(async (message) =>
rpcClient.handleResponse(message.id as string, message),
);
drainPromise.catch(logger.error);

await ping(); // Wait for the kernel to be ready
await startDefaultSubcluster();
await startDefaultSubcluster(kernelP);

try {
await drainPromise;
} catch (error) {
throw new Error('Offscreen connection closed unexpectedly', {
const finalError = new Error('Offscreen connection closed unexpectedly', {
cause: error,
});
backgroundCapTP.abort(finalError);
throw finalError;
}
}

/**
* Idempotently starts the default subcluster.
*
* @param kernelPromise - Promise for the kernel facade.
*/
async function startDefaultSubcluster(): Promise<void> {
const kernelStream = await connectToKernel({ label: 'background', logger });
const rpcClient = new RpcClient(
rpcMethodSpecs,
async (request) => {
await kernelStream.write(request);
},
'background',
);
async function startDefaultSubcluster(
kernelPromise: Promise<KernelFacade>,
): Promise<void> {
const status = await E(kernelPromise).getStatus();

kernelStream
.drain(async (message) =>
rpcClient.handleResponse(message.id as string, message),
)
.catch(logger.error);

const status = await rpcClient.call('getStatus', []);
if (status.subclusters.length === 0) {
const result = await rpcClient.call('launchSubcluster', {
config: defaultSubcluster,
});
const result = await E(kernelPromise).launchSubcluster(defaultSubcluster);
logger.info(`Default subcluster launched: ${JSON.stringify(result)}`);
} else {
logger.info('Subclusters already exist. Not launching default subcluster.');
}
}

/**
* Define globals accessible via the background console.
*/
function defineGlobals(): void {
Object.defineProperty(globalThis, 'kernel', {
configurable: false,
enumerable: true,
writable: false,
value: {},
});

Object.defineProperties(globalThis.kernel, {
ping: {
get: () => ping,
},
getKernel: {
value: async () => kernelP,
},
});
harden(globalThis.kernel);

Object.defineProperty(globalThis, 'E', {
value: E,
configurable: false,
enumerable: true,
writable: false,
});
}
3 changes: 0 additions & 3 deletions packages/extension/src/env/background-trusted-prelude.js

This file was deleted.

9 changes: 0 additions & 9 deletions packages/extension/src/env/dev-console.js

This file was deleted.

20 changes: 0 additions & 20 deletions packages/extension/src/env/dev-console.test.ts

This file was deleted.

39 changes: 39 additions & 0 deletions packages/extension/src/global.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { KernelFacade } from '@metamask/kernel-browser-runtime';

// Type declarations for kernel dev console API.
declare global {
/**
* The E() function from @endo/eventual-send for making eventual sends.
* Set globally in the trusted prelude before lockdown.
*
* @example
* ```typescript
* const kernel = await kernel.getKernel();
* const status = await E(kernel).getStatus();
* ```
*/
// eslint-disable-next-line no-var,id-length
var E: typeof import('@endo/eventual-send').E;

// eslint-disable-next-line no-var
var kernel: {
/**
* Ping the kernel to verify connectivity.
*/
ping: () => Promise<void>;

/**
* Get the kernel remote presence for use with E().
*
* @returns A promise for the kernel facade remote presence.
* @example
* ```typescript
* const kernel = await kernel.getKernel();
* const status = await E(kernel).getStatus();
* ```
*/
getKernel: () => Promise<KernelFacade>;
};
}

export {};
22 changes: 10 additions & 12 deletions packages/extension/src/offscreen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import {
PlatformServicesServer,
createRelayQueryString,
} from '@metamask/kernel-browser-runtime';
import { delay, isJsonRpcCall } from '@metamask/kernel-utils';
import type { JsonRpcCall } from '@metamask/kernel-utils';
import { delay, isJsonRpcMessage } from '@metamask/kernel-utils';
import type { JsonRpcMessage } from '@metamask/kernel-utils';
Comment on lines +6 to +7
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we are swapping out JSON-RPC for CapTP as our IPC layer, what's going on here? The changes to this file substitute JsonRpcMessage for both JsonRpcCall and JsonRpcResponse, but it's still JsonRpcSomething. One of the things I find very pleasing about this PR is the net code reduction from getting rid of all the JSON-RPC boilerplate, but this particular change right here puzzles me.

Copy link
Member Author

@rekmarks rekmarks Jan 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, we get rid of the RPC Client / Server stuff and the kernel's command stream, but the captp wire messages have a JSON-RPC wrapper. This buys us a little bit of flexibility in case we ever want to pass other kinds of data over these streams. I think I can just remove the wrapper without side effects if you feel strongly about it.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I may actually have found another message type we want to pass over this stream so I think it's best to leave it.

import { Logger } from '@metamask/logger';
import type { DuplexStream } from '@metamask/streams';
import {
Expand All @@ -13,8 +13,6 @@ import {
MessagePortDuplexStream,
} from '@metamask/streams/browser';
import type { PostMessageTarget } from '@metamask/streams/browser';
import type { JsonRpcResponse } from '@metamask/utils';
import { isJsonRpcResponse } from '@metamask/utils';

const logger = new Logger('offscreen');

Expand All @@ -27,11 +25,11 @@ async function main(): Promise<void> {
// Without this delay, sending messages via the chrome.runtime API can fail.
await delay(50);

// Create stream for messages from the background script
// Create stream for CapTP messages from the background script
const backgroundStream = await ChromeRuntimeDuplexStream.make<
JsonRpcCall,
JsonRpcResponse
>(chrome.runtime, 'offscreen', 'background', isJsonRpcCall);
JsonRpcMessage,
JsonRpcMessage
>(chrome.runtime, 'offscreen', 'background', isJsonRpcMessage);

const kernelStream = await makeKernelWorker();

Expand All @@ -48,7 +46,7 @@ async function main(): Promise<void> {
* @returns The message port stream for worker communication
*/
async function makeKernelWorker(): Promise<
DuplexStream<JsonRpcResponse, JsonRpcCall>
DuplexStream<JsonRpcMessage, JsonRpcMessage>
> {
// Assign local relay address generated from `yarn ocap relay`
const relayQueryString = createRelayQueryString([
Expand All @@ -72,9 +70,9 @@ async function makeKernelWorker(): Promise<
);

const kernelStream = await MessagePortDuplexStream.make<
JsonRpcResponse,
JsonRpcCall
>(port, isJsonRpcResponse);
JsonRpcMessage,
JsonRpcMessage
>(port, isJsonRpcMessage);

await PlatformServicesServer.make(worker as PostMessageTarget, (vatId) =>
makeIframeVatWorker({
Expand Down
10 changes: 1 addition & 9 deletions packages/extension/test/build/build-tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,13 @@ import { runTests } from '@ocap/repo-tools/build-utils/test';
import type { UntransformedFiles } from '@ocap/repo-tools/build-utils/test';
import path from 'node:path';

import {
outDir,
sourceDir,
trustedPreludes,
} from '../../scripts/build-constants.mjs';
import { outDir, trustedPreludes } from '../../scripts/build-constants.mjs';

const untransformedFiles = [
{
sourcePath: path.resolve('../kernel-shims/dist/endoify.js'),
buildPath: path.resolve(outDir, 'endoify.js'),
},
{
sourcePath: path.resolve(sourceDir, 'env/dev-console.js'),
buildPath: path.resolve(outDir, 'dev-console.js'),
},
...Object.values(trustedPreludes).map((prelude) => {
if ('path' in prelude) {
return {
Expand Down
Loading
Loading