diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 79a36054f..5f5e0b8aa 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -165,5 +165,21 @@ module.exports = { ], }, }, + + { + // Rules for writing tests of typescript inference behavior. + // Mostly inherits rules for `files: ['**/*.test.{ts,js}']`. + files: ['**/*.types.test.ts'], + rules: { + // An explicit any type is useful for testing type narrowing. + '@typescript-eslint/no-explicit-any': 'off', + // Merely expressing an object is enough to invoke type inference. + '@typescript-eslint/no-unused-expressions': 'off', + // These sorts of tests don't generally need to run, only compile. + 'vitest/expect-expect': 'off', + // Useful for `if (typeGuard(object))` statements. + 'vitest/no-conditional-in-test': 'off', + }, + }, ], }; diff --git a/packages/extension/src/envelope.ts b/packages/extension/src/envelope.ts deleted file mode 100644 index cc5e72e37..000000000 --- a/packages/extension/src/envelope.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { isObject } from '@metamask/utils'; - -import type { WrappedIframeMessage } from './message.js'; -import { isWrappedIframeMessage } from './message.js'; - -export enum EnvelopeLabel { - Command = 'command', - CapTp = 'capTp', -} - -export type StreamEnvelope = - | { - label: EnvelopeLabel.Command; - content: WrappedIframeMessage; - } - | { label: EnvelopeLabel.CapTp; content: unknown }; - -export const isStreamEnvelope = (value: unknown): value is StreamEnvelope => - isObject(value) && - (value.label === EnvelopeLabel.CapTp || - (value.label === EnvelopeLabel.Command && - isWrappedIframeMessage(value.content))); diff --git a/packages/extension/src/iframe-manager.test.ts b/packages/extension/src/iframe-manager.test.ts index 146c45799..d580f6b22 100644 --- a/packages/extension/src/iframe-manager.test.ts +++ b/packages/extension/src/iframe-manager.test.ts @@ -3,10 +3,10 @@ import * as snapsUtils from '@metamask/snaps-utils'; import { delay, makePromiseKitMock } from '@ocap/test-utils'; import { vi, describe, it, expect } from 'vitest'; -import { EnvelopeLabel } from './envelope.js'; import { IframeManager } from './iframe-manager.js'; import type { IframeMessage } from './message.js'; import { Command } from './message.js'; +import { wrapCommand, wrapCapTp } from './stream-envelope.js'; vi.mock('@endo/promise-kit', () => makePromiseKitMock()); @@ -68,12 +68,17 @@ describe('IframeManager', () => { it('creates a new iframe with the default getPort function', async () => { vi.resetModules(); - vi.doMock('@ocap/streams', () => ({ - initializeMessageChannel: vi.fn(), - makeMessagePortStreamPair: vi.fn(() => ({ reader: {}, writer: {} })), - MessagePortReader: class Mock1 {}, - MessagePortWriter: class Mock2 {}, - })); + 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; @@ -143,16 +148,13 @@ describe('IframeManager', () => { const postMessage = (i: number): void => { port2.postMessage({ done: false, - value: { - label: EnvelopeLabel.Command, - content: { - id: `foo-${i + 1}`, - message: { - type: Command.Evaluate, - data: `${i + 1}`, - }, + value: wrapCommand({ + id: `foo-${i + 1}`, + message: { + type: Command.Evaluate, + data: `${i + 1}`, }, - }, + }), }); }; @@ -181,6 +183,38 @@ describe('IframeManager', () => { }); 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( @@ -203,26 +237,20 @@ describe('IframeManager', () => { const id = 'frangooly'; const capTpInit = { - query: { - label: EnvelopeLabel.Command, - content: { - id: `${id}-1`, - message: { - data: null, - type: 'makeCapTp', - }, + query: wrapCommand({ + id: `${id}-1`, + message: { + data: null, + type: Command.CapTpInit, }, - }, - response: { - label: EnvelopeLabel.Command, - content: { - id: `${id}-1`, - message: { - type: 'makeCapTp', - data: null, - }, + }), + response: wrapCommand({ + id: `${id}-1`, + message: { + type: Command.CapTpInit, + data: null, }, - }, + }), }; vi.mocked(snapsUtils.createWindow).mockImplementationOnce(vi.fn()); @@ -277,77 +305,59 @@ describe('IframeManager', () => { const id = 'frangooly'; const capTpInit = { - query: { - label: EnvelopeLabel.Command, - content: { - id: `${id}-1`, - message: { - data: null, - type: 'makeCapTp', - }, + query: wrapCommand({ + id: `${id}-1`, + message: { + data: null, + type: Command.CapTpInit, }, - }, - response: { - label: EnvelopeLabel.Command, - content: { - id: `${id}-1`, - message: { - type: 'makeCapTp', - data: null, - }, + }), + response: wrapCommand({ + id: `${id}-1`, + message: { + type: Command.CapTpInit, + data: null, }, - }, + }), }; const greatFrangoolyBootstrap = { - query: { - label: 'capTp', - content: { - epoch: 0, - questionID: 'q-1', - type: 'CTP_BOOTSTRAP', - }, - }, - response: { - label: 'capTp', - content: { - type: 'CTP_RETURN', - epoch: 0, - answerID: 'q-1', - result: { - body: '{"@qclass":"slot","iface":"Alleged: TheGreatFrangooly","index":0}', - slots: ['o+1'], - }, + 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: { - label: 'capTp', - content: { - type: 'CTP_CALL', - epoch: 0, - method: { - body: '["whatIsTheGreatFrangooly",[]]', - slots: [], - }, - questionID: 'q-2', - target: 'o-1', + query: wrapCapTp({ + type: 'CTP_CALL', + epoch: 0, + method: { + body: '["whatIsTheGreatFrangooly",[]]', + slots: [], }, - }, - response: { - label: 'capTp', - content: { - type: 'CTP_RETURN', - epoch: 0, - answerID: 'q-2', - result: { - body: '"Crowned with Chaos"', - 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()); @@ -439,22 +449,19 @@ describe('IframeManager', () => { const message: IframeMessage = { type: Command.Evaluate, data: '2+2' }; const response: IframeMessage = { type: Command.Evaluate, data: '4' }; - // sendMessage wraps the content in a EnvelopeLabel.Command envelope + // sendMessage wraps the content in a Command envelope const messagePromise = manager.sendMessage(id, message); - const messageId: string | undefined = + 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: { - label: EnvelopeLabel.Command, - content: { - id: messageId, - message: response, - }, - }, + value: wrapCommand({ + id: messageId, + message: response, + }), }); // awaiting event loop should resolve the messagePromise @@ -464,13 +471,10 @@ describe('IframeManager', () => { expect(portPostMessageSpy).toHaveBeenCalledOnce(); expect(portPostMessageSpy).toHaveBeenCalledWith({ done: false, - value: { - label: EnvelopeLabel.Command, - content: { - id: messageId, - message, - }, - }, + value: wrapCommand({ + id: messageId, + message, + }), }); }); @@ -502,7 +506,7 @@ describe('IframeManager', () => { await delay(10); expect(warnSpy).toHaveBeenCalledWith( - 'Offscreen received message with unexpected format', + 'Stream envelope handler received unexpected value', 'foo', ); }); @@ -521,16 +525,13 @@ describe('IframeManager', () => { port2.postMessage({ done: false, - value: { - label: EnvelopeLabel.Command, - content: { - id: 'foo', - message: { - type: Command.Evaluate, - data: '"bar"', - }, + value: wrapCommand({ + id: 'foo', + message: { + type: Command.Evaluate, + data: '"bar"', }, - }, + }), }); await delay(10); diff --git a/packages/extension/src/iframe-manager.ts b/packages/extension/src/iframe-manager.ts index 0adea2c18..a61397fea 100644 --- a/packages/extension/src/iframe-manager.ts +++ b/packages/extension/src/iframe-manager.ts @@ -9,11 +9,23 @@ import { makeMessagePortStreamPair, } from '@ocap/streams'; -import type { StreamEnvelope } from './envelope.js'; -import { EnvelopeLabel, isStreamEnvelope } from './envelope.js'; -import type { CapTpPayload, IframeMessage, MessageId } from './message.js'; +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'; @@ -27,12 +39,15 @@ 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: Map; + unresolvedMessages: UnresolvedMessages; + streamEnvelopeHandler: StreamEnvelopeHandler; capTp?: ReturnType; }; @@ -63,26 +78,41 @@ export class IframeManager { async create( args: { id?: VatId; getPort?: GetPort } = {}, ): Promise { - const id = args.id ?? this.#nextVatId(); + const vatId = args.id ?? this.#nextVatId(); const getPort = args.getPort ?? initializeMessageChannel; - const newWindow = await createWindow(IFRAME_URI, getHtmlId(id)); + const newWindow = await createWindow(IFRAME_URI, getHtmlId(vatId)); const port = await getPort(newWindow); const streams = makeMessagePortStreamPair(port); - this.#vats.set(id, { + const unresolvedMessages = new Map(); + this.#vats.set(vatId, { streams, messageCounter: makeCounter(), - unresolvedMessages: new Map(), + 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(id, streams.reader).catch((error) => { - console.error(`Unexpected read error from vat "${id}"`, error); - this.delete(id).catch(() => undefined); + this.#receiveMessages(vatId, streams.reader).catch((error) => { + console.error(`Unexpected read error from vat "${vatId}"`, error); + this.delete(vatId).catch(() => undefined); }); - await this.sendMessage(id, { type: Command.Ping, data: null }); - console.debug(`Created vat with id "${id}"`); - return [newWindow, id] as const; + await this.sendMessage(vatId, { type: Command.Ping, data: null }); + console.debug(`Created vat with id "${vatId}"`); + return [newWindow, vatId] as const; } /** @@ -130,10 +160,7 @@ export class IframeManager { const messageId = this.#nextMessageId(id); vat.unresolvedMessages.set(messageId, { reject, resolve }); - await vat.streams.writer.next({ - label: EnvelopeLabel.Command, - content: { id: messageId, message }, - }); + await vat.streams.writer.next(wrapCommand({ id: messageId, message })); return promise; } @@ -157,10 +184,15 @@ export class IframeManager { // 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({ label: EnvelopeLabel.CapTp, content }); + 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 }); } @@ -169,45 +201,10 @@ export class IframeManager { reader: Reader, ): Promise { const vat = this.#expectGetVat(vatId); + for await (const rawMessage of reader) { console.debug('Offscreen received message', rawMessage); - - if (!isStreamEnvelope(rawMessage)) { - console.warn( - 'Offscreen received message with unexpected format', - rawMessage, - ); - return; - } - - switch (rawMessage.label) { - case EnvelopeLabel.CapTp: { - console.log( - 'CapTP from vat', - JSON.stringify(rawMessage.content, null, 2), - ); - const { capTp } = this.#expectGetVat(vatId); - if (capTp !== undefined) { - capTp.dispatch(rawMessage.content); - } - break; - } - case EnvelopeLabel.Command: { - const { id, message } = rawMessage.content; - const promiseCallbacks = vat.unresolvedMessages.get(id); - if (promiseCallbacks === undefined) { - console.error(`No unresolved message with id "${id}".`); - } else { - vat.unresolvedMessages.delete(id); - promiseCallbacks.resolve(message.data); - } - break; - } - /* v8 ignore next 3: Exhaustiveness check */ - default: - // @ts-expect-error The type of `rawMessage` is `never`, but this could happen at runtime. - throw new Error(`Unexpected message label "${rawMessage.label}".`); - } + await vat.streamEnvelopeHandler.handle(rawMessage); } } diff --git a/packages/extension/src/iframe.ts b/packages/extension/src/iframe.ts index bcd25f1ef..294560d4a 100644 --- a/packages/extension/src/iframe.ts +++ b/packages/extension/src/iframe.ts @@ -3,10 +3,18 @@ import { makeExo } from '@endo/exo'; import { M } from '@endo/patterns'; import { receiveMessagePort, makeMessagePortStreamPair } from '@ocap/streams'; -import type { StreamEnvelope } from './envelope.js'; -import { EnvelopeLabel, isStreamEnvelope } from './envelope.js'; -import type { IframeMessage, WrappedIframeMessage } from './message.js'; +import type { + CapTpMessage, + IframeMessage, + WrappedIframeMessage, +} from './message.js'; import { Command } from './message.js'; +import type { StreamEnvelope } from './stream-envelope.js'; +import { + wrapCapTp, + wrapCommand, + makeStreamEnvelopeHandler, +} from './stream-envelope.js'; const defaultCompartment = new Compartment({ URL }); @@ -20,31 +28,19 @@ async function main(): Promise { const streams = makeMessagePortStreamPair(port); let capTp: ReturnType | undefined; + const streamEnvelopeHandler = makeStreamEnvelopeHandler( + { + command: handleMessage, + capTp: async (content) => capTp?.dispatch(content), + }, + (reason, value) => { + throw new Error(`[vat IFRAME] ${reason} ${stringifyResult(value)}`); + }, + ); + for await (const rawMessage of streams.reader) { console.debug('iframe received message', rawMessage); - - if (!isStreamEnvelope(rawMessage)) { - console.error( - 'iframe received message with unexpected format', - rawMessage, - ); - return; - } - - switch (rawMessage.label) { - case EnvelopeLabel.CapTp: - if (capTp !== undefined) { - capTp.dispatch(rawMessage.content); - } - break; - case EnvelopeLabel.Command: - await handleMessage(rawMessage.content); - break; - /* v8 ignore next 3: Exhaustiveness check */ - default: - // @ts-expect-error The type of `rawMessage` is `never`, but this could happen at runtime. - throw new Error(`Unexpected message label "${rawMessage.label}".`); - } + await streamEnvelopeHandler.handle(rawMessage); } await streams.return(); @@ -88,7 +84,7 @@ async function main(): Promise { capTp = makeCapTP( 'iframe', async (content: unknown) => - streams.writer.next({ label: EnvelopeLabel.CapTp, content }), + streams.writer.next(wrapCapTp(content as CapTpMessage)), bootstrap, ); await replyToMessage(id, { type: Command.CapTpInit, data: null }); @@ -115,10 +111,7 @@ async function main(): Promise { id: string, message: IframeMessage, ): Promise { - await streams.writer.next({ - label: EnvelopeLabel.Command, - content: { id, message }, - }); + await streams.writer.next(wrapCommand({ id, message })); } /** diff --git a/packages/extension/src/message.ts b/packages/extension/src/message.ts index c9e26b614..3ec1eaeaf 100644 --- a/packages/extension/src/message.ts +++ b/packages/extension/src/message.ts @@ -14,7 +14,7 @@ export enum ExtensionMessageTarget { Offscreen = 'offscreen', } -type CommandForm< +type CommandLike< CommandType extends Command, Data extends DataObject, TargetType extends ExtensionMessageTarget, @@ -37,10 +37,10 @@ export type CapTpPayload = { }; type CommandMessage = - | CommandForm - | CommandForm - | CommandForm - | CommandForm; + | CommandLike + | CommandLike + | CommandLike + | CommandLike; export type ExtensionMessage = CommandMessage; export type IframeMessage = CommandMessage; @@ -58,3 +58,15 @@ export const isWrappedIframeMessage = ( 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/extension/src/stream-envelope.test.ts b/packages/extension/src/stream-envelope.test.ts new file mode 100644 index 000000000..94677e051 --- /dev/null +++ b/packages/extension/src/stream-envelope.test.ts @@ -0,0 +1,78 @@ +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, + makeStreamEnvelopeHandler, +} from './stream-envelope.js'; + +describe('StreamEnvelopeHandler', () => { + const commandContent: WrappedIframeMessage = { + id: '1', + message: { type: Command.Evaluate, data: '1 + 1' }, + }; + const capTpContent: CapTpMessage = { + type: 'CTP_CALL', + epoch: 0, + // Our assumptions about the form of a CapTpMessage are weak. + unreliableKey: Symbol('unreliableValue'), + }; + + const commandLabel = wrapCommand(commandContent).label; + const capTpLabel = wrapCapTp(capTpContent).label; + + const testEnvelopeHandlers = { + command: async () => commandLabel, + capTp: async () => capTpLabel, + }; + + const testErrorHandler = (problem: unknown): never => { + throw new Error(`TEST ${String(problem)}`); + }; + + it.each` + wrapper | content | label + ${wrapCommand} | ${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); + }); + + it('routes invalid envelopes to default error handler', async () => { + const handler = makeStreamEnvelopeHandler(testEnvelopeHandlers); + await expect( + // @ts-expect-error label is intentionally unknown + handler.handle({ label: 'unknown', content: [] }), + ).rejects.toThrow(/^Stream envelope handler received unexpected value/u); + }); + + it('routes invalid envelopes to supplied error handler', async () => { + const handler = makeStreamEnvelopeHandler( + testEnvelopeHandlers, + testErrorHandler, + ); + await expect( + // @ts-expect-error label is intentionally unknown + handler.handle({ label: 'unknown', content: [] }), + ).rejects.toThrow( + /^TEST Stream envelope handler received unexpected value/u, + ); + }); + + it('routes valid stream envelopes with an unhandled label to the error handler', async () => { + const handler = makeStreamEnvelopeHandler( + { command: testEnvelopeHandlers.command }, + testErrorHandler, + ); + await expect(handler.handle(wrapCapTp(capTpContent))).rejects.toThrow( + /^TEST Stream envelope handler received an envelope with known but unexpected label/u, + ); + }); +}); diff --git a/packages/extension/src/stream-envelope.ts b/packages/extension/src/stream-envelope.ts new file mode 100644 index 000000000..f120cfd70 --- /dev/null +++ b/packages/extension/src/stream-envelope.ts @@ -0,0 +1,50 @@ +import { makeStreamEnvelopeKit } from '@ocap/streams'; + +import type { CapTpMessage, WrappedIframeMessage } from './message.js'; +import { isCapTpMessage, isWrappedIframeMessage } from './message.js'; + +// Utilitous generic types. + +type GuardType = TypeGuard extends ( + value: unknown, +) => value is infer Type + ? Type + : never; + +// Declare and destructure the envelope kit. + +enum EnvelopeLabel { + Command = 'command', + CapTp = 'capTp', +} + +// makeStreamEnvelopeKit requires an enum of labels but typescript +// doesn't support enums as bounds on template parameters. +// +// See https://github.com/microsoft/TypeScript/issues/30611 +// +// This workaround makes something equivalently type inferenceable. +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const envelopeLabels = Object.values(EnvelopeLabel); + +const envelopeKit = makeStreamEnvelopeKit< + typeof envelopeLabels, + { + command: WrappedIframeMessage; + capTp: CapTpMessage; + } +>({ + command: isWrappedIframeMessage, + capTp: isCapTpMessage, +}); + +const { streamEnveloper, makeStreamEnvelopeHandler } = envelopeKit; + +export type StreamEnvelope = GuardType; +export type StreamEnvelopeHandler = ReturnType< + typeof makeStreamEnvelopeHandler +>; + +export const wrapCommand = streamEnveloper.command.wrap; +export const wrapCapTp = streamEnveloper.capTp.wrap; +export { makeStreamEnvelopeHandler }; diff --git a/packages/streams/documents/make-stream-envelope-kit.md b/packages/streams/documents/make-stream-envelope-kit.md new file mode 100644 index 000000000..2fd579d73 --- /dev/null +++ b/packages/streams/documents/make-stream-envelope-kit.md @@ -0,0 +1,124 @@ +--- +title: Making a StreamEnvelopeKit +group: Documents +category: Guides +--- + +# makeStreamEnvelopeKit + +### Template parameters must be explicitly declared + +To ensure proper typescript inference behavior, it is necessary to explicitly declare the template parameters when calling `makeStreamEnvelopeKit`. See the [example](#example) below for the recommended declaration pattern. + +### Passing an enum as a template parameter + +Due to a [typescript limitation](https://github.com/microsoft/TypeScript/issues/30611) it is not possible to specify an enum as the expected type of a template parameter. Therefore `makeStreamEnvelopeKit` will accept template parameters which are not within its intended bounds; improperly specified template parameters will result in improper typescript inference behavior. See the [example](#example) below for the recommended declaration pattern. + +### Example declaration + +```ts +import { makeStreamEnvelopeKit } from '@ocap/streams'; + +// Declare the content types. +type FooContent = { + a: number; + b: string; +}; + +type BarContent = { + c: boolean; +}; + +// Specify envelope labels in an enum. +enum EnvelopeLabel { + Foo = 'foo', + Bar = 'bar', +} + +// Create a string[] from the EnvelopeLabel enum. +const labels = Object.values(EnvelopeLabel); + +// Make the StreamEnvelopeKit. +export const myStreamEnvelopeKit = makeStreamEnvelopeKit< + // Pass the EnvelopeLabel enum as `typeof labels`. + typeof labels, + // Specify the content type for each content label. + { + // foo matches the value 'foo' of EnvelopeLabel.Foo + foo: FooContent; + bar: BarContent; + } +>({ + // Specify the type guards for each envelope label. + foo: (value: unknown): value is FooContent => + isObject(value) && + typeof value.a === 'number' && + typeof value.b === 'string', + + // bar matches the value 'bar' of EnvelopeLabel.Bar + bar: (value: unknown): value is BarContent => + isObject(value) && typeof value.c === 'boolean', +}); +``` + +### Enveloper use + +The low level enveloping functionality is available via the included `streamEnveloper` and `isStreamEnvelope`. + +```ts +// Destructure your new envelope kit. +const { streamEnveloper, isStreamEnvelope } = myStreamEnvelopeKit; + +// Wrap some FooContent. +const envelope = streamEnveloper.foo.wrap({ + a: 1, + b: 'one', +}); + +// Protect your assumptions with the supplied type guard. +if (isStreamEnvelope(envelope)) { + // ~~~ Unwrap your envelope right away! ~~~ + const content = streamEnveloper[envelope.label].unwrap(envelope); +} +``` + +### Handler use + +If you know in advance how you plan to handle with your envelopes, you can let a `StreamEnvelopeHandler` do the checking and unwrapping for you. + +```ts +// Destructure the maker from the kit. +const { makeStreamEnvelopeHandler } = myStreamEnvelopeKit; + +// Declare how you want your envelope labels handled. +const streamEnvelopeHandler = makeStreamEnvelopeHandler( + { + // The content type is automatically inferred in the declaration. + foo: async (content) => { + await delay(content.a); + return content.b; + }, + bar: async (content) => (content.c ? 'yes' : 'no'), + }, + // The optional errorHandler can throw or return. + // If unspecified, the default behavior is to throw. + (reason, value) => { + if (reason.match(/unexpected value/u)) { + throw new Error(`[myStreamError] ${reason}`); + } + return ['[myStreamWarning]', reason, value]; + }, +); + +// Read messages from an @ocap/streams Reader. +for await (const newMessage of myStreamReader) { + // And handle the message. + await streamEnvelopeHandler + .handle(newMessage) + // If the errorHandler throws, you can catch it here. + .catch(console.error) + // Otherwise, the promise resolves to the value returned by + // its appropriate content handler, or by the errorHandler. + .then(console.log); +} +``` diff --git a/packages/streams/src/envelope-handler.test.ts b/packages/streams/src/envelope-handler.test.ts new file mode 100644 index 000000000..2269153b4 --- /dev/null +++ b/packages/streams/src/envelope-handler.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest'; + +import { makeStreamEnvelopeHandler } from './envelope-handler.js'; +import { + barContent, + fooContent, + isStreamEnvelope, + Label, + streamEnveloper, +} from '../test/envelope-kit-fixtures.js'; + +describe('StreamEnvelopeHandler', () => { + const testEnvelopeHandlers = { + foo: async () => Label.Foo, + bar: async () => Label.Bar, + }; + + const testErrorHandler = (problem: unknown): never => { + throw new Error(`TEST ${String(problem)}`); + }; + + it.each` + wrapper | content | label + ${streamEnveloper.foo.wrap} | ${fooContent} | ${Label.Foo} + ${streamEnveloper.bar.wrap} | ${barContent} | ${Label.Bar} + `('handles valid StreamEnvelopes', async ({ wrapper, content, label }) => { + const handler = makeStreamEnvelopeHandler( + streamEnveloper, + isStreamEnvelope, + testEnvelopeHandlers, + testErrorHandler, + ); + console.debug(wrapper(content)); + expect(await handler.handle(wrapper(content))).toStrictEqual(label); + }); + + it('routes invalid envelopes to default error handler', async () => { + const handler = makeStreamEnvelopeHandler( + streamEnveloper, + isStreamEnvelope, + testEnvelopeHandlers, + ); + await expect( + // @ts-expect-error label is intentionally unknown + handler.handle({ label: 'unknown', content: [] }), + ).rejects.toThrow(/^Stream envelope handler received unexpected value/u); + }); + + it('routes invalid envelopes to supplied error handler', async () => { + const handler = makeStreamEnvelopeHandler( + streamEnveloper, + isStreamEnvelope, + testEnvelopeHandlers, + testErrorHandler, + ); + await expect( + // @ts-expect-error label is intentionally unknown + handler.handle({ label: 'unknown', content: [] }), + ).rejects.toThrow( + /^TEST Stream envelope handler received unexpected value/u, + ); + }); + + it('routes valid stream envelopes with an unhandled label to the error handler', async () => { + const handler = makeStreamEnvelopeHandler( + streamEnveloper, + isStreamEnvelope, + { foo: testEnvelopeHandlers.foo }, + testErrorHandler, + ); + await expect( + handler.handle(streamEnveloper.bar.wrap(barContent)), + ).rejects.toThrow( + /^TEST Stream envelope handler received an envelope with known but unexpected label/u, + ); + }); +}); diff --git a/packages/streams/src/envelope-handler.ts b/packages/streams/src/envelope-handler.ts new file mode 100644 index 000000000..b873b3a59 --- /dev/null +++ b/packages/streams/src/envelope-handler.ts @@ -0,0 +1,140 @@ +import type { StreamEnvelope } from './envelope.js'; +import type { StreamEnveloper } from './enveloper.js'; +import type { TypeMap } from './utils/generics.js'; + +/** + * A handler for automatically unwrapping stream envelopes and handling their content. + */ +export type StreamEnvelopeHandler< + Labels extends readonly string[], + ContentMap extends TypeMap, +> = { + /** + * Checks an unknown value for envelope labels, applying the label's handler + * if known, and applying the error handler if the label is not handled or if + * the content did not pass the envelope's type guard. + * + * @template Envelope - The type of the envelope. + * @param envelope - The envelope to handle. + * @returns The result of the handler. + */ + handle: >( + envelope: Envelope, + ) => Promise; + /** + * The bag of async content handlers labeled with the {@link EnvelopeLabel} they handle. + */ + contentHandlers: StreamEnvelopeContentHandlerBag; + /** + * The error handler for the stream envelope handler. + */ + errorHandler: StreamEnvelopeErrorHandler; +}; + +/** + * A handler for a specific stream envelope label. + */ +type StreamEnvelopeContentHandler< + EnvelopeLabel extends string, + ContentMap extends TypeMap, + Label extends EnvelopeLabel, +> = (content: ContentMap[Label]) => Promise; + +/** + * An object with {@link EnvelopeLabel} keys mapping to an appropriate {@link StreamEnvelopeContentHandler}. + * If the stream envelope handler encounters a well-formed stream envelope without a defined handler, + * the envelope will be passed to the {@link ErrorHandler}. + */ +export type StreamEnvelopeContentHandlerBag< + Labels extends readonly string[], + ContentMap extends TypeMap, +> = { + [Label in Labels[number]]?: (content: ContentMap[Label]) => Promise; +}; + +/** + * A handler for stream envelope parsing errors. + * If the {@link StreamEnvelopeHandler} encounters an error while parsing the supplied value, + * it will pass the reason and value to the error handler. + */ +export type StreamEnvelopeErrorHandler = ( + reason: string, + value: unknown, +) => unknown; + +/** + * The default handler for stream envelope parsing errors. + * + * @param reason - The reason for the error. + * @param value - The value that caused the error. + */ +const defaultStreamEnvelopeErrorHandler: StreamEnvelopeErrorHandler = ( + reason, + value, +) => { + throw new Error(`${reason} ${JSON.stringify(value, null, 2)}`); +}; + +/** + * Makes a {@link StreamEnvelopeHandler} which handles an unknown value. + * + * If the supplied value is a valid envelope with a defined {@link StreamEnvelopeHandler}, + * the stream envelope handler will return whatever the defined handler returns. + * + * If the stream envelope handler is passed a well-formed stream envelope without a defined handler, + * an explanation and the envelope will be passed to the supplied {@link StreamEnvelopeErrorHandler}. + * + * If the stream envelope handler encounters an error while parsing the supplied value, + * it will pass the reason and value to the supplied {@link StreamEnvelopeErrorHandler}. + * + * If no error handler is supplied, the default error handling behavior is to throw. + * + * @param streamEnveloper - A {@link StreamEnveloper} made with the same Labels. + * @param isStreamEnvelope - A type guard which identifies stream envelopes. + * @param contentHandlers - A bag of async content handlers labeled with the {@link EnvelopeLabel} they handle. + * @param errorHandler - An optional synchronous error handler. + * @returns The stream envelope handler. + */ +export const makeStreamEnvelopeHandler = < + Labels extends readonly string[], + ContentMap extends TypeMap, +>( + streamEnveloper: StreamEnveloper, + isStreamEnvelope: ( + value: unknown, + ) => value is StreamEnvelope, + contentHandlers: StreamEnvelopeContentHandlerBag, + errorHandler: StreamEnvelopeErrorHandler = defaultStreamEnvelopeErrorHandler, +): StreamEnvelopeHandler => { + return { + handle: async (value: unknown) => { + if (!isStreamEnvelope(value)) { + return errorHandler( + 'Stream envelope handler received unexpected value', + value, + ); + } + const envelope = value; + const handler = contentHandlers[envelope.label] as + | StreamEnvelopeContentHandler< + Labels[number], + ContentMap, + typeof envelope.label + > + | undefined; + const enveloper = streamEnveloper[envelope.label]; + if (!handler || !enveloper) { + console.debug(`handler: ${JSON.stringify(handler)}`); + console.debug(`enveloper: ${JSON.stringify(enveloper)}`); + return errorHandler( + 'Stream envelope handler received an envelope with known but unexpected label', + envelope, + ); + } + const content = enveloper.unwrap(envelope); + return await handler(content); + }, + contentHandlers, + errorHandler, + }; +}; diff --git a/packages/streams/src/envelope-kit.test.ts b/packages/streams/src/envelope-kit.test.ts new file mode 100644 index 000000000..f4eee1019 --- /dev/null +++ b/packages/streams/src/envelope-kit.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; + +import { makeStreamEnvelopeKit } from './envelope-kit.js'; +import type { + Bar, + ContentMap, + Foo, + labels, +} from '../test/envelope-kit-fixtures.js'; + +describe('makeStreamEnvelopeKit', () => { + it.each` + property + ${'streamEnveloper'} + ${'isStreamEnvelope'} + ${'makeStreamEnvelopeHandler'} + `('has the expected property: $property', ({ property }) => { + const streamEnvelopeKit = makeStreamEnvelopeKit({ + foo: (value: unknown): value is Foo => true, + bar: (value: unknown): value is Bar => true, + }); + expect(streamEnvelopeKit).toHaveProperty(property); + }); +}); diff --git a/packages/streams/src/envelope-kit.ts b/packages/streams/src/envelope-kit.ts new file mode 100644 index 000000000..ad6dc0d5a --- /dev/null +++ b/packages/streams/src/envelope-kit.ts @@ -0,0 +1,95 @@ +import type { + StreamEnvelopeContentHandlerBag, + StreamEnvelopeErrorHandler, + StreamEnvelopeHandler, +} from './envelope-handler.js'; +import { makeStreamEnvelopeHandler as makeHandler } from './envelope-handler.js'; +import type { StreamEnvelope } from './envelope.js'; +import { isLabeled } from './envelope.js'; +import type { + Enveloper, + StreamEnveloper, + StreamEnveloperGuards, +} from './enveloper.js'; +import { makeStreamEnveloper } from './enveloper.js'; +import type { TypeMap } from './utils/generics.js'; + +export type MakeStreamEnvelopeHandler< + Labels extends readonly string[], + ContentMap extends TypeMap, +> = ( + contentHandlers: StreamEnvelopeContentHandlerBag, + errorHandler?: StreamEnvelopeErrorHandler, +) => StreamEnvelopeHandler; + +export type StreamEnvelopeKit< + Labels extends readonly string[], + ContentMap extends TypeMap, +> = { + streamEnveloper: StreamEnveloper; + isStreamEnvelope: ( + value: unknown, + ) => value is StreamEnvelope; + makeStreamEnvelopeHandler: MakeStreamEnvelopeHandler; +}; + +/** + * Make a {@link StreamEnvelopeKit}. + * The template parameters must be explicitly declared. See tutorial for suggested declaration pattern. + * + * @tutorial documents/make-stream-envelope-kit.md - An example showing how to specify the template parameters, including how to pass an enum type as a template parameter. + * @template Labels - An enum of envelope labels. WARNING: if specified improperly, typescript inference fails. See referenced tutorial. + * @template Content - An object type mapping the specified labels to the type of content they label. + * @param guards - An object mapping the specified envelope labels to a type guard of their contents. + * @returns The {@link StreamEnvelopeKit}. + */ +export const makeStreamEnvelopeKit = < + Labels extends string[], + ContentMap extends TypeMap, +>( + guards: StreamEnveloperGuards, +): StreamEnvelopeKit => { + const streamEnveloper = makeStreamEnveloper(guards); + const isStreamEnvelope = ( + value: unknown, + ): value is StreamEnvelope => + isLabeled(value) && + ( + Object.values(streamEnveloper) as Enveloper[] + ).some((enveloper) => enveloper.check(value)); + + /** + * Makes a {@link StreamEnvelopeHandler} which handles an unknown value. + * + * If the supplied value is a valid envelope with a defined {@link StreamEnvelopeHandler}, + * the stream envelope handler will return whatever the defined handler returns. + * + * If the stream envelope handler is passed a well-formed stream envelope without a defined handler, + * an explanation and the envelope will be passed to the supplied {@link StreamEnvelopeErrorHandler}. + * + * If the stream envelope handler encounters an error while parsing the supplied value, + * it will pass the reason and value to the supplied {@link StreamEnvelopeErrorHandler}. + * + * If no error handler is supplied, the default error handling behavior is to throw. + * + * @param contentHandlers - A bag of async content handlers labeled with the {@link EnvelopeLabel} they handle. + * @param errorHandler - An optional synchronous error handler. + * @returns The stream envelope handler. + */ + const makeStreamEnvelopeHandler = ( + contentHandlers: StreamEnvelopeContentHandlerBag, + errorHandler?: StreamEnvelopeErrorHandler, + ): StreamEnvelopeHandler => + makeHandler( + streamEnveloper, + isStreamEnvelope, + contentHandlers, + errorHandler, + ); + + return { + streamEnveloper, + isStreamEnvelope, + makeStreamEnvelopeHandler, + }; +}; diff --git a/packages/streams/src/envelope-kit.types.test.ts b/packages/streams/src/envelope-kit.types.test.ts new file mode 100644 index 000000000..9823ec9f2 --- /dev/null +++ b/packages/streams/src/envelope-kit.types.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, it } from 'vitest'; + +import { makeStreamEnvelopeKit } from './envelope-kit.js'; +import type { + Bar, + ContentMap, + Foo, + labels, +} from '../test/envelope-kit-fixtures.js'; +import { + makeStreamEnvelopeHandler as kitMakeStreamEnvelopeHandler, + isStreamEnvelope, + Label, + streamEnveloper, + fooContent, + barContent, +} from '../test/envelope-kit-fixtures.js'; + +const inferNumber = (value: number): number => value; +const inferString = (value: string): string => value; +const inferBoolean = (value: boolean): boolean => value; + +describe('makeStreamEnvelopeKit', () => { + it('causes a typescript error when supplying typeguard keys not matching the label type', () => { + // @ts-expect-error the bar key is missing + makeStreamEnvelopeKit({ + foo: (value: unknown): value is Foo => true, + }); + makeStreamEnvelopeKit({ + foo: (value: unknown): value is Foo => true, + bar: (value: unknown): value is Bar => true, + // @ts-expect-error the qux key is not included in labels + qux: (value: unknown): value is 'qux' => false, + }); + }); + + describe('kitted makeStreamEnvelopeHandler', () => { + it('provides proper typescript inferences', () => { + // all label arguments are optional + kitMakeStreamEnvelopeHandler({}); + // bar is optional + kitMakeStreamEnvelopeHandler({ + foo: async (content) => { + inferNumber(content.a); + // @ts-expect-error a is not a string + inferString(content.a); + // @ts-expect-error b is not a number + inferNumber(content.b); + inferString(content.b); + // @ts-expect-error c is undefined + value.content.c; + }, + }); + // keys not included in labels are forbidden + kitMakeStreamEnvelopeHandler({ + // @ts-expect-error the qux key is not included in labels + qux: async (content: any) => content, + }); + }); + }); +}); + +describe('isStreamEnvelope', () => { + it('provides proper typescript inferences', () => { + const value: any = null; + if (isStreamEnvelope(value)) { + switch (value.label) { + case Label.Foo: + inferNumber(value.content.a); + // @ts-expect-error a is not a string + inferString(value.content.a); + // @ts-expect-error b is not a number + inferNumber(value.content.b); + inferString(value.content.b); + // @ts-expect-error c is undefined + value.content.c; + break; + case Label.Bar: + // @ts-expect-error a is undefined + value.content.a; + // @ts-expect-error a is undefined + value.content.b; + inferBoolean(value.content.c); + break; + default: // unreachable + // @ts-expect-error label options are exhausted + value.label; + } + } + }); +}); + +describe('StreamEnveloper', () => { + describe('check', () => { + it('provides proper typescript inferences', () => { + const envelope: any = null; + if (streamEnveloper.foo.check(envelope)) { + inferNumber(envelope.content.a); + // @ts-expect-error a is not a string + inferString(envelope.content.a); + // @ts-expect-error b is not a number + inferNumber(envelope.content.b); + inferString(envelope.content.b); + // @ts-expect-error c is not defined + envelope.content.c; + switch (envelope.label) { + case Label.Foo: + expect(envelope.label).toMatch(Label.Foo); + break; + // @ts-expect-error label is Label.Foo + case Label.Bar: // unreachable + // @ts-expect-error label is inferred to be never + envelope.label.length; + break; + default: // unreachable + // @ts-expect-error label is inferred to be never + envelope.label.length; + } + } + + if (streamEnveloper.bar.check(envelope)) { + // @ts-expect-error a is not defined + envelope.content.a; + // @ts-expect-error b is not defined + envelope.content.b; + inferBoolean(envelope.content.c); + switch (envelope.label) { + // @ts-expect-error label is Label.Bar + case Label.Foo: // unreachable + // @ts-expect-error label is inferred to be never + envelope.label.length; + break; + case Label.Bar: + expect(envelope.label).toMatch(Label.Bar); + break; + default: // unreachable + // @ts-expect-error label is inferred to be never + envelope.label.length; + } + } + }); + }); + + describe('wrap', () => { + it('provides proper typescript inferences', () => { + streamEnveloper.foo.wrap(fooContent); + // @ts-expect-error foo rejects barContent + streamEnveloper.foo.wrap(barContent); + // @ts-expect-error bar rejects fooContent + streamEnveloper.bar.wrap(fooContent); + streamEnveloper.bar.wrap(barContent); + }); + }); + + describe('unwrap', () => { + it('provides proper typescript inferences', () => { + const envelope: any = null; + try { + const content = streamEnveloper.foo.unwrap(envelope); + + inferNumber(content.a); + // @ts-expect-error a is not a string + inferString(content.a); + // @ts-expect-error b is not a number + inferNumber(content.b); + inferString(content.b); + // @ts-expect-error c is undefined + content.c; + } catch { + undefined; + } + + try { + // @ts-expect-error envelope was already inferred to be Envelope + content = streamEnveloper.bar.unwrap(envelope); + } catch { + undefined; + } + }); + }); + + describe('label', () => { + it('provides proper typescript inferences', () => { + const fooEnveloper: any = streamEnveloper.foo; + const inferFooEnveloper = ( + enveloper: typeof streamEnveloper.foo, + ): unknown => enveloper; + const inferBarEnveloper = ( + enveloper: typeof streamEnveloper.bar, + ): unknown => enveloper; + + type Enveloper = (typeof streamEnveloper)[keyof typeof streamEnveloper]; + const ambiguousEnveloper = fooEnveloper as Enveloper; + + switch (ambiguousEnveloper.label) { + case Label.Foo: + inferFooEnveloper(ambiguousEnveloper); + // @ts-expect-error label = Label.Foo implies ambiguousEnveloper is a FooEnveloper + inferBarEnveloper(ambiguousEnveloper); + break; + case Label.Bar: + // @ts-expect-error label = Label.Bar implies ambiguousEnveloper is a BarEnveloper + inferFooEnveloper(ambiguousEnveloper); + inferBarEnveloper(ambiguousEnveloper); + break; + default: // unreachable + // @ts-expect-error label options are exhausted + ambiguousEnveloper.label; + } + }); + }); +}); diff --git a/packages/streams/src/envelope.test.ts b/packages/streams/src/envelope.test.ts new file mode 100644 index 000000000..a7ef53db7 --- /dev/null +++ b/packages/streams/src/envelope.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; + +import type { Foo } from '../test/envelope-kit-fixtures.js'; +import { + barContent, + fooContent, + isStreamEnvelope, + streamEnveloper, +} from '../test/envelope-kit-fixtures.js'; + +describe('isStreamEnvelope', () => { + it.each` + value + ${streamEnveloper.foo.wrap(fooContent)} + ${streamEnveloper.bar.wrap(barContent)} + `('returns true for valid envelopes: $value', ({ value }) => { + expect(isStreamEnvelope(value)).toBe(true); + }); + + it.each` + value + ${null} + ${true} + ${[]} + ${{}} + ${fooContent} + ${{ id: '0x5012C312312' }} + ${streamEnveloper.foo.wrap(barContent as unknown as Foo)} + `('returns false for invalid values: $value', ({ value }) => { + expect(isStreamEnvelope(value)).toBe(false); + }); +}); diff --git a/packages/streams/src/envelope.ts b/packages/streams/src/envelope.ts new file mode 100644 index 000000000..5a53f4f2f --- /dev/null +++ b/packages/streams/src/envelope.ts @@ -0,0 +1,35 @@ +// Envelope types and type guards. + +import { isObject } from '@metamask/utils'; + +import type { TypeMap } from './utils/generics.js'; + +export type Envelope