diff --git a/packages/extension/src/background.ts b/packages/extension/src/background.ts index 40345b842..f4f7676c0 100644 --- a/packages/extension/src/background.ts +++ b/packages/extension/src/background.ts @@ -1,6 +1,7 @@ import type { Json } from '@metamask/utils'; import './background-trusted-prelude.js'; -import { CommandMethod } from '@ocap/utils'; +import type { Command } from '@ocap/utils'; +import { CommandMethod, isCommandReply } from '@ocap/utils'; import { ExtensionMessageTarget, @@ -44,7 +45,10 @@ chrome.action.onClicked.addListener(() => { * @param params - The message data. * @param params.name - The name to include in the message. */ -async function sendCommand(method: string, params?: Json): Promise { +async function sendCommand( + method: Type['method'], + params?: Type['params'], +): Promise { await provideOffScreenDocument(); await chrome.runtime.sendMessage({ @@ -72,7 +76,10 @@ async function provideOffScreenDocument(): Promise { // Handle replies from the offscreen document chrome.runtime.onMessage.addListener( makeHandledCallback(async (message: unknown) => { - if (!isExtensionRuntimeMessage(message)) { + if ( + !isExtensionRuntimeMessage(message) || + !isCommandReply(message.payload) + ) { console.error('Background received unexpected message', message); return; } diff --git a/packages/extension/src/iframe.ts b/packages/extension/src/iframe.ts index 734fc79bc..4cd08d395 100644 --- a/packages/extension/src/iframe.ts +++ b/packages/extension/src/iframe.ts @@ -2,7 +2,7 @@ import { makeExo } from '@endo/exo'; import { M } from '@endo/patterns'; import { Supervisor } from '@ocap/kernel'; import { makeMessagePortStreamPair, receiveMessagePort } from '@ocap/streams'; -import type { StreamEnvelope } from '@ocap/utils'; +import type { StreamEnvelope, StreamEnvelopeReply } from '@ocap/utils'; main().catch(console.error); @@ -11,7 +11,10 @@ main().catch(console.error); */ async function main(): Promise { const port = await receiveMessagePort(); - const streams = makeMessagePortStreamPair(port); + const streams = makeMessagePortStreamPair< + StreamEnvelope, + StreamEnvelopeReply + >(port); const bootstrap = makeExo( 'TheGreatFrangooly', diff --git a/packages/extension/src/kernel.html b/packages/extension/src/kernel.html new file mode 100644 index 000000000..d9b354d4d --- /dev/null +++ b/packages/extension/src/kernel.html @@ -0,0 +1,9 @@ + + + + + Ocap Kernel + + + + diff --git a/packages/extension/src/kernel.ts b/packages/extension/src/kernel.ts new file mode 100644 index 000000000..ef6746623 --- /dev/null +++ b/packages/extension/src/kernel.ts @@ -0,0 +1,78 @@ +import { Kernel } from '@ocap/kernel'; +import { initializeMessageChannel, makeMessagePortStreamPair, receiveMessagePort } from '@ocap/streams'; +import type { CommandReply } from '@ocap/utils'; +import { Command, CommandMethod } from '@ocap/utils'; + +import { makeIframeVatWorker } from './makeIframeVatWorker.js'; +main().catch(console.error); + +/** + * The main function for the kernel script. + */ +async function main(): Promise { + console.debug('starting kernel'); + const port = await receiveMessagePort(); + console.debug('kernel connected'); + const streams = makeMessagePortStreamPair(port); + const kernel = new Kernel(); + console.debug('launching vat'); + const iframeReadyP = kernel.launchVat({ + id: 'default', + worker: makeIframeVatWorker('default', initializeMessageChannel), + }); + let vatLaunchNotified: boolean = false; + + for await (const { method, params } of streams.reader) { + console.debug('kernel received message', { method, params }); + + const vat = await iframeReadyP; + if (!vatLaunchNotified) { + console.debug('vat connected'); + vatLaunchNotified = true; + } + + switch (method) { + case CommandMethod.Evaluate: + await streams.writer.next({ method: CommandMethod.Evaluate, params: await evaluate(vat.id, params) }); + break; + case CommandMethod.CapTpCall: + const result = await vat.callCapTp(params); + await streams.writer.next({ method: CommandMethod.CapTpCall, params: JSON.stringify(result, null, 2) }); + break; + case CommandMethod.CapTpInit: + await vat.makeCapTp(); + await streams.writer.next({ method: CommandMethod.CapTpInit, params: '~~~ CapTP Initialized ~~~'}); + break; + case CommandMethod.Ping: + await streams.writer.next({ method: CommandMethod.Ping, params: 'pong' }); + break; + default: + console.error( + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `Offscreen received unexpected command method: "${method}"`, + ); + } + } + + /** + * Evaluate a string in the default iframe. + * + * @param vatId - The ID of the vat to send the message to. + * @param source - The source string to evaluate. + * @returns The result of the evaluation, or an error message. + */ + async function evaluate(vatId: string, source: string): Promise { + try { + const result = await kernel.sendMessage(vatId, { + method: CommandMethod.Evaluate, + params: source, + }); + return String(result); + } catch (error) { + if (error instanceof Error) { + return `Error: ${error.message}`; + } + return `Error: Unknown error during evaluation.`; + } + } +} diff --git a/packages/extension/src/makeIframeVatWorker.ts b/packages/extension/src/makeIframeVatWorker.ts index 0d6ac1a2c..24f4be2e0 100644 --- a/packages/extension/src/makeIframeVatWorker.ts +++ b/packages/extension/src/makeIframeVatWorker.ts @@ -2,7 +2,7 @@ import { createWindow } from '@metamask/snaps-utils'; import type { VatId, VatWorker } from '@ocap/kernel'; import type { initializeMessageChannel } from '@ocap/streams'; import { makeMessagePortStreamPair } from '@ocap/streams'; -import type { StreamEnvelope } from '@ocap/utils'; +import type { StreamEnvelopeReply, StreamEnvelope } from '@ocap/utils'; const IFRAME_URI = 'iframe.html'; @@ -20,9 +20,15 @@ export const makeIframeVatWorker = ( ): VatWorker => { return { init: async () => { + console.debug('creating new vat worker'); const newWindow = await createWindow(IFRAME_URI, getHtmlId(id)); + console.debug('waiting for vat worker connection'); const port = await getPort(newWindow); - const streams = makeMessagePortStreamPair(port); + console.debug('vat worker connected'); + const streams = makeMessagePortStreamPair< + StreamEnvelopeReply, + StreamEnvelope + >(port); return [streams, newWindow]; }, diff --git a/packages/extension/src/manifest.json b/packages/extension/src/manifest.json index 8d1d99109..d2de110f7 100644 --- a/packages/extension/src/manifest.json +++ b/packages/extension/src/manifest.json @@ -10,7 +10,7 @@ "action": {}, "permissions": ["offscreen"], "sandbox": { - "pages": ["iframe.html"] + "pages": ["iframe.html", "kernel.html"] }, "content_security_policy": { "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'none'; frame-ancestors 'none';", diff --git a/packages/extension/src/offscreen.ts b/packages/extension/src/offscreen.ts index 4f06e41a6..6b6c8dcc2 100644 --- a/packages/extension/src/offscreen.ts +++ b/packages/extension/src/offscreen.ts @@ -1,30 +1,75 @@ -import { Kernel } from '@ocap/kernel'; -import { initializeMessageChannel } from '@ocap/streams'; -import { CommandMethod } from '@ocap/utils'; +import { createWindow } from '@metamask/snaps-utils'; +import { initializeMessageChannel, makeMessagePortStreamPair, StreamPair } from '@ocap/streams'; +import type { Command, CommandReply } from '@ocap/utils'; +import { CommandMethod, isCommand } from '@ocap/utils'; -import { makeIframeVatWorker } from './makeIframeVatWorker.js'; import { ExtensionMessageTarget, isExtensionRuntimeMessage, makeHandledCallback, } from './shared.js'; -main().catch(console.error); +const kernelStreams = startKernel({ + uri: 'kernel.html', + id: 'ocap-kernel' +}); + +Promise.race([ + receiveMessagesFromKernel(), + receiveMessagesFromBackground(), +]).catch(console.error).finally(); + +type StartKernelArgs = { + uri: string; + id: string; +} + +async function startKernel({ uri, id }: StartKernelArgs): Promise> { + console.debug('starting kernel'); + const targetWindow = await createWindow(uri, id); + const port = await initializeMessageChannel(targetWindow); + console.debug('kernel connected'); + return makeMessagePortStreamPair(port); +} /** - * The main function for the offscreen script. + * Listen to messages from the kernel. */ -async function main(): Promise { - const kernel = new Kernel(); - const iframeReadyP = kernel.launchVat({ - id: 'default', - worker: makeIframeVatWorker('default', initializeMessageChannel), - }); +async function receiveMessagesFromKernel(): Promise { + const streams = await kernelStreams; + + for await(const payload of streams.reader) { + + switch (payload.method) { + case CommandMethod.Evaluate: + case CommandMethod.CapTpCall: + case CommandMethod.CapTpInit: + case CommandMethod.Ping: + // For now, we only receive command replies, + // and we simply forward them to the background service worker. + await chrome.runtime.sendMessage({ + target: ExtensionMessageTarget.Background, + payload, + }); + break; + default: + console.error( + // @ts-expect-error The type of `payload` is `never`, but this could happen at runtime. + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `Offscreen received unexpected command reply method: "${payload.method}"`, + ); + } + } +} - // Handle messages from the background service worker +/** + * Listen to messages from the background service worker. + */ +async function receiveMessagesFromBackground(): Promise { + console.debug('starting background listener'); chrome.runtime.onMessage.addListener( makeHandledCallback(async (message: unknown) => { - if (!isExtensionRuntimeMessage(message)) { + if (!isExtensionRuntimeMessage(message) || !isCommand(message.payload)) { console.error('Offscreen received unexpected message', message); return; } @@ -35,34 +80,21 @@ async function main(): Promise { return; } - const vat = await iframeReadyP; + console.debug('offscreen received message', message); + + const streams = await kernelStreams; const { payload } = message; switch (payload.method) { case CommandMethod.Evaluate: - await replyToCommand( - CommandMethod.Evaluate, - await evaluate(vat.id, payload.params), - ); - break; - case CommandMethod.CapTpCall: { - const result = await vat.callCapTp(payload.params); - await replyToCommand( - CommandMethod.CapTpCall, - JSON.stringify(result, null, 2), - ); - break; - } + case CommandMethod.CapTpCall: case CommandMethod.CapTpInit: - await vat.makeCapTp(); - await replyToCommand( - CommandMethod.CapTpInit, - '~~~ CapTP Initialized ~~~', - ); - break; case CommandMethod.Ping: - await replyToCommand(CommandMethod.Ping, 'pong'); + // For now, we only recieve kernel commands, + // and we simply forward them to the kernel. + console.debug('forwarding message to kernel'); + await streams.writer.next(payload); break; default: console.error( @@ -73,45 +105,4 @@ async function main(): Promise { } }), ); - - /** - * Reply to a command from the background script. - * - * @param method - The command method. - * @param params - The command parameters. - */ - async function replyToCommand( - method: CommandMethod, - params?: string, - ): Promise { - await chrome.runtime.sendMessage({ - target: ExtensionMessageTarget.Background, - payload: { - method, - params: params ?? null, - }, - }); - } - - /** - * Evaluate a string in the default iframe. - * - * @param vatId - The ID of the vat to send the message to. - * @param source - The source string to evaluate. - * @returns The result of the evaluation, or an error message. - */ - async function evaluate(vatId: string, source: string): Promise { - try { - const result = await kernel.sendMessage(vatId, { - method: CommandMethod.Evaluate, - params: source, - }); - return String(result); - } catch (error) { - if (error instanceof Error) { - return `Error: ${error.message}`; - } - return `Error: Unknown error during evaluation.`; - } - } } diff --git a/packages/extension/src/shared.ts b/packages/extension/src/shared.ts index 6e1758ded..f95390ad0 100644 --- a/packages/extension/src/shared.ts +++ b/packages/extension/src/shared.ts @@ -1,6 +1,6 @@ import { isObject } from '@metamask/utils'; -import type { Command } from '@ocap/utils'; -import { isCommand } from '@ocap/utils'; +import type { Command, CommandReply } from '@ocap/utils'; +import { isCommand, isCommandReply } from '@ocap/utils'; export type VatId = string; @@ -10,7 +10,9 @@ export enum ExtensionMessageTarget { } export type ExtensionRuntimeMessage = { - payload: Command; + // On some systems, including CI, ESLint complains of overlap between the union operands. + // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents + payload: Command | CommandReply; target: ExtensionMessageTarget; }; @@ -22,7 +24,7 @@ export const isExtensionRuntimeMessage = ( Object.values(ExtensionMessageTarget).includes( message.target as ExtensionMessageTarget, ) && - isCommand(message.payload); + (isCommand(message.payload) || isCommandReply(message.payload)); /** * Wrap an async callback to ensure any errors are at least logged. diff --git a/packages/extension/vite.config.ts b/packages/extension/vite.config.ts index 09d5a97d5..2d729c140 100644 --- a/packages/extension/vite.config.ts +++ b/packages/extension/vite.config.ts @@ -41,6 +41,7 @@ export default defineConfig(({ mode }) => ({ background: path.resolve(projectRoot, 'background.ts'), offscreen: path.resolve(projectRoot, 'offscreen.html'), iframe: path.resolve(projectRoot, 'iframe.html'), + kernel: path.resolve(projectRoot, 'kernel.html'), }, output: { entryFileNames: '[name].js', diff --git a/packages/kernel/src/Supervisor.test.ts b/packages/kernel/src/Supervisor.test.ts index 9231586e6..95cf876e7 100644 --- a/packages/kernel/src/Supervisor.test.ts +++ b/packages/kernel/src/Supervisor.test.ts @@ -1,7 +1,7 @@ import '@ocap/shims/endoify'; import { makeMessagePortStreamPair, MessagePortWriter } from '@ocap/streams'; import { delay } from '@ocap/test-utils'; -import type { StreamEnvelope } from '@ocap/utils'; +import type { StreamEnvelope, StreamEnvelopeReply } from '@ocap/utils'; import * as ocapUtils from '@ocap/utils'; import { CommandMethod } from '@ocap/utils'; import { describe, it, expect, vi, beforeEach } from 'vitest'; @@ -17,9 +17,10 @@ describe('Supervisor', () => { messageChannel = new MessageChannel(); - const streams = makeMessagePortStreamPair( - messageChannel.port1, - ); + const streams = makeMessagePortStreamPair< + StreamEnvelope, + StreamEnvelopeReply + >(messageChannel.port1); supervisor = new Supervisor({ id: 'test-id', streams }); }); @@ -78,7 +79,7 @@ describe('Supervisor', () => { expect(replySpy).toHaveBeenCalledWith('message-id', { method: CommandMethod.CapTpInit, - params: null, + params: '~~~ CapTP Initialized ~~~', }); }); diff --git a/packages/kernel/src/Supervisor.ts b/packages/kernel/src/Supervisor.ts index 38fcda07a..32a9604b3 100644 --- a/packages/kernel/src/Supervisor.ts +++ b/packages/kernel/src/Supervisor.ts @@ -2,30 +2,31 @@ import { makeCapTP } from '@endo/captp'; import type { StreamPair, Reader } from '@ocap/streams'; import type { CapTpMessage, - Command, StreamEnvelope, - VatMessage, StreamEnvelopeHandler, + StreamEnvelopeReply, + CommandReply, + VatCommand, } from '@ocap/utils'; import { CommandMethod, makeStreamEnvelopeHandler, wrapCapTp, - wrapStreamCommand, + wrapStreamCommandReply, } from '@ocap/utils'; import { stringifyResult } from './utils/stringifyResult.js'; type SupervisorConstructorProps = { id: string; - streams: StreamPair; + streams: StreamPair; bootstrap?: unknown; }; export class Supervisor { readonly id: string; - readonly streams: StreamPair; + readonly streams: StreamPair; readonly streamEnvelopeHandler: StreamEnvelopeHandler; @@ -83,7 +84,7 @@ export class Supervisor { * @param vatMessage.id - The id of the message. * @param vatMessage.payload - The payload to handle. */ - async handleMessage({ id, payload }: VatMessage): Promise { + async handleMessage({ id, payload }: VatCommand): Promise { switch (payload.method) { case CommandMethod.Evaluate: { if (typeof payload.params !== 'string') { @@ -110,7 +111,7 @@ export class Supervisor { ); await this.replyToMessage(id, { method: CommandMethod.CapTpInit, - params: null, + params: '~~~ CapTP Initialized ~~~', }); break; } @@ -133,8 +134,8 @@ export class Supervisor { * @param id - The id of the message to reply to. * @param payload - The payload to reply with. */ - async replyToMessage(id: string, payload: Command): Promise { - await this.streams.writer.next(wrapStreamCommand({ id, payload })); + async replyToMessage(id: string, payload: CommandReply): Promise { + await this.streams.writer.next(wrapStreamCommandReply({ id, payload })); } /** diff --git a/packages/kernel/src/Vat.test.ts b/packages/kernel/src/Vat.test.ts index cdccfb39a..a71ba6891 100644 --- a/packages/kernel/src/Vat.test.ts +++ b/packages/kernel/src/Vat.test.ts @@ -1,8 +1,13 @@ import '@ocap/shims/endoify'; import { makeMessagePortStreamPair, MessagePortWriter } from '@ocap/streams'; import { delay, makeCapTpMock, makePromiseKitMock } from '@ocap/test-utils'; -import type { Command, StreamEnvelope } from '@ocap/utils'; -import { CommandMethod, makeStreamEnvelopeHandler } from '@ocap/utils'; +import type { + Command, + CommandReply, + StreamEnvelope, + StreamEnvelopeReply, +} from '@ocap/utils'; +import { CommandMethod, makeStreamEnvelopeReplyHandler } from '@ocap/utils'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { Vat } from './Vat.js'; @@ -18,9 +23,10 @@ describe('Vat', () => { messageChannel = new MessageChannel(); - const streams = makeMessagePortStreamPair( - messageChannel.port1, - ); + const streams = makeMessagePortStreamPair< + StreamEnvelopeReply, + StreamEnvelope + >(messageChannel.port1); vat = new Vat({ id: 'test-vat', @@ -61,7 +67,7 @@ describe('Vat', () => { it('receives messages correctly', async () => { vi.spyOn(vat, 'sendMessage').mockResolvedValueOnce(undefined); vi.spyOn(vat, 'makeCapTp').mockResolvedValueOnce(undefined); - const handleSpy = vi.spyOn(vat.streamEnvelopeHandler, 'handle'); + const handleSpy = vi.spyOn(vat.streamEnvelopeReplyHandler, 'handle'); await vat.init(); const writer = new MessagePortWriter(messageChannel.port2); const rawMessage = { type: 'command', payload: { method: 'test' } }; @@ -74,7 +80,7 @@ describe('Vat', () => { describe('handleMessage', () => { it('resolves the payload when the message id exists in unresolvedMessages', async () => { const mockMessageId = 'test-vat-1'; - const mockPayload: Command = { + const mockPayload: CommandReply = { method: CommandMethod.Evaluate, params: 'test-response', }; @@ -89,7 +95,10 @@ describe('Vat', () => { const consoleErrorSpy = vi.spyOn(console, 'error'); const nonExistentMessageId = 'non-existent-id'; - const mockPayload: Command = { method: CommandMethod.Ping, params: null }; + const mockPayload: CommandReply = { + method: CommandMethod.Ping, + params: 'pong', + }; await vat.handleMessage({ id: nonExistentMessageId, @@ -126,13 +135,18 @@ describe('Vat', () => { }); it('creates a CapTP connection and sends CapTpInit message', async () => { - // @ts-expect-error - streamEnvelopeHandler is readonly - vat.streamEnvelopeHandler = makeStreamEnvelopeHandler({}, console.warn); + // @ts-expect-error - streamEnvelopeReplyHandler is readonly + vat.streamEnvelopeReplyHandler = makeStreamEnvelopeReplyHandler( + {}, + console.warn, + ); const sendMessageMock = vi .spyOn(vat, 'sendMessage') .mockResolvedValueOnce(undefined); await vat.makeCapTp(); - expect(vat.streamEnvelopeHandler.contentHandlers.capTp).toBeDefined(); + expect( + vat.streamEnvelopeReplyHandler.contentHandlers.capTp, + ).toBeDefined(); expect(sendMessageMock).toHaveBeenCalledWith({ method: CommandMethod.CapTpInit, params: null, diff --git a/packages/kernel/src/Vat.ts b/packages/kernel/src/Vat.ts index 74b4548a0..4f7288ad7 100644 --- a/packages/kernel/src/Vat.ts +++ b/packages/kernel/src/Vat.ts @@ -4,17 +4,18 @@ import { makePromiseKit } from '@endo/promise-kit'; import type { StreamPair, Reader } from '@ocap/streams'; import type { StreamEnvelope, - StreamEnvelopeHandler, CapTpMessage, CapTpPayload, Command, - VatMessage, + StreamEnvelopeReply, + StreamEnvelopeReplyHandler, + VatCommandReply, } from '@ocap/utils'; import { wrapCapTp, - wrapStreamCommand, - makeStreamEnvelopeHandler, CommandMethod, + makeStreamEnvelopeReplyHandler, + wrapStreamCommand, } from '@ocap/utils'; import type { MessageId, UnresolvedMessages, VatId } from './types.js'; @@ -22,7 +23,7 @@ import { makeCounter } from './utils/makeCounter.js'; type VatConstructorProps = { id: VatId; - streams: StreamPair; + streams: StreamPair; }; export class Vat { @@ -34,7 +35,7 @@ export class Vat { readonly unresolvedMessages: UnresolvedMessages = new Map(); - readonly streamEnvelopeHandler: StreamEnvelopeHandler; + readonly streamEnvelopeReplyHandler: StreamEnvelopeReplyHandler; capTp?: ReturnType; @@ -42,7 +43,7 @@ export class Vat { this.id = id; this.streams = streams; this.#messageCounter = makeCounter(); - this.streamEnvelopeHandler = makeStreamEnvelopeHandler( + this.streamEnvelopeReplyHandler = makeStreamEnvelopeReplyHandler( { command: this.handleMessage.bind(this) }, (error) => console.error('Vat stream error:', error), ); @@ -55,7 +56,7 @@ export class Vat { * @param vatMessage.id - The id of the message. * @param vatMessage.payload - The payload to handle. */ - async handleMessage({ id, payload }: VatMessage): Promise { + async handleMessage({ id, payload }: VatCommandReply): Promise { const promiseCallbacks = this.unresolvedMessages.get(id); if (promiseCallbacks === undefined) { console.error(`No unresolved message with id "${id}".`); @@ -88,10 +89,10 @@ export class Vat { * * @param reader - The reader for the messages. */ - async #receiveMessages(reader: Reader): Promise { + async #receiveMessages(reader: Reader): Promise { for await (const rawMessage of reader) { console.debug('Vat received message', rawMessage); - await this.streamEnvelopeHandler.handle(rawMessage); + await this.streamEnvelopeReplyHandler.handle(rawMessage); } } @@ -115,7 +116,7 @@ export class Vat { }); this.capTp = ctp; - this.streamEnvelopeHandler.contentHandlers.capTp = async ( + this.streamEnvelopeReplyHandler.contentHandlers.capTp = async ( content: CapTpMessage, ) => { console.log('CapTP from vat', JSON.stringify(content, null, 2)); diff --git a/packages/kernel/src/types.ts b/packages/kernel/src/types.ts index 58aa20b18..d1afe7277 100644 --- a/packages/kernel/src/types.ts +++ b/packages/kernel/src/types.ts @@ -1,13 +1,15 @@ import type { PromiseKit } from '@endo/promise-kit'; import type { StreamPair } from '@ocap/streams'; -import type { StreamEnvelope } from '@ocap/utils'; +import type { StreamEnvelopeReply, StreamEnvelope } from '@ocap/utils'; export type MessageId = string; export type VatId = string; export type VatWorker = { - init: () => Promise<[StreamPair, unknown]>; + init: () => Promise< + [StreamPair, unknown] + >; delete: () => Promise; }; diff --git a/packages/streams/src/streams.ts b/packages/streams/src/streams.ts index 3433e86aa..d41616273 100644 --- a/packages/streams/src/streams.ts +++ b/packages/streams/src/streams.ts @@ -320,9 +320,9 @@ export class MessagePortWriter implements Writer { } harden(MessagePortWriter); -export type StreamPair = Readonly<{ - reader: Reader; - writer: Writer; +export type StreamPair = Readonly<{ + reader: Reader; + writer: Writer; /** * Calls `.return()` on both streams. */ @@ -343,11 +343,11 @@ export type StreamPair = Readonly<{ * @param port - The message port to make the streams over. * @returns The reader and writer streams, and cleanup methods. */ -export const makeMessagePortStreamPair = ( +export const makeMessagePortStreamPair = ( port: MessagePort, -): StreamPair => { - const reader = new MessagePortReader(port); - const writer = new MessagePortWriter(port); +): StreamPair => { + const reader = new MessagePortReader(port); + const writer = new MessagePortWriter(port); return harden({ reader, diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index df0933e7b..c8973fb3e 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -2,14 +2,20 @@ export type { CapTpMessage, CapTpPayload, Command, - VatMessage, + CommandReply, + VatCommand, + VatCommandReply, } from './types.js'; export { CommandMethod } from './types.js'; -export { isCommand } from './type-guards.js'; +export { isCommand, isCommandReply } from './type-guards.js'; export { wrapStreamCommand, wrapCapTp, makeStreamEnvelopeHandler, type StreamEnvelope, type StreamEnvelopeHandler, + wrapStreamCommandReply, + makeStreamEnvelopeReplyHandler, + type StreamEnvelopeReply, + type StreamEnvelopeReplyHandler, } from './stream-envelope.js'; diff --git a/packages/utils/src/stream-envelope.test.ts b/packages/utils/src/stream-envelope.test.ts index 4ba116c74..c1e780307 100644 --- a/packages/utils/src/stream-envelope.test.ts +++ b/packages/utils/src/stream-envelope.test.ts @@ -4,12 +4,14 @@ import { wrapCapTp, wrapStreamCommand, makeStreamEnvelopeHandler, + wrapStreamCommandReply, + makeStreamEnvelopeReplyHandler, } from './stream-envelope.js'; -import type { CapTpMessage, VatMessage } from './types.js'; +import type { CapTpMessage, VatCommand, VatCommandReply } from './types.js'; import { CommandMethod } from './types.js'; describe('StreamEnvelopeHandler', () => { - const commandContent: VatMessage = { + const commandContent: VatCommand = { id: '1', payload: { method: CommandMethod.Evaluate, params: '1 + 1' }, }; @@ -75,3 +77,71 @@ describe('StreamEnvelopeHandler', () => { ); }); }); + +describe('StreamEnvelopeReplyHandler', () => { + const commandContent: VatCommandReply = { + id: '1', + payload: { method: CommandMethod.Evaluate, params: '2' }, + }; + const capTpContent: CapTpMessage = { + type: 'CTP_CALL', + epoch: 0, + // Our assumptions about the form of a CapTpMessage are weak. + unreliableKey: Symbol('unreliableValue'), + }; + + const commandLabel = wrapStreamCommandReply(commandContent).label; + const capTpLabel = wrapCapTp(capTpContent).label; + + const testEnvelopeHandlers = { + command: async () => commandLabel, + capTp: async () => capTpLabel, + }; + + const testErrorHandler = (problem: unknown): never => { + throw new Error(`TEST ${String(problem)}`); + }; + + it.each` + wrapper | content | label + ${wrapStreamCommandReply} | ${commandContent} | ${commandLabel} + ${wrapCapTp} | ${capTpContent} | ${capTpLabel} + `('handles valid StreamEnvelopes', async ({ wrapper, content, label }) => { + const handler = makeStreamEnvelopeReplyHandler( + testEnvelopeHandlers, + testErrorHandler, + ); + expect(await handler.handle(wrapper(content))).toStrictEqual(label); + }); + + it('routes invalid envelopes to default error handler', async () => { + const handler = makeStreamEnvelopeReplyHandler(testEnvelopeHandlers); + await expect( + // @ts-expect-error label is intentionally unknown + handler.handle({ label: 'unknown', content: [] }), + ).rejects.toThrow(/^Stream envelope handler received unexpected value/u); + }); + + it('routes invalid envelopes to supplied error handler', async () => { + const handler = makeStreamEnvelopeReplyHandler( + testEnvelopeHandlers, + testErrorHandler, + ); + await expect( + // @ts-expect-error label is intentionally unknown + handler.handle({ label: 'unknown', content: [] }), + ).rejects.toThrow( + /^TEST Stream envelope handler received unexpected value/u, + ); + }); + + it('routes valid stream envelopes with an unhandled label to the error handler', async () => { + const handler = makeStreamEnvelopeReplyHandler( + { command: testEnvelopeHandlers.command }, + testErrorHandler, + ); + await expect(handler.handle(wrapCapTp(capTpContent))).rejects.toThrow( + /^TEST Stream envelope handler received an envelope with known but unexpected label/u, + ); + }); +}); diff --git a/packages/utils/src/stream-envelope.ts b/packages/utils/src/stream-envelope.ts index 3df0e539c..fbe9e833a 100644 --- a/packages/utils/src/stream-envelope.ts +++ b/packages/utils/src/stream-envelope.ts @@ -1,7 +1,11 @@ import { makeStreamEnvelopeKit } from '@ocap/streams'; -import { isCapTpMessage, isVatMessage } from './type-guards.js'; -import type { CapTpMessage, VatMessage } from './types.js'; +import { + isCapTpMessage, + isVatCommand, + isVatCommandReply, +} from './type-guards.js'; +import type { CapTpMessage, VatCommand, VatCommandReply } from './types.js'; type GuardType = TypeGuard extends ( value: unknown, @@ -25,14 +29,16 @@ enum EnvelopeLabel { // eslint-disable-next-line @typescript-eslint/no-unused-vars const envelopeLabels = Object.values(EnvelopeLabel); +// For now, this envelope kit is for intial sends only + const envelopeKit = makeStreamEnvelopeKit< typeof envelopeLabels, { - command: VatMessage; + command: VatCommand; capTp: CapTpMessage; } >({ - command: isVatMessage, + command: (value) => isVatCommand(value), capTp: isCapTpMessage, }); @@ -44,3 +50,29 @@ export type StreamEnvelopeHandler = ReturnType< export const wrapStreamCommand = envelopeKit.streamEnveloper.command.wrap; export const wrapCapTp = envelopeKit.streamEnveloper.capTp.wrap; export const { makeStreamEnvelopeHandler } = envelopeKit; + +// For now, a separate envelope kit for replies only + +const streamEnvelopeReplyKit = makeStreamEnvelopeKit< + typeof envelopeLabels, + { + command: VatCommandReply; + capTp: CapTpMessage; + } +>({ + command: (value) => isVatCommandReply(value), + capTp: isCapTpMessage, +}); + +export type StreamEnvelopeReply = GuardType< + typeof streamEnvelopeReplyKit.isStreamEnvelope +>; +export type StreamEnvelopeReplyHandler = ReturnType< + typeof streamEnvelopeReplyKit.makeStreamEnvelopeHandler +>; + +export const wrapStreamCommandReply = + streamEnvelopeReplyKit.streamEnveloper.command.wrap; +// Note: We don't differentiate between wrapCapTp and wrapCapTpReply +export const { makeStreamEnvelopeHandler: makeStreamEnvelopeReplyHandler } = + streamEnvelopeReplyKit; diff --git a/packages/utils/src/type-guards.test.ts b/packages/utils/src/type-guards.test.ts index 5d2adee10..f3dca313f 100644 --- a/packages/utils/src/type-guards.test.ts +++ b/packages/utils/src/type-guards.test.ts @@ -1,10 +1,12 @@ import { describe, it, expect } from 'vitest'; import { - isVatMessage, isCapTpMessage, isCommand, isCapTpPayload, + isCommandReply, + isVatCommand, + isVatCommandReply, } from './type-guards.js'; import { CommandMethod } from './types.js'; @@ -47,21 +49,60 @@ describe('type-guards', () => { ); }); - describe('isVatMessage', () => { + describe('isCommandReply', () => { + it.each` + value | expectedResult | description + ${{ method: CommandMethod.Ping, params: 'data' }} | ${true} | ${'valid command reply with string data'} + ${{ method: CommandMethod.Ping, params: null }} | ${false} | ${'invalid command reply: with null data'} + ${123} | ${false} | ${'invalid command reply: primitive number'} + ${{ method: true, params: 'data' }} | ${false} | ${'invalid command reply: invalid type'} + ${{ method: CommandMethod.Ping }} | ${false} | ${'invalid command reply: missing data'} + ${{ method: CommandMethod.Ping, params: 123 }} | ${false} | ${'invalid command reply: data is a primitive number'} + ${{ method: 123, params: null }} | ${false} | ${'invalid command reply: invalid type and valid data'} + ${{ method: 'some-type', params: true }} | ${false} | ${'invalid command reply: valid type and invalid data'} + `( + 'returns $expectedResult for $description', + ({ value, expectedResult }) => { + expect(isCommandReply(value)).toBe(expectedResult); + }, + ); + }); + + describe('isVatCommand', () => { it.each` value | expectedResult | description - ${{ id: 'some-id', payload: { method: CommandMethod.Ping, params: null } }} | ${true} | ${'valid vat message'} - ${123} | ${false} | ${'invalid vat message: primitive number'} - ${{ id: true, payload: {} }} | ${false} | ${'invalid vat message: invalid id and empty payload'} - ${{ id: 'some-id', payload: null }} | ${false} | ${'invalid vat message: payload is null'} - ${{ id: 123, payload: { method: CommandMethod.Ping, params: null } }} | ${false} | ${'invalid vat message: invalid id type'} - ${{ id: 'some-id' }} | ${false} | ${'invalid vat message: missing payload'} - ${{ id: 'some-id', payload: 123 }} | ${false} | ${'invalid vat message: payload is a primitive number'} - ${{ id: 'some-id', payload: { method: 123, params: null } }} | ${false} | ${'invalid vat message: invalid type in payload'} + ${{ id: 'some-id', payload: { method: CommandMethod.Ping, params: null } }} | ${true} | ${'valid vat command'} + ${123} | ${false} | ${'invalid vat command: primitive number'} + ${{ id: true, payload: {} }} | ${false} | ${'invalid vat command: invalid id and empty payload'} + ${{ id: 'some-id', payload: null }} | ${false} | ${'invalid vat command: payload is null'} + ${{ id: 123, payload: { method: CommandMethod.Ping, params: null } }} | ${false} | ${'invalid vat command: invalid id type'} + ${{ id: 'some-id' }} | ${false} | ${'invalid vat command: missing payload'} + ${{ id: 'some-id', payload: 123 }} | ${false} | ${'invalid vat command: payload is a primitive number'} + ${{ id: 'some-id', payload: { method: 123, params: null } }} | ${false} | ${'invalid vat command: invalid type in payload'} + `( + 'returns $expectedResult for $description', + ({ value, expectedResult }) => { + expect(isVatCommand(value)).toBe(expectedResult); + }, + ); + }); + + describe('isVatCommandReply', () => { + it.each` + value | expectedResult | description + ${{ id: 'some-id', payload: { method: CommandMethod.Ping, params: 'pong' } }} | ${true} | ${'valid vat command'} + ${123} | ${false} | ${'invalid vat command reply: primitive number'} + ${{ id: true, payload: {} }} | ${false} | ${'invalid vat command reply: invalid id and empty payload'} + ${{ id: 'some-id', payload: null }} | ${false} | ${'invalid vat command reply: payload is null'} + ${{ id: 'some-id', payload: { method: CommandMethod.Ping, params: null } }} | ${false} | ${'invalid vat command reply: payload.params is null'} + ${{ id: 123, payload: { method: CommandMethod.Ping, params: null } }} | ${false} | ${'invalid vat command reply: invalid id type'} + ${{ id: 'some-id' }} | ${false} | ${'invalid vat command reply: missing payload'} + ${{ id: 'some-id', payload: 123 }} | ${false} | ${'invalid vat command reply: payload is a primitive number'} + ${{ id: 'some-id', payload: { method: 123, params: null } }} | ${false} | ${'invalid vat command reply: invalid type in payload'} `( 'returns $expectedResult for $description', ({ value, expectedResult }) => { - expect(isVatMessage(value)).toBe(expectedResult); + expect(isVatCommandReply(value)).toBe(expectedResult); }, ); }); diff --git a/packages/utils/src/type-guards.ts b/packages/utils/src/type-guards.ts index 2483fbc57..7a9625473 100644 --- a/packages/utils/src/type-guards.ts +++ b/packages/utils/src/type-guards.ts @@ -1,10 +1,13 @@ -import { isObject } from '@metamask/utils'; +import { hasProperty, isObject } from '@metamask/utils'; -import type { - Command, - CapTpMessage, - VatMessage, - CapTpPayload, +import { + type Command, + type CapTpMessage, + type CapTpPayload, + type CommandReply, + CommandMethod, + type VatCommand, + type VatCommandReply, } from './types.js'; export const isCapTpPayload = (value: unknown): value is CapTpPayload => @@ -12,16 +15,33 @@ export const isCapTpPayload = (value: unknown): value is CapTpPayload => typeof value.method === 'string' && Array.isArray(value.params); -export const isCommand = (value: unknown): value is Command => +const isCommandLike = ( + value: unknown, +): value is { + method: CommandMethod; + params: string | null | CapTpPayload; +} => isObject(value) && - typeof value.method === 'string' && + Object.values(CommandMethod).includes(value.method as CommandMethod) && + hasProperty(value, 'params'); + +export const isCommand = (value: unknown): value is Command => + isCommandLike(value) && (typeof value.params === 'string' || value.params === null || isCapTpPayload(value.params)); -export const isVatMessage = (value: unknown): value is VatMessage => +export const isCommandReply = (value: unknown): value is CommandReply => + isCommandLike(value) && typeof value.params === 'string'; + +export const isVatCommand = (value: unknown): value is VatCommand => isObject(value) && typeof value.id === 'string' && isCommand(value.payload); +export const isVatCommandReply = (value: unknown): value is VatCommandReply => + isObject(value) && + typeof value.id === 'string' && + isCommandReply(value.payload); + export const isCapTpMessage = (value: unknown): value is CapTpMessage => isObject(value) && typeof value.type === 'string' && diff --git a/packages/utils/src/types.ts b/packages/utils/src/types.ts index 140909cac..58017791b 100644 --- a/packages/utils/src/types.ts +++ b/packages/utils/src/types.ts @@ -24,16 +24,27 @@ type CommandLike = { }; export type Command = - | CommandLike + | CommandLike | CommandLike | CommandLike | CommandLike; -export type VatMessage = { +export type CommandReply = + | CommandLike + | CommandLike + | CommandLike + | CommandLike; + +export type VatCommand = { id: string; payload: Command; }; +export type VatCommandReply = { + id: string; + payload: CommandReply; +}; + export type CapTpMessage = { type: Type; epoch: number;