diff --git a/packages/extension/package.json b/packages/extension/package.json index 5df46d844..7bfe5ec19 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -36,9 +36,9 @@ "@endo/eventual-send": "^1.2.4", "@endo/exo": "^1.5.2", "@endo/patterns": "^1.4.2", - "@endo/promise-kit": "^1.1.4", "@metamask/snaps-utils": "^7.8.0", "@metamask/utils": "^9.1.0", + "@ocap/kernel": "workspace:^", "@ocap/shims": "workspace:^", "@ocap/streams": "workspace:^", "ses": "^1.7.0" diff --git a/packages/extension/src/background.ts b/packages/extension/src/background.ts index 3182291dc..2e2704b34 100644 --- a/packages/extension/src/background.ts +++ b/packages/extension/src/background.ts @@ -1,8 +1,8 @@ import type { Json } from '@metamask/utils'; - import './background-trusted-prelude.js'; -import type { ExtensionMessage } from './message.js'; -import { Command, ExtensionMessageTarget } from './message.js'; +import type { KernelMessage } from '@ocap/streams'; +import { Command, KernelMessageTarget } from '@ocap/streams'; + import { makeHandledCallback } from './shared.js'; // globalThis.kernel will exist due to dev-console.js in background-trusted-prelude.js @@ -45,7 +45,7 @@ async function sendMessage(type: string, data?: Json): Promise { await chrome.runtime.sendMessage({ type, - target: ExtensionMessageTarget.Offscreen, + target: KernelMessageTarget.Offscreen, data: data ?? null, }); } @@ -65,8 +65,8 @@ async function provideOffScreenDocument(): Promise { // Handle replies from the offscreen document chrome.runtime.onMessage.addListener( - makeHandledCallback(async (message: ExtensionMessage) => { - if (message.target !== ExtensionMessageTarget.Background) { + makeHandledCallback(async (message: KernelMessage) => { + if (message.target !== KernelMessageTarget.Background) { console.warn( `Background received message with unexpected target: "${message.target}"`, ); diff --git a/packages/extension/src/iframe-manager.test.ts b/packages/extension/src/iframe-manager.test.ts deleted file mode 100644 index d580f6b22..000000000 --- a/packages/extension/src/iframe-manager.test.ts +++ /dev/null @@ -1,543 +0,0 @@ -import './endoify.js'; -import * as snapsUtils from '@metamask/snaps-utils'; -import { delay, makePromiseKitMock } from '@ocap/test-utils'; -import { vi, describe, it, expect } from 'vitest'; - -import { IframeManager } from './iframe-manager.js'; -import type { IframeMessage } from './message.js'; -import { Command } from './message.js'; -import { wrapCommand, wrapCapTp } from './stream-envelope.js'; - -vi.mock('@endo/promise-kit', () => makePromiseKitMock()); - -vi.mock('@metamask/snaps-utils', () => ({ - createWindow: vi.fn(), -})); - -describe('IframeManager', () => { - const makeGetPort = - (port: MessagePort = new MessageChannel().port1) => - async (_window: Window): Promise => - Promise.resolve(port); - - describe('create', () => { - it('creates a new iframe', async () => { - const mockWindow = {}; - vi.mocked(snapsUtils.createWindow).mockResolvedValueOnce( - mockWindow as Window, - ); - const manager = new IframeManager(); - const sendMessageSpy = vi - .spyOn(manager, 'sendMessage') - .mockImplementation(vi.fn()); - const [newWindow, id] = await manager.create({ getPort: makeGetPort() }); - - expect(newWindow).toBe(mockWindow); - expect(id).toBeTypeOf('string'); - expect(sendMessageSpy).toHaveBeenCalledOnce(); - expect(sendMessageSpy).toHaveBeenCalledWith(id, { - type: Command.Ping, - data: null, - }); - }); - - it('creates a new iframe with a specified id', async () => { - const mockWindow = {}; - vi.mocked(snapsUtils.createWindow).mockResolvedValueOnce( - mockWindow as Window, - ); - - const manager = new IframeManager(); - const sendMessageSpy = vi - .spyOn(manager, 'sendMessage') - .mockImplementation(vi.fn()); - const id = 'foo'; - const [newWindow, returnedId] = await manager.create({ - id, - getPort: makeGetPort(), - }); - - expect(newWindow).toBe(mockWindow); - expect(returnedId).toBe(id); - expect(sendMessageSpy).toHaveBeenCalledOnce(); - expect(sendMessageSpy).toHaveBeenCalledWith(id, { - type: Command.Ping, - data: null, - }); - }); - - it('creates a new iframe with the default getPort function', async () => { - vi.resetModules(); - vi.doMock('@ocap/streams', async (importOriginal) => { - // @ts-expect-error This import is known to exist, and the linter erases the appropriate assertion. - const { makeStreamEnvelopeKit } = await importOriginal(); - return { - initializeMessageChannel: vi.fn(), - makeMessagePortStreamPair: vi.fn(() => ({ reader: {}, writer: {} })), - MessagePortReader: class Mock1 {}, - MessagePortWriter: class Mock2 {}, - makeStreamEnvelopeKit, - }; - }); - const IframeManager2 = (await import('./iframe-manager.js')) - .IframeManager; - - const mockWindow = {}; - vi.mocked(snapsUtils.createWindow).mockResolvedValueOnce( - mockWindow as Window, - ); - const manager = new IframeManager2(); - const sendMessageSpy = vi - .spyOn(manager, 'sendMessage') - .mockImplementation(vi.fn()); - const [newWindow, id] = await manager.create(); - - expect(newWindow).toBe(mockWindow); - expect(id).toBeTypeOf('string'); - expect(sendMessageSpy).toHaveBeenCalledOnce(); - expect(sendMessageSpy).toHaveBeenCalledWith(id, { - type: Command.Ping, - data: null, - }); - }); - }); - - describe('delete', () => { - it('deletes an iframe', async () => { - const id = 'foo'; - const iframe = document.createElement('iframe'); - iframe.id = `ocap-iframe-${id}`; - const removeSpy = vi.spyOn(iframe, 'remove'); - - vi.mocked(snapsUtils.createWindow).mockImplementationOnce(async () => { - document.body.appendChild(iframe); - return iframe.contentWindow as Window; - }); - - const manager = new IframeManager(); - vi.spyOn(manager, 'sendMessage').mockImplementation(vi.fn()); - - await manager.create({ id, getPort: makeGetPort() }); - await manager.delete(id); - - expect(removeSpy).toHaveBeenCalledOnce(); - }); - - it('ignores attempt to delete unrecognized iframe', async () => { - const id = 'foo'; - const manager = new IframeManager(); - const iframe = document.createElement('iframe'); - - const removeSpy = vi.spyOn(iframe, 'remove'); - await manager.delete(id); - - expect(removeSpy).not.toHaveBeenCalled(); - }); - - it('rejects unresolved messages', async () => { - const id = 'foo'; - const messageCount = 7; - const awaitCount = 2; - - vi.mocked(snapsUtils.createWindow).mockImplementationOnce(vi.fn()); - - const manager = new IframeManager(); - vi.spyOn(manager, 'sendMessage').mockImplementationOnce(vi.fn()); - - const { port1, port2 } = new MessageChannel(); - const postMessage = (i: number): void => { - port2.postMessage({ - done: false, - value: wrapCommand({ - id: `foo-${i + 1}`, - message: { - type: Command.Evaluate, - data: `${i + 1}`, - }, - }), - }); - }; - - await manager.create({ id, getPort: makeGetPort(port1) }); - - const messagePromises = Array(messageCount) - .fill(0) - .map(async (_, i) => - manager.sendMessage(id, { type: Command.Evaluate, data: `${i}+1` }), - ); - - // resolve the first `awaitCount` promises - for (let i = 0; i < awaitCount; i++) { - postMessage(i); - await messagePromises[i]; - } - - await manager.delete(id); - - // reject the rest of the promises - for (let i = awaitCount; i < messageCount; i++) { - postMessage(i); - await expect(messagePromises[i]).rejects.toThrow('Vat was deleted'); - } - }); - }); - - describe('capTp', () => { - it('calls console.warn when receiving a capTp envelope before initialization', async () => { - const id = 'foo'; - - vi.mocked(snapsUtils.createWindow).mockImplementationOnce(vi.fn()); - const warnSpy = vi.spyOn(console, 'warn'); - - const manager = new IframeManager(); - vi.spyOn(manager, 'sendMessage').mockImplementationOnce(vi.fn()); - - const { port1, port2 } = new MessageChannel(); - - await manager.create({ id, getPort: makeGetPort(port1) }); - - const envelope = wrapCapTp({ - epoch: 0, - questionID: 'q-1', - type: 'CTP_BOOTSTRAP', - }); - - port2.postMessage({ - done: false, - value: envelope, - }); - - await delay(); - - expect(warnSpy).toHaveBeenCalledWith( - 'Stream envelope handler received an envelope with known but unexpected label', - envelope, - ); - }); - - it('throws if called before initialization', async () => { - const mockWindow = {}; - vi.mocked(snapsUtils.createWindow).mockResolvedValueOnce( - mockWindow as Window, - ); - const manager = new IframeManager(); - vi.spyOn(manager, 'sendMessage').mockImplementation(vi.fn()); - const [, id] = await manager.create({ getPort: makeGetPort() }); - - await expect( - async () => - await manager.callCapTp(id, { - method: 'whatIsTheGreatFrangooly', - params: [], - }), - ).rejects.toThrow(/does not have a CapTP connection\.$/u); - }); - - it('throws if initialization is called twice on the same vat', async () => { - const id = 'frangooly'; - - const capTpInit = { - query: wrapCommand({ - id: `${id}-1`, - message: { - data: null, - type: Command.CapTpInit, - }, - }), - response: wrapCommand({ - id: `${id}-1`, - message: { - type: Command.CapTpInit, - data: null, - }, - }), - }; - - vi.mocked(snapsUtils.createWindow).mockImplementationOnce(vi.fn()); - - const { port1, port2 } = new MessageChannel(); - const port1PostMessageSpy = vi - .spyOn(port1, 'postMessage') - .mockImplementation(vi.fn()); - - let port1PostMessageCallCounter: number = 0; - const expectSendMessageToHaveBeenCalledOnceMoreWith = ( - expectation: unknown, - ): void => { - port1PostMessageCallCounter += 1; - expect(port1PostMessageSpy).toHaveBeenCalledTimes( - port1PostMessageCallCounter, - ); - expect(port1PostMessageSpy).toHaveBeenLastCalledWith({ - done: false, - value: expectation, - }); - }; - - const mockReplyWith = (message: unknown): void => - port2.postMessage({ - done: false, - value: message, - }); - - const manager = new IframeManager(); - - vi.spyOn(manager, 'sendMessage').mockImplementationOnce(vi.fn()); - - await manager.create({ id, getPort: makeGetPort(port1) }); - - // Init CapTP connection - const initCapTpPromise = manager.makeCapTp(id); - - expectSendMessageToHaveBeenCalledOnceMoreWith(capTpInit.query); - mockReplyWith(capTpInit.response); - - await initCapTpPromise.then((resolvedValue) => - console.debug(`CapTp initialized: ${JSON.stringify(resolvedValue)}`), - ); - - await expect(async () => await manager.makeCapTp(id)).rejects.toThrow( - /already has a CapTP connection\./u, - ); - }); - - it('does TheGreatFrangooly', async () => { - const id = 'frangooly'; - - const capTpInit = { - query: wrapCommand({ - id: `${id}-1`, - message: { - data: null, - type: Command.CapTpInit, - }, - }), - response: wrapCommand({ - id: `${id}-1`, - message: { - type: Command.CapTpInit, - data: null, - }, - }), - }; - - const greatFrangoolyBootstrap = { - query: wrapCapTp({ - epoch: 0, - questionID: 'q-1', - type: 'CTP_BOOTSTRAP', - }), - response: wrapCapTp({ - type: 'CTP_RETURN', - epoch: 0, - answerID: 'q-1', - result: { - body: '{"@qclass":"slot","iface":"Alleged: TheGreatFrangooly","index":0}', - slots: ['o+1'], - }, - }), - }; - - const greatFrangoolyCall = { - query: wrapCapTp({ - type: 'CTP_CALL', - epoch: 0, - method: { - body: '["whatIsTheGreatFrangooly",[]]', - slots: [], - }, - questionID: 'q-2', - target: 'o-1', - }), - response: wrapCapTp({ - type: 'CTP_RETURN', - epoch: 0, - answerID: 'q-2', - result: { - body: '"Crowned with Chaos"', - slots: [], - }, - }), - }; - - vi.mocked(snapsUtils.createWindow).mockImplementationOnce(vi.fn()); - - const { port1, port2 } = new MessageChannel(); - const port1PostMessageSpy = vi - .spyOn(port1, 'postMessage') - .mockImplementation(vi.fn()); - - let port1PostMessageCallCounter: number = 0; - const expectSendMessageToHaveBeenCalledOnceMoreWith = ( - expectation: unknown, - ): void => { - port1PostMessageCallCounter += 1; - expect(port1PostMessageSpy).toHaveBeenCalledTimes( - port1PostMessageCallCounter, - ); - expect(port1PostMessageSpy).toHaveBeenLastCalledWith({ - done: false, - value: expectation, - }); - }; - - const mockReplyWith = (message: unknown): void => - port2.postMessage({ - done: false, - value: message, - }); - - const manager = new IframeManager(); - - vi.spyOn(manager, 'sendMessage').mockImplementationOnce(vi.fn()); - - await manager.create({ id, getPort: makeGetPort(port1) }); - - // Init CapTP connection - const initCapTpPromise = manager.makeCapTp(id); - - expectSendMessageToHaveBeenCalledOnceMoreWith(capTpInit.query); - mockReplyWith(capTpInit.response); - - await initCapTpPromise.then((resolvedValue) => - console.debug(`CapTp initialized: ${JSON.stringify(resolvedValue)}`), - ); - - // Bootstrap TheGreatFrangooly... - const callCapTpResponse = manager.callCapTp(id, { - method: 'whatIsTheGreatFrangooly', - params: [], - }); - - expectSendMessageToHaveBeenCalledOnceMoreWith( - greatFrangoolyBootstrap.query, - ); - mockReplyWith(greatFrangoolyBootstrap.response); - - await delay().then(() => - console.debug('TheGreatFrangooly bootstrapped...'), - ); - - // ...and call it. - expectSendMessageToHaveBeenCalledOnceMoreWith(greatFrangoolyCall.query); - mockReplyWith(greatFrangoolyCall.response); - - await callCapTpResponse.then((resolvedValue) => - console.debug( - `TheGreatFrangooly called: ${JSON.stringify(resolvedValue)}`, - ), - ); - - expect(await callCapTpResponse).equals('Crowned with Chaos'); - }); - }); - - describe('sendMessage', () => { - it('sends a message to an iframe', async () => { - vi.mocked(snapsUtils.createWindow).mockResolvedValueOnce({} as Window); - - const manager = new IframeManager(); - const sendMessageSpy = vi.spyOn(manager, 'sendMessage'); - // Intercept the ping message in create() - sendMessageSpy.mockImplementationOnce(async () => Promise.resolve()); - - const { port1, port2 } = new MessageChannel(); - const portPostMessageSpy = vi.spyOn(port1, 'postMessage'); - const id = 'foo'; - await manager.create({ id, getPort: makeGetPort(port1) }); - - const message: IframeMessage = { type: Command.Evaluate, data: '2+2' }; - const response: IframeMessage = { type: Command.Evaluate, data: '4' }; - - // sendMessage wraps the content in a Command envelope - const messagePromise = manager.sendMessage(id, message); - const messageId: string = - portPostMessageSpy.mock.lastCall?.[0]?.value?.content?.id; - expect(messageId).toBeTypeOf('string'); - - // postMessage sends the json directly, so we have to wrap it in an envelope here - port2.postMessage({ - done: false, - value: wrapCommand({ - id: messageId, - message: response, - }), - }); - - // awaiting event loop should resolve the messagePromise - expect(await messagePromise).toBe(response.data); - - // messagePromise doesn't resolve until message was posted - expect(portPostMessageSpy).toHaveBeenCalledOnce(); - expect(portPostMessageSpy).toHaveBeenCalledWith({ - done: false, - value: wrapCommand({ - id: messageId, - message, - }), - }); - }); - - it('throws if iframe not found', async () => { - const manager = new IframeManager(); - const id = 'foo'; - const message: IframeMessage = { type: Command.Ping, data: null }; - - await expect(manager.sendMessage(id, message)).rejects.toThrow( - `No vat with id "${id}"`, - ); - }); - }); - - describe('miscellaneous', () => { - it('calls console.warn when receiving unexpected message', async () => { - vi.mocked(snapsUtils.createWindow).mockResolvedValueOnce({} as Window); - - const manager = new IframeManager(); - const warnSpy = vi.spyOn(console, 'warn'); - const sendMessageSpy = vi.spyOn(manager, 'sendMessage'); - // Intercept the ping message in create() - sendMessageSpy.mockImplementationOnce(async () => Promise.resolve()); - - const { port1, port2 } = new MessageChannel(); - await manager.create({ getPort: makeGetPort(port1) }); - - port2.postMessage({ done: false, value: 'foo' }); - await delay(10); - - expect(warnSpy).toHaveBeenCalledWith( - 'Stream envelope handler received unexpected value', - 'foo', - ); - }); - - it('calls console.error when receiving message with unknown id', async () => { - vi.mocked(snapsUtils.createWindow).mockResolvedValueOnce({} as Window); - - const manager = new IframeManager(); - const errorSpy = vi.spyOn(console, 'error'); - const sendMessageSpy = vi.spyOn(manager, 'sendMessage'); - // Intercept the ping message in create() - sendMessageSpy.mockImplementationOnce(async () => Promise.resolve()); - - const { port1, port2 } = new MessageChannel(); - await manager.create({ getPort: makeGetPort(port1) }); - - port2.postMessage({ - done: false, - value: wrapCommand({ - id: 'foo', - message: { - type: Command.Evaluate, - data: '"bar"', - }, - }), - }); - await delay(10); - - expect(errorSpy).toHaveBeenCalledWith( - 'No unresolved message with id "foo".', - ); - }); - }); -}); diff --git a/packages/extension/src/iframe-manager.ts b/packages/extension/src/iframe-manager.ts deleted file mode 100644 index a61397fea..000000000 --- a/packages/extension/src/iframe-manager.ts +++ /dev/null @@ -1,232 +0,0 @@ -import { makeCapTP } from '@endo/captp'; -import { E } from '@endo/eventual-send'; -import type { PromiseKit } from '@endo/promise-kit'; -import { makePromiseKit } from '@endo/promise-kit'; -import { createWindow } from '@metamask/snaps-utils'; -import type { StreamPair, Reader } from '@ocap/streams'; -import { - initializeMessageChannel, - makeMessagePortStreamPair, -} from '@ocap/streams'; - -import type { - CapTpMessage, - CapTpPayload, - IframeMessage, - MessageId, -} from './message.js'; -import { Command } from './message.js'; -import { makeCounter, type VatId } from './shared.js'; -import { - makeStreamEnvelopeHandler, - wrapCapTp, - wrapCommand, -} from './stream-envelope.js'; -import type { - StreamEnvelope, - StreamEnvelopeHandler, -} from './stream-envelope.js'; - -const IFRAME_URI = 'iframe.html'; - -/** - * Get a DOM id for our iframes, for greater collision resistance. - * - * @param id - The vat id to base the DOM id on. - * @returns The DOM id. - */ -const getHtmlId = (id: VatId): string => `ocap-iframe-${id}`; - -type PromiseCallbacks = Omit, 'promise'>; - -type UnresolvedMessages = Map; - -type GetPort = (targetWindow: Window) => Promise; - -type VatRecord = { - streams: StreamPair; - messageCounter: () => number; - unresolvedMessages: UnresolvedMessages; - streamEnvelopeHandler: StreamEnvelopeHandler; - capTp?: ReturnType; -}; - -/** - * A singleton class to manage and message iframes. - */ -export class IframeManager { - readonly #vats: Map; - - readonly #vatIdCounter: () => number; - - /** - * Create a new IframeManager. - */ - constructor() { - this.#vats = new Map(); - this.#vatIdCounter = makeCounter(); - } - - /** - * Create a new vat, in the form of an iframe. - * - * @param args - Options bag. - * @param args.id - The id of the vat to create. - * @param args.getPort - A function to get the message port for the iframe. - * @returns The iframe's content window, and the id of the associated vat. - */ - async create( - args: { id?: VatId; getPort?: GetPort } = {}, - ): Promise { - const vatId = args.id ?? this.#nextVatId(); - const getPort = args.getPort ?? initializeMessageChannel; - - const newWindow = await createWindow(IFRAME_URI, getHtmlId(vatId)); - const port = await getPort(newWindow); - const streams = makeMessagePortStreamPair(port); - const unresolvedMessages = new Map(); - this.#vats.set(vatId, { - streams, - messageCounter: makeCounter(), - unresolvedMessages, - streamEnvelopeHandler: makeStreamEnvelopeHandler( - { - command: async ({ id, message }) => { - const promiseCallbacks = unresolvedMessages.get(id); - if (promiseCallbacks === undefined) { - console.error(`No unresolved message with id "${id}".`); - } else { - unresolvedMessages.delete(id); - promiseCallbacks.resolve(message.data); - } - }, - }, - console.warn, - ), - }); - /* v8 ignore next 4: Not known to be possible. */ - this.#receiveMessages(vatId, streams.reader).catch((error) => { - console.error(`Unexpected read error from vat "${vatId}"`, error); - this.delete(vatId).catch(() => undefined); - }); - - await this.sendMessage(vatId, { type: Command.Ping, data: null }); - console.debug(`Created vat with id "${vatId}"`); - return [newWindow, vatId] as const; - } - - /** - * Delete a vat and its associated iframe. - * - * @param id - The id of the vat to delete. - * @returns A promise that resolves when the iframe is deleted. - */ - async delete(id: VatId): Promise { - const vat = this.#vats.get(id); - if (vat === undefined) { - return undefined; - } - - const closeP = vat.streams.return(); - - // Handle orphaned messages - for (const [messageId, promiseCallback] of vat.unresolvedMessages) { - promiseCallback?.reject(new Error('Vat was deleted')); - vat.unresolvedMessages.delete(messageId); - } - this.#vats.delete(id); - - const iframe = document.getElementById(getHtmlId(id)); - /* v8 ignore next 6: Not known to be possible. */ - if (iframe === null) { - console.error(`iframe of vat with id "${id}" already removed from DOM`); - return undefined; - } - iframe.remove(); - - return closeP; - } - - /** - * Send a message to a vat. - * - * @param id - The id of the vat to send the message to. - * @param message - The message to send. - * @returns A promise that resolves the response to the message. - */ - async sendMessage(id: VatId, message: IframeMessage): Promise { - const vat = this.#expectGetVat(id); - const { promise, reject, resolve } = makePromiseKit(); - const messageId = this.#nextMessageId(id); - - vat.unresolvedMessages.set(messageId, { reject, resolve }); - await vat.streams.writer.next(wrapCommand({ id: messageId, message })); - return promise; - } - - async callCapTp(id: VatId, payload: CapTpPayload): Promise { - const { capTp } = this.#expectGetVat(id); - if (capTp === undefined) { - throw new Error(`Vat with id "${id}" does not have a CapTP connection.`); - } - return E(capTp.getBootstrap())[payload.method](...payload.params); - } - - async makeCapTp(id: VatId): Promise { - const vat = this.#expectGetVat(id); - if (vat.capTp !== undefined) { - throw new Error(`Vat with id "${id}" already has a CapTP connection.`); - } - - // Handle writes here. #receiveMessages() handles reads. - const { writer } = vat.streams; - // https://github.com/endojs/endo/issues/2412 - // eslint-disable-next-line @typescript-eslint/no-misused-promises - const ctp = makeCapTP(id, async (content: unknown) => { - console.log('CapTP to vat', JSON.stringify(content, null, 2)); - await writer.next(wrapCapTp(content as CapTpMessage)); - }); - - vat.capTp = ctp; - vat.streamEnvelopeHandler.contentHandlers.capTp = async (content) => { - console.log('CapTP from vat', JSON.stringify(content, null, 2)); - ctp.dispatch(content); - }; - - return this.sendMessage(id, { type: Command.CapTpInit, data: null }); - } - - async #receiveMessages( - vatId: VatId, - reader: Reader, - ): Promise { - const vat = this.#expectGetVat(vatId); - - for await (const rawMessage of reader) { - console.debug('Offscreen received message', rawMessage); - await vat.streamEnvelopeHandler.handle(rawMessage); - } - } - - /** - * Get a vat record by id, or throw an error if it doesn't exist. - * - * @param id - The id of the vat to get. - * @returns The vat record. - */ - #expectGetVat(id: VatId): VatRecord { - const vat = this.#vats.get(id); - if (vat === undefined) { - throw new Error(`No vat with id "${id}"`); - } - return vat; - } - - readonly #nextMessageId = (id: VatId): MessageId => { - return `${id}-${this.#expectGetVat(id).messageCounter()}`; - }; - - readonly #nextVatId = (): MessageId => { - return `${this.#vatIdCounter()}`; - }; -} diff --git a/packages/extension/src/iframe.ts b/packages/extension/src/iframe.ts index 294560d4a..fe23a7516 100644 --- a/packages/extension/src/iframe.ts +++ b/packages/extension/src/iframe.ts @@ -1,20 +1,20 @@ import { makeCapTP } from '@endo/captp'; import { makeExo } from '@endo/exo'; import { M } from '@endo/patterns'; -import { receiveMessagePort, makeMessagePortStreamPair } from '@ocap/streams'; - import type { + StreamEnvelope, CapTpMessage, - IframeMessage, - WrappedIframeMessage, -} from './message.js'; -import { Command } from './message.js'; -import type { StreamEnvelope } from './stream-envelope.js'; + VatMessage, + WrappedVatMessage, +} from '@ocap/streams'; import { - wrapCapTp, - wrapCommand, + receiveMessagePort, + makeMessagePortStreamPair, makeStreamEnvelopeHandler, -} from './stream-envelope.js'; + Command, + wrapCapTp, + wrapStreamCommand, +} from '@ocap/streams'; const defaultCompartment = new Compartment({ URL }); @@ -56,7 +56,7 @@ async function main(): Promise { async function handleMessage({ id, message, - }: WrappedIframeMessage): Promise { + }: WrappedVatMessage): Promise { switch (message.type) { case Command.Evaluate: { if (typeof message.data !== 'string') { @@ -109,9 +109,9 @@ async function main(): Promise { */ async function replyToMessage( id: string, - message: IframeMessage, + message: VatMessage, ): Promise { - await streams.writer.next(wrapCommand({ id, message })); + await streams.writer.next(wrapStreamCommand({ id, message })); } /** diff --git a/packages/extension/src/makeIframeVatWorker.ts b/packages/extension/src/makeIframeVatWorker.ts new file mode 100644 index 000000000..3282a2c22 --- /dev/null +++ b/packages/extension/src/makeIframeVatWorker.ts @@ -0,0 +1,39 @@ +import { createWindow } from '@metamask/snaps-utils'; +import type { VatId, VatWorker } from '@ocap/kernel'; +import type { initializeMessageChannel, StreamEnvelope } from '@ocap/streams'; +import { makeMessagePortStreamPair } from '@ocap/streams'; + +const IFRAME_URI = 'iframe.html'; + +/** + * Get a DOM id for our iframes, for greater collision resistance. + * + * @param id - The vat id to base the DOM id on. + * @returns The DOM id. + */ +const getHtmlId = (id: VatId): string => `ocap-iframe-${id}`; + +export const makeIframeVatWorker = ( + id: VatId, + getPort: typeof initializeMessageChannel, +): VatWorker => { + return { + init: async () => { + const newWindow = await createWindow(IFRAME_URI, getHtmlId(id)); + const port = await getPort(newWindow); + const streams = makeMessagePortStreamPair(port); + + return [streams, newWindow]; + }, + delete: async (): Promise => { + const iframe = document.getElementById(getHtmlId(id)); + /* v8 ignore next 6: Not known to be possible. */ + if (iframe === null) { + console.error(`iframe of vat with id "${id}" already removed from DOM`); + return undefined; + } + iframe.remove(); + return undefined; + }, + }; +}; diff --git a/packages/extension/src/message.test.ts b/packages/extension/src/message.test.ts deleted file mode 100644 index 8fdec3480..000000000 --- a/packages/extension/src/message.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, it, expect } from 'vitest'; - -import { isWrappedIframeMessage } from './message.js'; - -describe('message', () => { - describe('isWrappedIframeMessage', () => { - it('returns true for valid messages', () => { - expect( - isWrappedIframeMessage({ - id: '1', - message: { type: 'evaluate', data: '1 + 1' }, - }), - ).toBe(true); - }); - - it.each([ - [{}], - [{ id: '1' }], - [{ message: { type: 'evaluate' } }], - [{ id: '1', message: { type: 'evaluate' } }], - [{ id: '1', message: { type: 'evaluate', data: 1 } }], - ])('returns false for invalid messages: %j', (message) => { - expect(isWrappedIframeMessage(message)).toBe(false); - }); - }); -}); diff --git a/packages/extension/src/offscreen.ts b/packages/extension/src/offscreen.ts index c379f3c85..e904a2d79 100644 --- a/packages/extension/src/offscreen.ts +++ b/packages/extension/src/offscreen.ts @@ -1,6 +1,12 @@ -import { IframeManager } from './iframe-manager.js'; -import type { ExtensionMessage } from './message.js'; -import { Command, ExtensionMessageTarget } from './message.js'; +import { Kernel } from '@ocap/kernel'; +import { + initializeMessageChannel, + Command, + KernelMessageTarget, +} from '@ocap/streams'; +import type { KernelMessage } from '@ocap/streams'; + +import { makeIframeVatWorker } from './makeIframeVatWorker.js'; import { makeHandledCallback } from './shared.js'; main().catch(console.error); @@ -9,36 +15,35 @@ main().catch(console.error); * The main function for the offscreen script. */ async function main(): Promise { - // Hard-code a single iframe for now. - const IFRAME_ID = 'default'; - const iframeManager = new IframeManager(); - const iframeReadyP = iframeManager - .create({ id: IFRAME_ID }) - .then(async () => iframeManager.makeCapTp(IFRAME_ID)); + const kernel = new Kernel(); + const iframeReadyP = kernel.launchVat({ + id: 'default', + worker: makeIframeVatWorker('default', initializeMessageChannel), + }); // Handle messages from the background service worker chrome.runtime.onMessage.addListener( - makeHandledCallback(async (message: ExtensionMessage) => { - if (message.target !== ExtensionMessageTarget.Offscreen) { + makeHandledCallback(async (message: KernelMessage) => { + if (message.target !== KernelMessageTarget.Offscreen) { console.warn( `Offscreen received message with unexpected target: "${message.target}"`, ); return; } - await iframeReadyP; + const vat = await iframeReadyP; switch (message.type) { case Command.Evaluate: - await reply(Command.Evaluate, await evaluate(message.data)); + await reply(Command.Evaluate, await evaluate(vat.id, message.data)); break; case Command.CapTpCall: { - const result = await iframeManager.callCapTp(IFRAME_ID, message.data); + const result = await vat.callCapTp(message.data); await reply(Command.CapTpCall, JSON.stringify(result, null, 2)); break; } case Command.CapTpInit: - await iframeManager.makeCapTp(IFRAME_ID); + await vat.makeCapTp(); await reply(Command.CapTpInit, '~~~ CapTP Initialized ~~~'); break; case Command.Ping: @@ -63,7 +68,7 @@ async function main(): Promise { async function reply(type: Command, data?: string): Promise { await chrome.runtime.sendMessage({ data: data ?? null, - target: ExtensionMessageTarget.Background, + target: KernelMessageTarget.Background, type, }); } @@ -71,12 +76,13 @@ async function main(): Promise { /** * 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(source: string): Promise { + async function evaluate(vatId: string, source: string): Promise { try { - const result = await iframeManager.sendMessage(IFRAME_ID, { + const result = await kernel.sendMessage(vatId, { type: Command.Evaluate, data: source, }); diff --git a/packages/extension/src/shared.test.ts b/packages/extension/src/shared.test.ts index 54c4ff8aa..c409eaec8 100644 --- a/packages/extension/src/shared.test.ts +++ b/packages/extension/src/shared.test.ts @@ -2,7 +2,7 @@ import './endoify.js'; import { delay } from '@ocap/test-utils'; import { vi, describe, it, expect } from 'vitest'; -import { makeCounter, makeHandledCallback } from './shared.js'; +import { makeHandledCallback } from './shared.js'; describe('shared', () => { describe('makeHandledCallback', () => { @@ -37,25 +37,4 @@ describe('shared', () => { ); }); }); - - describe('makeCounter', () => { - it('starts at 1 by default', () => { - const counter = makeCounter(); - expect(counter()).toBe(1); - }); - - it('starts counting from the supplied argument', () => { - const start = 50; - const counter = makeCounter(start); - expect(counter()).toStrictEqual(start + 1); - }); - - it('increments convincingly', () => { - const counter = makeCounter(); - const first = counter(); - expect(counter()).toStrictEqual(first + 1); - expect(counter()).toStrictEqual(first + 2); - expect(counter()).toStrictEqual(first + 3); - }); - }); }); diff --git a/packages/extension/src/shared.ts b/packages/extension/src/shared.ts index c41aa7abc..2bc7a4e10 100644 --- a/packages/extension/src/shared.ts +++ b/packages/extension/src/shared.ts @@ -14,17 +14,3 @@ export const makeHandledCallback = ( callback(...args).catch(console.error); }; }; - -/** - * A simple counter which increments and returns when called. - * - * @param start - One less than the first returned number. - * @returns A counter. - */ -export const makeCounter = (start: number = 0) => { - let counter: number = start; - return () => { - counter += 1; - return counter; - }; -}; diff --git a/packages/kernel/.eslintrc.cjs b/packages/kernel/.eslintrc.cjs new file mode 100644 index 000000000..165e7042e --- /dev/null +++ b/packages/kernel/.eslintrc.cjs @@ -0,0 +1,3 @@ +module.exports = { + extends: ['../../.eslintrc.cjs'], +}; diff --git a/packages/kernel/CHANGELOG.md b/packages/kernel/CHANGELOG.md new file mode 100644 index 000000000..0c82cb1ed --- /dev/null +++ b/packages/kernel/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +[Unreleased]: https://github.com/MetaMask/ocap-kernel/ diff --git a/packages/kernel/README.md b/packages/kernel/README.md new file mode 100644 index 000000000..fd1a5cbfc --- /dev/null +++ b/packages/kernel/README.md @@ -0,0 +1,7 @@ +# `@ocap/kernel` + +OCap kernel core components + +## Contributing + +This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/ocap-kernel#readme). diff --git a/packages/kernel/package.json b/packages/kernel/package.json new file mode 100644 index 000000000..32d2d4245 --- /dev/null +++ b/packages/kernel/package.json @@ -0,0 +1,80 @@ +{ + "name": "@ocap/kernel", + "version": "0.0.0", + "private": true, + "description": "OCap kernel core components", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/ocap-kernel.git" + }, + "type": "module", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "files": [ + "dist/" + ], + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --clean", + "build:docs": "typedoc", + "changelog:validate": "../../scripts/validate-changelog.sh @ocap/kernel", + "clean": "rimraf --glob ./dist './*.tsbuildinfo'", + "lint": "yarn lint:eslint && yarn lint:misc --check && yarn constraints && yarn lint:dependencies", + "lint:dependencies": "depcheck", + "lint:eslint": "eslint . --cache --ext js,mjs,cjs,ts,mts,cts", + "lint:fix": "yarn constraints --fix && yarn lint:eslint --fix && yarn lint:misc --write", + "lint:misc": "prettier --no-error-on-unmatched-pattern '**/*.json' '**/*.md' '**/*.html' '!**/CHANGELOG.old.md' '**/*.yml' '!.yarnrc.yml' '!merged-packages/**' --ignore-path ../../.gitignore", + "publish:preview": "yarn npm publish --tag preview", + "test": "vitest run --config vitest.config.ts", + "test:clean": "yarn test --no-cache --coverage.clean", + "test:dev": "yarn test --coverage false", + "test:verbose": "yarn test --reporter verbose", + "test:watch": "vitest --config vitest.config.ts" + }, + "dependencies": { + "@endo/captp": "^4.2.2", + "@endo/eventual-send": "^1.2.4", + "@endo/promise-kit": "^1.1.4", + "@ocap/shims": "workspace:^", + "@ocap/streams": "workspace:^", + "ses": "^1.7.0" + }, + "devDependencies": { + "@metamask/auto-changelog": "^3.4.4", + "@metamask/eslint-config": "^13.0.0", + "@metamask/eslint-config-nodejs": "^13.0.0", + "@metamask/eslint-config-typescript": "^13.0.0", + "@ocap/test-utils": "workspace:^", + "@ts-bridge/cli": "^0.5.1", + "@ts-bridge/shims": "^0.1.1", + "@typescript-eslint/eslint-plugin": "^8.1.0", + "@typescript-eslint/parser": "^8.1.0", + "depcheck": "^1.4.7", + "eslint": "^8.57.0", + "eslint-config-prettier": "^8.8.0", + "eslint-plugin-import-x": "^0.5.1", + "eslint-plugin-jsdoc": "^47.0.2", + "eslint-plugin-n": "^16.6.2", + "eslint-plugin-prettier": "^4.2.1", + "eslint-plugin-promise": "^6.1.1", + "eslint-plugin-vitest": "^0.4.1", + "prettier": "^2.7.1", + "rimraf": "^6.0.1", + "typescript": "~5.5.4", + "vite": "^5.3.5", + "vitest": "^2.0.5" + }, + "engines": { + "node": "^18.18 || >=20" + } +} diff --git a/packages/kernel/src/Kernel.test.ts b/packages/kernel/src/Kernel.test.ts new file mode 100644 index 000000000..36971c827 --- /dev/null +++ b/packages/kernel/src/Kernel.test.ts @@ -0,0 +1,131 @@ +import type { VatMessage } from '@ocap/streams'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { Kernel } from './Kernel.js'; +import type { VatWorker } from './types.js'; +import { Vat } from './Vat.js'; + +describe('Kernel', () => { + let mockWorker: VatWorker; + let initMock: unknown; + let terminateMock: unknown; + + beforeEach(() => { + vi.resetAllMocks(); + + mockWorker = { + init: vi.fn().mockResolvedValue([{}]), + delete: vi.fn(), + }; + + initMock = vi.spyOn(Vat.prototype, 'init').mockImplementation(vi.fn()); + terminateMock = vi + .spyOn(Vat.prototype, 'terminate') + .mockImplementation(vi.fn()); + }); + + describe('getVatIds()', () => { + it('returns an empty array when no vats are added', () => { + const kernel = new Kernel(); + expect(kernel.getVatIds()).toStrictEqual([]); + }); + + 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']); + }); + + 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']); + }); + }); + + 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 }); + expect(initMock).toHaveBeenCalledOnce(); + expect(mockWorker.init).toHaveBeenCalled(); + expect(kernel.getVatIds()).toStrictEqual(['vat-id-1']); + }); + + 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 expect( + kernel.launchVat({ + id: 'vat-id-1', + worker: mockWorker, + }), + ).rejects.toThrow('Vat with ID vat-id-1 already exists.'); + expect(kernel.getVatIds()).toStrictEqual(['vat-id-1']); + }); + }); + + 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'); + expect(terminateMock).toHaveBeenCalledOnce(); + expect(mockWorker.delete).toHaveBeenCalledOnce(); + expect(kernel.getVatIds()).toStrictEqual([]); + }); + + it('throws an error when deleting a vat that does not exist in the kernel', async () => { + const kernel = new Kernel(); + await expect(async () => + kernel.deleteVat('non-existent-vat-id'), + ).rejects.toThrow('Vat with ID non-existent-vat-id 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 }); + vi.spyOn(Vat.prototype, 'terminate').mockRejectedValueOnce('Test error'); + await expect(async () => kernel.deleteVat('vat-id')).rejects.toThrow( + 'Test error', + ); + }); + }); + + 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 }); + vi.spyOn(Vat.prototype, 'sendMessage').mockResolvedValueOnce('test'); + expect( + await kernel.sendMessage('vat-id', 'test' as unknown as VatMessage), + ).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(); + await expect(async () => + kernel.sendMessage('non-existent-vat-id', {} as VatMessage), + ).rejects.toThrow('Vat with ID non-existent-vat-id 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 }); + vi.spyOn(Vat.prototype, 'sendMessage').mockRejectedValueOnce('error'); + await expect(async () => + kernel.sendMessage('vat-id', {} as VatMessage), + ).rejects.toThrow('error'); + }); + }); + + describe('constructor()', () => { + it('initializes the kernel without errors', () => { + expect(async () => new Kernel()).not.toThrow(); + }); + }); +}); diff --git a/packages/kernel/src/Kernel.ts b/packages/kernel/src/Kernel.ts new file mode 100644 index 000000000..0d540919e --- /dev/null +++ b/packages/kernel/src/Kernel.ts @@ -0,0 +1,86 @@ +import '@ocap/shims/endoify'; +import type { VatMessage } from '@ocap/streams'; + +import type { VatId, VatWorker } from './types.js'; +import { Vat } from './Vat.js'; + +export class Kernel { + readonly #vats: Map; + + constructor() { + this.#vats = new Map(); + } + + /** + * Gets the vat IDs in the kernel. + * + * @returns An array of vat IDs. + */ + getVatIds(): VatId[] { + return Array.from(this.#vats.keys()); + } + + /** + * Launches a vat in the kernel. + * + * @param options - The options for launching the vat. + * @param options.id - The ID of the vat. + * @param options.worker - The worker to use for the vat. + * @returns A promise that resolves the vat. + */ + async launchVat({ + id, + worker, + }: { + id: VatId; + worker: VatWorker; + }): Promise { + if (this.#vats.has(id)) { + throw new Error(`Vat with ID ${id} already exists.`); + } + const [streams] = await worker.init(); + const vat = new Vat({ id, streams }); + this.#vats.set(vat.id, { vat, worker }); + await vat.init(); + return vat; + } + + /** + * Deletes a vat from the kernel. + * + * @param id - The ID of the vat. + */ + async deleteVat(id: VatId): Promise { + const vatRecord = this.#getVatRecord(id); + const { vat, worker } = vatRecord; + await vat.terminate(); + await worker.delete(); + this.#vats.delete(id); + } + + /** + * Send a message to a vat. + * + * @param id - The id of the vat to send the message to. + * @param message - The message to send. + * @returns A promise that resolves the response to the message. + */ + async sendMessage(id: VatId, message: VatMessage): Promise { + const { vat } = this.#getVatRecord(id); + return vat.sendMessage(message); + } + + /** + * Gets a vat record from the kernel. + * + * @param id - The ID of the vat. + * @returns The vat record (vat and worker). + */ + #getVatRecord(id: VatId): { vat: Vat; worker: VatWorker } { + const vatRecord = this.#vats.get(id); + if (vatRecord === undefined) { + throw new Error(`Vat with ID ${id} does not exist.`); + } + return vatRecord; + } +} diff --git a/packages/kernel/src/Vat.test.ts b/packages/kernel/src/Vat.test.ts new file mode 100644 index 000000000..867d69766 --- /dev/null +++ b/packages/kernel/src/Vat.test.ts @@ -0,0 +1,105 @@ +import '@ocap/shims/endoify'; +import { + makeMessagePortStreamPair, + makeStreamEnvelopeHandler, + Command, +} from '@ocap/streams'; +import type { StreamEnvelope, VatMessage } from '@ocap/streams'; +import { makeCapTpMock, makePromiseKitMock } from '@ocap/test-utils'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { Vat } from './Vat.js'; + +vi.mock('@endo/captp', () => makeCapTpMock()); + +describe('Vat', () => { + let vat: Vat; + let port1: MessagePort; + + beforeEach(() => { + vi.resetAllMocks(); + + const messageChannel = new MessageChannel(); + port1 = messageChannel.port1; + + const streams = makeMessagePortStreamPair(port1); + + vat = new Vat({ + id: 'test-vat', + streams, + }); + }); + + describe('init', () => { + it('initializes the vat and sends a ping message', async () => { + const sendMessageMock = vi + .spyOn(vat, 'sendMessage') + .mockResolvedValueOnce(undefined); + const capTpMock = vi + .spyOn(vat, 'makeCapTp') + .mockResolvedValueOnce(undefined); + + await vat.init(); + + expect(sendMessageMock).toHaveBeenCalledWith({ + type: Command.Ping, + data: null, + }); + expect(capTpMock).toHaveBeenCalled(); + }); + }); + + describe('sendMessage', () => { + it('sends a message and resolves the promise', async () => { + const mockMessage = { type: 'makeCapTp', data: null } as VatMessage; + const sendMessagePromise = vat.sendMessage(mockMessage); + vat.unresolvedMessages.get('test-vat-1')?.resolve('test-response'); + const result = await sendMessagePromise; + expect(result).toBe('test-response'); + }); + }); + + describe('terminate', () => { + it('terminates the vat and resolves/rejects unresolved messages', async () => { + const mockMessageId = 'test-vat-1'; + const mockPromiseKit = makePromiseKitMock().makePromiseKit(); + const mockSpy = vi.spyOn(mockPromiseKit, 'reject'); + vat.unresolvedMessages.set(mockMessageId, mockPromiseKit); + await vat.terminate(); + expect(mockSpy).toHaveBeenCalledWith(expect.any(Error)); + }); + }); + + describe('makeCapTp', () => { + it('throws an error if CapTP connection already exists', async () => { + // @ts-expect-error - Simulating an existing CapTP + vat.capTp = {}; + await expect(vat.makeCapTp()).rejects.toThrow( + `Vat with id "${vat.id}" already has a CapTP connection.`, + ); + }); + + it('creates a CapTP connection and sends CapTpInit message', async () => { + vat.streamEnvelopeHandler = makeStreamEnvelopeHandler({}, console.warn); + const sendMessageMock = vi + .spyOn(vat, 'sendMessage') + .mockResolvedValueOnce(undefined); + await vat.makeCapTp(); + expect(vat.streamEnvelopeHandler.contentHandlers.capTp).toBeDefined(); + expect(sendMessageMock).toHaveBeenCalledWith({ + type: Command.CapTpInit, + data: null, + }); + }); + }); + + describe('callCapTp', () => { + 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.`, + ); + }); + }); +}); diff --git a/packages/kernel/src/Vat.ts b/packages/kernel/src/Vat.ts new file mode 100644 index 000000000..ae2439028 --- /dev/null +++ b/packages/kernel/src/Vat.ts @@ -0,0 +1,175 @@ +import { makeCapTP } from '@endo/captp'; +import { E } from '@endo/eventual-send'; +import { makePromiseKit } from '@endo/promise-kit'; +import type { + StreamPair, + StreamEnvelope, + StreamEnvelopeHandler, + Reader, + CapTpMessage, + CapTpPayload, + VatMessage, + MessageId, +} from '@ocap/streams'; +import { + wrapCapTp, + wrapStreamCommand, + Command, + makeStreamEnvelopeHandler, +} from '@ocap/streams'; + +import type { UnresolvedMessages, VatId } from './types.js'; +import { makeCounter } from './utils/makeCounter.js'; + +type VatConstructorProps = { + id: VatId; + streams: StreamPair; +}; + +export class Vat { + readonly id: VatConstructorProps['id']; + + readonly streams: VatConstructorProps['streams']; + + readonly #messageCounter: () => number; + + readonly unresolvedMessages: UnresolvedMessages = new Map(); + + streamEnvelopeHandler: StreamEnvelopeHandler; + + capTp?: ReturnType; + + constructor({ id, streams }: VatConstructorProps) { + this.id = id; + this.streams = streams; + this.#messageCounter = makeCounter(); + this.streamEnvelopeHandler = makeStreamEnvelopeHandler( + { + command: async ({ id: messageId, message }) => { + const promiseCallbacks = this.unresolvedMessages.get(messageId); + if (promiseCallbacks === undefined) { + console.error(`No unresolved message with id "${messageId}".`); + } else { + this.unresolvedMessages.delete(messageId); + promiseCallbacks.resolve(message.data); + } + }, + }, + console.warn, + ); + } + + /** + * Initializes the vat. + * + * @returns A promise that resolves when the vat is initialized. + */ + async init(): Promise { + /* v8 ignore next 4: Not known to be possible. */ + this.#receiveMessages(this.streams.reader).catch((error) => { + console.error(`Unexpected read error from vat "${this.id}"`, error); + throw error; + }); + + await this.sendMessage({ type: Command.Ping, data: null }); + console.debug(`Created vat with id "${this.id}"`); + + return await this.makeCapTp(); + } + + /** + * Receives messages from a vat. + * + * @param reader - The reader for the messages. + */ + async #receiveMessages(reader: Reader): Promise { + for await (const rawMessage of reader) { + console.debug('Vat received message', rawMessage); + await this.streamEnvelopeHandler.handle(rawMessage); + } + } + + /** + * Make a CapTP connection. + * + * @returns A promise that resolves when the CapTP connection is made. + */ + async makeCapTp(): Promise { + if (this.capTp !== undefined) { + throw new Error( + `Vat with id "${this.id}" already has a CapTP connection.`, + ); + } + + // Handle writes here. #receiveMessages() handles reads. + const { writer } = this.streams; + const ctp = makeCapTP(this.id, async (content: unknown) => { + console.log('CapTP to vat', JSON.stringify(content, null, 2)); + await writer.next(wrapCapTp(content as CapTpMessage)); + }); + + this.capTp = ctp; + this.streamEnvelopeHandler.contentHandlers.capTp = async ( + content: CapTpMessage, + ) => { + console.log('CapTP from vat', JSON.stringify(content, null, 2)); + ctp.dispatch(content); + }; + + return this.sendMessage({ type: Command.CapTpInit, data: null }); + } + + /** + * Call a CapTP method. + * + * @param payload - The CapTP payload. + * @returns A promise that resolves the result of the CapTP call. + */ + async callCapTp(payload: CapTpPayload): Promise { + if (!this.capTp) { + throw new Error( + `Vat with id "${this.id}" does not have a CapTP connection.`, + ); + } + return E(this.capTp.getBootstrap())[payload.method](...payload.params); + } + + /** + * Terminates the vat. + */ + async terminate(): Promise { + await this.streams.return(); + + // Handle orphaned messages + for (const [messageId, promiseCallback] of this.unresolvedMessages) { + promiseCallback?.reject(new Error('Vat was deleted')); + this.unresolvedMessages.delete(messageId); + } + } + + /** + * Send a message to a vat. + * + * @param message - The message to send. + * @returns A promise that resolves the response to the message. + */ + async sendMessage(message: VatMessage): Promise { + console.debug(`Sending message to vat "${this.id}"`, message); + const { promise, reject, resolve } = makePromiseKit(); + const messageId = this.#nextMessageId(); + this.unresolvedMessages.set(messageId, { reject, resolve }); + await this.streams.writer.next( + wrapStreamCommand({ id: messageId, message }), + ); + return promise; + } + + /** + * Gets the next message ID. + * + * @returns The message ID. + */ + readonly #nextMessageId = (): MessageId => { + return `${this.id}-${this.#messageCounter()}`; + }; +} diff --git a/packages/kernel/src/index.test.ts b/packages/kernel/src/index.test.ts new file mode 100644 index 000000000..f4ef29a61 --- /dev/null +++ b/packages/kernel/src/index.test.ts @@ -0,0 +1,11 @@ +import { describe, it, expect } from 'vitest'; + +import * as indexModule from './index.js'; + +describe('index', () => { + it('has the expected exports', () => { + expect(Object.keys(indexModule)).toStrictEqual( + expect.arrayContaining(['Kernel', 'Vat']), + ); + }); +}); diff --git a/packages/kernel/src/index.ts b/packages/kernel/src/index.ts new file mode 100644 index 000000000..4985294f7 --- /dev/null +++ b/packages/kernel/src/index.ts @@ -0,0 +1,3 @@ +export { Kernel } from './Kernel.js'; +export { Vat } from './Vat.js'; +export type { VatId, VatWorker } from './types.js'; diff --git a/packages/kernel/src/types.ts b/packages/kernel/src/types.ts new file mode 100644 index 000000000..5b8d304f6 --- /dev/null +++ b/packages/kernel/src/types.ts @@ -0,0 +1,13 @@ +import type { PromiseKit } from '@endo/promise-kit'; +import type { StreamPair, MessageId, StreamEnvelope } from '@ocap/streams'; + +export type VatId = string; + +export type VatWorker = { + init: () => Promise<[StreamPair, unknown]>; + delete: () => Promise; +}; + +export type PromiseCallbacks = Omit, 'promise'>; + +export type UnresolvedMessages = Map; diff --git a/packages/kernel/src/utils/makeCounter.test.ts b/packages/kernel/src/utils/makeCounter.test.ts new file mode 100644 index 000000000..d40784d27 --- /dev/null +++ b/packages/kernel/src/utils/makeCounter.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest'; + +import { makeCounter } from './makeCounter.js'; + +describe('makeCounter', () => { + it('starts at 1 by default', () => { + const counter = makeCounter(); + expect(counter()).toBe(1); + }); + + it('starts counting from the supplied argument', () => { + const start = 50; + const counter = makeCounter(start); + expect(counter()).toStrictEqual(start + 1); + }); + + it('increments convincingly', () => { + const counter = makeCounter(); + const first = counter(); + expect(counter()).toStrictEqual(first + 1); + expect(counter()).toStrictEqual(first + 2); + expect(counter()).toStrictEqual(first + 3); + }); +}); diff --git a/packages/kernel/src/utils/makeCounter.ts b/packages/kernel/src/utils/makeCounter.ts new file mode 100644 index 000000000..0a74c3737 --- /dev/null +++ b/packages/kernel/src/utils/makeCounter.ts @@ -0,0 +1,13 @@ +/** + * A simple counter which increments and returns when called. + * + * @param start - One less than the first returned number. + * @returns A counter. + */ +export const makeCounter = (start: number = 0) => { + let counter: number = start; + return () => { + counter += 1; + return counter; + }; +}; diff --git a/packages/kernel/tsconfig.build.json b/packages/kernel/tsconfig.build.json new file mode 100644 index 000000000..a406f5f5a --- /dev/null +++ b/packages/kernel/tsconfig.build.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src", + "lib": ["DOM", "ES2022"], + "types": ["ses"] + }, + "references": [], + "include": ["./src"] +} diff --git a/packages/kernel/tsconfig.json b/packages/kernel/tsconfig.json new file mode 100644 index 000000000..6f1d89de4 --- /dev/null +++ b/packages/kernel/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [], + "include": ["./src"] +} diff --git a/packages/kernel/typedoc.json b/packages/kernel/typedoc.json new file mode 100644 index 000000000..c9da015db --- /dev/null +++ b/packages/kernel/typedoc.json @@ -0,0 +1,7 @@ +{ + "entryPoints": ["./src/index.ts"], + "excludePrivate": true, + "hideGenerator": true, + "out": "docs", + "tsconfig": "./tsconfig.build.json" +} diff --git a/packages/kernel/vitest.config.ts b/packages/kernel/vitest.config.ts new file mode 100644 index 000000000..177cd87c8 --- /dev/null +++ b/packages/kernel/vitest.config.ts @@ -0,0 +1,28 @@ +// eslint-disable-next-line spaced-comment +/// + +import path from 'path'; +import { defineConfig, mergeConfig } from 'vite'; + +import { getDefaultConfig } from '../../vitest.config.packages.js'; + +const defaultConfig = getDefaultConfig(); + +const config = mergeConfig( + defaultConfig, + defineConfig({ + test: { + pool: 'vmThreads', + alias: [ + { + find: '@ocap/shims/endoify', + replacement: path.resolve('../shims/src/endoify.js'), + customResolver: (id) => ({ external: true, id }), + }, + ], + }, + }), +); + +delete config.test.coverage.thresholds; +export default config; diff --git a/packages/streams/package.json b/packages/streams/package.json index d2e4b164a..6ba009be2 100644 --- a/packages/streams/package.json +++ b/packages/streams/package.json @@ -46,6 +46,7 @@ "test:watch": "vitest --config vitest.config.ts" }, "dependencies": { + "@endo/captp": "^4.2.2", "@endo/promise-kit": "^1.1.4", "@endo/stream": "^1.2.2", "@metamask/utils": "^9.1.0" diff --git a/packages/streams/src/index.test.ts b/packages/streams/src/index.test.ts index 3ddc0dee0..625957abd 100644 --- a/packages/streams/src/index.test.ts +++ b/packages/streams/src/index.test.ts @@ -6,11 +6,15 @@ describe('index', () => { it('has the expected exports', () => { expect(Object.keys(indexModule)).toStrictEqual( expect.arrayContaining([ - 'makeMessagePortStreamPair', - 'MessagePortReader', - 'MessagePortWriter', 'initializeMessageChannel', 'receiveMessagePort', + 'makeMessagePortStreamPair', + 'makeStreamEnvelopeKit', + 'KernelMessageTarget', + 'Command', + 'wrapStreamCommand', + 'wrapCapTp', + 'makeStreamEnvelopeHandler', ]), ); }); diff --git a/packages/streams/src/index.ts b/packages/streams/src/index.ts index 7ddd045fa..b794edc38 100644 --- a/packages/streams/src/index.ts +++ b/packages/streams/src/index.ts @@ -3,16 +3,21 @@ export { receiveMessagePort, } from './message-channel.js'; export type { StreamPair, Reader, Writer } from './streams.js'; +export { makeMessagePortStreamPair } from './streams.js'; +export { makeStreamEnvelopeKit } from './envelope-kit.js'; +export type { + CapTpMessage, + CapTpPayload, + MessageId, + VatMessage, + KernelMessage, + WrappedVatMessage, +} from './types.js'; +export { KernelMessageTarget, Command } from './types.js'; export { - makeMessagePortStreamPair, - MessagePortReader, - MessagePortWriter, -} from './streams.js'; -export { - makeStreamEnvelopeKit, - type StreamEnvelopeKit, - type MakeStreamEnvelopeHandler, -} from './envelope-kit.js'; -export type { StreamEnvelopeHandler } from './envelope-handler.js'; -export type { StreamEnveloper } from './enveloper.js'; -export type { Envelope, StreamEnvelope } from './envelope.js'; + wrapStreamCommand, + wrapCapTp, + makeStreamEnvelopeHandler, + type StreamEnvelope, + type StreamEnvelopeHandler, +} from './stream-envelope.js'; diff --git a/packages/extension/src/stream-envelope.test.ts b/packages/streams/src/stream-envelope.test.ts similarity index 83% rename from packages/extension/src/stream-envelope.test.ts rename to packages/streams/src/stream-envelope.test.ts index 94677e051..50256a55f 100644 --- a/packages/extension/src/stream-envelope.test.ts +++ b/packages/streams/src/stream-envelope.test.ts @@ -1,16 +1,15 @@ -import '@ocap/shims/endoify'; import { describe, it, expect } from 'vitest'; -import type { CapTpMessage, WrappedIframeMessage } from './message.js'; -import { Command } from './message.js'; import { wrapCapTp, - wrapCommand, + wrapStreamCommand, makeStreamEnvelopeHandler, } from './stream-envelope.js'; +import type { CapTpMessage, WrappedVatMessage } from './types.js'; +import { Command } from './types.js'; describe('StreamEnvelopeHandler', () => { - const commandContent: WrappedIframeMessage = { + const commandContent: WrappedVatMessage = { id: '1', message: { type: Command.Evaluate, data: '1 + 1' }, }; @@ -21,7 +20,7 @@ describe('StreamEnvelopeHandler', () => { unreliableKey: Symbol('unreliableValue'), }; - const commandLabel = wrapCommand(commandContent).label; + const commandLabel = wrapStreamCommand(commandContent).label; const capTpLabel = wrapCapTp(capTpContent).label; const testEnvelopeHandlers = { @@ -34,9 +33,9 @@ describe('StreamEnvelopeHandler', () => { }; it.each` - wrapper | content | label - ${wrapCommand} | ${commandContent} | ${commandLabel} - ${wrapCapTp} | ${capTpContent} | ${capTpLabel} + wrapper | content | label + ${wrapStreamCommand} | ${commandContent} | ${commandLabel} + ${wrapCapTp} | ${capTpContent} | ${capTpLabel} `('handles valid StreamEnvelopes', async ({ wrapper, content, label }) => { const handler = makeStreamEnvelopeHandler( testEnvelopeHandlers, diff --git a/packages/extension/src/stream-envelope.ts b/packages/streams/src/stream-envelope.ts similarity index 74% rename from packages/extension/src/stream-envelope.ts rename to packages/streams/src/stream-envelope.ts index f120cfd70..a53b3fef4 100644 --- a/packages/extension/src/stream-envelope.ts +++ b/packages/streams/src/stream-envelope.ts @@ -1,9 +1,6 @@ -import { makeStreamEnvelopeKit } from '@ocap/streams'; - -import type { CapTpMessage, WrappedIframeMessage } from './message.js'; -import { isCapTpMessage, isWrappedIframeMessage } from './message.js'; - -// Utilitous generic types. +import { makeStreamEnvelopeKit } from './envelope-kit.js'; +import { isCapTpMessage, isWrappedVatMessage } from './type-guards.js'; +import type { CapTpMessage, WrappedVatMessage } from './types.js'; type GuardType = TypeGuard extends ( value: unknown, @@ -30,11 +27,11 @@ const envelopeLabels = Object.values(EnvelopeLabel); const envelopeKit = makeStreamEnvelopeKit< typeof envelopeLabels, { - command: WrappedIframeMessage; + command: WrappedVatMessage; capTp: CapTpMessage; } >({ - command: isWrappedIframeMessage, + command: isWrappedVatMessage, capTp: isCapTpMessage, }); @@ -45,6 +42,6 @@ export type StreamEnvelopeHandler = ReturnType< typeof makeStreamEnvelopeHandler >; -export const wrapCommand = streamEnveloper.command.wrap; +export const wrapStreamCommand = streamEnveloper.command.wrap; export const wrapCapTp = streamEnveloper.capTp.wrap; export { makeStreamEnvelopeHandler }; diff --git a/packages/streams/src/type-guards.test.ts b/packages/streams/src/type-guards.test.ts new file mode 100644 index 000000000..96175bde1 --- /dev/null +++ b/packages/streams/src/type-guards.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest'; + +import { isWrappedVatMessage, isCapTpMessage } from './type-guards.js'; +import { Command } from './types.js'; + +describe('type-guards', () => { + describe('isWrappedVatMessage', () => { + it.each` + value | expectedResult | description + ${{ id: 'some-id', message: { type: Command.Ping, data: null } }} | ${true} | ${'valid wrapped vat message'} + ${123} | ${false} | ${'invalid wrapped vat message: primitive number'} + ${{ id: true, message: {} }} | ${false} | ${'invalid wrapped vat message: invalid id and empty message'} + ${{ id: 'some-id', message: null }} | ${false} | ${'invalid wrapped vat message: message is null'} + ${{ id: 123, message: { type: Command.Ping, data: null } }} | ${false} | ${'invalid wrapped vat message: invalid id type'} + ${{ id: 'some-id' }} | ${false} | ${'invalid wrapped vat message: missing message'} + ${{ id: 'some-id', message: 123 }} | ${false} | ${'invalid wrapped vat message: message is a primitive number'} + ${{ id: 'some-id', message: { type: 123, data: null } }} | ${false} | ${'invalid wrapped vat message: invalid type in message'} + `( + 'returns $expectedResult for $description', + ({ value, expectedResult }) => { + expect(isWrappedVatMessage(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/streams/src/type-guards.ts b/packages/streams/src/type-guards.ts new file mode 100644 index 000000000..cec6426df --- /dev/null +++ b/packages/streams/src/type-guards.ts @@ -0,0 +1,18 @@ +import { isObject } from '@metamask/utils'; + +import type { CapTpMessage, WrappedVatMessage } from './types.js'; + +export const isWrappedVatMessage = ( + value: unknown, +): value is WrappedVatMessage => + isObject(value) && + typeof value.id === 'string' && + isObject(value.message) && + typeof value.message.type === 'string' && + (typeof value.message.data === 'string' || value.message.data === null); + +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/extension/src/message.ts b/packages/streams/src/types.ts similarity index 51% rename from packages/extension/src/message.ts rename to packages/streams/src/types.ts index 3ec1eaeaf..18ec5d4c7 100644 --- a/packages/extension/src/message.ts +++ b/packages/streams/src/types.ts @@ -1,23 +1,24 @@ import type { Primitive } from '@endo/captp'; -import { isObject } from '@metamask/utils'; export type MessageId = string; -type DataObject = +export enum KernelMessageTarget { + Background = 'background', + Offscreen = 'offscreen', + WebWorker = 'webWorker', + Node = 'node', +} + +export type DataObject = | Primitive | Promise | DataObject[] | { [key: string]: DataObject }; -export enum ExtensionMessageTarget { - Background = 'background', - Offscreen = 'offscreen', -} - type CommandLike< CommandType extends Command, Data extends DataObject, - TargetType extends ExtensionMessageTarget, + TargetType extends KernelMessageTarget, > = { type: CommandType; target?: TargetType; @@ -36,37 +37,22 @@ export type CapTpPayload = { params: DataObject[]; }; -type CommandMessage = +type CommandMessage = | CommandLike | CommandLike | CommandLike | CommandLike; -export type ExtensionMessage = CommandMessage; -export type IframeMessage = CommandMessage; +export type KernelMessage = CommandMessage; +export type VatMessage = CommandMessage; -export type WrappedIframeMessage = { +export type WrappedVatMessage = { id: MessageId; - message: IframeMessage; + message: VatMessage; }; -export const isWrappedIframeMessage = ( - value: unknown, -): value is WrappedIframeMessage => - isObject(value) && - typeof value.id === 'string' && - isObject(value.message) && - typeof value.message.type === 'string' && - (typeof value.message.data === 'string' || value.message.data === null); - export type CapTpMessage = { type: Type; epoch: number; [key: string]: unknown; }; - -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/test-utils/src/utils.ts b/packages/test-utils/src/delay.ts similarity index 100% rename from packages/test-utils/src/utils.ts rename to packages/test-utils/src/delay.ts diff --git a/packages/test-utils/src/index.ts b/packages/test-utils/src/index.ts index 6aa734f18..af7069437 100644 --- a/packages/test-utils/src/index.ts +++ b/packages/test-utils/src/index.ts @@ -1,2 +1,3 @@ -export * from './mocks.js'; -export * from './utils.js'; +export { makePromiseKitMock } from './makePromiseKitMock.js'; +export { makeCapTpMock } from './makeCapTpMock.js'; +export { delay } from './delay.js'; diff --git a/packages/test-utils/src/makeCapTpMock.ts b/packages/test-utils/src/makeCapTpMock.ts new file mode 100644 index 000000000..7911f69f0 --- /dev/null +++ b/packages/test-utils/src/makeCapTpMock.ts @@ -0,0 +1,23 @@ +/** + * Create a module mock for `@endo/captp`. + * + * @returns The mock. + */ + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export const makeCapTpMock = () => ({ + makeCapTP: ( + id: string, + send: (message: unknown) => Promise, + bootstrapObj?: unknown, + ) => { + const capTp = { + id, + send, + bootstrapObj, + dispatch: () => undefined, + getBootstrap: () => capTp.bootstrapObj, + }; + return capTp; + }, +}); diff --git a/packages/test-utils/src/mocks.ts b/packages/test-utils/src/makePromiseKitMock.ts similarity index 85% rename from packages/test-utils/src/mocks.ts rename to packages/test-utils/src/makePromiseKitMock.ts index 0f77249d2..f71bfec7c 100644 --- a/packages/test-utils/src/mocks.ts +++ b/packages/test-utils/src/makePromiseKitMock.ts @@ -1,10 +1,9 @@ -/* eslint-disable @typescript-eslint/explicit-function-return-type */ - /** * Create a module mock for `@endo/promise-kit`. * * @returns The mock. */ +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type export const makePromiseKitMock = () => ({ makePromiseKit: () => { let resolve: (value: unknown) => void, reject: (reason?: unknown) => void; diff --git a/yarn.lock b/yarn.lock index 15519ea65..879db5dad 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1372,13 +1372,13 @@ __metadata: "@endo/eventual-send": "npm:^1.2.4" "@endo/exo": "npm:^1.5.2" "@endo/patterns": "npm:^1.4.2" - "@endo/promise-kit": "npm:^1.1.4" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/eslint-config": "npm:^13.0.0" "@metamask/eslint-config-nodejs": "npm:^13.0.0" "@metamask/eslint-config-typescript": "npm:^13.0.0" "@metamask/snaps-utils": "npm:^7.8.0" "@metamask/utils": "npm:^9.1.0" + "@ocap/kernel": "workspace:^" "@ocap/shims": "workspace:^" "@ocap/streams": "workspace:^" "@ocap/test-utils": "workspace:^" @@ -1408,6 +1408,42 @@ __metadata: languageName: unknown linkType: soft +"@ocap/kernel@workspace:^, @ocap/kernel@workspace:packages/kernel": + version: 0.0.0-use.local + resolution: "@ocap/kernel@workspace:packages/kernel" + dependencies: + "@endo/captp": "npm:^4.2.2" + "@endo/eventual-send": "npm:^1.2.4" + "@endo/promise-kit": "npm:^1.1.4" + "@metamask/auto-changelog": "npm:^3.4.4" + "@metamask/eslint-config": "npm:^13.0.0" + "@metamask/eslint-config-nodejs": "npm:^13.0.0" + "@metamask/eslint-config-typescript": "npm:^13.0.0" + "@ocap/shims": "workspace:^" + "@ocap/streams": "workspace:^" + "@ocap/test-utils": "workspace:^" + "@ts-bridge/cli": "npm:^0.5.1" + "@ts-bridge/shims": "npm:^0.1.1" + "@typescript-eslint/eslint-plugin": "npm:^8.1.0" + "@typescript-eslint/parser": "npm:^8.1.0" + depcheck: "npm:^1.4.7" + eslint: "npm:^8.57.0" + eslint-config-prettier: "npm:^8.8.0" + eslint-plugin-import-x: "npm:^0.5.1" + eslint-plugin-jsdoc: "npm:^47.0.2" + eslint-plugin-n: "npm:^16.6.2" + eslint-plugin-prettier: "npm:^4.2.1" + eslint-plugin-promise: "npm:^6.1.1" + eslint-plugin-vitest: "npm:^0.4.1" + prettier: "npm:^2.7.1" + rimraf: "npm:^6.0.1" + ses: "npm:^1.7.0" + typescript: "npm:~5.5.4" + vite: "npm:^5.3.5" + vitest: "npm:^2.0.5" + languageName: unknown + linkType: soft + "@ocap/monorepo@workspace:.": version: 0.0.0-use.local resolution: "@ocap/monorepo@workspace:." @@ -1485,6 +1521,7 @@ __metadata: resolution: "@ocap/streams@workspace:packages/streams" dependencies: "@arethetypeswrong/cli": "npm:^0.15.3" + "@endo/captp": "npm:^4.2.2" "@endo/promise-kit": "npm:^1.1.4" "@endo/stream": "npm:^1.2.2" "@metamask/auto-changelog": "npm:^3.4.4"