From 307ccfbe073bdf77c7835a1144555ba4d8f8ffac Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Tue, 24 Sep 2024 20:06:26 -0500 Subject: [PATCH 1/9] feat(utils): Add CommandReply. --- packages/utils/src/index.ts | 3 ++- packages/utils/src/type-guards.test.ts | 20 +++++++++++++++++ packages/utils/src/type-guards.ts | 30 +++++++++++++++++++------- packages/utils/src/types.ts | 10 +++++++-- 4 files changed, 52 insertions(+), 11 deletions(-) diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index df0933e7b..3d6b692cd 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -2,10 +2,11 @@ export type { CapTpMessage, CapTpPayload, Command, + CommandReply, VatMessage, } from './types.js'; export { CommandMethod } from './types.js'; -export { isCommand } from './type-guards.js'; +export { isCommand, isCommandReply } from './type-guards.js'; export { wrapStreamCommand, wrapCapTp, diff --git a/packages/utils/src/type-guards.test.ts b/packages/utils/src/type-guards.test.ts index 5d2adee10..12598daa5 100644 --- a/packages/utils/src/type-guards.test.ts +++ b/packages/utils/src/type-guards.test.ts @@ -5,6 +5,7 @@ import { isCapTpMessage, isCommand, isCapTpPayload, + isCommandReply, } from './type-guards.js'; import { CommandMethod } from './types.js'; @@ -47,6 +48,25 @@ describe('type-guards', () => { ); }); + 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('isVatMessage', () => { it.each` value | expectedResult | description diff --git a/packages/utils/src/type-guards.ts b/packages/utils/src/type-guards.ts index 2483fbc57..197d06a09 100644 --- a/packages/utils/src/type-guards.ts +++ b/packages/utils/src/type-guards.ts @@ -1,10 +1,12 @@ -import { isObject } from '@metamask/utils'; +import { hasProperty, isObject } from '@metamask/utils'; -import type { - Command, - CapTpMessage, - VatMessage, - CapTpPayload, +import { + type Command, + type CapTpMessage, + type VatMessage, + type CapTpPayload, + type CommandReply, + CommandMethod, } from './types.js'; export const isCapTpPayload = (value: unknown): value is CapTpPayload => @@ -12,13 +14,25 @@ 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 isCommandReply = (value: unknown): value is CommandReply => + isCommandLike(value) && typeof value.params === 'string'; + export const isVatMessage = (value: unknown): value is VatMessage => isObject(value) && typeof value.id === 'string' && isCommand(value.payload); diff --git a/packages/utils/src/types.ts b/packages/utils/src/types.ts index 140909cac..f56311b61 100644 --- a/packages/utils/src/types.ts +++ b/packages/utils/src/types.ts @@ -24,14 +24,20 @@ type CommandLike = { }; export type Command = - | CommandLike + | CommandLike | CommandLike | CommandLike | CommandLike; +export type CommandReply = + | CommandLike + | CommandLike + | CommandLike + | CommandLike; + export type VatMessage = { id: string; - payload: Command; + payload: Command | CommandReply; }; export type CapTpMessage = { From c2bbaaf2cda68e9f8dee70e757ad4470ac299da8 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Wed, 25 Sep 2024 01:07:30 -0500 Subject: [PATCH 2/9] Differentiate commands and command replies --- packages/extension/src/background.ts | 12 ++- packages/extension/src/iframe.ts | 7 +- packages/extension/src/makeIframeVatWorker.ts | 7 +- packages/extension/src/offscreen.ts | 11 +-- packages/extension/src/shared.ts | 12 ++- packages/kernel/src/Supervisor.test.ts | 11 +-- packages/kernel/src/Supervisor.ts | 19 ++--- packages/kernel/src/Vat.test.ts | 34 ++++++--- packages/kernel/src/Vat.ts | 23 +++--- packages/kernel/src/types.ts | 6 +- packages/streams/src/streams.ts | 14 ++-- packages/utils/src/index.ts | 7 +- packages/utils/src/stream-envelope.test.ts | 74 ++++++++++++++++++- packages/utils/src/stream-envelope.ts | 40 +++++++++- packages/utils/src/type-guards.test.ts | 42 ++++++++--- packages/utils/src/type-guards.ts | 8 +- packages/utils/src/types.ts | 9 ++- 17 files changed, 254 insertions(+), 82 deletions(-) diff --git a/packages/extension/src/background.ts b/packages/extension/src/background.ts index 40345b842..f74e4bd10 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,9 @@ 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/makeIframeVatWorker.ts b/packages/extension/src/makeIframeVatWorker.ts index 0d6ac1a2c..cc69f73bb 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'; @@ -22,7 +22,10 @@ export const makeIframeVatWorker = ( init: async () => { const newWindow = await createWindow(IFRAME_URI, getHtmlId(id)); const port = await getPort(newWindow); - const streams = makeMessagePortStreamPair(port); + const streams = makeMessagePortStreamPair< + StreamEnvelopeReply, + StreamEnvelope + >(port); return [streams, newWindow]; }, diff --git a/packages/extension/src/offscreen.ts b/packages/extension/src/offscreen.ts index 4f06e41a6..c00216f1e 100644 --- a/packages/extension/src/offscreen.ts +++ b/packages/extension/src/offscreen.ts @@ -1,6 +1,7 @@ import { Kernel } from '@ocap/kernel'; import { initializeMessageChannel } from '@ocap/streams'; -import { CommandMethod } from '@ocap/utils'; +import type { CommandReply } from '@ocap/utils'; +import { CommandMethod, isCommand } from '@ocap/utils'; import { makeIframeVatWorker } from './makeIframeVatWorker.js'; import { @@ -24,7 +25,7 @@ async function main(): Promise { // Handle messages from the background service worker 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; } @@ -80,9 +81,9 @@ async function main(): Promise { * @param method - The command method. * @param params - The command parameters. */ - async function replyToCommand( - method: CommandMethod, - params?: string, + async function replyToCommand( + method: Type['method'], + params?: Type['params'], ): Promise { await chrome.runtime.sendMessage({ target: ExtensionMessageTarget.Background, diff --git a/packages/extension/src/shared.ts b/packages/extension/src/shared.ts index 6e1758ded..b329886a8 100644 --- a/packages/extension/src/shared.ts +++ b/packages/extension/src/shared.ts @@ -1,6 +1,10 @@ import { isObject } from '@metamask/utils'; -import type { Command } from '@ocap/utils'; -import { isCommand } from '@ocap/utils'; +import { + isCommand, + isCommandReply, + type Command, + type CommandReply, +} from '@ocap/utils'; export type VatId = string; @@ -10,7 +14,7 @@ export enum ExtensionMessageTarget { } export type ExtensionRuntimeMessage = { - payload: Command; + payload: Command | CommandReply; target: ExtensionMessageTarget; }; @@ -22,7 +26,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/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..6c0fa5369 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.replyStreamEnvelopeHandler, '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, @@ -127,12 +136,17 @@ describe('Vat', () => { it('creates a CapTP connection and sends CapTpInit message', async () => { // @ts-expect-error - streamEnvelopeHandler is readonly - vat.streamEnvelopeHandler = makeStreamEnvelopeHandler({}, console.warn); + vat.replyStreamEnvelopeHandler = makeStreamEnvelopeReplyHandler( + {}, + console.warn, + ); const sendMessageMock = vi .spyOn(vat, 'sendMessage') .mockResolvedValueOnce(undefined); await vat.makeCapTp(); - expect(vat.streamEnvelopeHandler.contentHandlers.capTp).toBeDefined(); + expect( + vat.replyStreamEnvelopeHandler.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..2632cdd08 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 replyStreamEnvelopeHandler: StreamEnvelopeReplyHandler; capTp?: ReturnType; @@ -42,7 +43,7 @@ export class Vat { this.id = id; this.streams = streams; this.#messageCounter = makeCounter(); - this.streamEnvelopeHandler = makeStreamEnvelopeHandler( + this.replyStreamEnvelopeHandler = 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.replyStreamEnvelopeHandler.handle(rawMessage); } } @@ -115,7 +116,7 @@ export class Vat { }); this.capTp = ctp; - this.streamEnvelopeHandler.contentHandlers.capTp = async ( + this.replyStreamEnvelopeHandler.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 3d6b692cd..c8973fb3e 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -3,7 +3,8 @@ export type { CapTpPayload, Command, CommandReply, - VatMessage, + VatCommand, + VatCommandReply, } from './types.js'; export { CommandMethod } from './types.js'; export { isCommand, isCommandReply } from './type-guards.js'; @@ -13,4 +14,8 @@ export { 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..636ef2fff 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 replyEnvelopeKit = makeStreamEnvelopeKit< + typeof envelopeLabels, + { + command: VatCommandReply; + capTp: CapTpMessage; + } +>({ + command: (value) => isVatCommandReply(value), + capTp: isCapTpMessage, +}); + +export type StreamEnvelopeReply = GuardType< + typeof replyEnvelopeKit.isStreamEnvelope +>; +export type StreamEnvelopeReplyHandler = ReturnType< + typeof replyEnvelopeKit.makeStreamEnvelopeHandler +>; + +export const wrapStreamCommandReply = + replyEnvelopeKit.streamEnveloper.command.wrap; +// Note: We don't differentiate between wrapCapTp and wrapCapTpReply +export const { makeStreamEnvelopeHandler: makeStreamEnvelopeReplyHandler } = + replyEnvelopeKit; diff --git a/packages/utils/src/type-guards.test.ts b/packages/utils/src/type-guards.test.ts index 12598daa5..37f56b66c 100644 --- a/packages/utils/src/type-guards.test.ts +++ b/packages/utils/src/type-guards.test.ts @@ -1,11 +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'; @@ -67,21 +68,40 @@ describe('type-guards', () => { ); }); - describe('isVatMessage', () => { + 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(isVatMessage(value)).toBe(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: 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(isVatCommandReply(value)).toBe(expectedResult); }, ); }); diff --git a/packages/utils/src/type-guards.ts b/packages/utils/src/type-guards.ts index 197d06a09..5b1fb55d7 100644 --- a/packages/utils/src/type-guards.ts +++ b/packages/utils/src/type-guards.ts @@ -3,10 +3,11 @@ import { hasProperty, isObject } from '@metamask/utils'; import { type Command, type CapTpMessage, - type VatMessage, type CapTpPayload, type CommandReply, CommandMethod, + type VatCommand, + type VatCommandReply, } from './types.js'; export const isCapTpPayload = (value: unknown): value is CapTpPayload => @@ -33,7 +34,10 @@ export const isCommand = (value: unknown): value is Command => export const isCommandReply = (value: unknown): value is CommandReply => isCommandLike(value) && typeof value.params === 'string'; -export const isVatMessage = (value: unknown): value is VatMessage => +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' && isCommand(value.payload); export const isCapTpMessage = (value: unknown): value is CapTpMessage => diff --git a/packages/utils/src/types.ts b/packages/utils/src/types.ts index f56311b61..c15c38385 100644 --- a/packages/utils/src/types.ts +++ b/packages/utils/src/types.ts @@ -35,9 +35,14 @@ export type CommandReply = | CommandLike | CommandLike; -export type VatMessage = { +export type VatCommand = { id: string; - payload: Command | CommandReply; + payload: Command; +}; + +export type VatCommandReply = { + id: string; + payload: CommandReply; }; export type CapTpMessage = { From e39dec9f21b0a01f36bb3c155835efde5d5f3057 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Wed, 25 Sep 2024 01:55:04 -0500 Subject: [PATCH 3/9] Pick a naming convention. --- packages/kernel/src/Vat.test.ts | 8 ++++---- packages/kernel/src/Vat.ts | 8 ++++---- packages/utils/src/stream-envelope.ts | 10 +++++----- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/kernel/src/Vat.test.ts b/packages/kernel/src/Vat.test.ts index 6c0fa5369..a71ba6891 100644 --- a/packages/kernel/src/Vat.test.ts +++ b/packages/kernel/src/Vat.test.ts @@ -67,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.replyStreamEnvelopeHandler, 'handle'); + const handleSpy = vi.spyOn(vat.streamEnvelopeReplyHandler, 'handle'); await vat.init(); const writer = new MessagePortWriter(messageChannel.port2); const rawMessage = { type: 'command', payload: { method: 'test' } }; @@ -135,8 +135,8 @@ describe('Vat', () => { }); it('creates a CapTP connection and sends CapTpInit message', async () => { - // @ts-expect-error - streamEnvelopeHandler is readonly - vat.replyStreamEnvelopeHandler = makeStreamEnvelopeReplyHandler( + // @ts-expect-error - streamEnvelopeReplyHandler is readonly + vat.streamEnvelopeReplyHandler = makeStreamEnvelopeReplyHandler( {}, console.warn, ); @@ -145,7 +145,7 @@ describe('Vat', () => { .mockResolvedValueOnce(undefined); await vat.makeCapTp(); expect( - vat.replyStreamEnvelopeHandler.contentHandlers.capTp, + vat.streamEnvelopeReplyHandler.contentHandlers.capTp, ).toBeDefined(); expect(sendMessageMock).toHaveBeenCalledWith({ method: CommandMethod.CapTpInit, diff --git a/packages/kernel/src/Vat.ts b/packages/kernel/src/Vat.ts index 2632cdd08..4f7288ad7 100644 --- a/packages/kernel/src/Vat.ts +++ b/packages/kernel/src/Vat.ts @@ -35,7 +35,7 @@ export class Vat { readonly unresolvedMessages: UnresolvedMessages = new Map(); - readonly replyStreamEnvelopeHandler: StreamEnvelopeReplyHandler; + readonly streamEnvelopeReplyHandler: StreamEnvelopeReplyHandler; capTp?: ReturnType; @@ -43,7 +43,7 @@ export class Vat { this.id = id; this.streams = streams; this.#messageCounter = makeCounter(); - this.replyStreamEnvelopeHandler = makeStreamEnvelopeReplyHandler( + this.streamEnvelopeReplyHandler = makeStreamEnvelopeReplyHandler( { command: this.handleMessage.bind(this) }, (error) => console.error('Vat stream error:', error), ); @@ -92,7 +92,7 @@ export class Vat { async #receiveMessages(reader: Reader): Promise { for await (const rawMessage of reader) { console.debug('Vat received message', rawMessage); - await this.replyStreamEnvelopeHandler.handle(rawMessage); + await this.streamEnvelopeReplyHandler.handle(rawMessage); } } @@ -116,7 +116,7 @@ export class Vat { }); this.capTp = ctp; - this.replyStreamEnvelopeHandler.contentHandlers.capTp = async ( + this.streamEnvelopeReplyHandler.contentHandlers.capTp = async ( content: CapTpMessage, ) => { console.log('CapTP from vat', JSON.stringify(content, null, 2)); diff --git a/packages/utils/src/stream-envelope.ts b/packages/utils/src/stream-envelope.ts index 636ef2fff..fbe9e833a 100644 --- a/packages/utils/src/stream-envelope.ts +++ b/packages/utils/src/stream-envelope.ts @@ -53,7 +53,7 @@ export const { makeStreamEnvelopeHandler } = envelopeKit; // For now, a separate envelope kit for replies only -const replyEnvelopeKit = makeStreamEnvelopeKit< +const streamEnvelopeReplyKit = makeStreamEnvelopeKit< typeof envelopeLabels, { command: VatCommandReply; @@ -65,14 +65,14 @@ const replyEnvelopeKit = makeStreamEnvelopeKit< }); export type StreamEnvelopeReply = GuardType< - typeof replyEnvelopeKit.isStreamEnvelope + typeof streamEnvelopeReplyKit.isStreamEnvelope >; export type StreamEnvelopeReplyHandler = ReturnType< - typeof replyEnvelopeKit.makeStreamEnvelopeHandler + typeof streamEnvelopeReplyKit.makeStreamEnvelopeHandler >; export const wrapStreamCommandReply = - replyEnvelopeKit.streamEnveloper.command.wrap; + streamEnvelopeReplyKit.streamEnveloper.command.wrap; // Note: We don't differentiate between wrapCapTp and wrapCapTpReply export const { makeStreamEnvelopeHandler: makeStreamEnvelopeReplyHandler } = - replyEnvelopeKit; + streamEnvelopeReplyKit; From a30d99aaeddc75bea549814d91ef66e198f38939 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Wed, 25 Sep 2024 09:51:09 -0500 Subject: [PATCH 4/9] finesse CI-specific lint error --- packages/extension/src/shared.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/extension/src/shared.ts b/packages/extension/src/shared.ts index b329886a8..a7105718d 100644 --- a/packages/extension/src/shared.ts +++ b/packages/extension/src/shared.ts @@ -1,10 +1,6 @@ import { isObject } from '@metamask/utils'; -import { - isCommand, - isCommandReply, - type Command, - type CommandReply, -} from '@ocap/utils'; +import type { Command, CommandReply } from '@ocap/utils'; +import { isCommand, isCommandReply } from '@ocap/utils'; export type VatId = string; @@ -14,6 +10,8 @@ export enum ExtensionMessageTarget { } export type ExtensionRuntimeMessage = { + // There is overlap between the Evaluate type of Command and CommandReply. + // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents payload: Command | CommandReply; target: ExtensionMessageTarget; }; From 627966002cf5829fced601954c19b659534fbd3f Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Thu, 26 Sep 2024 00:02:36 -0500 Subject: [PATCH 5/9] fix(utils): Correct type definitions. --- packages/utils/src/type-guards.test.ts | 1 + packages/utils/src/type-guards.ts | 4 +++- packages/utils/src/types.ts | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/utils/src/type-guards.test.ts b/packages/utils/src/type-guards.test.ts index 37f56b66c..f3dca313f 100644 --- a/packages/utils/src/type-guards.test.ts +++ b/packages/utils/src/type-guards.test.ts @@ -94,6 +94,7 @@ describe('type-guards', () => { ${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'} diff --git a/packages/utils/src/type-guards.ts b/packages/utils/src/type-guards.ts index 5b1fb55d7..7a9625473 100644 --- a/packages/utils/src/type-guards.ts +++ b/packages/utils/src/type-guards.ts @@ -38,7 +38,9 @@ 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' && isCommand(value.payload); + isObject(value) && + typeof value.id === 'string' && + isCommandReply(value.payload); export const isCapTpMessage = (value: unknown): value is CapTpMessage => isObject(value) && diff --git a/packages/utils/src/types.ts b/packages/utils/src/types.ts index c15c38385..58017791b 100644 --- a/packages/utils/src/types.ts +++ b/packages/utils/src/types.ts @@ -32,7 +32,7 @@ export type Command = export type CommandReply = | CommandLike | CommandLike - | CommandLike + | CommandLike | CommandLike; export type VatCommand = { From 0c89e42fce5a3e31a5bb760d6fa02e6938d6e95f Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Thu, 26 Sep 2024 00:06:02 -0500 Subject: [PATCH 6/9] refactor(extension): Demorgan unexpected message checks. --- packages/extension/src/background.ts | 3 ++- packages/extension/src/offscreen.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/extension/src/background.ts b/packages/extension/src/background.ts index f74e4bd10..f4f7676c0 100644 --- a/packages/extension/src/background.ts +++ b/packages/extension/src/background.ts @@ -77,7 +77,8 @@ async function provideOffScreenDocument(): Promise { chrome.runtime.onMessage.addListener( makeHandledCallback(async (message: unknown) => { if ( - !(isExtensionRuntimeMessage(message) && isCommandReply(message.payload)) + !isExtensionRuntimeMessage(message) || + !isCommandReply(message.payload) ) { console.error('Background received unexpected message', message); return; diff --git a/packages/extension/src/offscreen.ts b/packages/extension/src/offscreen.ts index c00216f1e..0029638e7 100644 --- a/packages/extension/src/offscreen.ts +++ b/packages/extension/src/offscreen.ts @@ -25,7 +25,7 @@ async function main(): Promise { // Handle messages from the background service worker chrome.runtime.onMessage.addListener( makeHandledCallback(async (message: unknown) => { - if (!(isExtensionRuntimeMessage(message) && isCommand(message.payload))) { + if (!isExtensionRuntimeMessage(message) || !isCommand(message.payload)) { console.error('Offscreen received unexpected message', message); return; } From faa44cda79c6a5b40112a99dd1cad5a316a3d547 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Thu, 26 Sep 2024 02:12:38 -0500 Subject: [PATCH 7/9] fix(extension): Remove generics. --- packages/extension/src/offscreen.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/extension/src/offscreen.ts b/packages/extension/src/offscreen.ts index 0029638e7..5a327427b 100644 --- a/packages/extension/src/offscreen.ts +++ b/packages/extension/src/offscreen.ts @@ -81,9 +81,9 @@ async function main(): Promise { * @param method - The command method. * @param params - The command parameters. */ - async function replyToCommand( - method: Type['method'], - params?: Type['params'], + async function replyToCommand( + method: CommandReply['method'], + params?: CommandReply['params'], ): Promise { await chrome.runtime.sendMessage({ target: ExtensionMessageTarget.Background, From bec043494838ea2f533aeef1f2dd06349c57942f Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Thu, 26 Sep 2024 03:17:07 -0400 Subject: [PATCH 8/9] docs(extension): Edit lint exception explanation. Co-authored-by: Erik Marks <25517051+rekmarks@users.noreply.github.com> --- packages/extension/src/shared.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/extension/src/shared.ts b/packages/extension/src/shared.ts index a7105718d..f95390ad0 100644 --- a/packages/extension/src/shared.ts +++ b/packages/extension/src/shared.ts @@ -10,7 +10,7 @@ export enum ExtensionMessageTarget { } export type ExtensionRuntimeMessage = { - // There is overlap between the Evaluate type of Command and CommandReply. + // 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; From b1e33f6f7012ce6f8b3ee28607f59e404b83e7be Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Thu, 26 Sep 2024 12:30:04 -0500 Subject: [PATCH 9/9] draft --- packages/extension/src/kernel.html | 9 ++ packages/extension/src/kernel.ts | 78 ++++++++++ packages/extension/src/makeIframeVatWorker.ts | 3 + packages/extension/src/manifest.json | 2 +- packages/extension/src/offscreen.ts | 140 ++++++++---------- packages/extension/vite.config.ts | 1 + 6 files changed, 157 insertions(+), 76 deletions(-) create mode 100644 packages/extension/src/kernel.html create mode 100644 packages/extension/src/kernel.ts 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 cc69f73bb..24f4be2e0 100644 --- a/packages/extension/src/makeIframeVatWorker.ts +++ b/packages/extension/src/makeIframeVatWorker.ts @@ -20,8 +20,11 @@ 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); + console.debug('vat worker connected'); const streams = makeMessagePortStreamPair< StreamEnvelopeReply, StreamEnvelope 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 5a327427b..6b6c8dcc2 100644 --- a/packages/extension/src/offscreen.ts +++ b/packages/extension/src/offscreen.ts @@ -1,28 +1,72 @@ -import { Kernel } from '@ocap/kernel'; -import { initializeMessageChannel } from '@ocap/streams'; -import type { CommandReply } 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) || !isCommand(message.payload)) { @@ -36,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( @@ -74,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: CommandReply['method'], - params?: CommandReply['params'], - ): 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/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',