From 7c534a350aab43e2b738e10e5df9756c80ff0773 Mon Sep 17 00:00:00 2001 From: Erik Marks Date: Fri, 16 Aug 2024 14:12:24 +0100 Subject: [PATCH 01/24] feat(extension): Add distributed object capability programming --- packages/extension/package.json | 4 + packages/extension/src/background.ts | 35 +++-- packages/extension/src/iframe-manager.ts | 120 +++++++++++++--- packages/extension/src/iframe.ts | 84 ++++++++--- packages/extension/src/offscreen.ts | 19 ++- packages/extension/src/shared.ts | 45 ++++++ packages/shims/package.json | 2 +- yarn.lock | 173 ++++++++++++++++++++++- 8 files changed, 426 insertions(+), 56 deletions(-) diff --git a/packages/extension/package.json b/packages/extension/package.json index 68ca6c89d..d2307e9d2 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -31,7 +31,11 @@ "test:watch": "vitest --config vitest.config.ts" }, "dependencies": { + "@endo/captp": "^4.2.2", "@endo/eventual-send": "^1.2.4", + "@endo/exo": "^1.5.2", + "@endo/lockdown": "^1.0.9", + "@endo/patterns": "^1.4.2", "@endo/promise-kit": "^1.1.4", "@metamask/snaps-utils": "^7.8.0", "@metamask/utils": "^9.1.0", diff --git a/packages/extension/src/background.ts b/packages/extension/src/background.ts index c22a61d42..42d0826ed 100644 --- a/packages/extension/src/background.ts +++ b/packages/extension/src/background.ts @@ -4,8 +4,12 @@ import { Command, makeHandledCallback } from './shared.js'; // globalThis.kernel will exist due to dev-console.js Object.defineProperties(globalThis.kernel, { - sendMessage: { - value: sendMessage, + capTpCall: { + value: async (method: string, params: Json[]) => + sendMessage(Command.CapTpCall, { method, params }), + }, + capTpInit: { + value: async () => sendMessage(Command.CapTpInit), }, evaluate: { value: async (source: string) => sendMessage(Command.Evaluate, source), @@ -13,6 +17,9 @@ Object.defineProperties(globalThis.kernel, { ping: { value: async () => sendMessage(Command.Ping), }, + sendMessage: { + value: sendMessage, + }, }); const OFFSCREEN_DOCUMENT_PATH = '/offscreen.html'; @@ -29,7 +36,7 @@ chrome.action.onClicked.addListener(() => { * @param data - The message data. * @param data.name - The name to include in the message. */ -async function sendMessage(type: string, data?: string): Promise { +async function sendMessage(type: string, data?: Json): Promise { await provideOffScreenDocument(); await chrome.runtime.sendMessage({ @@ -64,9 +71,10 @@ chrome.runtime.onMessage.addListener( switch (message.type) { case Command.Evaluate: + case Command.CapTpCall: + case Command.CapTpInit: case Command.Ping: console.log(message.data); - await closeOffscreenDocument(); break; default: console.error( @@ -77,12 +85,13 @@ chrome.runtime.onMessage.addListener( }), ); -/** - * Close the offscreen document if it exists. - */ -async function closeOffscreenDocument(): Promise { - if (!(await chrome.offscreen.hasDocument())) { - return; - } - await chrome.offscreen.closeDocument(); -} +// TODO: Add method to close offscreen document? +// /** +// * Close the offscreen document if it exists. +// */ +// async function closeOffscreenDocument(): Promise { +// if (!(await chrome.offscreen.hasDocument())) { +// return; +// } +// await chrome.offscreen.closeDocument(); +// } diff --git a/packages/extension/src/iframe-manager.ts b/packages/extension/src/iframe-manager.ts index 5e5c1866d..025ef1101 100644 --- a/packages/extension/src/iframe-manager.ts +++ b/packages/extension/src/iframe-manager.ts @@ -1,14 +1,17 @@ +import { makeCapTP } from '@endo/captp'; +import { E } from '@endo/eventual-send'; import type { PromiseKit } from '@endo/promise-kit'; import { makePromiseKit } from '@endo/promise-kit'; import { createWindow } from '@metamask/snaps-utils'; +import type { Json } from '@metamask/utils'; import type { MessagePortReader, MessagePortStreamPair } from '@ocap/streams'; import { initializeMessageChannel, makeMessagePortStreamPair, } from '@ocap/streams'; -import type { IframeMessage, WrappedIframeMessage } from './shared.js'; -import { Command, isWrappedIframeMessage } from './shared.js'; +import type { IframeMessage, StreamPayloadEnvelope } from './shared.js'; +import { isStreamPayloadEnvelope, Command } from './shared.js'; const IFRAME_URI = 'iframe.html'; @@ -24,6 +27,11 @@ type PromiseCallbacks = Omit, 'promise'>; type GetPort = (targetWindow: Window) => Promise; +type VatRecord = { + streams: MessagePortStreamPair; + capTp?: ReturnType; +}; + /** * A singleton class to manage and message iframes. */ @@ -32,7 +40,7 @@ export class IframeManager { readonly #unresolvedMessages: Map; - readonly #vats: Map>; + readonly #vats: Map; /** * Create a new IframeManager. @@ -59,10 +67,10 @@ export class IframeManager { const newWindow = await createWindow(IFRAME_URI, getHtmlId(id)); const port = await getPort(newWindow); - const streams = makeMessagePortStreamPair(port); - this.#vats.set(id, streams); + const streams = makeMessagePortStreamPair(port); + this.#vats.set(id, { streams }); /* v8 ignore next 4: Not known to be possible. */ - this.#receiveMessages(streams.reader).catch((error) => { + this.#receiveMessages(id, streams.reader).catch((error) => { console.error(`Unexpected read error from vat "${id}"`, error); this.delete(id).catch(() => undefined); }); @@ -79,12 +87,12 @@ export class IframeManager { * @returns A promise that resolves when the iframe is deleted. */ async delete(id: string): Promise { - const streams = this.#vats.get(id); - if (streams === undefined) { + const vat = this.#vats.get(id); + if (vat === undefined) { return undefined; } - const closeP = streams.return(); + const closeP = vat.streams.return(); // TODO: Handle orphaned messages this.#vats.delete(id); @@ -110,25 +118,58 @@ export class IframeManager { id: string, message: IframeMessage, ): Promise { - const streams = this.#vats.get(id); - if (streams === undefined) { - throw new Error(`No vat with id "${id}"`); - } - + const vat = this.#expectGetVat(id); const { promise, reject, resolve } = makePromiseKit(); const messageId = this.#nextId(); + this.#unresolvedMessages.set(messageId, { reject, resolve }); - await streams.writer.next({ id: messageId, message }); + await vat.streams.writer.next({ + label: 'message', + payload: { id: messageId, message }, + }); return promise; } + async callCapTp( + id: string, + method: string, + ...params: Json[] + ): Promise { + const { capTp } = this.#expectGetVat(id); + if (capTp === undefined) { + throw new Error(`Vat with id "${id}" does not have a CapTP connection.`); + } + // @ts-expect-error The types are unwell. + return E(capTp.getBootstrap())[method](...params); + } + + async makeCapTp(id: string): Promise { + const vat = this.#expectGetVat(id); + if (vat.capTp !== undefined) { + throw new Error(`Vat with id "${id}" already has a CapTP connection.`); + } + + // Handle writes here. #receiveMessages() handles reads. + const { writer } = vat.streams; + // https://github.com/endojs/endo/issues/2412 + // eslint-disable-next-line @typescript-eslint/no-misused-promises + const ctp = makeCapTP(id, async (payload: unknown) => { + console.log('CapTP to vat', JSON.stringify(payload, null, 2)); + await writer.next({ label: 'capTp', payload }); + }); + + vat.capTp = ctp; + await this.sendMessage(id, { type: Command.CapTpInit, data: null }); + } + async #receiveMessages( - reader: MessagePortReader, + vatId: string, + reader: MessagePortReader, ): Promise { for await (const rawMessage of reader) { console.debug('Offscreen received message', rawMessage); - if (!isWrappedIframeMessage(rawMessage)) { + if (!isStreamPayloadEnvelope(rawMessage)) { console.warn( 'Offscreen received message with unexpected format', rawMessage, @@ -136,15 +177,48 @@ export class IframeManager { return; } - const { id, message } = rawMessage; - const promiseCallbacks = this.#unresolvedMessages.get(id); - if (promiseCallbacks === undefined) { - console.error(`No unresolved message with id "${id}".`); - continue; + switch (rawMessage.label) { + case 'capTp': { + console.log( + 'CapTP from vat', + JSON.stringify(rawMessage.payload, null, 2), + ); + const { capTp } = this.#expectGetVat(vatId); + if (capTp !== undefined) { + capTp.dispatch(rawMessage.payload); + } + break; + } + case 'message': { + const { id, message } = rawMessage.payload; + const promiseCallbacks = this.#unresolvedMessages.get(id); + if (promiseCallbacks === undefined) { + console.error(`No unresolved message with id "${id}".`); + } else { + promiseCallbacks.resolve(message.data); + } + break; + } + /* v8 ignore next 3: Exhaustiveness check */ + default: + // @ts-expect-error Exhaustiveness check + throw new Error(`Unexpected message label "${rawMessage.label}".`); } + } + } - promiseCallbacks.resolve(message.data); + /** + * Get a vat record by id, or throw an error if it doesn't exist. + * + * @param id - The id of the vat to get. + * @returns The vat record. + */ + #expectGetVat(id: string): VatRecord { + const vat = this.#vats.get(id); + if (vat === undefined) { + throw new Error(`No vat with id "${id}"`); } + return vat; } #nextId(): string { diff --git a/packages/extension/src/iframe.ts b/packages/extension/src/iframe.ts index e5940f27c..36472c0ff 100644 --- a/packages/extension/src/iframe.ts +++ b/packages/extension/src/iframe.ts @@ -1,7 +1,10 @@ +import { makeCapTP } from '@endo/captp'; +import { makeExo } from '@endo/exo'; +import { M } from '@endo/patterns'; import { receiveMessagePort, makeMessagePortStreamPair } from '@ocap/streams'; -import type { WrappedIframeMessage } from './shared.js'; -import { Command, isWrappedIframeMessage } from './shared.js'; +import type { StreamPayloadEnvelope, WrappedIframeMessage } from './shared.js'; +import { isStreamPayloadEnvelope, Command } from './shared.js'; const defaultCompartment = new Compartment({ URL }); @@ -12,21 +15,50 @@ main().catch(console.error); */ async function main(): Promise { const port = await receiveMessagePort(); - const streams = makeMessagePortStreamPair(port); + const streams = makeMessagePortStreamPair(port); + let capTp: ReturnType | undefined; - for await (const wrappedMessage of streams.reader) { - console.debug('iframe received message', wrappedMessage); + for await (const rawMessage of streams.reader) { + console.debug('iframe received message', rawMessage); - if (!isWrappedIframeMessage(wrappedMessage)) { + if (!isStreamPayloadEnvelope(rawMessage)) { console.error( 'iframe received message with unexpected format', - wrappedMessage, + rawMessage, ); return; } - const { id, message } = wrappedMessage; + switch (rawMessage.label) { + case 'capTp': + if (capTp !== undefined) { + capTp.dispatch(rawMessage.payload); + } + break; + case 'message': + await handleMessage(rawMessage.payload); + break; + /* v8 ignore next 3: Exhaustiveness check */ + default: + // @ts-expect-error Exhaustiveness check + throw new Error(`Unexpected message label "${rawMessage.label}".`); + } + } + await streams.return(); + throw new Error('MessagePortReader ended unexpectedly.'); + + /** + * Handle a message from the parent window. + * + * @param wrappedMessage - The wrapped message to handle. + * @param wrappedMessage.id - The id of the message. + * @param wrappedMessage.message - The message to handle. + */ + async function handleMessage({ + id, + message, + }: WrappedIframeMessage): Promise { switch (message.type) { case Command.Evaluate: { if (typeof message.data !== 'string') { @@ -37,11 +69,29 @@ async function main(): Promise { return; } const result = safelyEvaluate(message.data); - await reply(id, Command.Evaluate, stringifyResult(result)); + await replyToMessage(id, Command.Evaluate, stringifyResult(result)); + break; + } + case Command.CapTpInit: { + const bootstrap = makeExo( + 'TheGreatFrangooly', + M.interface('TheGreatFrangooly', {}, { defaultGuards: 'passable' }), + { whatIsTheGreatFrangooly: () => 'Crowned with Chaos' }, + ); + + capTp = makeCapTP( + 'iframe', // TODO + // https://github.com/endojs/endo/issues/2412 + // eslint-disable-next-line @typescript-eslint/no-misused-promises + async (payload: unknown) => + streams.writer.next({ label: 'capTp', payload }), + bootstrap, + ); + await replyToMessage(id, Command.CapTpInit); break; } case Command.Ping: - await reply(id, Command.Ping, 'pong'); + await replyToMessage(id, Command.Ping, 'pong'); break; default: console.error( @@ -51,22 +101,22 @@ async function main(): Promise { } } - await streams.return(); - throw new Error('MessagePortReader ended unexpectedly.'); - /** - * Reply to the parent window. + * Reply to a message from the parent window. * * @param id - The id of the message to reply to. * @param messageType - The message type. * @param data - The message data. */ - async function reply( + async function replyToMessage( id: string, messageType: Command, - data: string, + data: string | null = null, ): Promise { - await streams.writer.next({ id, message: { type: messageType, data } }); + await streams.writer.next({ + label: 'message', + payload: { id, message: { type: messageType, data } }, + }); } /** diff --git a/packages/extension/src/offscreen.ts b/packages/extension/src/offscreen.ts index 5c48b8d13..9e98f25ae 100644 --- a/packages/extension/src/offscreen.ts +++ b/packages/extension/src/offscreen.ts @@ -11,7 +11,9 @@ async function main(): Promise { // Hard-code a single iframe for now. const IFRAME_ID = 'default'; const iframeManager = new IframeManager(); - const iframeReadyP = iframeManager.create({ id: IFRAME_ID }); + const iframeReadyP = iframeManager + .create({ id: IFRAME_ID }) + .then(async () => iframeManager.makeCapTp(IFRAME_ID)); // Handle messages from the background service worker chrome.runtime.onMessage.addListener( @@ -29,6 +31,21 @@ async function main(): Promise { case Command.Evaluate: await reply(Command.Evaluate, await evaluate(message.data)); break; + case Command.CapTpCall: { + const result = await iframeManager.callCapTp( + IFRAME_ID, + // @ts-expect-error TODO: Type assertions + message.data.method, + // @ts-expect-error TODO: Type assertions + ...message.data.params, + ); + await reply(Command.CapTpCall, JSON.stringify(result, null, 2)); + break; + } + case Command.CapTpInit: + await iframeManager.makeCapTp(IFRAME_ID); + await reply(Command.CapTpInit, '~~~ CapTP Initialized ~~~'); + break; case Command.Ping: await reply(Command.Ping, 'pong'); break; diff --git a/packages/extension/src/shared.ts b/packages/extension/src/shared.ts index 99c82f6b9..dda929a74 100644 --- a/packages/extension/src/shared.ts +++ b/packages/extension/src/shared.ts @@ -1,6 +1,8 @@ import { isObject } from '@metamask/utils'; export enum Command { + CapTpCall = 'callCapTp', + CapTpInit = 'makeCapTp', Evaluate = 'evaluate', Ping = 'ping', } @@ -36,6 +38,49 @@ export const isWrappedIframeMessage = ( typeof value.message.type === 'string' && (typeof value.message.data === 'string' || value.message.data === null); +export type StreamPayloadEnvelope = + | { + label: 'message'; + payload: WrappedIframeMessage; + } + | { label: 'capTp'; payload: unknown }; + +type MessageHandler = (message: WrappedIframeMessage) => void | Promise; +type CapTpHandler = (capTpMessage: unknown) => void | Promise; +export const makeEnvelopeUnwrapper = + (handleMessage: MessageHandler, handleCapTp: CapTpHandler) => + async (envelope: StreamPayloadEnvelope): Promise => { + switch (envelope.label) { + case 'capTp': + return handleCapTp(envelope.payload); + case 'message': + return handleMessage(envelope.payload); + default: + throw new Error( + `Unexpected message label in message:\n${JSON.stringify( + envelope, + null, + 2, + )}`, + ); + } + }; + +export const isStreamPayloadEnvelope = ( + value: unknown, +): value is StreamPayloadEnvelope => { + if (!isObject(value)) { + return false; + } + if ( + value.label !== 'capTp' && + (value.label !== 'message' || !isWrappedIframeMessage(value.payload)) + ) { + return false; + } + return true; +}; + /** * Wrap an async callback to ensure any errors are at least logged. * diff --git a/packages/shims/package.json b/packages/shims/package.json index 1081d73ee..66ac71035 100644 --- a/packages/shims/package.json +++ b/packages/shims/package.json @@ -36,7 +36,7 @@ "test:watch": "vitest --config vitest.config.ts" }, "dependencies": { - "@endo/eventual-send": "^1.2.4", + "@endo/eventual-send": "^1.2.2", "@endo/lockdown": "^1.0.9", "ses": "^1.7.0" }, diff --git a/yarn.lock b/yarn.lock index f788ec267..b702bf601 100644 --- a/yarn.lock +++ b/yarn.lock @@ -296,6 +296,19 @@ __metadata: languageName: node linkType: hard +"@endo/captp@npm:^4.2.2": + version: 4.3.0 + resolution: "@endo/captp@npm:4.3.0" + dependencies: + "@endo/errors": "npm:^1.2.5" + "@endo/eventual-send": "npm:^1.2.5" + "@endo/marshal": "npm:^1.5.3" + "@endo/nat": "npm:^5.0.10" + "@endo/promise-kit": "npm:^1.1.5" + checksum: 10/496ae0f7f46160680efb213e2043b33ce6347eb05d07d05dd70ca3ff873f2215a28d6bc346159ba8d9075d246a46ae4778ba5003490641ab53572c1a5fe9bcf0 + languageName: node + linkType: hard + "@endo/cjs-module-analyzer@npm:^1.0.6": version: 1.0.6 resolution: "@endo/cjs-module-analyzer@npm:1.0.6" @@ -303,6 +316,17 @@ __metadata: languageName: node linkType: hard +"@endo/common@npm:^1.2.5": + version: 1.2.5 + resolution: "@endo/common@npm:1.2.5" + dependencies: + "@endo/errors": "npm:^1.2.5" + "@endo/eventual-send": "npm:^1.2.5" + "@endo/promise-kit": "npm:^1.1.5" + checksum: 10/11c026e09d716bb3d8384a0090e6b938e05616d88f99ef126d8e3dc5f4e5f343fb93874d6bf5b1ca61d85048c4a9c8d9317ee6ee1a2adf249aa4da6f4d31635c + languageName: node + linkType: hard + "@endo/compartment-mapper@npm:^1.2.0": version: 1.2.1 resolution: "@endo/compartment-mapper@npm:1.2.1" @@ -322,6 +346,22 @@ __metadata: languageName: node linkType: hard +"@endo/env-options@npm:^1.1.6": + version: 1.1.6 + resolution: "@endo/env-options@npm:1.1.6" + checksum: 10/c16675a18e70caf92a2cc35bab40e8a34a761b3bd355ea480b4092d1e0e15dfdeec48246e470b5f4dfe40d2a140d07c7926dd8b76df5fcc294bc21ebb18d5d3d + languageName: node + linkType: hard + +"@endo/errors@npm:^1.2.5": + version: 1.2.5 + resolution: "@endo/errors@npm:1.2.5" + dependencies: + ses: "npm:^1.8.0" + checksum: 10/50ffbd939cf5d2b6388e9f362c92b0b0c4be3d0e14fa79c0a57d67adbf5b657f76a3999eb0b89a94528c5b96420f8981caf93a820790b3ae41cb02db58553af5 + languageName: node + linkType: hard + "@endo/evasive-transform@npm:^1.2.0": version: 1.2.1 resolution: "@endo/evasive-transform@npm:1.2.1" @@ -343,6 +383,41 @@ __metadata: languageName: node linkType: hard +"@endo/eventual-send@npm:^1.2.5": + version: 1.2.5 + resolution: "@endo/eventual-send@npm:1.2.5" + dependencies: + "@endo/env-options": "npm:^1.1.6" + checksum: 10/3d3c8eb48fc48a96f8ca8c96baeccd8a8cbc14d4dd64583e44034e0aab77be4a54ad80e87992d63a003c105889df5dbbd75f21271598f1ca6b0dfb36c2e081c8 + languageName: node + linkType: hard + +"@endo/exo@npm:^1.5.2": + version: 1.5.3 + resolution: "@endo/exo@npm:1.5.3" + dependencies: + "@endo/common": "npm:^1.2.5" + "@endo/env-options": "npm:^1.1.6" + "@endo/errors": "npm:^1.2.5" + "@endo/eventual-send": "npm:^1.2.5" + "@endo/far": "npm:^1.1.5" + "@endo/pass-style": "npm:^1.4.3" + "@endo/patterns": "npm:^1.4.3" + checksum: 10/1106370db7efa55b790bad645321d31b4969cc1b8f98e2af80d93e64da3cdb7e2e52e855ccd1951f23606383716ac673333471705896addf3ae84b6a12880cae + languageName: node + linkType: hard + +"@endo/far@npm:^1.1.5": + version: 1.1.5 + resolution: "@endo/far@npm:1.1.5" + dependencies: + "@endo/errors": "npm:^1.2.5" + "@endo/eventual-send": "npm:^1.2.5" + "@endo/pass-style": "npm:^1.4.3" + checksum: 10/c420c13a204ec2891b60d13d7af428fb2969c098519430e991dafdeb7868343b0cc160e2b1b0872587f112c423638254d8c4ffefee20f2725f1d29b0dabbd8e7 + languageName: node + linkType: hard + "@endo/init@npm:^1.1.3": version: 1.1.3 resolution: "@endo/init@npm:1.1.3" @@ -364,6 +439,20 @@ __metadata: languageName: node linkType: hard +"@endo/marshal@npm:^1.5.3": + version: 1.5.3 + resolution: "@endo/marshal@npm:1.5.3" + dependencies: + "@endo/common": "npm:^1.2.5" + "@endo/errors": "npm:^1.2.5" + "@endo/eventual-send": "npm:^1.2.5" + "@endo/nat": "npm:^5.0.10" + "@endo/pass-style": "npm:^1.4.3" + "@endo/promise-kit": "npm:^1.1.5" + checksum: 10/51e96383ce8de2ad834804f8154bb121482afe5739b22a84759b31240498b920f691667fb5a5cc5a49327a3f4ec7d0679e4e05d7f9be5b27acc8d761809d92aa + languageName: node + linkType: hard + "@endo/module-source@npm:^1.0.1": version: 1.0.1 resolution: "@endo/module-source@npm:1.0.1" @@ -377,6 +466,39 @@ __metadata: languageName: node linkType: hard +"@endo/nat@npm:^5.0.10": + version: 5.0.10 + resolution: "@endo/nat@npm:5.0.10" + checksum: 10/4c582a96a96f3413de7945dc5be92eb3bc63692440a541ee42607ebdaaf6af3083777344d8e6b8056915d065a32485d00bc3b6047bc205af586d421c453be494 + languageName: node + linkType: hard + +"@endo/pass-style@npm:^1.4.3": + version: 1.4.3 + resolution: "@endo/pass-style@npm:1.4.3" + dependencies: + "@endo/env-options": "npm:^1.1.6" + "@endo/errors": "npm:^1.2.5" + "@endo/eventual-send": "npm:^1.2.5" + "@endo/promise-kit": "npm:^1.1.5" + "@fast-check/ava": "npm:^1.1.5" + checksum: 10/3358431007927cef4fad8a6c9d7f0035ab0d17a243867635cf5e279097803dc4e226acb44e221bf12faeca9799cd0db897435849b4eacdfce6c3c0c7ad9bbbd1 + languageName: node + linkType: hard + +"@endo/patterns@npm:^1.4.2, @endo/patterns@npm:^1.4.3": + version: 1.4.3 + resolution: "@endo/patterns@npm:1.4.3" + dependencies: + "@endo/common": "npm:^1.2.5" + "@endo/errors": "npm:^1.2.5" + "@endo/eventual-send": "npm:^1.2.5" + "@endo/marshal": "npm:^1.5.3" + "@endo/promise-kit": "npm:^1.1.5" + checksum: 10/34581909ecf1283ed4ab3179643db47ba0f32621b39879766f1053395903b1cac462fb81b32a81334c17a642cff17349b2f8d5f13c711555dee09f2c1bb444b3 + languageName: node + linkType: hard + "@endo/promise-kit@npm:^1.1.2, @endo/promise-kit@npm:^1.1.3, @endo/promise-kit@npm:^1.1.4": version: 1.1.4 resolution: "@endo/promise-kit@npm:1.1.4" @@ -386,6 +508,15 @@ __metadata: languageName: node linkType: hard +"@endo/promise-kit@npm:^1.1.5": + version: 1.1.5 + resolution: "@endo/promise-kit@npm:1.1.5" + dependencies: + ses: "npm:^1.8.0" + checksum: 10/a0483335f36c0614066906afd246e98e185dfe1079cc949b2031db5fdf727096ca0cccb21e294ba2bff3a82b54c8ff4e53b6e13506b325c56774374d8b90eb5b + languageName: node + linkType: hard + "@endo/stream@npm:^1.2.2": version: 1.2.2 resolution: "@endo/stream@npm:1.2.2" @@ -667,6 +798,17 @@ __metadata: languageName: node linkType: hard +"@fast-check/ava@npm:^1.1.5": + version: 1.2.1 + resolution: "@fast-check/ava@npm:1.2.1" + dependencies: + fast-check: "npm:^3.0.0" + peerDependencies: + ava: ^4 || ^5 || ^6 + checksum: 10/816ac43e5fb0c2a101bc7e2307f67b68ede4dd64d029cecc78795113580c90f4120ccc3ea6931ad757c465dff06a79c24f45cbe399b8ab6d74494d72f9f19736 + languageName: node + linkType: hard + "@humanwhocodes/config-array@npm:^0.11.14": version: 0.11.14 resolution: "@humanwhocodes/config-array@npm:0.11.14" @@ -1251,7 +1393,11 @@ __metadata: resolution: "@ocap/extension@workspace:packages/extension" dependencies: "@arethetypeswrong/cli": "npm:^0.15.3" + "@endo/captp": "npm:^4.2.2" "@endo/eventual-send": "npm:^1.2.4" + "@endo/exo": "npm:^1.5.2" + "@endo/lockdown": "npm:^1.0.9" + "@endo/patterns": "npm:^1.4.2" "@endo/promise-kit": "npm:^1.1.4" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/eslint-config": "npm:^13.0.0" @@ -1333,7 +1479,7 @@ __metadata: resolution: "@ocap/shims@workspace:packages/shims" dependencies: "@endo/bundle-source": "npm:^3.3.0" - "@endo/eventual-send": "npm:^1.2.4" + "@endo/eventual-send": "npm:^1.2.2" "@endo/lockdown": "npm:^1.0.9" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/eslint-config": "npm:^13.0.0" @@ -3663,6 +3809,15 @@ __metadata: languageName: node linkType: hard +"fast-check@npm:^3.0.0": + version: 3.22.0 + resolution: "fast-check@npm:3.22.0" + dependencies: + pure-rand: "npm:^6.1.0" + checksum: 10/26ae7cc228fcd9759124db10cbbc01efff730bcdc848544ec7c3a533b9d88dec88d2a4a79da0ea4eb1ec78611dc6576f06f3fa5f8ff7126ad2eecf5ce3da57c6 + languageName: node + linkType: hard + "fast-deep-equal@npm:^3.1.1, fast-deep-equal@npm:^3.1.3": version: 3.1.3 resolution: "fast-deep-equal@npm:3.1.3" @@ -5649,6 +5804,13 @@ __metadata: languageName: node linkType: hard +"pure-rand@npm:^6.1.0": + version: 6.1.0 + resolution: "pure-rand@npm:6.1.0" + checksum: 10/256aa4bcaf9297256f552914e03cbdb0039c8fe1db11fa1e6d3f80790e16e563eb0a859a1e61082a95e224fc0c608661839439f8ecc6a3db4e48d46d99216ee4 + languageName: node + linkType: hard + "querystringify@npm:^2.1.1": version: 2.2.0 resolution: "querystringify@npm:2.2.0" @@ -5982,6 +6144,15 @@ __metadata: languageName: node linkType: hard +"ses@npm:^1.8.0": + version: 1.8.0 + resolution: "ses@npm:1.8.0" + dependencies: + "@endo/env-options": "npm:^1.1.6" + checksum: 10/ce1cb7f85147ce8c83f63b6d7cfb0a38bc4ca31a85fe9c7d86547595fb2ea0503ab1bacf7c9eb1dd5c7796638d4fbac51608a8f4493d71b56a584262837819ba + languageName: node + linkType: hard + "shebang-command@npm:^2.0.0": version: 2.0.0 resolution: "shebang-command@npm:2.0.0" From 14ad89cad083ebadf15e16270a9f7930a4a4edd8 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Mon, 2 Sep 2024 19:54:11 -0500 Subject: [PATCH 02/24] test(extension): Manually envelope messages mocked directly through ports. --- packages/extension/src/iframe-manager.test.ts | 36 ++++++++++++------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/packages/extension/src/iframe-manager.test.ts b/packages/extension/src/iframe-manager.test.ts index b8dd6ef16..a0730167d 100644 --- a/packages/extension/src/iframe-manager.test.ts +++ b/packages/extension/src/iframe-manager.test.ts @@ -143,32 +143,41 @@ describe('IframeManager', () => { await manager.create({ id, getPort: makeGetPort(port1) }); const message = { type: Command.Evaluate, data: '2+2' }; + const response = { type: Command.Evaluate, data: '4' }; + // sendMessage wraps the payload in a 'message' envelope const messagePromise = manager.sendMessage(id, message); const messageId: string | undefined = - portPostMessageSpy.mock.lastCall?.[0]?.value?.id; + portPostMessageSpy.mock.lastCall?.[0]?.value?.payload?.id; expect(messageId).toBeTypeOf('string'); + // postMessage sends the json directly, so we have to wrap it in an envelope here port2.postMessage({ done: false, value: { - id: messageId, - message: { - type: Command.Evaluate, - data: '4', + label: 'message', + payload: { + id: messageId, + message: response, }, }, }); + // awaiting event loop should resolve the messagePromise + expect(await messagePromise).toBe(response.data); + + // messagePromise doesn't resolve until message was posted expect(portPostMessageSpy).toHaveBeenCalledOnce(); expect(portPostMessageSpy).toHaveBeenCalledWith({ done: false, value: { - id: messageId, - message, + label: 'message', + payload: { + id: messageId, + message, + }, }, }); - expect(await messagePromise).toBe('4'); }); it('throws if iframe not found', async () => { @@ -219,10 +228,13 @@ describe('IframeManager', () => { port2.postMessage({ done: false, value: { - id: 'foo', - message: { - type: Command.Evaluate, - data: '"bar"', + label: 'message', + payload: { + id: 'foo', + message: { + type: Command.Evaluate, + data: '"bar"', + }, }, }, }); From 7e0d1826a18b6046aed664b40bbf220b175df70d Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Mon, 2 Sep 2024 21:05:02 -0500 Subject: [PATCH 03/24] types(extension): Distinguish VatId and MessageId with type aliases. --- packages/extension/src/iframe-manager.ts | 57 ++++++++++++++---------- packages/extension/src/shared.ts | 5 ++- 2 files changed, 37 insertions(+), 25 deletions(-) diff --git a/packages/extension/src/iframe-manager.ts b/packages/extension/src/iframe-manager.ts index 025ef1101..003d55cae 100644 --- a/packages/extension/src/iframe-manager.ts +++ b/packages/extension/src/iframe-manager.ts @@ -10,18 +10,23 @@ import { makeMessagePortStreamPair, } from '@ocap/streams'; -import type { IframeMessage, StreamPayloadEnvelope } from './shared.js'; import { isStreamPayloadEnvelope, Command } from './shared.js'; +import type { + IframeMessage, + StreamPayloadEnvelope, + VatId, + MessageId, +} from './shared.js'; const IFRAME_URI = 'iframe.html'; /** * Get a DOM id for our iframes, for greater collision resistance. * - * @param id - The id to base the DOM id on. + * @param id - The vat id to base the DOM id on. * @returns The DOM id. */ -const getHtmlId = (id: string): string => `ocap-iframe-${id}`; +const getHtmlId = (id: VatId): string => `ocap-iframe-${id}`; type PromiseCallbacks = Omit, 'promise'>; @@ -38,9 +43,9 @@ type VatRecord = { export class IframeManager { #currentId: number; - readonly #unresolvedMessages: Map; + readonly #unresolvedMessages: Map; - readonly #vats: Map; + readonly #vats: Map; /** * Create a new IframeManager. @@ -57,12 +62,12 @@ export class IframeManager { * @param args - Options bag. * @param args.id - The id of the vat to create. * @param args.getPort - A function to get the message port for the iframe. - * @returns The iframe's content window, and its internal id. + * @returns The iframe's content window, and the id of the associated vat. */ async create( - args: { id?: string; getPort?: GetPort } = {}, - ): Promise { - const id = args.id ?? this.#nextId(); + args: { id?: VatId; getPort?: GetPort } = {}, + ): Promise { + const id = args.id ?? this.#nextVatId(); const getPort = args.getPort ?? initializeMessageChannel; const newWindow = await createWindow(IFRAME_URI, getHtmlId(id)); @@ -81,12 +86,12 @@ export class IframeManager { } /** - * Delete an iframe. + * Delete a vat and its associated iframe. * - * @param id - The id of the iframe to delete. + * @param id - The id of the vat to delete. * @returns A promise that resolves when the iframe is deleted. */ - async delete(id: string): Promise { + async delete(id: VatId): Promise { const vat = this.#vats.get(id); if (vat === undefined) { return undefined; @@ -108,19 +113,19 @@ export class IframeManager { } /** - * Send a message to an iframe. + * Send a message to a vat. * - * @param id - The id of the iframe to send the message to. + * @param id - The id of the vat to send the message to. * @param message - The message to send. * @returns A promise that resolves the response to the message. */ async sendMessage( - id: string, + id: VatId, message: IframeMessage, ): Promise { const vat = this.#expectGetVat(id); const { promise, reject, resolve } = makePromiseKit(); - const messageId = this.#nextId(); + const messageId = this.#nextMessageId(id); this.#unresolvedMessages.set(messageId, { reject, resolve }); await vat.streams.writer.next({ @@ -131,7 +136,7 @@ export class IframeManager { } async callCapTp( - id: string, + id: VatId, method: string, ...params: Json[] ): Promise { @@ -143,7 +148,7 @@ export class IframeManager { return E(capTp.getBootstrap())[method](...params); } - async makeCapTp(id: string): Promise { + async makeCapTp(id: VatId): Promise { const vat = this.#expectGetVat(id); if (vat.capTp !== undefined) { throw new Error(`Vat with id "${id}" already has a CapTP connection.`); @@ -163,7 +168,7 @@ export class IframeManager { } async #receiveMessages( - vatId: string, + vatId: VatId, reader: MessagePortReader, ): Promise { for await (const rawMessage of reader) { @@ -213,7 +218,7 @@ export class IframeManager { * @param id - The id of the vat to get. * @returns The vat record. */ - #expectGetVat(id: string): VatRecord { + #expectGetVat(id: VatId): VatRecord { const vat = this.#vats.get(id); if (vat === undefined) { throw new Error(`No vat with id "${id}"`); @@ -221,9 +226,13 @@ export class IframeManager { return vat; } - #nextId(): string { - const id = this.#currentId; + readonly #nextMessageId = (id: VatId): MessageId => { this.#currentId += 1; - return String(id); - } + return `${id}-${this.#currentId}`; + }; + + readonly #nextVatId = (): MessageId => { + this.#currentId += 1; + return `${this.#currentId}`; + }; } diff --git a/packages/extension/src/shared.ts b/packages/extension/src/shared.ts index dda929a74..2c8051b0c 100644 --- a/packages/extension/src/shared.ts +++ b/packages/extension/src/shared.ts @@ -1,5 +1,8 @@ import { isObject } from '@metamask/utils'; +export type VatId = string; +export type MessageId = string; + export enum Command { CapTpCall = 'callCapTp', CapTpInit = 'makeCapTp', @@ -25,7 +28,7 @@ export type IframeMessage< }; export type WrappedIframeMessage = { - id: string; + id: MessageId; message: IframeMessage; }; From ea7c2d53893bcc1b36ff41a286f3f01c018fb38a Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Mon, 2 Sep 2024 23:21:44 -0500 Subject: [PATCH 04/24] feat(extension): Add generator for vat unresolved messages. --- packages/extension/src/iframe-manager.test.ts | 49 +++++++++++++++++++ packages/extension/src/iframe-manager.ts | 17 +++++++ 2 files changed, 66 insertions(+) diff --git a/packages/extension/src/iframe-manager.test.ts b/packages/extension/src/iframe-manager.test.ts index a0730167d..933efe531 100644 --- a/packages/extension/src/iframe-manager.test.ts +++ b/packages/extension/src/iframe-manager.test.ts @@ -126,6 +126,55 @@ describe('IframeManager', () => { expect(removeSpy).not.toHaveBeenCalled(); }); + + it('warns of unresolved messages', async () => { + const id = 'foo'; + const messageCount = 7; + const awaitCount = 2; + + vi.mocked(snapsUtils.createWindow).mockImplementationOnce(vi.fn()); + + const manager = new IframeManager(); + + vi.spyOn(manager, 'sendMessage').mockImplementationOnce(vi.fn()); + + const { port1, port2 } = new MessageChannel(); + + await manager.create({ id, getPort: makeGetPort(port1) }); + + const warnSpy = vi.spyOn(console, 'warn'); + + const messagePromises = Array(messageCount) + .fill(0) + .map(async (_, i) => + manager.sendMessage(id, { type: Command.Evaluate, data: `${i}+1` }), + ); + + // resolve the first `awaitCount` promises + for (let i = 0; i < awaitCount; i++) { + port2.postMessage({ + done: false, + value: { + label: 'message', + payload: { + id: `foo-${i + 1}`, + message: { + type: Command.Evaluate, + data: `${i + 1}`, + }, + }, + }, + }); + await messagePromises[i]; + } + + await manager.delete(id); + expect(warnSpy).toHaveBeenCalledTimes(messageCount - awaitCount); + // This test assumes messageIds begin at 1, not 0 + expect(warnSpy).toHaveBeenLastCalledWith( + `Unhandled orphaned message: ${id}-${messageCount}`, + ); + }); }); describe('sendMessage', () => { diff --git a/packages/extension/src/iframe-manager.ts b/packages/extension/src/iframe-manager.ts index 003d55cae..be8e3eb3d 100644 --- a/packages/extension/src/iframe-manager.ts +++ b/packages/extension/src/iframe-manager.ts @@ -99,6 +99,9 @@ export class IframeManager { const closeP = vat.streams.return(); // TODO: Handle orphaned messages + for (const [messageId] of this.#unresolvedMessagesOf(id)) { + console.warn(`Unhandled orphaned message: ${messageId}`); + } this.#vats.delete(id); const iframe = document.getElementById(getHtmlId(id)); @@ -200,6 +203,7 @@ export class IframeManager { if (promiseCallbacks === undefined) { console.error(`No unresolved message with id "${id}".`); } else { + this.#unresolvedMessages.delete(id); promiseCallbacks.resolve(message.data); } break; @@ -212,6 +216,19 @@ export class IframeManager { } } + *#unresolvedMessagesOf( + id: VatId, + ): Generator { + for (const messageId of this.#unresolvedMessages.keys()) { + if (messageId.split('-').slice(0, -1).join('-') === id) { + yield [ + messageId, + this.#unresolvedMessages.get(messageId) as PromiseCallbacks, + ] as const; + } + } + } + /** * Get a vat record by id, or throw an error if it doesn't exist. * From 20616effca7cd9a03c830a43e20800b7469ff71f Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Tue, 3 Sep 2024 08:58:36 -0500 Subject: [PATCH 05/24] chore(extension): The types are well again. --- packages/extension/src/iframe-manager.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/extension/src/iframe-manager.ts b/packages/extension/src/iframe-manager.ts index be8e3eb3d..e624de379 100644 --- a/packages/extension/src/iframe-manager.ts +++ b/packages/extension/src/iframe-manager.ts @@ -147,7 +147,6 @@ export class IframeManager { if (capTp === undefined) { throw new Error(`Vat with id "${id}" does not have a CapTP connection.`); } - // @ts-expect-error The types are unwell. return E(capTp.getBootstrap())[method](...params); } From a5aafd3a50faa41e8729ad0f9accc45b59e37138 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Tue, 3 Sep 2024 16:41:17 -0500 Subject: [PATCH 06/24] test(extension): Prevent an unlikely future headache. --- packages/extension/src/iframe-manager.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/extension/src/iframe-manager.test.ts b/packages/extension/src/iframe-manager.test.ts index 933efe531..c6195624b 100644 --- a/packages/extension/src/iframe-manager.test.ts +++ b/packages/extension/src/iframe-manager.test.ts @@ -34,7 +34,7 @@ describe('IframeManager', () => { expect(id).toBeTypeOf('string'); expect(sendMessageSpy).toHaveBeenCalledOnce(); expect(sendMessageSpy).toHaveBeenCalledWith(id, { - type: 'ping', + type: Command.Ping, data: null, }); }); @@ -59,7 +59,7 @@ describe('IframeManager', () => { expect(returnedId).toBe(id); expect(sendMessageSpy).toHaveBeenCalledOnce(); expect(sendMessageSpy).toHaveBeenCalledWith(id, { - type: 'ping', + type: Command.Ping, data: null, }); }); @@ -89,7 +89,7 @@ describe('IframeManager', () => { expect(id).toBeTypeOf('string'); expect(sendMessageSpy).toHaveBeenCalledOnce(); expect(sendMessageSpy).toHaveBeenCalledWith(id, { - type: 'ping', + type: Command.Ping, data: null, }); }); From 87112dfd403d7e14b19571de3e0eb675982947a6 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Wed, 4 Sep 2024 20:58:19 -0500 Subject: [PATCH 07/24] refactor(extension,streams): Generify StreamPair to allow MessagePort-less implementations. --- packages/extension/src/iframe-manager.ts | 6 +++--- packages/streams/src/index.ts | 2 +- packages/streams/src/streams.test.ts | 2 +- packages/streams/src/streams.ts | 10 ++++++---- 4 files changed, 11 insertions(+), 9 deletions(-) diff --git a/packages/extension/src/iframe-manager.ts b/packages/extension/src/iframe-manager.ts index e624de379..2c9a25a44 100644 --- a/packages/extension/src/iframe-manager.ts +++ b/packages/extension/src/iframe-manager.ts @@ -4,7 +4,7 @@ import type { PromiseKit } from '@endo/promise-kit'; import { makePromiseKit } from '@endo/promise-kit'; import { createWindow } from '@metamask/snaps-utils'; import type { Json } from '@metamask/utils'; -import type { MessagePortReader, MessagePortStreamPair } from '@ocap/streams'; +import type { StreamPair, Reader } from '@ocap/streams'; import { initializeMessageChannel, makeMessagePortStreamPair, @@ -33,7 +33,7 @@ type PromiseCallbacks = Omit, 'promise'>; type GetPort = (targetWindow: Window) => Promise; type VatRecord = { - streams: MessagePortStreamPair; + streams: StreamPair; capTp?: ReturnType; }; @@ -171,7 +171,7 @@ export class IframeManager { async #receiveMessages( vatId: VatId, - reader: MessagePortReader, + reader: Reader, ): Promise { for await (const rawMessage of reader) { console.debug('Offscreen received message', rawMessage); diff --git a/packages/streams/src/index.ts b/packages/streams/src/index.ts index 7fbe64309..aacd60335 100644 --- a/packages/streams/src/index.ts +++ b/packages/streams/src/index.ts @@ -2,7 +2,7 @@ export { initializeMessageChannel, receiveMessagePort, } from './message-channel.js'; -export type { MessagePortStreamPair } from './streams.js'; +export type { StreamPair, Reader, Writer } from './streams.js'; export { makeMessagePortStreamPair, MessagePortReader, diff --git a/packages/streams/src/streams.test.ts b/packages/streams/src/streams.test.ts index f2375fe25..a609a1578 100644 --- a/packages/streams/src/streams.test.ts +++ b/packages/streams/src/streams.test.ts @@ -313,7 +313,7 @@ describe('makeMessagePortStreamPair', () => { const { port1, port2 } = new MessageChannel(); const streamPair = makeMessagePortStreamPair(port1); const remoteReader = new MessagePortReader(port2); - const localReadP = streamPair.reader.next(); + const localReadP = (streamPair.reader as MessagePortReader).next(); const remoteReadP = remoteReader.next(); expect(port1.onmessage).toBeDefined(); diff --git a/packages/streams/src/streams.ts b/packages/streams/src/streams.ts index f7a13109a..3433e86aa 100644 --- a/packages/streams/src/streams.ts +++ b/packages/streams/src/streams.ts @@ -22,6 +22,8 @@ import { makePromiseKit } from '@endo/promise-kit'; import type { Reader, Writer } from '@endo/stream'; import { hasProperty, isObject } from '@metamask/utils'; +export type { Reader, Writer }; + type PromiseCallbacks = { resolve: (value: unknown) => void; reject: (reason: unknown) => void; @@ -318,9 +320,9 @@ export class MessagePortWriter implements Writer { } harden(MessagePortWriter); -export type MessagePortStreamPair = Readonly<{ - reader: MessagePortReader; - writer: MessagePortWriter; +export type StreamPair = Readonly<{ + reader: Reader; + writer: Writer; /** * Calls `.return()` on both streams. */ @@ -343,7 +345,7 @@ export type MessagePortStreamPair = Readonly<{ */ export const makeMessagePortStreamPair = ( port: MessagePort, -): MessagePortStreamPair => { +): StreamPair => { const reader = new MessagePortReader(port); const writer = new MessagePortWriter(port); From 979b9b3981da5d07e6881770295dc39cb381a4d8 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Mon, 9 Sep 2024 17:28:41 -0500 Subject: [PATCH 08/24] test(extension): Add capTp frangooly unit test. --- packages/extension/src/iframe-manager.test.ts | 150 ++++++++++++++++++ packages/extension/src/iframe-manager.ts | 4 +- packages/extension/src/iframe.ts | 2 +- 3 files changed, 153 insertions(+), 3 deletions(-) diff --git a/packages/extension/src/iframe-manager.test.ts b/packages/extension/src/iframe-manager.test.ts index c6195624b..9a89aca8b 100644 --- a/packages/extension/src/iframe-manager.test.ts +++ b/packages/extension/src/iframe-manager.test.ts @@ -177,6 +177,156 @@ describe('IframeManager', () => { }); }); + describe('capTp', () => { + it('does TheGreatFrangooly', async () => { + const id = 'frangooly'; + + const capTpInit = { + query: { + label: 'message', + payload: { + id: `${id}-1`, + message: { + data: null, + type: 'makeCapTp', + }, + }, + }, + response: { + label: 'message', + payload: { + id: `${id}-1`, + message: { + type: 'makeCapTp', + data: null, + }, + }, + }, + }; + + const greatFrangoolyBootstrap = { + query: { + label: 'capTp', + payload: { + epoch: 0, + questionID: 'q-1', + type: 'CTP_BOOTSTRAP', + }, + }, + response: { + label: 'capTp', + payload: { + type: 'CTP_RETURN', + epoch: 0, + answerID: 'q-1', + result: { + body: '{"@qclass":"slot","iface":"Alleged: TheGreatFrangooly","index":0}', + slots: ['o+1'], + }, + }, + }, + }; + + const greatFrangoolyCall = { + query: { + label: 'capTp', + payload: { + type: 'CTP_CALL', + epoch: 0, + method: { + body: '["whatIsTheGreatFrangooly",[]]', + slots: [], + }, + questionID: 'q-2', + target: 'o-1', + }, + }, + response: { + label: 'capTp', + payload: { + type: 'CTP_RETURN', + epoch: 0, + answerID: 'q-2', + result: { + body: '"Crowned with Chaos"', + slots: [], + }, + }, + }, + }; + + vi.mocked(snapsUtils.createWindow).mockImplementationOnce(vi.fn()); + + const { port1, port2 } = new MessageChannel(); + const port1PostMessageSpy = vi + .spyOn(port1, 'postMessage') + .mockImplementation(vi.fn()); + + let port1PostMessageCallCounter: number = 0; + const expectSendMessageToHaveBeenCalledOnceMoreWith = ( + expectation: unknown, + ): void => { + port1PostMessageCallCounter += 1; + expect(port1PostMessageSpy).toHaveBeenCalledTimes( + port1PostMessageCallCounter, + ); + expect(port1PostMessageSpy).toHaveBeenLastCalledWith({ + done: false, + value: expectation, + }); + }; + + const mockReplyWith = (message: unknown): void => + port2.postMessage({ + done: false, + value: message, + }); + + const manager = new IframeManager(); + + vi.spyOn(manager, 'sendMessage').mockImplementationOnce(vi.fn()); + + await manager.create({ id, getPort: makeGetPort(port1) }); + + // Init CapTP connection + const initCapTpPromise = manager.makeCapTp(id); + + expectSendMessageToHaveBeenCalledOnceMoreWith(capTpInit.query); + mockReplyWith(capTpInit.response); + + await initCapTpPromise.then((resolvedValue) => + console.debug(`CapTp initialized: ${JSON.stringify(resolvedValue)}`), + ); + + // Bootstrap TheGreatFrangooly... + const callCapTpResponse = manager.callCapTp( + id, + 'whatIsTheGreatFrangooly', + ); + + expectSendMessageToHaveBeenCalledOnceMoreWith( + greatFrangoolyBootstrap.query, + ); + mockReplyWith(greatFrangoolyBootstrap.response); + + await delay().then(() => + console.debug('TheGreatFrangooly bootstrapped...'), + ); + + // ...and call it. + expectSendMessageToHaveBeenCalledOnceMoreWith(greatFrangoolyCall.query); + mockReplyWith(greatFrangoolyCall.response); + + await callCapTpResponse.then((resolvedValue) => + console.debug( + `TheGreatFrangooly called: ${JSON.stringify(resolvedValue)}`, + ), + ); + + expect(await callCapTpResponse).equals('Crowned with Chaos'); + }); + }); + describe('sendMessage', () => { it('sends a message to an iframe', async () => { vi.mocked(snapsUtils.createWindow).mockResolvedValueOnce({} as Window); diff --git a/packages/extension/src/iframe-manager.ts b/packages/extension/src/iframe-manager.ts index 2c9a25a44..214590939 100644 --- a/packages/extension/src/iframe-manager.ts +++ b/packages/extension/src/iframe-manager.ts @@ -150,7 +150,7 @@ export class IframeManager { return E(capTp.getBootstrap())[method](...params); } - async makeCapTp(id: VatId): Promise { + async makeCapTp(id: VatId): Promise { const vat = this.#expectGetVat(id); if (vat.capTp !== undefined) { throw new Error(`Vat with id "${id}" already has a CapTP connection.`); @@ -166,7 +166,7 @@ export class IframeManager { }); vat.capTp = ctp; - await this.sendMessage(id, { type: Command.CapTpInit, data: null }); + return this.sendMessage(id, { type: Command.CapTpInit, data: null }); } async #receiveMessages( diff --git a/packages/extension/src/iframe.ts b/packages/extension/src/iframe.ts index 36472c0ff..3002889e8 100644 --- a/packages/extension/src/iframe.ts +++ b/packages/extension/src/iframe.ts @@ -64,7 +64,7 @@ async function main(): Promise { if (typeof message.data !== 'string') { console.error( 'iframe received message with unexpected data type', - message.data, + stringifyResult(message.data), ); return; } From c01440cea40d2a5f1cb77f9be66f6e65d350ebe3 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Tue, 10 Sep 2024 19:36:24 -0500 Subject: [PATCH 09/24] chore: Update @endo/eventual-send@^1.2.4 --- packages/shims/package.json | 2 +- yarn.lock | 42 ++++--------------------------------- 2 files changed, 5 insertions(+), 39 deletions(-) diff --git a/packages/shims/package.json b/packages/shims/package.json index 66ac71035..1081d73ee 100644 --- a/packages/shims/package.json +++ b/packages/shims/package.json @@ -36,7 +36,7 @@ "test:watch": "vitest --config vitest.config.ts" }, "dependencies": { - "@endo/eventual-send": "^1.2.2", + "@endo/eventual-send": "^1.2.4", "@endo/lockdown": "^1.0.9", "ses": "^1.7.0" }, diff --git a/yarn.lock b/yarn.lock index b702bf601..3d8a9d57e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -339,13 +339,6 @@ __metadata: languageName: node linkType: hard -"@endo/env-options@npm:^1.1.5": - version: 1.1.5 - resolution: "@endo/env-options@npm:1.1.5" - checksum: 10/ce4cb29ecf387f52f7d1c9e7e43b0a1064326587ebac62e7c239bf2df71aa4c3296d2a05cf169d1efcd8c1ddf73aeede8afd86e7b5c9387b80e8e0939d1af0f6 - languageName: node - linkType: hard - "@endo/env-options@npm:^1.1.6": version: 1.1.6 resolution: "@endo/env-options@npm:1.1.6" @@ -374,16 +367,7 @@ __metadata: languageName: node linkType: hard -"@endo/eventual-send@npm:^1.2.2, @endo/eventual-send@npm:^1.2.3, @endo/eventual-send@npm:^1.2.4": - version: 1.2.4 - resolution: "@endo/eventual-send@npm:1.2.4" - dependencies: - "@endo/env-options": "npm:^1.1.5" - checksum: 10/5b46f7987af609dd52e6c65fd828ca29e36cbf88128e435ccf9cadfb72457c4342d5b6b49a3dc977d2203cefc6c956d6aa0280e086c3bca6f4b1d7228c50810e - languageName: node - linkType: hard - -"@endo/eventual-send@npm:^1.2.5": +"@endo/eventual-send@npm:^1.2.2, @endo/eventual-send@npm:^1.2.3, @endo/eventual-send@npm:^1.2.4, @endo/eventual-send@npm:^1.2.5": version: 1.2.5 resolution: "@endo/eventual-send@npm:1.2.5" dependencies: @@ -499,16 +483,7 @@ __metadata: languageName: node linkType: hard -"@endo/promise-kit@npm:^1.1.2, @endo/promise-kit@npm:^1.1.3, @endo/promise-kit@npm:^1.1.4": - version: 1.1.4 - resolution: "@endo/promise-kit@npm:1.1.4" - dependencies: - ses: "npm:^1.7.0" - checksum: 10/794c38d2105597dfec999fb55e0865edd5ce24c86e42eb58561514b5ee68dedae423a7e79e9d75fa66447a88d7c4755d28f848ee841cc531c0ced9253b121fc6 - languageName: node - linkType: hard - -"@endo/promise-kit@npm:^1.1.5": +"@endo/promise-kit@npm:^1.1.2, @endo/promise-kit@npm:^1.1.3, @endo/promise-kit@npm:^1.1.4, @endo/promise-kit@npm:^1.1.5": version: 1.1.5 resolution: "@endo/promise-kit@npm:1.1.5" dependencies: @@ -1479,7 +1454,7 @@ __metadata: resolution: "@ocap/shims@workspace:packages/shims" dependencies: "@endo/bundle-source": "npm:^3.3.0" - "@endo/eventual-send": "npm:^1.2.2" + "@endo/eventual-send": "npm:^1.2.4" "@endo/lockdown": "npm:^1.0.9" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/eslint-config": "npm:^13.0.0" @@ -6135,16 +6110,7 @@ __metadata: languageName: node linkType: hard -"ses@npm:^1.1.0, ses@npm:^1.5.0, ses@npm:^1.7.0": - version: 1.7.0 - resolution: "ses@npm:1.7.0" - dependencies: - "@endo/env-options": "npm:^1.1.5" - checksum: 10/8d1227fadcd06653d1b49083c067ae07e55164af984c9e8b393238fbbd315f47216472e3ac65a78638955f3f1a2537e9c9865f0ab142639a6862b902cb1cf6f2 - languageName: node - linkType: hard - -"ses@npm:^1.8.0": +"ses@npm:^1.1.0, ses@npm:^1.5.0, ses@npm:^1.7.0, ses@npm:^1.8.0": version: 1.8.0 resolution: "ses@npm:1.8.0" dependencies: From 324c051931aa64a3987e29346914e99a797a8718 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Wed, 11 Sep 2024 04:02:02 -0500 Subject: [PATCH 10/24] types(extension): Strengthen message and envelope types. --- packages/extension/src/background.ts | 10 ++- packages/extension/src/envelope.ts | 47 +++++++++++ packages/extension/src/iframe-manager.test.ts | 17 ++-- packages/extension/src/iframe-manager.ts | 34 +++----- packages/extension/src/iframe.ts | 36 ++++---- packages/extension/src/message.test.ts | 30 +++++++ packages/extension/src/message.ts | 60 +++++++++++++ packages/extension/src/offscreen.ts | 16 ++-- packages/extension/src/shared.test.ts | 27 +----- packages/extension/src/shared.ts | 84 ------------------- 10 files changed, 191 insertions(+), 170 deletions(-) create mode 100644 packages/extension/src/envelope.ts create mode 100644 packages/extension/src/message.test.ts create mode 100644 packages/extension/src/message.ts diff --git a/packages/extension/src/background.ts b/packages/extension/src/background.ts index 42d0826ed..afedf6b6b 100644 --- a/packages/extension/src/background.ts +++ b/packages/extension/src/background.ts @@ -1,6 +1,9 @@ +import type { Json } from '@metamask/utils'; + import './background-trusted-prelude.js'; -import type { ExtensionMessage } from './shared.js'; -import { Command, makeHandledCallback } from './shared.js'; +import type { ExtensionMessage } from './message.js'; +import { Command } from './message.js'; +import { makeHandledCallback } from './shared.js'; // globalThis.kernel will exist due to dev-console.js Object.defineProperties(globalThis.kernel, { @@ -61,7 +64,7 @@ async function provideOffScreenDocument(): Promise { // Handle replies from the offscreen document chrome.runtime.onMessage.addListener( - makeHandledCallback(async (message: ExtensionMessage) => { + makeHandledCallback(async (message: ExtensionMessage) => { if (message.target !== 'background') { console.warn( `Background received message with unexpected target: "${message.target}"`, @@ -78,6 +81,7 @@ chrome.runtime.onMessage.addListener( break; default: console.error( + // @ts-expect-error Exhaustiveness check // eslint-disable-next-line @typescript-eslint/restrict-template-expressions `Background received unexpected message type: "${message.type}"`, ); diff --git a/packages/extension/src/envelope.ts b/packages/extension/src/envelope.ts new file mode 100644 index 000000000..526682861 --- /dev/null +++ b/packages/extension/src/envelope.ts @@ -0,0 +1,47 @@ +import { isObject } from '@metamask/utils'; + +import type { WrappedIframeMessage } from './message.js'; +import { isWrappedIframeMessage } from './message.js'; + +export enum EnvelopeLabel { + Command = 'message', + CapTp = 'capTp', +} + +export type StreamPayloadEnvelope = + | { + label: EnvelopeLabel.Command; + payload: WrappedIframeMessage; + } + | { label: EnvelopeLabel.CapTp; payload: unknown }; + +/* +type MessageHandler = (message: WrappedIframeMessage) => void | Promise; +type CapTpHandler = (capTpMessage: unknown) => void | Promise; +export const makeEnvelopeUnwrapper = + (handleMessage: MessageHandler, handleCapTp: CapTpHandler) => + async (envelope: StreamPayloadEnvelope): Promise => { + switch (envelope.label) { + case EnvelopeLabel.CapTp: + return handleCapTp(envelope.payload); + case EnvelopeLabel.Command: + return handleMessage(envelope.payload); + default: + throw new Error( + `Unexpected message label in message:\n${JSON.stringify( + envelope, + null, + 2, + )}`, + ); + } + }; + */ + +export const isStreamPayloadEnvelope = ( + value: unknown, +): value is StreamPayloadEnvelope => + isObject(value) && + (value.label === EnvelopeLabel.CapTp || + (value.label === EnvelopeLabel.Command && + isWrappedIframeMessage(value.payload))); diff --git a/packages/extension/src/iframe-manager.test.ts b/packages/extension/src/iframe-manager.test.ts index 9a89aca8b..04bd258df 100644 --- a/packages/extension/src/iframe-manager.test.ts +++ b/packages/extension/src/iframe-manager.test.ts @@ -4,7 +4,8 @@ import { delay, makePromiseKitMock } from '@ocap/test-utils'; import { vi, describe, it, expect } from 'vitest'; import { IframeManager } from './iframe-manager.js'; -import { Command } from './shared.js'; +import type { IframeMessage } from './message.js'; +import { Command } from './message.js'; vi.mock('@endo/promise-kit', () => makePromiseKitMock()); @@ -299,10 +300,10 @@ describe('IframeManager', () => { ); // Bootstrap TheGreatFrangooly... - const callCapTpResponse = manager.callCapTp( - id, - 'whatIsTheGreatFrangooly', - ); + const callCapTpResponse = manager.callCapTp(id, { + method: 'whatIsTheGreatFrangooly', + params: [], + }); expectSendMessageToHaveBeenCalledOnceMoreWith( greatFrangoolyBootstrap.query, @@ -341,8 +342,8 @@ describe('IframeManager', () => { const id = 'foo'; await manager.create({ id, getPort: makeGetPort(port1) }); - const message = { type: Command.Evaluate, data: '2+2' }; - const response = { type: Command.Evaluate, data: '4' }; + const message: IframeMessage = { type: Command.Evaluate, data: '2+2' }; + const response: IframeMessage = { type: Command.Evaluate, data: '4' }; // sendMessage wraps the payload in a 'message' envelope const messagePromise = manager.sendMessage(id, message); @@ -382,7 +383,7 @@ describe('IframeManager', () => { it('throws if iframe not found', async () => { const manager = new IframeManager(); const id = 'foo'; - const message = { type: Command.Ping, data: null }; + const message: IframeMessage = { type: Command.Ping, data: null }; await expect(manager.sendMessage(id, message)).rejects.toThrow( `No vat with id "${id}"`, diff --git a/packages/extension/src/iframe-manager.ts b/packages/extension/src/iframe-manager.ts index 214590939..442550b59 100644 --- a/packages/extension/src/iframe-manager.ts +++ b/packages/extension/src/iframe-manager.ts @@ -3,20 +3,17 @@ import { E } from '@endo/eventual-send'; import type { PromiseKit } from '@endo/promise-kit'; import { makePromiseKit } from '@endo/promise-kit'; import { createWindow } from '@metamask/snaps-utils'; -import type { Json } from '@metamask/utils'; import type { StreamPair, Reader } from '@ocap/streams'; import { initializeMessageChannel, makeMessagePortStreamPair, } from '@ocap/streams'; -import { isStreamPayloadEnvelope, Command } from './shared.js'; -import type { - IframeMessage, - StreamPayloadEnvelope, - VatId, - MessageId, -} from './shared.js'; +import type { StreamPayloadEnvelope } from './envelope.js'; +import { EnvelopeLabel, isStreamPayloadEnvelope } from './envelope.js'; +import type { CapTpPayload, IframeMessage, MessageId } from './message.js'; +import { Command } from './message.js'; +import type { VatId } from './shared.js'; const IFRAME_URI = 'iframe.html'; @@ -122,32 +119,25 @@ export class IframeManager { * @param message - The message to send. * @returns A promise that resolves the response to the message. */ - async sendMessage( - id: VatId, - message: IframeMessage, - ): Promise { + async sendMessage(id: VatId, message: IframeMessage): Promise { const vat = this.#expectGetVat(id); const { promise, reject, resolve } = makePromiseKit(); const messageId = this.#nextMessageId(id); this.#unresolvedMessages.set(messageId, { reject, resolve }); await vat.streams.writer.next({ - label: 'message', + label: EnvelopeLabel.Command, payload: { id: messageId, message }, }); return promise; } - async callCapTp( - id: VatId, - method: string, - ...params: Json[] - ): Promise { + async callCapTp(id: VatId, payload: CapTpPayload): Promise { const { capTp } = this.#expectGetVat(id); if (capTp === undefined) { throw new Error(`Vat with id "${id}" does not have a CapTP connection.`); } - return E(capTp.getBootstrap())[method](...params); + return E(capTp.getBootstrap())[payload.method](...payload.params); } async makeCapTp(id: VatId): Promise { @@ -162,7 +152,7 @@ export class IframeManager { // eslint-disable-next-line @typescript-eslint/no-misused-promises const ctp = makeCapTP(id, async (payload: unknown) => { console.log('CapTP to vat', JSON.stringify(payload, null, 2)); - await writer.next({ label: 'capTp', payload }); + await writer.next({ label: EnvelopeLabel.CapTp, payload }); }); vat.capTp = ctp; @@ -185,7 +175,7 @@ export class IframeManager { } switch (rawMessage.label) { - case 'capTp': { + case EnvelopeLabel.CapTp: { console.log( 'CapTP from vat', JSON.stringify(rawMessage.payload, null, 2), @@ -196,7 +186,7 @@ export class IframeManager { } break; } - case 'message': { + case EnvelopeLabel.Command: { const { id, message } = rawMessage.payload; const promiseCallbacks = this.#unresolvedMessages.get(id); if (promiseCallbacks === undefined) { diff --git a/packages/extension/src/iframe.ts b/packages/extension/src/iframe.ts index 3002889e8..18f2eacf2 100644 --- a/packages/extension/src/iframe.ts +++ b/packages/extension/src/iframe.ts @@ -3,8 +3,10 @@ import { makeExo } from '@endo/exo'; import { M } from '@endo/patterns'; import { receiveMessagePort, makeMessagePortStreamPair } from '@ocap/streams'; -import type { StreamPayloadEnvelope, WrappedIframeMessage } from './shared.js'; -import { isStreamPayloadEnvelope, Command } from './shared.js'; +import type { StreamPayloadEnvelope } from './envelope.js'; +import { EnvelopeLabel, isStreamPayloadEnvelope } from './envelope.js'; +import type { IframeMessage, WrappedIframeMessage } from './message.js'; +import { Command } from './message.js'; const defaultCompartment = new Compartment({ URL }); @@ -30,12 +32,12 @@ async function main(): Promise { } switch (rawMessage.label) { - case 'capTp': + case EnvelopeLabel.CapTp: if (capTp !== undefined) { capTp.dispatch(rawMessage.payload); } break; - case 'message': + case EnvelopeLabel.Command: await handleMessage(rawMessage.payload); break; /* v8 ignore next 3: Exhaustiveness check */ @@ -64,12 +66,16 @@ async function main(): Promise { if (typeof message.data !== 'string') { console.error( 'iframe received message with unexpected data type', + // @ts-expect-error Exhaustiveness check stringifyResult(message.data), ); return; } const result = safelyEvaluate(message.data); - await replyToMessage(id, Command.Evaluate, stringifyResult(result)); + await replyToMessage(id, { + type: Command.Evaluate, + data: stringifyResult(result), + }); break; } case Command.CapTpInit: { @@ -80,18 +86,16 @@ async function main(): Promise { ); capTp = makeCapTP( - 'iframe', // TODO - // https://github.com/endojs/endo/issues/2412 - // eslint-disable-next-line @typescript-eslint/no-misused-promises + 'iframe', async (payload: unknown) => - streams.writer.next({ label: 'capTp', payload }), + streams.writer.next({ label: EnvelopeLabel.CapTp, payload }), bootstrap, ); - await replyToMessage(id, Command.CapTpInit); + await replyToMessage(id, { type: Command.CapTpInit, data: null }); break; } case Command.Ping: - await replyToMessage(id, Command.Ping, 'pong'); + await replyToMessage(id, { type: Command.Ping, data: 'pong' }); break; default: console.error( @@ -105,17 +109,15 @@ async function main(): Promise { * Reply to a message from the parent window. * * @param id - The id of the message to reply to. - * @param messageType - The message type. - * @param data - The message data. + * @param message - The message to reply with. */ async function replyToMessage( id: string, - messageType: Command, - data: string | null = null, + message: IframeMessage, ): Promise { await streams.writer.next({ - label: 'message', - payload: { id, message: { type: messageType, data } }, + label: EnvelopeLabel.Command, + payload: { id, message }, }); } diff --git a/packages/extension/src/message.test.ts b/packages/extension/src/message.test.ts new file mode 100644 index 000000000..e9bb807c8 --- /dev/null +++ b/packages/extension/src/message.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from 'vitest'; + +import { isWrappedIframeMessage } from './message.js'; + +describe('message', () => { + describe('isWrappedIframeMessage', () => { + it('returns true for valid messages', () => { + expect( + isWrappedIframeMessage({ + id: '1', + message: { type: 'evaluate', data: '1 + 1' }, + }), + ).toBe(true); + }); + + it('returns false for invalid messages', () => { + const invalidMessages = [ + {}, + { id: '1' }, + { message: { type: 'evaluate' } }, + { id: '1', message: { type: 'evaluate' } }, + { id: '1', message: { type: 'evaluate', data: 1 } }, + ]; + + invalidMessages.forEach((message) => { + expect(isWrappedIframeMessage(message)).toBe(false); + }); + }); + }); +}); diff --git a/packages/extension/src/message.ts b/packages/extension/src/message.ts new file mode 100644 index 000000000..4269455b8 --- /dev/null +++ b/packages/extension/src/message.ts @@ -0,0 +1,60 @@ +import type { Primitive } from '@endo/captp'; +import { isObject } from '@metamask/utils'; + +export type MessageId = string; + +type DataObject = + | Primitive + | Promise + | DataObject[] + | { [key: string]: DataObject }; + +export enum ExtensionTarget { + Background = 'background', + Offscreen = 'offscreen', +} + +type CommandForm< + CommandType extends Command, + Data extends DataObject, + TargetType extends ExtensionTarget, +> = { + type: CommandType; + target?: TargetType; + data: Data; +}; + +export enum Command { + CapTpCall = 'callCapTp', + CapTpInit = 'makeCapTp', + Evaluate = 'evaluate', + Ping = 'ping', +} + +export type CapTpPayload = { + method: string; + params: DataObject[]; +}; + +type CommandMessage = + | CommandForm + | CommandForm + | CommandForm + | CommandForm; + +export type ExtensionMessage = CommandMessage; +export type IframeMessage = CommandMessage; + +export type WrappedIframeMessage = { + id: MessageId; + message: IframeMessage; +}; + +export const isWrappedIframeMessage = ( + value: unknown, +): value is WrappedIframeMessage => + isObject(value) && + typeof value.id === 'string' && + isObject(value.message) && + typeof value.message.type === 'string' && + (typeof value.message.data === 'string' || value.message.data === null); diff --git a/packages/extension/src/offscreen.ts b/packages/extension/src/offscreen.ts index 9e98f25ae..4871f385f 100644 --- a/packages/extension/src/offscreen.ts +++ b/packages/extension/src/offscreen.ts @@ -1,6 +1,7 @@ import { IframeManager } from './iframe-manager.js'; -import type { ExtensionMessage } from './shared.js'; -import { Command, makeHandledCallback } from './shared.js'; +import type { ExtensionMessage } from './message.js'; +import { Command } from './message.js'; +import { makeHandledCallback } from './shared.js'; main().catch(console.error); @@ -17,7 +18,7 @@ async function main(): Promise { // Handle messages from the background service worker chrome.runtime.onMessage.addListener( - makeHandledCallback(async (message: ExtensionMessage) => { + makeHandledCallback(async (message: ExtensionMessage) => { if (message.target !== 'offscreen') { console.warn( `Offscreen received message with unexpected target: "${message.target}"`, @@ -32,13 +33,7 @@ async function main(): Promise { await reply(Command.Evaluate, await evaluate(message.data)); break; case Command.CapTpCall: { - const result = await iframeManager.callCapTp( - IFRAME_ID, - // @ts-expect-error TODO: Type assertions - message.data.method, - // @ts-expect-error TODO: Type assertions - ...message.data.params, - ); + const result = await iframeManager.callCapTp(IFRAME_ID, message.data); await reply(Command.CapTpCall, JSON.stringify(result, null, 2)); break; } @@ -51,6 +46,7 @@ async function main(): Promise { break; default: console.error( + // @ts-expect-error Exhaustiveness check // eslint-disable-next-line @typescript-eslint/restrict-template-expressions `Offscreen received unexpected message type: "${message.type}"`, ); diff --git a/packages/extension/src/shared.test.ts b/packages/extension/src/shared.test.ts index d10870d46..c409eaec8 100644 --- a/packages/extension/src/shared.test.ts +++ b/packages/extension/src/shared.test.ts @@ -2,34 +2,9 @@ import './endoify.js'; import { delay } from '@ocap/test-utils'; import { vi, describe, it, expect } from 'vitest'; -import { isWrappedIframeMessage, makeHandledCallback } from './shared.js'; +import { makeHandledCallback } from './shared.js'; describe('shared', () => { - describe('isWrappedIframeMessage', () => { - it('returns true for valid messages', () => { - expect( - isWrappedIframeMessage({ - id: '1', - message: { type: 'evaluate', data: '1 + 1' }, - }), - ).toBe(true); - }); - - it('returns false for invalid messages', () => { - const invalidMessages = [ - {}, - { id: '1' }, - { message: { type: 'evaluate' } }, - { id: '1', message: { type: 'evaluate' } }, - { id: '1', message: { type: 'evaluate', data: 1 } }, - ]; - - invalidMessages.forEach((message) => { - expect(isWrappedIframeMessage(message)).toBe(false); - }); - }); - }); - describe('makeHandledCallback', () => { it('returns a function', () => { const callback = makeHandledCallback(async () => Promise.resolve()); diff --git a/packages/extension/src/shared.ts b/packages/extension/src/shared.ts index 2c8051b0c..2bc7a4e10 100644 --- a/packages/extension/src/shared.ts +++ b/packages/extension/src/shared.ts @@ -1,88 +1,4 @@ -import { isObject } from '@metamask/utils'; - export type VatId = string; -export type MessageId = string; - -export enum Command { - CapTpCall = 'callCapTp', - CapTpInit = 'makeCapTp', - Evaluate = 'evaluate', - Ping = 'ping', -} - -export type ExtensionMessage< - Type extends Command, - Data extends null | string | unknown[] | Record, -> = { - type: Type; - target: 'background' | 'offscreen'; - data: Data; -}; - -export type IframeMessage< - Type extends Command, - Data extends null | string | unknown[] | Record, -> = { - type: Type; - data: Data; -}; - -export type WrappedIframeMessage = { - id: MessageId; - message: IframeMessage; -}; - -export const isWrappedIframeMessage = ( - value: unknown, -): value is WrappedIframeMessage => - isObject(value) && - typeof value.id === 'string' && - isObject(value.message) && - typeof value.message.type === 'string' && - (typeof value.message.data === 'string' || value.message.data === null); - -export type StreamPayloadEnvelope = - | { - label: 'message'; - payload: WrappedIframeMessage; - } - | { label: 'capTp'; payload: unknown }; - -type MessageHandler = (message: WrappedIframeMessage) => void | Promise; -type CapTpHandler = (capTpMessage: unknown) => void | Promise; -export const makeEnvelopeUnwrapper = - (handleMessage: MessageHandler, handleCapTp: CapTpHandler) => - async (envelope: StreamPayloadEnvelope): Promise => { - switch (envelope.label) { - case 'capTp': - return handleCapTp(envelope.payload); - case 'message': - return handleMessage(envelope.payload); - default: - throw new Error( - `Unexpected message label in message:\n${JSON.stringify( - envelope, - null, - 2, - )}`, - ); - } - }; - -export const isStreamPayloadEnvelope = ( - value: unknown, -): value is StreamPayloadEnvelope => { - if (!isObject(value)) { - return false; - } - if ( - value.label !== 'capTp' && - (value.label !== 'message' || !isWrappedIframeMessage(value.payload)) - ) { - return false; - } - return true; -}; /** * Wrap an async callback to ensure any errors are at least logged. From 9496c1bfc06f106d4798fb1d6fe08c2b46a3602f Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Wed, 11 Sep 2024 04:37:05 -0500 Subject: [PATCH 11/24] test(extension): Test cover capTp usage errors. --- packages/extension/src/iframe-manager.test.ts | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/packages/extension/src/iframe-manager.test.ts b/packages/extension/src/iframe-manager.test.ts index 04bd258df..114eb809f 100644 --- a/packages/extension/src/iframe-manager.test.ts +++ b/packages/extension/src/iframe-manager.test.ts @@ -179,6 +179,98 @@ describe('IframeManager', () => { }); describe('capTp', () => { + it('throws if called before initialization', async () => { + const mockWindow = {}; + vi.mocked(snapsUtils.createWindow).mockResolvedValueOnce( + mockWindow as Window, + ); + const manager = new IframeManager(); + vi.spyOn(manager, 'sendMessage').mockImplementation(vi.fn()); + const [, id] = await manager.create({ getPort: makeGetPort() }); + + await expect( + async () => + await manager.callCapTp(id, { + method: 'whatIsTheGreatFrangooly', + params: [], + }), + ).rejects.toThrow(/does not have a CapTP connection\.$/u); + }); + + it('throws if initialization is called twice on the same vat', async () => { + const id = 'frangooly'; + + const capTpInit = { + query: { + label: 'message', + payload: { + id: `${id}-1`, + message: { + data: null, + type: 'makeCapTp', + }, + }, + }, + response: { + label: 'message', + payload: { + id: `${id}-1`, + message: { + type: 'makeCapTp', + data: null, + }, + }, + }, + }; + + vi.mocked(snapsUtils.createWindow).mockImplementationOnce(vi.fn()); + + const { port1, port2 } = new MessageChannel(); + const port1PostMessageSpy = vi + .spyOn(port1, 'postMessage') + .mockImplementation(vi.fn()); + + let port1PostMessageCallCounter: number = 0; + const expectSendMessageToHaveBeenCalledOnceMoreWith = ( + expectation: unknown, + ): void => { + port1PostMessageCallCounter += 1; + expect(port1PostMessageSpy).toHaveBeenCalledTimes( + port1PostMessageCallCounter, + ); + expect(port1PostMessageSpy).toHaveBeenLastCalledWith({ + done: false, + value: expectation, + }); + }; + + const mockReplyWith = (message: unknown): void => + port2.postMessage({ + done: false, + value: message, + }); + + const manager = new IframeManager(); + + vi.spyOn(manager, 'sendMessage').mockImplementationOnce(vi.fn()); + + await manager.create({ id, getPort: makeGetPort(port1) }); + + // Init CapTP connection + const initCapTpPromise = manager.makeCapTp(id); + + expectSendMessageToHaveBeenCalledOnceMoreWith(capTpInit.query); + mockReplyWith(capTpInit.response); + + await initCapTpPromise.then((resolvedValue) => + console.debug(`CapTp initialized: ${JSON.stringify(resolvedValue)}`), + ); + + await expect(async () => await manager.makeCapTp(id)).rejects.toThrow( + /already has a CapTP connection\./u, + ); + }); + it('does TheGreatFrangooly', async () => { const id = 'frangooly'; From 0006d3a55e7201ee5631b73ca0d7e6c9a4548cbe Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Wed, 11 Sep 2024 05:19:58 -0500 Subject: [PATCH 12/24] test(extension): Add dev-console tests. --- packages/extension/src/dev-console.test.ts | 29 ++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 packages/extension/src/dev-console.test.ts diff --git a/packages/extension/src/dev-console.test.ts b/packages/extension/src/dev-console.test.ts new file mode 100644 index 000000000..75c77a42f --- /dev/null +++ b/packages/extension/src/dev-console.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from 'vitest'; +import './dev-console.js'; +import '@ocap/shims/endoify'; + +describe('vat-console', () => { + describe('kernel', () => { + it('is available on globalThis', async () => { + expect(kernel).toBeDefined(); + }); + + it('is writable', async () => { + Object.defineProperty(globalThis.kernel, 'namingThings', { + value: 'is hard', + }); + expect(kernel).toHaveProperty('namingThings', 'is hard'); + }); + + it('is not rewritable', async () => { + Object.defineProperty(globalThis.kernel, 'namingThings', { + value: 'is hard', + }); + expect(() => + Object.defineProperty(globalThis.kernel, 'namingThings', { + value: 'and final', + }), + ).toThrow(/Cannot redefine property:/u); + }); + }); +}); From ccf3c1e11481ce22539cb9da7adacce8dd7e96b1 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Wed, 11 Sep 2024 05:27:45 -0500 Subject: [PATCH 13/24] refactor(extension): Avert a name collision with design doc. --- packages/extension/src/envelope.ts | 14 ++++------ packages/extension/src/iframe-manager.test.ts | 28 +++++++++---------- packages/extension/src/iframe-manager.ts | 26 ++++++++--------- packages/extension/src/iframe.ts | 18 ++++++------ 4 files changed, 42 insertions(+), 44 deletions(-) diff --git a/packages/extension/src/envelope.ts b/packages/extension/src/envelope.ts index 526682861..c525d2a3a 100644 --- a/packages/extension/src/envelope.ts +++ b/packages/extension/src/envelope.ts @@ -8,19 +8,19 @@ export enum EnvelopeLabel { CapTp = 'capTp', } -export type StreamPayloadEnvelope = +export type StreamEnvelope = | { label: EnvelopeLabel.Command; - payload: WrappedIframeMessage; + content: WrappedIframeMessage; } - | { label: EnvelopeLabel.CapTp; payload: unknown }; + | { label: EnvelopeLabel.CapTp; content: unknown }; /* type MessageHandler = (message: WrappedIframeMessage) => void | Promise; type CapTpHandler = (capTpMessage: unknown) => void | Promise; export const makeEnvelopeUnwrapper = (handleMessage: MessageHandler, handleCapTp: CapTpHandler) => - async (envelope: StreamPayloadEnvelope): Promise => { + async (envelope: StreamEnvelope): Promise => { switch (envelope.label) { case EnvelopeLabel.CapTp: return handleCapTp(envelope.payload); @@ -38,10 +38,8 @@ export const makeEnvelopeUnwrapper = }; */ -export const isStreamPayloadEnvelope = ( - value: unknown, -): value is StreamPayloadEnvelope => +export const isStreamEnvelope = (value: unknown): value is StreamEnvelope => isObject(value) && (value.label === EnvelopeLabel.CapTp || (value.label === EnvelopeLabel.Command && - isWrappedIframeMessage(value.payload))); + isWrappedIframeMessage(value.content))); diff --git a/packages/extension/src/iframe-manager.test.ts b/packages/extension/src/iframe-manager.test.ts index 114eb809f..107668716 100644 --- a/packages/extension/src/iframe-manager.test.ts +++ b/packages/extension/src/iframe-manager.test.ts @@ -157,7 +157,7 @@ describe('IframeManager', () => { done: false, value: { label: 'message', - payload: { + content: { id: `foo-${i + 1}`, message: { type: Command.Evaluate, @@ -203,7 +203,7 @@ describe('IframeManager', () => { const capTpInit = { query: { label: 'message', - payload: { + content: { id: `${id}-1`, message: { data: null, @@ -213,7 +213,7 @@ describe('IframeManager', () => { }, response: { label: 'message', - payload: { + content: { id: `${id}-1`, message: { type: 'makeCapTp', @@ -277,7 +277,7 @@ describe('IframeManager', () => { const capTpInit = { query: { label: 'message', - payload: { + content: { id: `${id}-1`, message: { data: null, @@ -287,7 +287,7 @@ describe('IframeManager', () => { }, response: { label: 'message', - payload: { + content: { id: `${id}-1`, message: { type: 'makeCapTp', @@ -300,7 +300,7 @@ describe('IframeManager', () => { const greatFrangoolyBootstrap = { query: { label: 'capTp', - payload: { + content: { epoch: 0, questionID: 'q-1', type: 'CTP_BOOTSTRAP', @@ -308,7 +308,7 @@ describe('IframeManager', () => { }, response: { label: 'capTp', - payload: { + content: { type: 'CTP_RETURN', epoch: 0, answerID: 'q-1', @@ -323,7 +323,7 @@ describe('IframeManager', () => { const greatFrangoolyCall = { query: { label: 'capTp', - payload: { + content: { type: 'CTP_CALL', epoch: 0, method: { @@ -336,7 +336,7 @@ describe('IframeManager', () => { }, response: { label: 'capTp', - payload: { + content: { type: 'CTP_RETURN', epoch: 0, answerID: 'q-2', @@ -437,10 +437,10 @@ describe('IframeManager', () => { const message: IframeMessage = { type: Command.Evaluate, data: '2+2' }; const response: IframeMessage = { type: Command.Evaluate, data: '4' }; - // sendMessage wraps the payload in a 'message' envelope + // sendMessage wraps the content in a 'message' envelope const messagePromise = manager.sendMessage(id, message); const messageId: string | undefined = - portPostMessageSpy.mock.lastCall?.[0]?.value?.payload?.id; + portPostMessageSpy.mock.lastCall?.[0]?.value?.content?.id; expect(messageId).toBeTypeOf('string'); // postMessage sends the json directly, so we have to wrap it in an envelope here @@ -448,7 +448,7 @@ describe('IframeManager', () => { done: false, value: { label: 'message', - payload: { + content: { id: messageId, message: response, }, @@ -464,7 +464,7 @@ describe('IframeManager', () => { done: false, value: { label: 'message', - payload: { + content: { id: messageId, message, }, @@ -521,7 +521,7 @@ describe('IframeManager', () => { done: false, value: { label: 'message', - payload: { + content: { id: 'foo', message: { type: Command.Evaluate, diff --git a/packages/extension/src/iframe-manager.ts b/packages/extension/src/iframe-manager.ts index 442550b59..27c19e053 100644 --- a/packages/extension/src/iframe-manager.ts +++ b/packages/extension/src/iframe-manager.ts @@ -9,8 +9,8 @@ import { makeMessagePortStreamPair, } from '@ocap/streams'; -import type { StreamPayloadEnvelope } from './envelope.js'; -import { EnvelopeLabel, isStreamPayloadEnvelope } from './envelope.js'; +import type { StreamEnvelope } from './envelope.js'; +import { EnvelopeLabel, isStreamEnvelope } from './envelope.js'; import type { CapTpPayload, IframeMessage, MessageId } from './message.js'; import { Command } from './message.js'; import type { VatId } from './shared.js'; @@ -30,7 +30,7 @@ type PromiseCallbacks = Omit, 'promise'>; type GetPort = (targetWindow: Window) => Promise; type VatRecord = { - streams: StreamPair; + streams: StreamPair; capTp?: ReturnType; }; @@ -69,7 +69,7 @@ export class IframeManager { const newWindow = await createWindow(IFRAME_URI, getHtmlId(id)); const port = await getPort(newWindow); - const streams = makeMessagePortStreamPair(port); + const streams = makeMessagePortStreamPair(port); this.#vats.set(id, { streams }); /* v8 ignore next 4: Not known to be possible. */ this.#receiveMessages(id, streams.reader).catch((error) => { @@ -127,7 +127,7 @@ export class IframeManager { this.#unresolvedMessages.set(messageId, { reject, resolve }); await vat.streams.writer.next({ label: EnvelopeLabel.Command, - payload: { id: messageId, message }, + content: { id: messageId, message }, }); return promise; } @@ -150,9 +150,9 @@ export class IframeManager { const { writer } = vat.streams; // https://github.com/endojs/endo/issues/2412 // eslint-disable-next-line @typescript-eslint/no-misused-promises - const ctp = makeCapTP(id, async (payload: unknown) => { - console.log('CapTP to vat', JSON.stringify(payload, null, 2)); - await writer.next({ label: EnvelopeLabel.CapTp, payload }); + const ctp = makeCapTP(id, async (content: unknown) => { + console.log('CapTP to vat', JSON.stringify(content, null, 2)); + await writer.next({ label: EnvelopeLabel.CapTp, content }); }); vat.capTp = ctp; @@ -161,12 +161,12 @@ export class IframeManager { async #receiveMessages( vatId: VatId, - reader: Reader, + reader: Reader, ): Promise { for await (const rawMessage of reader) { console.debug('Offscreen received message', rawMessage); - if (!isStreamPayloadEnvelope(rawMessage)) { + if (!isStreamEnvelope(rawMessage)) { console.warn( 'Offscreen received message with unexpected format', rawMessage, @@ -178,16 +178,16 @@ export class IframeManager { case EnvelopeLabel.CapTp: { console.log( 'CapTP from vat', - JSON.stringify(rawMessage.payload, null, 2), + JSON.stringify(rawMessage.content, null, 2), ); const { capTp } = this.#expectGetVat(vatId); if (capTp !== undefined) { - capTp.dispatch(rawMessage.payload); + capTp.dispatch(rawMessage.content); } break; } case EnvelopeLabel.Command: { - const { id, message } = rawMessage.payload; + const { id, message } = rawMessage.content; const promiseCallbacks = this.#unresolvedMessages.get(id); if (promiseCallbacks === undefined) { console.error(`No unresolved message with id "${id}".`); diff --git a/packages/extension/src/iframe.ts b/packages/extension/src/iframe.ts index 18f2eacf2..15be68619 100644 --- a/packages/extension/src/iframe.ts +++ b/packages/extension/src/iframe.ts @@ -3,8 +3,8 @@ import { makeExo } from '@endo/exo'; import { M } from '@endo/patterns'; import { receiveMessagePort, makeMessagePortStreamPair } from '@ocap/streams'; -import type { StreamPayloadEnvelope } from './envelope.js'; -import { EnvelopeLabel, isStreamPayloadEnvelope } from './envelope.js'; +import type { StreamEnvelope } from './envelope.js'; +import { EnvelopeLabel, isStreamEnvelope } from './envelope.js'; import type { IframeMessage, WrappedIframeMessage } from './message.js'; import { Command } from './message.js'; @@ -17,13 +17,13 @@ main().catch(console.error); */ async function main(): Promise { const port = await receiveMessagePort(); - const streams = makeMessagePortStreamPair(port); + const streams = makeMessagePortStreamPair(port); let capTp: ReturnType | undefined; for await (const rawMessage of streams.reader) { console.debug('iframe received message', rawMessage); - if (!isStreamPayloadEnvelope(rawMessage)) { + if (!isStreamEnvelope(rawMessage)) { console.error( 'iframe received message with unexpected format', rawMessage, @@ -34,11 +34,11 @@ async function main(): Promise { switch (rawMessage.label) { case EnvelopeLabel.CapTp: if (capTp !== undefined) { - capTp.dispatch(rawMessage.payload); + capTp.dispatch(rawMessage.content); } break; case EnvelopeLabel.Command: - await handleMessage(rawMessage.payload); + await handleMessage(rawMessage.content); break; /* v8 ignore next 3: Exhaustiveness check */ default: @@ -87,8 +87,8 @@ async function main(): Promise { capTp = makeCapTP( 'iframe', - async (payload: unknown) => - streams.writer.next({ label: EnvelopeLabel.CapTp, payload }), + async (content: unknown) => + streams.writer.next({ label: EnvelopeLabel.CapTp, content }), bootstrap, ); await replyToMessage(id, { type: Command.CapTpInit, data: null }); @@ -117,7 +117,7 @@ async function main(): Promise { ): Promise { await streams.writer.next({ label: EnvelopeLabel.Command, - payload: { id, message }, + content: { id, message }, }); } From 19f16cbf03272af98a6f918de093a73e092dc4a4 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Wed, 11 Sep 2024 05:45:09 -0500 Subject: [PATCH 14/24] feat(extension): Give vats individual message counters. --- packages/extension/src/iframe-manager.ts | 17 ++++++++--------- packages/extension/src/shared.test.ts | 23 ++++++++++++++++++++++- packages/extension/src/shared.ts | 14 ++++++++++++++ 3 files changed, 44 insertions(+), 10 deletions(-) diff --git a/packages/extension/src/iframe-manager.ts b/packages/extension/src/iframe-manager.ts index 27c19e053..0ee6f0b59 100644 --- a/packages/extension/src/iframe-manager.ts +++ b/packages/extension/src/iframe-manager.ts @@ -13,7 +13,7 @@ import type { StreamEnvelope } from './envelope.js'; import { EnvelopeLabel, isStreamEnvelope } from './envelope.js'; import type { CapTpPayload, IframeMessage, MessageId } from './message.js'; import { Command } from './message.js'; -import type { VatId } from './shared.js'; +import { makeCounter, type VatId } from './shared.js'; const IFRAME_URI = 'iframe.html'; @@ -31,6 +31,7 @@ type GetPort = (targetWindow: Window) => Promise; type VatRecord = { streams: StreamPair; + messageCounter: () => number; capTp?: ReturnType; }; @@ -38,19 +39,19 @@ type VatRecord = { * A singleton class to manage and message iframes. */ export class IframeManager { - #currentId: number; - readonly #unresolvedMessages: Map; readonly #vats: Map; + readonly #vatIdCounter: () => number; + /** * Create a new IframeManager. */ constructor() { - this.#currentId = 0; this.#vats = new Map(); this.#unresolvedMessages = new Map(); + this.#vatIdCounter = makeCounter(); } /** @@ -70,7 +71,7 @@ export class IframeManager { const newWindow = await createWindow(IFRAME_URI, getHtmlId(id)); const port = await getPort(newWindow); const streams = makeMessagePortStreamPair(port); - this.#vats.set(id, { streams }); + this.#vats.set(id, { streams, messageCounter: makeCounter() }); /* v8 ignore next 4: Not known to be possible. */ this.#receiveMessages(id, streams.reader).catch((error) => { console.error(`Unexpected read error from vat "${id}"`, error); @@ -233,12 +234,10 @@ export class IframeManager { } readonly #nextMessageId = (id: VatId): MessageId => { - this.#currentId += 1; - return `${id}-${this.#currentId}`; + return `${id}-${this.#expectGetVat(id).messageCounter()}`; }; readonly #nextVatId = (): MessageId => { - this.#currentId += 1; - return `${this.#currentId}`; + return `${this.#vatIdCounter()}`; }; } diff --git a/packages/extension/src/shared.test.ts b/packages/extension/src/shared.test.ts index c409eaec8..54c4ff8aa 100644 --- a/packages/extension/src/shared.test.ts +++ b/packages/extension/src/shared.test.ts @@ -2,7 +2,7 @@ import './endoify.js'; import { delay } from '@ocap/test-utils'; import { vi, describe, it, expect } from 'vitest'; -import { makeHandledCallback } from './shared.js'; +import { makeCounter, makeHandledCallback } from './shared.js'; describe('shared', () => { describe('makeHandledCallback', () => { @@ -37,4 +37,25 @@ describe('shared', () => { ); }); }); + + describe('makeCounter', () => { + it('starts at 1 by default', () => { + const counter = makeCounter(); + expect(counter()).toBe(1); + }); + + it('starts counting from the supplied argument', () => { + const start = 50; + const counter = makeCounter(start); + expect(counter()).toStrictEqual(start + 1); + }); + + it('increments convincingly', () => { + const counter = makeCounter(); + const first = counter(); + expect(counter()).toStrictEqual(first + 1); + expect(counter()).toStrictEqual(first + 2); + expect(counter()).toStrictEqual(first + 3); + }); + }); }); diff --git a/packages/extension/src/shared.ts b/packages/extension/src/shared.ts index 2bc7a4e10..c41aa7abc 100644 --- a/packages/extension/src/shared.ts +++ b/packages/extension/src/shared.ts @@ -14,3 +14,17 @@ export const makeHandledCallback = ( callback(...args).catch(console.error); }; }; + +/** + * A simple counter which increments and returns when called. + * + * @param start - One less than the first returned number. + * @returns A counter. + */ +export const makeCounter = (start: number = 0) => { + let counter: number = start; + return () => { + counter += 1; + return counter; + }; +}; From c32f62b50e2ee411404d7e64878ba76eaa7ccf3c Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Wed, 11 Sep 2024 06:28:42 -0500 Subject: [PATCH 15/24] refactor(extension): Use ExtensionTarget enum. --- packages/extension/src/background.ts | 6 +++--- packages/extension/src/offscreen.ts | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/extension/src/background.ts b/packages/extension/src/background.ts index afedf6b6b..2362405e7 100644 --- a/packages/extension/src/background.ts +++ b/packages/extension/src/background.ts @@ -2,7 +2,7 @@ import type { Json } from '@metamask/utils'; import './background-trusted-prelude.js'; import type { ExtensionMessage } from './message.js'; -import { Command } from './message.js'; +import { Command, ExtensionTarget } from './message.js'; import { makeHandledCallback } from './shared.js'; // globalThis.kernel will exist due to dev-console.js @@ -44,7 +44,7 @@ async function sendMessage(type: string, data?: Json): Promise { await chrome.runtime.sendMessage({ type, - target: 'offscreen', + target: ExtensionTarget.Offscreen, data: data ?? null, }); } @@ -65,7 +65,7 @@ async function provideOffScreenDocument(): Promise { // Handle replies from the offscreen document chrome.runtime.onMessage.addListener( makeHandledCallback(async (message: ExtensionMessage) => { - if (message.target !== 'background') { + if (message.target !== ExtensionTarget.Background) { console.warn( `Background received message with unexpected target: "${message.target}"`, ); diff --git a/packages/extension/src/offscreen.ts b/packages/extension/src/offscreen.ts index 4871f385f..7d79aa531 100644 --- a/packages/extension/src/offscreen.ts +++ b/packages/extension/src/offscreen.ts @@ -1,6 +1,6 @@ import { IframeManager } from './iframe-manager.js'; import type { ExtensionMessage } from './message.js'; -import { Command } from './message.js'; +import { Command, ExtensionTarget } from './message.js'; import { makeHandledCallback } from './shared.js'; main().catch(console.error); @@ -19,7 +19,7 @@ async function main(): Promise { // Handle messages from the background service worker chrome.runtime.onMessage.addListener( makeHandledCallback(async (message: ExtensionMessage) => { - if (message.target !== 'offscreen') { + if (message.target !== ExtensionTarget.Offscreen) { console.warn( `Offscreen received message with unexpected target: "${message.target}"`, ); @@ -63,7 +63,7 @@ async function main(): Promise { async function reply(type: Command, data?: string): Promise { await chrome.runtime.sendMessage({ data: data ?? null, - target: 'background', + target: ExtensionTarget.Background, type, }); } From f566a66378010a9e225fa72ecebb56f11ac3a722 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Wed, 11 Sep 2024 06:44:49 -0500 Subject: [PATCH 16/24] docs(extension): Flatten a transitive explanation. --- packages/extension/src/background.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/extension/src/background.ts b/packages/extension/src/background.ts index 2362405e7..5632a78bb 100644 --- a/packages/extension/src/background.ts +++ b/packages/extension/src/background.ts @@ -5,7 +5,7 @@ import type { ExtensionMessage } from './message.js'; import { Command, ExtensionTarget } from './message.js'; import { makeHandledCallback } from './shared.js'; -// globalThis.kernel will exist due to dev-console.js +// globalThis.kernel will exist due to dev-console.js in background-trusted-prelude.js Object.defineProperties(globalThis.kernel, { capTpCall: { value: async (method: string, params: Json[]) => From 5b595ae3e630265e2ea5cc551c982273559df458 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Wed, 11 Sep 2024 12:23:21 -0500 Subject: [PATCH 17/24] chore(packages): Remove unused @endo/lockdown dependency. --- packages/extension/package.json | 1 - yarn.lock | 1 - 2 files changed, 2 deletions(-) diff --git a/packages/extension/package.json b/packages/extension/package.json index d2307e9d2..1a3559723 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -34,7 +34,6 @@ "@endo/captp": "^4.2.2", "@endo/eventual-send": "^1.2.4", "@endo/exo": "^1.5.2", - "@endo/lockdown": "^1.0.9", "@endo/patterns": "^1.4.2", "@endo/promise-kit": "^1.1.4", "@metamask/snaps-utils": "^7.8.0", diff --git a/yarn.lock b/yarn.lock index 3d8a9d57e..10c624eb7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1371,7 +1371,6 @@ __metadata: "@endo/captp": "npm:^4.2.2" "@endo/eventual-send": "npm:^1.2.4" "@endo/exo": "npm:^1.5.2" - "@endo/lockdown": "npm:^1.0.9" "@endo/patterns": "npm:^1.4.2" "@endo/promise-kit": "npm:^1.1.4" "@metamask/auto-changelog": "npm:^3.4.4" From bf7be4b8f3ad57d42862694c298bc4baab6ad974 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Wed, 11 Sep 2024 12:30:54 -0500 Subject: [PATCH 18/24] chore: Remove unused code. --- packages/extension/src/background.ts | 11 ----------- packages/extension/src/envelope.ts | 23 ----------------------- 2 files changed, 34 deletions(-) diff --git a/packages/extension/src/background.ts b/packages/extension/src/background.ts index 5632a78bb..db83c15a6 100644 --- a/packages/extension/src/background.ts +++ b/packages/extension/src/background.ts @@ -88,14 +88,3 @@ chrome.runtime.onMessage.addListener( } }), ); - -// TODO: Add method to close offscreen document? -// /** -// * Close the offscreen document if it exists. -// */ -// async function closeOffscreenDocument(): Promise { -// if (!(await chrome.offscreen.hasDocument())) { -// return; -// } -// await chrome.offscreen.closeDocument(); -// } diff --git a/packages/extension/src/envelope.ts b/packages/extension/src/envelope.ts index c525d2a3a..a5f13d368 100644 --- a/packages/extension/src/envelope.ts +++ b/packages/extension/src/envelope.ts @@ -15,29 +15,6 @@ export type StreamEnvelope = } | { label: EnvelopeLabel.CapTp; content: unknown }; -/* -type MessageHandler = (message: WrappedIframeMessage) => void | Promise; -type CapTpHandler = (capTpMessage: unknown) => void | Promise; -export const makeEnvelopeUnwrapper = - (handleMessage: MessageHandler, handleCapTp: CapTpHandler) => - async (envelope: StreamEnvelope): Promise => { - switch (envelope.label) { - case EnvelopeLabel.CapTp: - return handleCapTp(envelope.payload); - case EnvelopeLabel.Command: - return handleMessage(envelope.payload); - default: - throw new Error( - `Unexpected message label in message:\n${JSON.stringify( - envelope, - null, - 2, - )}`, - ); - } - }; - */ - export const isStreamEnvelope = (value: unknown): value is StreamEnvelope => isObject(value) && (value.label === EnvelopeLabel.CapTp || From 7dd0cbc2c1a5181b7622f7792a53e5b28cee7d1b Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Thu, 12 Sep 2024 13:06:37 -0500 Subject: [PATCH 19/24] fix(extension): Organize dev-console priority in trusted-prelude. --- .../src/background-trusted-prelude.js | 2 +- packages/extension/src/background.ts | 1 + packages/extension/src/dev-console.test.ts | 24 +++++++------------ 3 files changed, 10 insertions(+), 17 deletions(-) diff --git a/packages/extension/src/background-trusted-prelude.js b/packages/extension/src/background-trusted-prelude.js index 19405f97b..fd661e49f 100644 --- a/packages/extension/src/background-trusted-prelude.js +++ b/packages/extension/src/background-trusted-prelude.js @@ -1,2 +1,2 @@ -import './dev-console.js'; import './endoify.js'; +import './dev-console.js'; diff --git a/packages/extension/src/background.ts b/packages/extension/src/background.ts index db83c15a6..c6c691ea8 100644 --- a/packages/extension/src/background.ts +++ b/packages/extension/src/background.ts @@ -24,6 +24,7 @@ Object.defineProperties(globalThis.kernel, { value: sendMessage, }, }); +harden(globalThis.kernel); const OFFSCREEN_DOCUMENT_PATH = '/offscreen.html'; diff --git a/packages/extension/src/dev-console.test.ts b/packages/extension/src/dev-console.test.ts index 75c77a42f..c3c6dfb76 100644 --- a/packages/extension/src/dev-console.test.ts +++ b/packages/extension/src/dev-console.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; -import './dev-console.js'; import '@ocap/shims/endoify'; +import './dev-console.js'; describe('vat-console', () => { describe('kernel', () => { @@ -8,22 +8,14 @@ describe('vat-console', () => { expect(kernel).toBeDefined(); }); - it('is writable', async () => { - Object.defineProperty(globalThis.kernel, 'namingThings', { - value: 'is hard', - }); - expect(kernel).toHaveProperty('namingThings', 'is hard'); - }); - - it('is not rewritable', async () => { - Object.defineProperty(globalThis.kernel, 'namingThings', { - value: 'is hard', + it('has expected property descriptors', async () => { + expect( + Object.getOwnPropertyDescriptor(globalThis, 'kernel'), + ).toMatchObject({ + configurable: false, + enumerable: true, + writable: false, }); - expect(() => - Object.defineProperty(globalThis.kernel, 'namingThings', { - value: 'and final', - }), - ).toThrow(/Cannot redefine property:/u); }); }); }); From ad535fa75546d0652239edfcce657940dc7f6db7 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Thu, 12 Sep 2024 13:38:42 -0500 Subject: [PATCH 20/24] refactor: Label command envelopes 'command'. --- packages/extension/src/envelope.ts | 2 +- packages/extension/src/iframe-manager.test.ts | 19 ++++++++++--------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/packages/extension/src/envelope.ts b/packages/extension/src/envelope.ts index a5f13d368..cc5e72e37 100644 --- a/packages/extension/src/envelope.ts +++ b/packages/extension/src/envelope.ts @@ -4,7 +4,7 @@ import type { WrappedIframeMessage } from './message.js'; import { isWrappedIframeMessage } from './message.js'; export enum EnvelopeLabel { - Command = 'message', + Command = 'command', CapTp = 'capTp', } diff --git a/packages/extension/src/iframe-manager.test.ts b/packages/extension/src/iframe-manager.test.ts index 107668716..45bb5b191 100644 --- a/packages/extension/src/iframe-manager.test.ts +++ b/packages/extension/src/iframe-manager.test.ts @@ -3,6 +3,7 @@ import * as snapsUtils from '@metamask/snaps-utils'; import { delay, makePromiseKitMock } from '@ocap/test-utils'; import { vi, describe, it, expect } from 'vitest'; +import { EnvelopeLabel } from './envelope.js'; import { IframeManager } from './iframe-manager.js'; import type { IframeMessage } from './message.js'; import { Command } from './message.js'; @@ -156,7 +157,7 @@ describe('IframeManager', () => { port2.postMessage({ done: false, value: { - label: 'message', + label: EnvelopeLabel.Command, content: { id: `foo-${i + 1}`, message: { @@ -202,7 +203,7 @@ describe('IframeManager', () => { const capTpInit = { query: { - label: 'message', + label: EnvelopeLabel.Command, content: { id: `${id}-1`, message: { @@ -212,7 +213,7 @@ describe('IframeManager', () => { }, }, response: { - label: 'message', + label: EnvelopeLabel.Command, content: { id: `${id}-1`, message: { @@ -276,7 +277,7 @@ describe('IframeManager', () => { const capTpInit = { query: { - label: 'message', + label: EnvelopeLabel.Command, content: { id: `${id}-1`, message: { @@ -286,7 +287,7 @@ describe('IframeManager', () => { }, }, response: { - label: 'message', + label: EnvelopeLabel.Command, content: { id: `${id}-1`, message: { @@ -437,7 +438,7 @@ describe('IframeManager', () => { const message: IframeMessage = { type: Command.Evaluate, data: '2+2' }; const response: IframeMessage = { type: Command.Evaluate, data: '4' }; - // sendMessage wraps the content in a 'message' envelope + // sendMessage wraps the content in a EnvelopeLabel.Command envelope const messagePromise = manager.sendMessage(id, message); const messageId: string | undefined = portPostMessageSpy.mock.lastCall?.[0]?.value?.content?.id; @@ -447,7 +448,7 @@ describe('IframeManager', () => { port2.postMessage({ done: false, value: { - label: 'message', + label: EnvelopeLabel.Command, content: { id: messageId, message: response, @@ -463,7 +464,7 @@ describe('IframeManager', () => { expect(portPostMessageSpy).toHaveBeenCalledWith({ done: false, value: { - label: 'message', + label: EnvelopeLabel.Command, content: { id: messageId, message, @@ -520,7 +521,7 @@ describe('IframeManager', () => { port2.postMessage({ done: false, value: { - label: 'message', + label: EnvelopeLabel.Command, content: { id: 'foo', message: { From f430c104ed9275ea762194d8d7a2e0a5174fcfa6 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Thu, 12 Sep 2024 14:23:17 -0500 Subject: [PATCH 21/24] test: Use it.each for multi-case test. --- packages/extension/src/message.test.ts | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/packages/extension/src/message.test.ts b/packages/extension/src/message.test.ts index e9bb807c8..8fdec3480 100644 --- a/packages/extension/src/message.test.ts +++ b/packages/extension/src/message.test.ts @@ -13,18 +13,14 @@ describe('message', () => { ).toBe(true); }); - it('returns false for invalid messages', () => { - const invalidMessages = [ - {}, - { id: '1' }, - { message: { type: 'evaluate' } }, - { id: '1', message: { type: 'evaluate' } }, - { id: '1', message: { type: 'evaluate', data: 1 } }, - ]; - - invalidMessages.forEach((message) => { - expect(isWrappedIframeMessage(message)).toBe(false); - }); + it.each([ + [{}], + [{ id: '1' }], + [{ message: { type: 'evaluate' } }], + [{ id: '1', message: { type: 'evaluate' } }], + [{ id: '1', message: { type: 'evaluate', data: 1 } }], + ])('returns false for invalid messages: %j', (message) => { + expect(isWrappedIframeMessage(message)).toBe(false); }); }); }); From 447acf9703c6bf15c8ce1752d7cf127bdd20821f Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Thu, 12 Sep 2024 14:34:34 -0500 Subject: [PATCH 22/24] docs: Clarify explanation for ts-ignore statements. --- packages/extension/src/background.ts | 2 +- packages/extension/src/iframe-manager.ts | 2 +- packages/extension/src/iframe.ts | 4 ++-- packages/extension/src/offscreen.ts | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/extension/src/background.ts b/packages/extension/src/background.ts index c6c691ea8..453f559f4 100644 --- a/packages/extension/src/background.ts +++ b/packages/extension/src/background.ts @@ -82,7 +82,7 @@ chrome.runtime.onMessage.addListener( break; default: console.error( - // @ts-expect-error Exhaustiveness check + // @ts-expect-error The type of `message` is `never`, but this could happen at runtime. // eslint-disable-next-line @typescript-eslint/restrict-template-expressions `Background received unexpected message type: "${message.type}"`, ); diff --git a/packages/extension/src/iframe-manager.ts b/packages/extension/src/iframe-manager.ts index 0ee6f0b59..95491799d 100644 --- a/packages/extension/src/iframe-manager.ts +++ b/packages/extension/src/iframe-manager.ts @@ -200,7 +200,7 @@ export class IframeManager { } /* v8 ignore next 3: Exhaustiveness check */ default: - // @ts-expect-error Exhaustiveness check + // @ts-expect-error The type of `rawMessage` is `never`, but this could happen at runtime. throw new Error(`Unexpected message label "${rawMessage.label}".`); } } diff --git a/packages/extension/src/iframe.ts b/packages/extension/src/iframe.ts index 15be68619..bcd25f1ef 100644 --- a/packages/extension/src/iframe.ts +++ b/packages/extension/src/iframe.ts @@ -42,7 +42,7 @@ async function main(): Promise { break; /* v8 ignore next 3: Exhaustiveness check */ default: - // @ts-expect-error Exhaustiveness check + // @ts-expect-error The type of `rawMessage` is `never`, but this could happen at runtime. throw new Error(`Unexpected message label "${rawMessage.label}".`); } } @@ -66,7 +66,7 @@ async function main(): Promise { if (typeof message.data !== 'string') { console.error( 'iframe received message with unexpected data type', - // @ts-expect-error Exhaustiveness check + // @ts-expect-error The type of `message.data` is `never`, but this could happen at runtime. stringifyResult(message.data), ); return; diff --git a/packages/extension/src/offscreen.ts b/packages/extension/src/offscreen.ts index 7d79aa531..b39536098 100644 --- a/packages/extension/src/offscreen.ts +++ b/packages/extension/src/offscreen.ts @@ -46,7 +46,7 @@ async function main(): Promise { break; default: console.error( - // @ts-expect-error Exhaustiveness check + // @ts-expect-error The type of `message` is `never`, but this could happen at runtime. // eslint-disable-next-line @typescript-eslint/restrict-template-expressions `Offscreen received unexpected message type: "${message.type}"`, ); From 5074743b3b3d7b657afa3bfb962bcc5700190bed Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Thu, 12 Sep 2024 15:17:13 -0500 Subject: [PATCH 23/24] refactor: Move unresolved messages into VatRecord. --- packages/extension/src/iframe-manager.ts | 32 ++++++++---------------- 1 file changed, 11 insertions(+), 21 deletions(-) diff --git a/packages/extension/src/iframe-manager.ts b/packages/extension/src/iframe-manager.ts index 95491799d..abd994263 100644 --- a/packages/extension/src/iframe-manager.ts +++ b/packages/extension/src/iframe-manager.ts @@ -32,6 +32,7 @@ type GetPort = (targetWindow: Window) => Promise; type VatRecord = { streams: StreamPair; messageCounter: () => number; + unresolvedMessages: Map; capTp?: ReturnType; }; @@ -39,8 +40,6 @@ type VatRecord = { * A singleton class to manage and message iframes. */ export class IframeManager { - readonly #unresolvedMessages: Map; - readonly #vats: Map; readonly #vatIdCounter: () => number; @@ -50,7 +49,6 @@ export class IframeManager { */ constructor() { this.#vats = new Map(); - this.#unresolvedMessages = new Map(); this.#vatIdCounter = makeCounter(); } @@ -71,7 +69,11 @@ export class IframeManager { const newWindow = await createWindow(IFRAME_URI, getHtmlId(id)); const port = await getPort(newWindow); const streams = makeMessagePortStreamPair(port); - this.#vats.set(id, { streams, messageCounter: makeCounter() }); + this.#vats.set(id, { + streams, + messageCounter: makeCounter(), + unresolvedMessages: new Map(), + }); /* v8 ignore next 4: Not known to be possible. */ this.#receiveMessages(id, streams.reader).catch((error) => { console.error(`Unexpected read error from vat "${id}"`, error); @@ -97,7 +99,7 @@ export class IframeManager { const closeP = vat.streams.return(); // TODO: Handle orphaned messages - for (const [messageId] of this.#unresolvedMessagesOf(id)) { + for (const [messageId] of vat.unresolvedMessages) { console.warn(`Unhandled orphaned message: ${messageId}`); } this.#vats.delete(id); @@ -125,7 +127,7 @@ export class IframeManager { const { promise, reject, resolve } = makePromiseKit(); const messageId = this.#nextMessageId(id); - this.#unresolvedMessages.set(messageId, { reject, resolve }); + vat.unresolvedMessages.set(messageId, { reject, resolve }); await vat.streams.writer.next({ label: EnvelopeLabel.Command, content: { id: messageId, message }, @@ -164,6 +166,7 @@ export class IframeManager { vatId: VatId, reader: Reader, ): Promise { + const vat = this.#expectGetVat(vatId); for await (const rawMessage of reader) { console.debug('Offscreen received message', rawMessage); @@ -189,11 +192,11 @@ export class IframeManager { } case EnvelopeLabel.Command: { const { id, message } = rawMessage.content; - const promiseCallbacks = this.#unresolvedMessages.get(id); + const promiseCallbacks = vat.unresolvedMessages.get(id); if (promiseCallbacks === undefined) { console.error(`No unresolved message with id "${id}".`); } else { - this.#unresolvedMessages.delete(id); + vat.unresolvedMessages.delete(id); promiseCallbacks.resolve(message.data); } break; @@ -206,19 +209,6 @@ export class IframeManager { } } - *#unresolvedMessagesOf( - id: VatId, - ): Generator { - for (const messageId of this.#unresolvedMessages.keys()) { - if (messageId.split('-').slice(0, -1).join('-') === id) { - yield [ - messageId, - this.#unresolvedMessages.get(messageId) as PromiseCallbacks, - ] as const; - } - } - } - /** * Get a vat record by id, or throw an error if it doesn't exist. * From c55469cee192b086d498d9cbbc34c3c419cb214f Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Thu, 12 Sep 2024 15:20:16 -0500 Subject: [PATCH 24/24] refactor: s/ExtensionTarget/ExtensionMessageTarget/g --- packages/extension/src/background.ts | 6 +++--- packages/extension/src/message.ts | 8 ++++---- packages/extension/src/offscreen.ts | 6 +++--- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/extension/src/background.ts b/packages/extension/src/background.ts index 453f559f4..3182291dc 100644 --- a/packages/extension/src/background.ts +++ b/packages/extension/src/background.ts @@ -2,7 +2,7 @@ import type { Json } from '@metamask/utils'; import './background-trusted-prelude.js'; import type { ExtensionMessage } from './message.js'; -import { Command, ExtensionTarget } from './message.js'; +import { Command, ExtensionMessageTarget } from './message.js'; import { makeHandledCallback } from './shared.js'; // globalThis.kernel will exist due to dev-console.js in background-trusted-prelude.js @@ -45,7 +45,7 @@ async function sendMessage(type: string, data?: Json): Promise { await chrome.runtime.sendMessage({ type, - target: ExtensionTarget.Offscreen, + target: ExtensionMessageTarget.Offscreen, data: data ?? null, }); } @@ -66,7 +66,7 @@ async function provideOffScreenDocument(): Promise { // Handle replies from the offscreen document chrome.runtime.onMessage.addListener( makeHandledCallback(async (message: ExtensionMessage) => { - if (message.target !== ExtensionTarget.Background) { + if (message.target !== ExtensionMessageTarget.Background) { console.warn( `Background received message with unexpected target: "${message.target}"`, ); diff --git a/packages/extension/src/message.ts b/packages/extension/src/message.ts index 4269455b8..c9e26b614 100644 --- a/packages/extension/src/message.ts +++ b/packages/extension/src/message.ts @@ -9,7 +9,7 @@ type DataObject = | DataObject[] | { [key: string]: DataObject }; -export enum ExtensionTarget { +export enum ExtensionMessageTarget { Background = 'background', Offscreen = 'offscreen', } @@ -17,7 +17,7 @@ export enum ExtensionTarget { type CommandForm< CommandType extends Command, Data extends DataObject, - TargetType extends ExtensionTarget, + TargetType extends ExtensionMessageTarget, > = { type: CommandType; target?: TargetType; @@ -36,13 +36,13 @@ export type CapTpPayload = { params: DataObject[]; }; -type CommandMessage = +type CommandMessage = | CommandForm | CommandForm | CommandForm | CommandForm; -export type ExtensionMessage = CommandMessage; +export type ExtensionMessage = CommandMessage; export type IframeMessage = CommandMessage; export type WrappedIframeMessage = { diff --git a/packages/extension/src/offscreen.ts b/packages/extension/src/offscreen.ts index b39536098..c379f3c85 100644 --- a/packages/extension/src/offscreen.ts +++ b/packages/extension/src/offscreen.ts @@ -1,6 +1,6 @@ import { IframeManager } from './iframe-manager.js'; import type { ExtensionMessage } from './message.js'; -import { Command, ExtensionTarget } from './message.js'; +import { Command, ExtensionMessageTarget } from './message.js'; import { makeHandledCallback } from './shared.js'; main().catch(console.error); @@ -19,7 +19,7 @@ async function main(): Promise { // Handle messages from the background service worker chrome.runtime.onMessage.addListener( makeHandledCallback(async (message: ExtensionMessage) => { - if (message.target !== ExtensionTarget.Offscreen) { + if (message.target !== ExtensionMessageTarget.Offscreen) { console.warn( `Offscreen received message with unexpected target: "${message.target}"`, ); @@ -63,7 +63,7 @@ async function main(): Promise { async function reply(type: Command, data?: string): Promise { await chrome.runtime.sendMessage({ data: data ?? null, - target: ExtensionTarget.Background, + target: ExtensionMessageTarget.Background, type, }); }