diff --git a/packages/extension/src/background.ts b/packages/extension/src/background.ts index 7dfb52536..8665097de 100644 --- a/packages/extension/src/background.ts +++ b/packages/extension/src/background.ts @@ -1,7 +1,7 @@ import './background-trusted-prelude.js'; import type { Json } from '@metamask/utils'; -import { CommandMethod, isCommandReply } from '@ocap/kernel'; -import type { Command, CommandFunction } from '@ocap/kernel'; +import { ClusterCommandMethod, isClusterCommandReply } from '@ocap/kernel'; +import type { ClusterCommand, ClusterCommandFunction } from '@ocap/kernel'; import { ChromeRuntimeTarget, makeChromeRuntimeStreamPair, @@ -26,9 +26,9 @@ async function main(): Promise { * @param params - The message data. * @param params.name - The name to include in the message. */ - const sendCommand: CommandFunction> = async ( - method: CommandMethod, - params?: Command['params'], + const sendClusterCommand: ClusterCommandFunction> = async ( + method: ClusterCommand['method'], + params?: ClusterCommand['params'], ) => { await provideOffScreenDocument(); @@ -42,27 +42,25 @@ async function main(): Promise { Object.defineProperties(globalThis.kernel, { capTpCall: { value: async (method: string, params: Json[]) => - sendCommand(CommandMethod.CapTpCall, { method, params }), - }, - capTpInit: { - value: async () => sendCommand(CommandMethod.CapTpInit), + sendClusterCommand(ClusterCommandMethod.CapTpCall, { method, params }), }, evaluate: { value: async (source: string) => - sendCommand(CommandMethod.Evaluate, source), + sendClusterCommand(ClusterCommandMethod.Evaluate, source), }, ping: { - value: async () => sendCommand(CommandMethod.Ping), + value: async () => sendClusterCommand(ClusterCommandMethod.Ping), }, sendMessage: { - value: sendCommand, + value: sendClusterCommand, }, kvGet: { - value: async (key: string) => sendCommand(CommandMethod.KVGet, key), + value: async (key: string) => + sendClusterCommand(ClusterCommandMethod.KVGet, key), }, kvSet: { value: async (key: string, value: string) => - sendCommand(CommandMethod.KVSet, { key, value }), + sendClusterCommand(ClusterCommandMethod.KVSet, { key, value }), }, }); harden(globalThis.kernel); @@ -71,7 +69,7 @@ async function main(): Promise { // With this we can click the extension action button to wake up the service worker. chrome.action.onClicked.addListener(() => { - sendCommand(CommandMethod.Ping).catch(console.error); + sendClusterCommand(ClusterCommandMethod.Ping).catch(console.error); }); /** @@ -89,18 +87,17 @@ async function main(): Promise { // Handle replies from the offscreen document for await (const message of offscreenStreams.reader) { - if (!isCommandReply(message)) { + if (!isClusterCommandReply(message)) { console.error('Background received unexpected message', message); continue; } switch (message.method) { - case CommandMethod.Evaluate: - case CommandMethod.CapTpCall: - case CommandMethod.CapTpInit: - case CommandMethod.Ping: - case CommandMethod.KVGet: - case CommandMethod.KVSet: + case ClusterCommandMethod.Evaluate: + case ClusterCommandMethod.CapTpCall: + case ClusterCommandMethod.Ping: + case ClusterCommandMethod.KVGet: + case ClusterCommandMethod.KVSet: console.log(message.params); break; default: diff --git a/packages/extension/src/kernel-worker.ts b/packages/extension/src/kernel-worker.ts index d64566cd6..f3c9a5a45 100644 --- a/packages/extension/src/kernel-worker.ts +++ b/packages/extension/src/kernel-worker.ts @@ -1,12 +1,23 @@ import './kernel-worker-trusted-prelude.js'; -import { CommandMethod, isCommand } from '@ocap/kernel'; -import type { CommandReply, CommandReplyFunction } from '@ocap/kernel'; -import { stringify } from '@ocap/utils'; +import type { KernelCommand, KernelCommandReply } from '@ocap/kernel'; +import { isKernelCommand, KernelCommandMethod } from '@ocap/kernel'; import type { Database } from '@sqlite.org/sqlite-wasm'; import sqlite3InitModule from '@sqlite.org/sqlite-wasm'; main().catch(console.error); +// We temporarily have the kernel commands split between offscreen and kernel-worker +type KernelWorkerCommand = Extract< + KernelCommand, + | { method: typeof KernelCommandMethod.KVSet } + | { method: typeof KernelCommandMethod.KVGet } +>; + +const isKernelWorkerCommand = (value: unknown): value is KernelWorkerCommand => + isKernelCommand(value) && + (value.method === KernelCommandMethod.KVSet || + value.method === KernelCommandMethod.KVGet); + type Queue = Type[]; type VatId = `v${number}`; @@ -165,10 +176,10 @@ async function main(): Promise { * @param method - The message method. * @param params - The message params. */ - const reply: CommandReplyFunction = ( - method: CommandMethod, - params?: CommandReply['params'], - ) => { + const reply = ( + method: KernelCommandReply['method'], + params?: KernelCommandReply['params'], + ): void => { postMessage({ method, params }); }; @@ -183,71 +194,49 @@ async function main(): Promise { ? problem : new Error('Unknown', { cause: problem }); - // Handle messages from the console service worker - globalThis.onmessage = async (event: MessageEvent) => { - if (!isCommand(event.data)) { - console.error('Received unexpected message', event.data); - return; - } - - const { method, params } = event.data; - console.log('received message: ', method, params); - + /** + * Handle a KernelCommand sent from the offscreen. + * + * @param command - The KernelCommand to handle. + * @param command.method - The command method. + * @param command.params - The command params. + */ + const handleKernelCommand = ({ + method, + params, + }: KernelWorkerCommand): void => { switch (method) { - case CommandMethod.Evaluate: - reply(CommandMethod.Evaluate, await evaluate(params)); - break; - case CommandMethod.CapTpCall: { - reply( - CommandMethod.CapTpCall, - 'Error: CapTpCall not implemented here (yet)', - ); - break; - } - case CommandMethod.CapTpInit: - reply( - CommandMethod.CapTpInit, - 'Error: CapTpInit not implemented here (yet)', - ); - break; - case CommandMethod.Ping: - reply(CommandMethod.Ping, 'pong'); - break; - case CommandMethod.KVSet: { - const { key, value } = params; - kvSet(key, value); - reply(CommandMethod.KVSet, `~~~ set "${key}" to "${value}" ~~~`); + case KernelCommandMethod.KVSet: + kvSet(params.key, params.value); + reply(method, `~~~ set "${params.key}" to "${params.value}" ~~~`); break; - } - case CommandMethod.KVGet: { + case KernelCommandMethod.KVGet: { try { const result = kvGet(params); - reply(CommandMethod.KVGet, result); + reply(method, result); } catch (problem) { - // The below will work because we call into globalThis.postMessage() directly, - // which can handle errors. This will need to be addressed once we use streams here. - // @ts-expect-error TODO: Fix when we have streams. - reply(CommandMethod.KVGet, asError(problem)); + // TODO: marshal + reply(method, String(asError(problem))); } break; } default: console.error( - `Kernel received unexpected method in message: "${stringify( - // @ts-expect-error Runtime does not respect "never". - method.valueOf(), - )}"`, + 'kernel worker received unexpected command', + // @ts-expect-error Runtime does not respect "never". + { method: method.valueOf(), params }, ); } }; - /** - * Evaluate a string in the default iframe. - * - * @param _source - The source string to evaluate. - * @returns The result of the evaluation, or an error message. - */ - async function evaluate(_source: string): Promise { - return `Error: evaluate not implemented here (yet)`; - } + // Handle messages from the console service worker + globalThis.onmessage = async ( + event: MessageEvent, + ): Promise => { + if (isKernelWorkerCommand(event.data)) { + handleKernelCommand(event.data); + } else { + console.error('Received unexpected message', event.data); + } + }; } diff --git a/packages/extension/src/offscreen.ts b/packages/extension/src/offscreen.ts index 7768279a6..980b3a103 100644 --- a/packages/extension/src/offscreen.ts +++ b/packages/extension/src/offscreen.ts @@ -1,5 +1,10 @@ -import { Kernel, CommandMethod, isCommand } from '@ocap/kernel'; -import type { CommandReply, Command, CommandReplyFunction } from '@ocap/kernel'; +import { + Kernel, + KernelCommandMethod, + isKernelCommand, + isKernelCommandReply, +} from '@ocap/kernel'; +import type { KernelCommandReply, KernelCommand, VatId } from '@ocap/kernel'; import { ChromeRuntimeTarget, initializeMessageChannel, @@ -24,24 +29,19 @@ async function main(): Promise { const kernel = new Kernel(); const iframeReadyP = kernel.launchVat({ - id: 'default', - worker: makeIframeVatWorker('default', initializeMessageChannel), + id: 'v0', + worker: makeIframeVatWorker('v0', initializeMessageChannel), }); /** * Reply to a command from the background script. * - * @param method - The command method. - * @param params - The command parameters. + * @param commandReply - The reply to send. */ - const replyToCommand: CommandReplyFunction> = async ( - method: CommandMethod, - params?: CommandReply['params'], - ) => { - await backgroundStreams.writer.next({ - method, - params: params ?? null, - }); + const replyToBackground = async ( + commandReply: KernelCommandReply, + ): Promise => { + await backgroundStreams.writer.next(commandReply); }; const receiveFromKernel = async (event: MessageEvent): Promise => { @@ -54,8 +54,9 @@ async function main(): Promise { // XXX TODO: Using the IframeMessage type here assumes that the set of response messages is the // same as (and aligns perfectly with) the set of command messages, which is horribly, terribly, // awfully wrong. Need to add types to account for the replies. - if (!isCommand(event.data)) { + if (!isKernelCommandReply(event.data)) { console.error('kernel received unexpected message', event.data); + return; } const { method, params } = event.data; let result: string; @@ -68,9 +69,15 @@ async function main(): Promise { // the sole eventual recipient is a human eyeball, and even then it's questionable. result = `ERROR: ${possibleError.message}`; } else { - result = params as string; + result = params; + } + const reply = { method, params: result ?? null }; + if (!isKernelCommandReply(reply)) { + // Internal error. + console.error('Malformed command reply', reply); + return; } - await replyToCommand(method, result); + await replyToBackground(reply); }; const kernelWorker = new Worker('kernel-worker.js', { type: 'module' }); @@ -79,49 +86,76 @@ async function main(): Promise { makeHandledCallback(receiveFromKernel), ); - // Handle messages from the background service worker, which for the time being stands in for the - // user console. - for await (const message of backgroundStreams.reader) { - if (!isCommand(message)) { - console.error('Offscreen received unexpected message', message); - continue; - } - + const handleVatTestCommand = async ({ + method, + params, + }: Extract< + KernelCommand, + | { method: typeof KernelCommandMethod.Evaluate } + | { method: typeof KernelCommandMethod.CapTpCall } + >): Promise => { const vat = await iframeReadyP; - - switch (message.method) { - case CommandMethod.Evaluate: - await replyToCommand( - CommandMethod.Evaluate, - await evaluate(vat.id, message.params), - ); + switch (method) { + case KernelCommandMethod.Evaluate: + await replyToBackground({ + method, + params: await evaluate(vat.id, params), + }); break; - case CommandMethod.CapTpCall: { - const result = await vat.callCapTp(message.params); - await replyToCommand(CommandMethod.CapTpCall, stringify(result)); + case KernelCommandMethod.CapTpCall: + await replyToBackground({ + method, + params: stringify(await vat.callCapTp(params)), + }); break; - } - case CommandMethod.CapTpInit: - await vat.makeCapTp(); - await replyToCommand( - CommandMethod.CapTpInit, - '~~~ CapTP Initialized ~~~', + default: + console.error( + 'Offscreen received unexpected vat command', + // @ts-expect-error Runtime does not respect "never". + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + { method: method.valueOf(), params }, ); + } + }; + + const handleKernelCommand = async ({ + method, + params, + }: KernelCommand): Promise => { + switch (method) { + case KernelCommandMethod.Ping: + await replyToBackground({ method, params: 'pong' }); + break; + case KernelCommandMethod.Evaluate: + await handleVatTestCommand({ method, params }); break; - case CommandMethod.Ping: - await replyToCommand(CommandMethod.Ping, 'pong'); + case KernelCommandMethod.CapTpCall: + await handleVatTestCommand({ method, params }); break; - case CommandMethod.KVGet: - case CommandMethod.KVSet: - sendKernelMessage(message); + case KernelCommandMethod.KVGet: + sendKernelMessage({ method, params }); + break; + case KernelCommandMethod.KVSet: + sendKernelMessage({ method, params }); break; default: console.error( + 'Offscreen received unexpected kernel command', // @ts-expect-error Runtime does not respect "never". - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - `Offscreen received unexpected command method: "${message.method}"`, + { method: method.valueOf(), params }, ); } + }; + + // Handle messages from the background service worker, which for the time being stands in for the + // user console. + for await (const message of backgroundStreams.reader) { + if (!isKernelCommand(message)) { + console.error('Offscreen received unexpected message', message); + continue; + } + + await handleKernelCommand(message); } /** @@ -131,10 +165,10 @@ async function main(): Promise { * @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 { + async function evaluate(vatId: VatId, source: string): Promise { try { const result = await kernel.sendMessage(vatId, { - method: CommandMethod.Evaluate, + method: KernelCommandMethod.Evaluate, params: source, }); return String(result); @@ -151,7 +185,7 @@ async function main(): Promise { * * @param payload - The message to send. */ - function sendKernelMessage(payload: Command): void { + function sendKernelMessage(payload: KernelCommand): void { kernelWorker.postMessage(payload); } } diff --git a/packages/extension/src/shared.ts b/packages/extension/src/shared.ts index 2bc7a4e10..a4da266d7 100644 --- a/packages/extension/src/shared.ts +++ b/packages/extension/src/shared.ts @@ -1,5 +1,3 @@ -export type VatId = string; - /** * Wrap an async callback to ensure any errors are at least logged. * diff --git a/packages/kernel/src/Kernel.test.ts b/packages/kernel/src/Kernel.test.ts index b1482098e..edbefb774 100644 --- a/packages/kernel/src/Kernel.test.ts +++ b/packages/kernel/src/Kernel.test.ts @@ -1,8 +1,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import type { Command } from './command.js'; import { Kernel } from './Kernel.js'; -import type { VatWorker } from './types.js'; +import type { VatCommand } from './messages.js'; +import type { VatId, VatWorker } from './types.js'; import { Vat } from './Vat.js'; describe('Kernel', () => { @@ -32,47 +32,47 @@ describe('Kernel', () => { it('returns the vat IDs after adding a vat', async () => { const kernel = new Kernel(); - await kernel.launchVat({ id: 'vat-id-1', worker: mockWorker }); - expect(kernel.getVatIds()).toStrictEqual(['vat-id-1']); + await kernel.launchVat({ id: 'v0', worker: mockWorker }); + expect(kernel.getVatIds()).toStrictEqual(['v0']); }); it('returns multiple vat IDs after adding multiple vats', async () => { const kernel = new Kernel(); - await kernel.launchVat({ id: 'vat-id-1', worker: mockWorker }); - await kernel.launchVat({ id: 'vat-id-2', worker: mockWorker }); - expect(kernel.getVatIds()).toStrictEqual(['vat-id-1', 'vat-id-2']); + await kernel.launchVat({ id: 'v0', worker: mockWorker }); + await kernel.launchVat({ id: 'v1', worker: mockWorker }); + expect(kernel.getVatIds()).toStrictEqual(['v0', 'v1']); }); }); describe('launchVat()', () => { it('adds a vat to the kernel without errors when no vat with the same ID exists', async () => { const kernel = new Kernel(); - await kernel.launchVat({ id: 'vat-id-1', worker: mockWorker }); + await kernel.launchVat({ id: 'v0', worker: mockWorker }); expect(initMock).toHaveBeenCalledOnce(); expect(mockWorker.init).toHaveBeenCalled(); - expect(kernel.getVatIds()).toStrictEqual(['vat-id-1']); + expect(kernel.getVatIds()).toStrictEqual(['v0']); }); it('throws an error when launching a vat that already exists in the kernel', async () => { const kernel = new Kernel(); - await kernel.launchVat({ id: 'vat-id-1', worker: mockWorker }); - expect(kernel.getVatIds()).toStrictEqual(['vat-id-1']); + await kernel.launchVat({ id: 'v0', worker: mockWorker }); + expect(kernel.getVatIds()).toStrictEqual(['v0']); await expect( kernel.launchVat({ - id: 'vat-id-1', + id: 'v0', worker: mockWorker, }), - ).rejects.toThrow('Vat with ID vat-id-1 already exists.'); - expect(kernel.getVatIds()).toStrictEqual(['vat-id-1']); + ).rejects.toThrow('Vat with ID v0 already exists.'); + expect(kernel.getVatIds()).toStrictEqual(['v0']); }); }); describe('deleteVat()', () => { it('deletes a vat from the kernel without errors when the vat exists', async () => { const kernel = new Kernel(); - await kernel.launchVat({ id: 'vat-id', worker: mockWorker }); - expect(kernel.getVatIds()).toStrictEqual(['vat-id']); - await kernel.deleteVat('vat-id'); + await kernel.launchVat({ id: 'v0', worker: mockWorker }); + expect(kernel.getVatIds()).toStrictEqual(['v0']); + await kernel.deleteVat('v0'); expect(terminateMock).toHaveBeenCalledOnce(); expect(mockWorker.delete).toHaveBeenCalledOnce(); expect(kernel.getVatIds()).toStrictEqual([]); @@ -80,17 +80,18 @@ describe('Kernel', () => { it('throws an error when deleting a vat that does not exist in the kernel', async () => { const kernel = new Kernel(); + const nonExistentVatId: VatId = 'v9'; await expect(async () => - kernel.deleteVat('non-existent-vat-id'), - ).rejects.toThrow('Vat with ID non-existent-vat-id does not exist.'); + kernel.deleteVat(nonExistentVatId), + ).rejects.toThrow(`Vat with ID ${nonExistentVatId} does not exist.`); expect(terminateMock).not.toHaveBeenCalled(); }); it('throws an error when a vat terminate method throws', async () => { const kernel = new Kernel(); - await kernel.launchVat({ id: 'vat-id', worker: mockWorker }); + await kernel.launchVat({ id: 'v0', worker: mockWorker }); vi.spyOn(Vat.prototype, 'terminate').mockRejectedValueOnce('Test error'); - await expect(async () => kernel.deleteVat('vat-id')).rejects.toThrow( + await expect(async () => kernel.deleteVat('v0')).rejects.toThrow( 'Test error', ); }); @@ -99,26 +100,30 @@ describe('Kernel', () => { describe('sendMessage()', () => { it('sends a message to the vat without errors when the vat exists', async () => { const kernel = new Kernel(); - await kernel.launchVat({ id: 'vat-id', worker: mockWorker }); + await kernel.launchVat({ id: 'v0', worker: mockWorker }); vi.spyOn(Vat.prototype, 'sendMessage').mockResolvedValueOnce('test'); expect( - await kernel.sendMessage('vat-id', 'test' as unknown as Command), + await kernel.sendMessage( + 'v0', + 'test' as unknown as VatCommand['payload'], + ), ).toBe('test'); }); it('throws an error when sending a message to the vat that does not exist in the kernel', async () => { const kernel = new Kernel(); + const nonExistentVatId: VatId = 'v9'; await expect(async () => - kernel.sendMessage('non-existent-vat-id', {} as Command), - ).rejects.toThrow('Vat with ID non-existent-vat-id does not exist.'); + kernel.sendMessage(nonExistentVatId, {} as VatCommand['payload']), + ).rejects.toThrow(`Vat with ID ${nonExistentVatId} does not exist.`); }); it('throws an error when sending a message to the vat throws', async () => { const kernel = new Kernel(); - await kernel.launchVat({ id: 'vat-id', worker: mockWorker }); + await kernel.launchVat({ id: 'v0', worker: mockWorker }); vi.spyOn(Vat.prototype, 'sendMessage').mockRejectedValueOnce('error'); await expect(async () => - kernel.sendMessage('vat-id', {} as Command), + kernel.sendMessage('v0', {} as VatCommand['payload']), ).rejects.toThrow('error'); }); }); diff --git a/packages/kernel/src/Kernel.ts b/packages/kernel/src/Kernel.ts index 5499b62a8..056ed6b2d 100644 --- a/packages/kernel/src/Kernel.ts +++ b/packages/kernel/src/Kernel.ts @@ -1,5 +1,5 @@ import '@ocap/shims/endoify'; -import type { Command } from './command.js'; +import type { VatCommand } from './messages.js'; import type { VatId, VatWorker } from './types.js'; import { Vat } from './Vat.js'; @@ -64,7 +64,10 @@ export class Kernel { * @param command - The command to send. * @returns A promise that resolves the response to the message. */ - async sendMessage(id: VatId, command: Command): Promise { + async sendMessage( + id: VatId, + command: VatCommand['payload'], + ): Promise { const { vat } = this.#getVatRecord(id); return vat.sendMessage(command); } diff --git a/packages/kernel/src/Supervisor.test.ts b/packages/kernel/src/Supervisor.test.ts index 52b942b03..fd1b91dee 100644 --- a/packages/kernel/src/Supervisor.test.ts +++ b/packages/kernel/src/Supervisor.test.ts @@ -3,7 +3,7 @@ import { makeMessagePortStreamPair, MessagePortWriter } from '@ocap/streams'; import { delay } from '@ocap/test-utils'; import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { CommandMethod } from './command.js'; +import { VatCommandMethod } from './messages.js'; import type { StreamEnvelope, StreamEnvelopeReply } from './stream-envelope.js'; import * as streamEnvelope from './stream-envelope.js'; import { Supervisor } from './Supervisor.js'; @@ -59,12 +59,12 @@ describe('Supervisor', () => { const replySpy = vi.spyOn(supervisor, 'replyToMessage'); await supervisor.handleMessage({ - id: 'message-id', - payload: { method: CommandMethod.Ping, params: null }, + id: 'v0:0', + payload: { method: VatCommandMethod.Ping, params: null }, }); - expect(replySpy).toHaveBeenCalledWith('message-id', { - method: CommandMethod.Ping, + expect(replySpy).toHaveBeenCalledWith('v0:0', { + method: VatCommandMethod.Ping, params: 'pong', }); }); @@ -73,12 +73,12 @@ describe('Supervisor', () => { const replySpy = vi.spyOn(supervisor, 'replyToMessage'); await supervisor.handleMessage({ - id: 'message-id', - payload: { method: CommandMethod.CapTpInit, params: null }, + id: 'v0:0', + payload: { method: VatCommandMethod.CapTpInit, params: null }, }); - expect(replySpy).toHaveBeenCalledWith('message-id', { - method: CommandMethod.CapTpInit, + expect(replySpy).toHaveBeenCalledWith('v0:0', { + method: VatCommandMethod.CapTpInit, params: '~~~ CapTP Initialized ~~~', }); }); @@ -87,8 +87,8 @@ describe('Supervisor', () => { const wrapCapTpSpy = vi.spyOn(streamEnvelope, 'wrapCapTp'); await supervisor.handleMessage({ - id: 'message-id', - payload: { method: CommandMethod.CapTpInit, params: null }, + id: 'v0:0', + payload: { method: VatCommandMethod.CapTpInit, params: null }, }); const capTpQuestion = { @@ -116,12 +116,12 @@ describe('Supervisor', () => { const replySpy = vi.spyOn(supervisor, 'replyToMessage'); await supervisor.handleMessage({ - id: 'message-id', - payload: { method: CommandMethod.Evaluate, params: '2 + 2' }, + id: 'v0:0', + payload: { method: VatCommandMethod.Evaluate, params: '2 + 2' }, }); - expect(replySpy).toHaveBeenCalledWith('message-id', { - method: CommandMethod.Evaluate, + expect(replySpy).toHaveBeenCalledWith('v0:0', { + method: VatCommandMethod.Evaluate, params: '4', }); }); @@ -131,9 +131,9 @@ describe('Supervisor', () => { const replySpy = vi.spyOn(supervisor, 'replyToMessage'); await supervisor.handleMessage({ - id: 'message-id', + id: 'v0:0', // @ts-expect-error - invalid params type. - payload: { method: CommandMethod.Evaluate, params: null }, + payload: { method: VatCommandMethod.Evaluate, params: null }, }); expect(replySpy).not.toHaveBeenCalled(); @@ -147,13 +147,14 @@ describe('Supervisor', () => { const consoleErrorSpy = vi.spyOn(console, 'error'); await supervisor.handleMessage({ - id: 'message-id', + id: 'v0:0', // @ts-expect-error - unknown message type. payload: { method: 'UnknownType' }, }); expect(consoleErrorSpy).toHaveBeenCalledWith( - 'Supervisor received unexpected command method: "UnknownType"', + 'Supervisor received unexpected command method:', + 'UnknownType', ); }); }); diff --git a/packages/kernel/src/Supervisor.ts b/packages/kernel/src/Supervisor.ts index 2118e10b9..eb3dbc849 100644 --- a/packages/kernel/src/Supervisor.ts +++ b/packages/kernel/src/Supervisor.ts @@ -2,8 +2,13 @@ import { makeCapTP } from '@endo/captp'; import type { StreamPair, Reader } from '@ocap/streams'; import { stringify } from '@ocap/utils'; -import type { CapTpMessage, CommandReply, VatCommand } from './command.js'; -import { CommandMethod } from './command.js'; +import type { + CapTpMessage, + VatCommand, + VatCommandReply, + VatMessageId, +} from './messages.js'; +import { VatCommandMethod } from './messages.js'; import type { StreamEnvelope, StreamEnvelopeHandler, @@ -84,7 +89,7 @@ export class Supervisor { */ async handleMessage({ id, payload }: VatCommand): Promise { switch (payload.method) { - case CommandMethod.Evaluate: { + case VatCommandMethod.Evaluate: { if (typeof payload.params !== 'string') { console.error( 'Supervisor received command with unexpected params', @@ -95,12 +100,12 @@ export class Supervisor { } const result = this.evaluate(payload.params); await this.replyToMessage(id, { - method: CommandMethod.Evaluate, + method: VatCommandMethod.Evaluate, params: stringify(result), }); break; } - case CommandMethod.CapTpInit: { + case VatCommandMethod.CapTpInit: { this.capTp = makeCapTP( 'iframe', async (content: unknown) => @@ -108,20 +113,22 @@ export class Supervisor { this.#bootstrap, ); await this.replyToMessage(id, { - method: CommandMethod.CapTpInit, + method: VatCommandMethod.CapTpInit, params: '~~~ CapTP Initialized ~~~', }); break; } - case CommandMethod.Ping: + case VatCommandMethod.Ping: await this.replyToMessage(id, { - method: CommandMethod.Ping, + method: VatCommandMethod.Ping, params: 'pong', }); break; default: console.error( - `Supervisor received unexpected command method: "${payload.method}"`, + 'Supervisor received unexpected command method:', + // @ts-expect-error Runtime does not respect "never". + payload.method, ); } } @@ -132,7 +139,10 @@ 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: CommandReply): Promise { + async replyToMessage( + id: VatMessageId, + payload: VatCommandReply['payload'], + ): 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 3be6d485b..7e202e4ae 100644 --- a/packages/kernel/src/Vat.test.ts +++ b/packages/kernel/src/Vat.test.ts @@ -4,8 +4,8 @@ import { delay, makePromiseKitMock } from '@ocap/test-utils'; import { stringify } from '@ocap/utils'; import { describe, it, expect, vi, beforeEach } from 'vitest'; -import type { CapTpMessage, Command, CommandReply } from './command.js'; -import { CommandMethod } from './command.js'; +import { VatCommandMethod } from './messages.js'; +import type { CapTpMessage, VatCommand, VatCommandReply } from './messages.js'; import type { StreamEnvelope, StreamEnvelopeReply } from './stream-envelope.js'; import * as streamEnvelope from './stream-envelope.js'; import { makeStreamEnvelopeReplyHandler } from './stream-envelope.js'; @@ -34,7 +34,7 @@ describe('Vat', () => { >(messageChannel.port1); vat = new Vat({ - id: 'test-vat', + id: 'v0', streams, }); }); @@ -51,7 +51,7 @@ describe('Vat', () => { await vat.init(); expect(sendMessageMock).toHaveBeenCalledWith({ - method: CommandMethod.Ping, + method: VatCommandMethod.Ping, params: null, }); expect(capTpMock).toHaveBeenCalled(); @@ -74,9 +74,12 @@ describe('Vat', () => { describe('sendMessage', () => { it('sends a message and resolves the promise', async () => { - const mockMessage = { method: 'makeCapTp', params: null } as Command; + const mockMessage = { + method: VatCommandMethod.Ping, + params: null, + } as VatCommand['payload']; const sendMessagePromise = vat.sendMessage(mockMessage); - vat.unresolvedMessages.get('test-vat-1')?.resolve('test-response'); + vat.unresolvedMessages.get('v0:1')?.resolve('test-response'); const result = await sendMessagePromise; expect(result).toBe('test-response'); }); @@ -98,9 +101,9 @@ describe('Vat', () => { describe('handleMessage', () => { it('resolves the payload when the message id exists in unresolvedMessages', async () => { - const mockMessageId = 'test-vat-1'; - const mockPayload: CommandReply = { - method: CommandMethod.Evaluate, + const mockMessageId = 'v0:1'; + const mockPayload: VatCommandReply['payload'] = { + method: VatCommandMethod.Evaluate, params: 'test-response', }; const mockPromiseKit = { resolve: vi.fn(), reject: vi.fn() }; @@ -113,9 +116,9 @@ describe('Vat', () => { it('logs an error when the message id does not exist in unresolvedMessages', async () => { const consoleErrorSpy = vi.spyOn(console, 'error'); - const nonExistentMessageId = 'non-existent-id'; - const mockPayload: CommandReply = { - method: CommandMethod.Ping, + const nonExistentMessageId = 'v0:9'; + const mockPayload: VatCommandReply['payload'] = { + method: VatCommandMethod.Ping, params: 'pong', }; @@ -133,7 +136,7 @@ describe('Vat', () => { describe('terminate', () => { it('terminates the vat and resolves/rejects unresolved messages', async () => { - const mockMessageId = 'test-vat-1'; + const mockMessageId = 'v0:1'; const mockPromiseKit = makePromiseKitMock().makePromiseKit(); const mockSpy = vi.spyOn(mockPromiseKit, 'reject'); vat.unresolvedMessages.set(mockMessageId, mockPromiseKit); @@ -167,7 +170,7 @@ describe('Vat', () => { vat.streamEnvelopeReplyHandler.contentHandlers.capTp, ).toBeDefined(); expect(sendMessageMock).toHaveBeenCalledWith({ - method: CommandMethod.CapTpInit, + method: VatCommandMethod.CapTpInit, params: null, }); }); @@ -210,9 +213,7 @@ describe('Vat', () => { it('throws an error if CapTP connection is not established', async () => { await expect( vat.callCapTp({ method: 'testMethod', params: [] }), - ).rejects.toThrow( - `Vat with id "test-vat" does not have a CapTP connection.`, - ); + ).rejects.toThrow(`Vat with id "v0" does not have a CapTP connection.`); }); it('calls CapTP method with parameters using eventual send', async () => { diff --git a/packages/kernel/src/Vat.ts b/packages/kernel/src/Vat.ts index f4145d928..a5b1681a1 100644 --- a/packages/kernel/src/Vat.ts +++ b/packages/kernel/src/Vat.ts @@ -5,13 +5,14 @@ import type { StreamPair, Reader } from '@ocap/streams'; import type { Logger } from '@ocap/utils'; import { makeLogger, makeCounter, stringify } from '@ocap/utils'; +import { VatCommandMethod } from './messages.js'; import type { CapTpMessage, CapTpPayload, - Command, VatCommandReply, -} from './command.js'; -import { CommandMethod } from './command.js'; + VatCommand, + VatMessageId, +} from './messages.js'; import type { StreamEnvelope, StreamEnvelopeReply, @@ -22,7 +23,7 @@ import { wrapCapTp, wrapStreamCommand, } from './stream-envelope.js'; -import type { MessageId, UnresolvedMessages, VatId } from './types.js'; +import type { PromiseCallbacks, VatId } from './types.js'; type VatConstructorProps = { id: VatId; @@ -38,7 +39,7 @@ export class Vat { readonly #messageCounter: () => number; - readonly unresolvedMessages: UnresolvedMessages = new Map(); + readonly unresolvedMessages: Map = new Map(); readonly streamEnvelopeReplyHandler: StreamEnvelopeReplyHandler; @@ -84,7 +85,7 @@ export class Vat { throw error; }); - await this.sendMessage({ method: CommandMethod.Ping, params: null }); + await this.sendMessage({ method: VatCommandMethod.Ping, params: null }); this.logger.debug('Created'); return await this.makeCapTp(); @@ -129,7 +130,10 @@ export class Vat { ctp.dispatch(content); }; - return this.sendMessage({ method: CommandMethod.CapTpInit, params: null }); + return this.sendMessage({ + method: VatCommandMethod.CapTpInit, + params: null, + }); } /** @@ -166,7 +170,7 @@ export class Vat { * @param payload - The message to send. * @returns A promise that resolves the response to the message. */ - async sendMessage(payload: Command): Promise { + async sendMessage(payload: VatCommand['payload']): Promise { this.logger.debug('Sending message to vat', payload); const { promise, reject, resolve } = makePromiseKit(); const messageId = this.#nextMessageId(); @@ -182,7 +186,7 @@ export class Vat { * * @returns The message ID. */ - readonly #nextMessageId = (): MessageId => { - return `${this.id}-${this.#messageCounter()}`; + readonly #nextMessageId = (): VatMessageId => { + return `${this.id}:${this.#messageCounter()}`; }; } diff --git a/packages/kernel/src/command.test.ts b/packages/kernel/src/command.test.ts deleted file mode 100644 index 4b4ae5e8d..000000000 --- a/packages/kernel/src/command.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { describe, it, expect } from 'vitest'; - -import { - isCapTpMessage, - isCommand, - isCapTpPayload, - isCommandReply, - isVatCommand, - isVatCommandReply, - CommandMethod, -} from './command.js'; - -describe('type-guards', () => { - describe('isCapTpPayload', () => { - it.each` - value | expectedResult | description - ${{ method: 'someMethod', params: [] }} | ${true} | ${'valid cap tp payload with empty params'} - ${{ method: 'someMethod', params: ['param1'] }} | ${true} | ${'valid cap tp payload with non-empty params'} - ${123} | ${false} | ${'invalid cap tp payload: primitive number'} - ${{ method: true, params: [] }} | ${false} | ${'invalid cap tp payload: invalid method type'} - ${{ method: 'someMethod' }} | ${false} | ${'invalid cap tp payload: missing params'} - ${{ method: 'someMethod', params: 'param1' }} | ${false} | ${'invalid cap tp payload: params is a primitive string'} - ${{ method: 123, params: [] }} | ${false} | ${'invalid cap tp payload: invalid method type and valid params'} - ${{ method: 'someMethod', params: true }} | ${false} | ${'invalid cap tp payload: valid method and invalid params'} - `( - 'returns $expectedResult for $description', - ({ value, expectedResult }) => { - expect(isCapTpPayload(value)).toBe(expectedResult); - }, - ); - }); - - describe('isCommand', () => { - it.each` - value | expectedResult | description - ${{ method: CommandMethod.Ping, params: null }} | ${true} | ${'valid command with null data'} - ${{ method: CommandMethod.Ping, params: 'data' }} | ${true} | ${'valid command with string data'} - ${123} | ${false} | ${'invalid command: primitive number'} - ${{ method: true, params: 'data' }} | ${false} | ${'invalid command: invalid type'} - ${{ method: CommandMethod.Ping }} | ${false} | ${'invalid command: missing data'} - ${{ method: CommandMethod.Ping, params: 123 }} | ${false} | ${'invalid command: data is a primitive number'} - ${{ method: 123, params: null }} | ${false} | ${'invalid command: invalid type and valid data'} - ${{ method: 'some-type', params: true }} | ${false} | ${'invalid command: valid type and invalid data'} - `( - 'returns $expectedResult for $description', - ({ value, expectedResult }) => { - expect(isCommand(value)).toBe(expectedResult); - }, - ); - }); - - 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 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(isVatCommandReply(value)).toBe(expectedResult); - }, - ); - }); - - describe('isCapTpMessage', () => { - it.each` - value | expectedResult | description - ${{ type: 'CTP_some-type', epoch: 123 }} | ${true} | ${'valid cap tp message'} - ${{ type: true, epoch: null }} | ${false} | ${'invalid cap tp message: invalid type and epoch'} - ${{ type: 'some-type' }} | ${false} | ${'invalid cap tp message: missing epoch'} - ${{ type: 123, epoch: null }} | ${false} | ${'invalid cap tp message: invalid type'} - ${{ type: 'CTP_some-type' }} | ${false} | ${'invalid cap tp message: missing epoch'} - ${{ type: 'CTP_some-type', epoch: true }} | ${false} | ${'invalid cap tp message: invalid epoch type'} - `( - 'returns $expectedResult for $description', - ({ value, expectedResult }) => { - expect(isCapTpMessage(value)).toBe(expectedResult); - }, - ); - }); -}); diff --git a/packages/kernel/src/command.ts b/packages/kernel/src/command.ts deleted file mode 100644 index 35f249c90..000000000 --- a/packages/kernel/src/command.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { is } from '@metamask/superstruct'; -import type { Json } from '@metamask/utils'; -import { hasProperty, isObject, UnsafeJsonStruct } from '@metamask/utils'; - -export enum CommandMethod { - CapTpCall = 'callCapTp', - CapTpInit = 'makeCapTp', - Evaluate = 'evaluate', - Ping = 'ping', - KVSet = 'kvSet', - KVGet = 'kvGet', -} - -// The "Unsafe" here is because this guard can actually be cheated at runtime, -// but so long as we're only using it within our type boundaries, it should be fine. -const isJsonUnsafe = (value: unknown): value is Json => - is(value, UnsafeJsonStruct); - -export type CapTpPayload = { - method: string; - params: Json[]; -}; - -export const isCapTpPayload = (value: unknown): value is CapTpPayload => - isObject(value) && - typeof value.method === 'string' && - Array.isArray(value.params); - -type CommandLike = { - method: Method; - params: Data; -}; - -type CommandReplyLike = { - method: Method; - params: Data; -}; - -const isCommandLike = ( - value: unknown, -): value is { - method: CommandMethod; - params: string | null | CapTpPayload; -} => - isObject(value) && - Object.values(CommandMethod).includes(value.method as CommandMethod) && - hasProperty(value, 'params') && - isJsonUnsafe(value.params); - -export type Command = - | CommandLike - | CommandLike - | CommandLike - | CommandLike - | CommandLike - | CommandLike; - -export const isCommand = (value: unknown): value is Command => - isCommandLike(value) && - (typeof value.params === 'string' || - value.params === null || - isObject(value.params) || // XXX certainly wrong, needs better TypeScript magic - isCapTpPayload(value.params)); - -export type CommandFunction> = { - (method: CommandMethod.Ping | CommandMethod.CapTpInit, params?: null): Return; - ( - method: CommandMethod.Evaluate | CommandMethod.KVGet, - params: string, - ): Return; - (method: CommandMethod.CapTpCall, params: CapTpPayload): Return; - (method: CommandMethod.KVSet, params: { key: string; value: string }): Return; -}; - -export type CommandReply = - | CommandReplyLike - | CommandReplyLike - | CommandReplyLike - | CommandReplyLike - | CommandReplyLike - | CommandReplyLike; - -export const isCommandReply = (value: unknown): value is CommandReply => - isCommandLike(value) && typeof value.params === 'string'; - -export type CommandReplyFunction = { - (method: CommandMethod.Ping, params: 'pong'): Return; - (method: Exclude, params: string): Return; -}; - -export type VatCommand = { - id: string; - payload: Command; -}; - -export const isVatCommand = (value: unknown): value is VatCommand => - isObject(value) && typeof value.id === 'string' && isCommand(value.payload); - -export type VatCommandReply = { - id: string; - payload: CommandReply; -}; - -export const isVatCommandReply = (value: unknown): value is VatCommandReply => - isObject(value) && - typeof value.id === 'string' && - isCommandReply(value.payload); - -export type CapTpMessage = { - type: Type; - epoch: number; - [key: string]: Json; -}; - -export const isCapTpMessage = (value: unknown): value is CapTpMessage => - isObject(value) && - typeof value.type === 'string' && - value.type.startsWith('CTP_') && - typeof value.epoch === 'number'; diff --git a/packages/kernel/src/index.test.ts b/packages/kernel/src/index.test.ts index ab8ce6756..5e7a1d2ca 100644 --- a/packages/kernel/src/index.test.ts +++ b/packages/kernel/src/index.test.ts @@ -5,13 +5,14 @@ import * as indexModule from './index.js'; describe('index', () => { it('has the expected exports', () => { expect(Object.keys(indexModule)).toStrictEqual( - expect.arrayContaining([ - 'Kernel', - 'Vat', - 'isCommand', - 'isCommandReply', - 'CommandMethod', - ]), + expect.arrayContaining( + ['Kernel', 'Vat'].concat( + ['Cluster', 'Kernel', 'Vat'].flatMap((value) => [ + `is${value}Command`, + `is${value}CommandReply`, + ]), + ), + ), ); }); }); diff --git a/packages/kernel/src/index.ts b/packages/kernel/src/index.ts index a9a74eef8..41bb133af 100644 --- a/packages/kernel/src/index.ts +++ b/packages/kernel/src/index.ts @@ -1,12 +1,7 @@ -export { CommandMethod, isCommand, isCommandReply } from './command.js'; +export * from './messages.js'; export { Kernel } from './Kernel.js'; export { Vat } from './Vat.js'; export { Supervisor } from './Supervisor.js'; -export type { - Command, - CommandFunction, - CommandReply, - CommandReplyFunction, -} from './command.js'; +export type * from './messages.js'; export type { StreamEnvelope, StreamEnvelopeReply } from './stream-envelope.js'; export type { VatId, VatWorker } from './types.js'; diff --git a/packages/kernel/src/messages.ts b/packages/kernel/src/messages.ts new file mode 100644 index 000000000..e4b9136f9 --- /dev/null +++ b/packages/kernel/src/messages.ts @@ -0,0 +1,47 @@ +// Base messages. + +export type { VatMessageId } from './messages/vat-message.js'; + +// CapTp. + +export { isCapTpPayload, isCapTpMessage } from './messages/captp.js'; +export type { CapTpPayload, CapTpMessage } from './messages/captp.js'; + +// Cluster commands. + +export { + ClusterCommandMethod, + isClusterCommand, + isClusterCommandReply, +} from './messages/cluster.js'; +export type { + ClusterCommand, + ClusterCommandFunction, + ClusterCommandReply, + ClusterCommandReplyFunction, +} from './messages/cluster.js'; + +// Kernel commands. + +export { + KernelCommandMethod, + isKernelCommand, + isKernelCommandReply, +} from './messages/kernel.js'; +export type { + KernelCommand, + KernelCommandFunction, + KernelCommandReply, + KernelCommandReplyFunction, +} from './messages/kernel.js'; + +// Vat commands. + +export { + VatCommandMethod, + isVatCommand, + isVatCommandReply, +} from './messages/vat.js'; +export type { VatCommand, VatCommandReply } from './messages/vat.js'; + +// Syscalls. diff --git a/packages/kernel/src/messages/captp.test.ts b/packages/kernel/src/messages/captp.test.ts new file mode 100644 index 000000000..3a49222ea --- /dev/null +++ b/packages/kernel/src/messages/captp.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; + +import { isCapTpMessage, isCapTpPayload } from './captp.js'; + +describe('isCapTpMessage', () => { + it.each` + value | expectedResult | description + ${{ type: 'CTP_CALL', epoch: 0 }} | ${true} | ${'valid type with numerical epoch'} + ${{ type: 'CTP_CALL' }} | ${false} | ${'missing epoch'} + `('returns $expectedResult for $description', ({ value, expectedResult }) => { + expect(isCapTpMessage(value)).toBe(expectedResult); + }); +}); + +describe('isCapTpPayload', () => { + it.each` + value | expectedResult | description + ${{ method: 'foo', params: [0, 'bar', false] }} | ${true} | ${'valid command with string data'} + ${{ method: 'foo' }} | ${false} | ${'no params'} + ${{ method: 'foo', params: 'bar' }} | ${false} | ${'nonarray params'} + `('returns $expectedResult for $description', ({ value, expectedResult }) => { + expect(isCapTpPayload(value)).toBe(expectedResult); + }); +}); diff --git a/packages/kernel/src/messages/captp.ts b/packages/kernel/src/messages/captp.ts new file mode 100644 index 000000000..9f0d7d9fe --- /dev/null +++ b/packages/kernel/src/messages/captp.ts @@ -0,0 +1,24 @@ +import type { Json } from '@metamask/utils'; +import { isObject } from '@metamask/utils'; + +export type CapTpPayload = { + method: string; + params: Json[]; +}; + +export const isCapTpPayload = (value: unknown): value is CapTpPayload => + isObject(value) && + typeof value.method === 'string' && + Array.isArray(value.params); + +export type CapTpMessage = { + type: Type; + epoch: number; + [key: string]: Json; +}; + +export const isCapTpMessage = (value: unknown): value is CapTpMessage => + isObject(value) && + typeof value.type === 'string' && + value.type.startsWith('CTP_') && + typeof value.epoch === 'number'; diff --git a/packages/kernel/src/messages/cluster.ts b/packages/kernel/src/messages/cluster.ts new file mode 100644 index 000000000..ec76f633b --- /dev/null +++ b/packages/kernel/src/messages/cluster.ts @@ -0,0 +1,26 @@ +import type { TypeGuard } from '@ocap/utils'; + +import { kernelTestCommand } from './kernel-test.js'; +import { makeMessageKit } from './message-kit.js'; + +const clusterCommand = { + ...kernelTestCommand, +}; + +const clusterCommandKit = makeMessageKit(clusterCommand); + +export const ClusterCommandMethod = clusterCommandKit.methods; + +export type ClusterCommand = typeof clusterCommandKit.send; +export const isClusterCommand: TypeGuard = + clusterCommandKit.sendGuard; +export type ClusterCommandFunction = ReturnType< + typeof clusterCommandKit.sendFunction +>; + +export type ClusterCommandReply = typeof clusterCommandKit.reply; +export const isClusterCommandReply: TypeGuard = + clusterCommandKit.replyGuard; +export type ClusterCommandReplyFunction = ReturnType< + typeof clusterCommandKit.replyFunction +>; diff --git a/packages/kernel/src/messages/kernel-test.test.ts b/packages/kernel/src/messages/kernel-test.test.ts new file mode 100644 index 000000000..99fdf53d1 --- /dev/null +++ b/packages/kernel/src/messages/kernel-test.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; + +import { + isKernelTestCommand, + isKernelTestCommandReply, + KernelTestCommandMethod, +} from './kernel-test.js'; + +describe('isKernelTestCommand', () => { + it.each` + value | expectedResult | description + ${{ method: KernelTestCommandMethod.KVGet, params: 'data' }} | ${true} | ${'valid command with string data'} + ${{ method: KernelTestCommandMethod.KVSet, params: { key: 'foo', value: 'bar' } }} | ${true} | ${'valid command with object data'} + ${123} | ${false} | ${'invalid command: primitive number'} + ${{ method: true, params: 'data' }} | ${false} | ${'invalid command: invalid type'} + ${{ method: KernelTestCommandMethod.KVSet }} | ${false} | ${'invalid command: missing data'} + ${{ method: KernelTestCommandMethod.KVSet, params: 123 }} | ${false} | ${'invalid command: data is a primitive number'} + ${{ method: 123, params: null }} | ${false} | ${'invalid command: invalid type and valid data'} + ${{ method: 'some-type', params: true }} | ${false} | ${'invalid command: valid type and invalid data'} + `('returns $expectedResult for $description', ({ value, expectedResult }) => { + expect(isKernelTestCommand(value)).toBe(expectedResult); + }); +}); + +describe('isKernelTestCommandReply', () => { + it.each` + value | expectedResult | description + ${{ method: KernelTestCommandMethod.KVGet, params: 'foo' }} | ${true} | ${'valid command reply with string data'} + ${{ method: KernelTestCommandMethod.KVGet, 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: KernelTestCommandMethod.KVSet }} | ${false} | ${'invalid command reply: missing data'} + ${{ method: KernelTestCommandMethod.KVSet, 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(isKernelTestCommandReply(value)).toBe(expectedResult); + }); +}); diff --git a/packages/kernel/src/messages/kernel-test.ts b/packages/kernel/src/messages/kernel-test.ts new file mode 100644 index 000000000..40ee06211 --- /dev/null +++ b/packages/kernel/src/messages/kernel-test.ts @@ -0,0 +1,47 @@ +import { isObject } from '@metamask/utils'; +import type { TypeGuard } from '@ocap/utils'; + +import type { CapTpPayload } from './captp.js'; +import { isCapTpPayload } from './captp.js'; +import { makeMessageKit, messageType } from './message-kit.js'; +import { vatTestCommand } from './vat-test.js'; + +export const kernelTestCommand = { + CapTpCall: messageType( + (send) => isCapTpPayload(send), + (reply) => typeof reply === 'string', + ), + + KVSet: messageType<{ key: string; value: string }, string>( + (send) => + isObject(send) && + typeof send.key === 'string' && + typeof send.value === 'string', + (reply) => typeof reply === 'string', + ), + + KVGet: messageType( + (send) => typeof send === 'string', + (reply) => typeof reply === 'string', + ), + + ...vatTestCommand, +}; + +const kernelTestCommandKit = makeMessageKit(kernelTestCommand); + +export const KernelTestCommandMethod = kernelTestCommandKit.methods; + +export type KernelTestCommand = typeof kernelTestCommandKit.send; +export const isKernelTestCommand: TypeGuard = + kernelTestCommandKit.sendGuard; +export type KernelTestCommandFunction = ReturnType< + typeof kernelTestCommandKit.sendFunction +>; + +export type KernelTestCommandReply = typeof kernelTestCommandKit.reply; +export const isKernelTestCommandReply: TypeGuard = + kernelTestCommandKit.replyGuard; +export type KernelTestCommandReplyFunction = ReturnType< + typeof kernelTestCommandKit.replyFunction +>; diff --git a/packages/kernel/src/messages/kernel.ts b/packages/kernel/src/messages/kernel.ts new file mode 100644 index 000000000..a04924364 --- /dev/null +++ b/packages/kernel/src/messages/kernel.ts @@ -0,0 +1,26 @@ +import type { TypeGuard } from '@ocap/utils'; + +import { kernelTestCommand } from './kernel-test.js'; +import { makeMessageKit } from './message-kit.js'; + +export const kernelCommand = { + ...kernelTestCommand, +}; + +const kernelCommandKit = makeMessageKit(kernelCommand); + +export const KernelCommandMethod = kernelCommandKit.methods; + +export type KernelCommand = typeof kernelCommandKit.send; +export const isKernelCommand: TypeGuard = + kernelCommandKit.sendGuard; +export type KernelCommandFunction = ReturnType< + typeof kernelCommandKit.sendFunction +>; + +export type KernelCommandReply = typeof kernelCommandKit.reply; +export const isKernelCommandReply: TypeGuard = + kernelCommandKit.replyGuard; +export type KernelCommandReplyFunction = ReturnType< + typeof kernelCommandKit.replyFunction +>; diff --git a/packages/kernel/src/messages/message-kit.ts b/packages/kernel/src/messages/message-kit.ts new file mode 100644 index 000000000..5e8e5bdb6 --- /dev/null +++ b/packages/kernel/src/messages/message-kit.ts @@ -0,0 +1,114 @@ +import '@ocap/shims/endoify'; + +import type { Json } from '@metamask/utils'; +import type { ExtractGuardType, TypeGuard } from '@ocap/utils'; + +import { isMessageLike, type MessageLike } from './message.js'; +import type { UnionToIntersection } from './utils.js'; +import { uncapitalize } from './utils.js'; + +// Message kit. + +type BoolExpr = (value: unknown) => boolean; + +type SourceLike = Record; + +type MessageUnion = { + [Key in keyof Source]: Key extends string + ? { + method: Uncapitalize; + params: ExtractGuardType; + } + : never; +}[keyof Source]; + +export type Send = MessageUnion; + +export type Reply = MessageUnion; + +type MessageFunction = UnionToIntersection< + { + [Key in Union as Key['method']]: Key['params'] extends null + ? (method: Key['method']) => Return + : (method: Key['method'], params: Key['params']) => Return; + }[Union['method']] +>; + +/** + * A typescript utility used to reduce boilerplate in message type declarations. + * + * @param sendGuard - A boolean expression that returns true for SendType values. + * @param replyGuard - A boolean expression that returns true for ReplyType values. + * @returns A pair of type guards. + */ +export const messageType = ( + sendGuard: BoolExpr, + replyGuard: BoolExpr, +): [TypeGuard, TypeGuard] => [ + (val): val is SendType => sendGuard(val), + (val): val is ReplyType => replyGuard(val), +]; + +type Methods = { + [Key in keyof Source]: Key extends string ? Uncapitalize : never; +}; + +const makeMethods = ( + source: Source, +): Methods => { + return Object.fromEntries( + Object.keys(source).map((key) => [key, uncapitalize(key)]), + ) as Methods; +}; + +const makeGuard = ( + source: Source, + methods: Methods, + index: Index, +): TypeGuard> => { + const guards = Object.fromEntries( + Object.entries(source).map(([key, value]) => [ + uncapitalize(key), + value[index], + ]), + ) as Record>; + + return (value: unknown): value is MessageUnion => + isMessageLike(value) && + Object.values(methods).includes(value.method) && + guards[value.method as keyof typeof guards](value.params); +}; + +// Applying ReturnType to the type of this function allows us to curry the +// template parameter Return. +type MakeMessageFunction = < + Return, +>() => MessageFunction; + +/** + * An object type encapsulating all of the schematics that define a functional + * group of messages. + */ +export type MessageKit = { + source: Source; + methods: Methods; + send: Send; + sendGuard: TypeGuard>; + sendFunction: MakeMessageFunction>; + reply: Reply; + replyGuard: TypeGuard>; + replyFunction: MakeMessageFunction>; +}; + +export const makeMessageKit = ( + source: Source, +): MessageKit => { + const methods = makeMethods(source); + + return { + source, + methods, + sendGuard: makeGuard(source, methods, 0), + replyGuard: makeGuard(source, methods, 1), + } as MessageKit; +}; diff --git a/packages/kernel/src/messages/message.ts b/packages/kernel/src/messages/message.ts new file mode 100644 index 000000000..cd0c2ca10 --- /dev/null +++ b/packages/kernel/src/messages/message.ts @@ -0,0 +1,16 @@ +import { is } from '@metamask/superstruct'; +import type { Json } from '@metamask/utils'; +import { isObject, UnsafeJsonStruct } from '@metamask/utils'; + +import { uncapitalize } from './utils.js'; + +const isJsonUnsafe = (value: unknown): value is Json => + is(value, UnsafeJsonStruct); + +export type MessageLike = { method: Uncapitalize; params: Json }; + +const isMethodLike = (value: unknown): value is Uncapitalize => + typeof value === 'string' && uncapitalize(value) === value; + +export const isMessageLike = (value: unknown): value is MessageLike => + isObject(value) && isMethodLike(value.method) && isJsonUnsafe(value.params); diff --git a/packages/kernel/src/messages/utils.ts b/packages/kernel/src/messages/utils.ts new file mode 100644 index 000000000..9145aa0e9 --- /dev/null +++ b/packages/kernel/src/messages/utils.ts @@ -0,0 +1,13 @@ +// Uncapitalize. + +export const uncapitalize = (value: string): Uncapitalize => + (value.at(0)?.toLowerCase() + value.slice(1)) as Uncapitalize; + +// Union to intersection. +// https://github.com/sindresorhus/type-fest/blob/v2.12.2/source/union-to-intersection.d.ts + +export type UnionToIntersection = ( + Union extends unknown ? (distributedUnion: Union) => void : never +) extends (mergedIntersection: infer Intersection) => void + ? Intersection + : never; diff --git a/packages/kernel/src/messages/vat-message.test.ts b/packages/kernel/src/messages/vat-message.test.ts new file mode 100644 index 000000000..4c3d85810 --- /dev/null +++ b/packages/kernel/src/messages/vat-message.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; + +import { isVatMessage } from './vat-message.js'; +import { VatCommandMethod } from './vat.js'; + +describe('isVatMessage', () => { + const validPayload = { method: VatCommandMethod.Evaluate, params: '3 + 3' }; + + it.each` + value | expectedResult | description + ${{ id: 'v0:1', payload: validPayload }} | ${true} | ${'valid message id with valid payload'} + ${{ id: 'vat-message-id', payload: validPayload }} | ${false} | ${'invalid id'} + ${{ id: 1, payload: validPayload }} | ${false} | ${'numerical id'} + ${{ id: 'v0:1' }} | ${false} | ${'missing payload'} + `('returns $expectedResult for $description', ({ value, expectedResult }) => { + expect(isVatMessage(value)).toBe(expectedResult); + }); +}); diff --git a/packages/kernel/src/messages/vat-message.ts b/packages/kernel/src/messages/vat-message.ts new file mode 100644 index 000000000..6e3562ecc --- /dev/null +++ b/packages/kernel/src/messages/vat-message.ts @@ -0,0 +1,23 @@ +import { hasProperty, isObject } from '@metamask/utils'; + +import type { VatId } from '../types.js'; +import { isVatId } from '../types.js'; + +export type VatMessageId = `${VatId}:${number}`; + +export const isVatMessageId = (value: unknown): value is VatMessageId => { + if (typeof value !== 'string') { + return false; + } + const parts = value.split(':'); + return ( + parts.length === 2 && + isVatId(parts[0]) && + parts[1] === String(Number(parts[1])) + ); +}; + +export type VatMessage = { id: VatMessageId; payload: Payload }; + +export const isVatMessage = (value: unknown): value is VatMessage => + isObject(value) && isVatMessageId(value.id) && hasProperty(value, 'payload'); diff --git a/packages/kernel/src/messages/vat-test.ts b/packages/kernel/src/messages/vat-test.ts new file mode 100644 index 000000000..74f257090 --- /dev/null +++ b/packages/kernel/src/messages/vat-test.ts @@ -0,0 +1,13 @@ +import { messageType } from './message-kit.js'; + +export const vatTestCommand = { + Evaluate: messageType( + (send) => typeof send === 'string', + (reply) => typeof reply === 'string', + ), + + Ping: messageType( + (send) => send === null, + (reply) => reply === 'pong', + ), +}; diff --git a/packages/kernel/src/messages/vat.ts b/packages/kernel/src/messages/vat.ts new file mode 100644 index 000000000..77eb99866 --- /dev/null +++ b/packages/kernel/src/messages/vat.ts @@ -0,0 +1,25 @@ +import { makeMessageKit, messageType } from './message-kit.js'; +import type { VatMessage } from './vat-message.js'; +import { isVatMessage } from './vat-message.js'; +import { vatTestCommand } from './vat-test.js'; + +export const vatCommand = { + CapTpInit: messageType( + (send) => send === null, + (reply) => typeof reply === 'string', + ), + + ...vatTestCommand, +}; + +const vatMessageKit = makeMessageKit(vatCommand); + +export const VatCommandMethod = vatMessageKit.methods; + +export type VatCommand = VatMessage; +export const isVatCommand = (value: unknown): value is VatCommand => + isVatMessage(value) && vatMessageKit.sendGuard(value.payload); + +export type VatCommandReply = VatMessage; +export const isVatCommandReply = (value: unknown): value is VatCommandReply => + isVatMessage(value) && vatMessageKit.replyGuard(value.payload); diff --git a/packages/kernel/src/stream-envelope.test.ts b/packages/kernel/src/stream-envelope.test.ts index aef56466a..cc8eb34a9 100644 --- a/packages/kernel/src/stream-envelope.test.ts +++ b/packages/kernel/src/stream-envelope.test.ts @@ -1,9 +1,10 @@ import '@ocap/shims/endoify'; +import { stringify } from '@ocap/utils'; import { describe, it, expect } from 'vitest'; -import type { CapTpMessage, VatCommand, VatCommandReply } from './command.js'; -import { CommandMethod } from './command.js'; +import type { CapTpMessage, VatCommand, VatCommandReply } from './messages.js'; +import { VatCommandMethod } from './messages.js'; import { wrapCapTp, wrapStreamCommand, @@ -14,8 +15,8 @@ import { describe('StreamEnvelopeHandler', () => { const commandContent: VatCommand = { - id: '1', - payload: { method: CommandMethod.Evaluate, params: '1 + 1' }, + id: 'v0:0', + payload: { method: VatCommandMethod.Evaluate, params: '1 + 1' }, }; const capTpContent: CapTpMessage = { type: 'CTP_CALL', @@ -32,21 +33,24 @@ describe('StreamEnvelopeHandler', () => { capTp: async () => capTpLabel, }; - const testErrorHandler = (problem: unknown): never => { - throw new Error(`TEST ${String(problem)}`); + const testErrorHandler = (problem: unknown, value: unknown): never => { + throw new Error(`TEST ${String(problem)} ${stringify(value)}`); }; it.each` wrapper | content | label ${wrapStreamCommand} | ${commandContent} | ${commandLabel} ${wrapCapTp} | ${capTpContent} | ${capTpLabel} - `('handles valid StreamEnvelopes', async ({ wrapper, content, label }) => { - const handler = makeStreamEnvelopeHandler( - testEnvelopeHandlers, - testErrorHandler, - ); - expect(await handler.handle(wrapper(content))).toStrictEqual(label); - }); + `( + 'handles valid StreamEnvelope $content', + async ({ wrapper, content, label }) => { + const handler = makeStreamEnvelopeHandler( + testEnvelopeHandlers, + testErrorHandler, + ); + expect(await handler.handle(wrapper(content))).toStrictEqual(label); + }, + ); it('routes invalid envelopes to default error handler', async () => { const handler = makeStreamEnvelopeHandler(testEnvelopeHandlers); @@ -82,8 +86,8 @@ describe('StreamEnvelopeHandler', () => { describe('StreamEnvelopeReplyHandler', () => { const commandContent: VatCommandReply = { - id: '1', - payload: { method: CommandMethod.Evaluate, params: '2' }, + id: 'v0:0', + payload: { method: VatCommandMethod.Evaluate, params: '2' }, }; const capTpContent: CapTpMessage = { type: 'CTP_CALL', @@ -100,21 +104,24 @@ describe('StreamEnvelopeReplyHandler', () => { capTp: async () => capTpLabel, }; - const testErrorHandler = (problem: unknown): never => { - throw new Error(`TEST ${String(problem)}`); + const testErrorHandler = (problem: unknown, value: unknown): never => { + throw new Error(`TEST ${String(problem)} ${stringify(value)}`); }; 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); - }); + `( + 'handles valid StreamEnvelopeReply $content', + 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); diff --git a/packages/kernel/src/stream-envelope.ts b/packages/kernel/src/stream-envelope.ts index a2c58cc7d..ec1bc34fc 100644 --- a/packages/kernel/src/stream-envelope.ts +++ b/packages/kernel/src/stream-envelope.ts @@ -1,13 +1,8 @@ import { makeStreamEnvelopeKit } from '@ocap/streams'; +import type { ExtractGuardType } from '@ocap/utils'; -import { isCapTpMessage, isVatCommand, isVatCommandReply } from './command.js'; -import type { CapTpMessage, VatCommand, VatCommandReply } from './command.js'; - -type GuardType = TypeGuard extends ( - value: unknown, -) => value is infer Type - ? Type - : never; +import { isCapTpMessage, isVatCommand, isVatCommandReply } from './messages.js'; +import type { CapTpMessage, VatCommand, VatCommandReply } from './messages.js'; // Declare and destructure the envelope kit. @@ -34,11 +29,13 @@ const envelopeKit = makeStreamEnvelopeKit< capTp: CapTpMessage; } >({ - command: (value) => isVatCommand(value), + command: isVatCommand, capTp: isCapTpMessage, }); -export type StreamEnvelope = GuardType; +export type StreamEnvelope = ExtractGuardType< + typeof envelopeKit.isStreamEnvelope +>; export type StreamEnvelopeHandler = ReturnType< typeof envelopeKit.makeStreamEnvelopeHandler >; @@ -56,11 +53,11 @@ const streamEnvelopeReplyKit = makeStreamEnvelopeKit< capTp: CapTpMessage; } >({ - command: (value) => isVatCommandReply(value), + command: isVatCommandReply, capTp: isCapTpMessage, }); -export type StreamEnvelopeReply = GuardType< +export type StreamEnvelopeReply = ExtractGuardType< typeof streamEnvelopeReplyKit.isStreamEnvelope >; export type StreamEnvelopeReplyHandler = ReturnType< diff --git a/packages/kernel/src/types.ts b/packages/kernel/src/types.ts index 455c3ea0f..3eab9ffe5 100644 --- a/packages/kernel/src/types.ts +++ b/packages/kernel/src/types.ts @@ -3,9 +3,12 @@ import type { StreamPair } from '@ocap/streams'; import type { StreamEnvelopeReply, StreamEnvelope } from './stream-envelope.js'; -export type MessageId = string; +export type VatId = `v${number}`; -export type VatId = string; +export const isVatId = (value: unknown): value is VatId => + typeof value === 'string' && + value.at(0) === 'v' && + value.slice(1) === String(Number(value.slice(1))); export type VatWorker = { init: () => Promise< @@ -15,5 +18,3 @@ export type VatWorker = { }; export type PromiseCallbacks = Omit, 'promise'>; - -export type UnresolvedMessages = Map; diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 4d9a599c4..32c8eaedb 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -2,5 +2,5 @@ export type { Logger } from './logger.js'; export { makeLogger } from './logger.js'; export { makeCounter } from './counter.js'; export { stringify } from './stringify.js'; -export type { TypeGuard } from './types.js'; +export type { TypeGuard, ExtractGuardType } from './types.js'; export { isPrimitive, isTypedArray, isTypedObject } from './types.js'; diff --git a/packages/utils/src/types.ts b/packages/utils/src/types.ts index da984ee0c..6dd442069 100644 --- a/packages/utils/src/types.ts +++ b/packages/utils/src/types.ts @@ -3,6 +3,14 @@ import { isObject } from '@metamask/utils'; export type TypeGuard = (value: unknown) => value is Type; +export type ExtractGuardType = Guard extends ( + value: unknown, +) => value is infer Type + ? Type extends Bound + ? Type + : never + : never; + const primitives = [ 'string', 'number',