From 8b39384c267e17d1e36101bd547cd4c95c34147a Mon Sep 17 00:00:00 2001 From: Erik Marks Date: Mon, 29 Jul 2024 13:49:54 +0200 Subject: [PATCH 1/9] feat: Add streams package --- constraints.pro | 12 +- packages/extension/package.json | 1 + packages/extension/src/iframe-manager.test.ts | 193 +++++++++------- packages/extension/src/iframe-manager.ts | 161 ++++++------- packages/extension/src/iframe.ts | 164 ++++++------- packages/extension/src/offscreen.ts | 125 +++++----- packages/extension/src/shared.test.ts | 4 +- packages/extension/src/shared.ts | 8 +- packages/extension/tsconfig.build.json | 5 +- packages/extension/tsconfig.json | 4 +- packages/streams/CHANGELOG.md | 10 + packages/streams/README.md | 7 + packages/streams/package.json | 68 ++++++ packages/streams/src/index.test.ts | 16 ++ packages/streams/src/index.ts | 5 + packages/streams/src/message-channel.test.ts | 216 ++++++++++++++++++ packages/streams/src/message-channel.ts | 104 +++++++++ packages/streams/src/streams.test.ts | 166 ++++++++++++++ packages/streams/src/streams.ts | 182 +++++++++++++++ packages/streams/tsconfig.build.json | 12 + packages/streams/tsconfig.json | 10 + packages/streams/typedoc.json | 7 + packages/streams/vitest.config.ts | 17 ++ tsconfig.build.json | 3 +- tsconfig.json | 1 + yarn.lock | 81 ++++++- 26 files changed, 1272 insertions(+), 310 deletions(-) create mode 100644 packages/streams/CHANGELOG.md create mode 100644 packages/streams/README.md create mode 100644 packages/streams/package.json create mode 100644 packages/streams/src/index.test.ts create mode 100644 packages/streams/src/index.ts create mode 100644 packages/streams/src/message-channel.test.ts create mode 100644 packages/streams/src/message-channel.ts create mode 100644 packages/streams/src/streams.test.ts create mode 100644 packages/streams/src/streams.ts create mode 100644 packages/streams/tsconfig.build.json create mode 100644 packages/streams/tsconfig.json create mode 100644 packages/streams/typedoc.json create mode 100644 packages/streams/vitest.config.ts diff --git a/constraints.pro b/constraints.pro index 64f16b016..bfe1280c9 100644 --- a/constraints.pro +++ b/constraints.pro @@ -208,10 +208,12 @@ gen_enforced_field(WorkspaceCwd, 'module', './dist/index.mjs') :- \+ workspace_field(WorkspaceCwd, 'private', true). % Non-published packages must not specify an entrypoint. gen_enforced_field(WorkspaceCwd, 'main', null) :- - WorkspaceCwd \= 'packages/shims'. + WorkspaceCwd \= 'packages/shims', + WorkspaceCwd \= 'packages/streams', workspace_field(WorkspaceCwd, 'private', true). gen_enforced_field(WorkspaceCwd, 'module', null) :- - WorkspaceCwd \= 'packages/shims'. + WorkspaceCwd \= 'packages/shims', + WorkspaceCwd \= 'packages/streams', workspace_field(WorkspaceCwd, 'private', true). % The type definitions entrypoint for all publishable packages must be the same. @@ -219,7 +221,8 @@ gen_enforced_field(WorkspaceCwd, 'types', './dist/index.d.cts') :- \+ workspace_field(WorkspaceCwd, 'private', true). % Non-published packages must not specify a type definitions entrypoint. gen_enforced_field(WorkspaceCwd, 'types', null) :- - WorkspaceCwd \= 'packages/shims'. + WorkspaceCwd \= 'packages/shims', + WorkspaceCwd \= 'packages/streams', workspace_field(WorkspaceCwd, 'private', true). % The exports for all published packages must be the same. @@ -246,7 +249,8 @@ gen_enforced_field(WorkspaceCwd, 'sideEffects', false) :- \+ workspace_field(WorkspaceCwd, 'private', true). % Non-published packages must not specify side effects. gen_enforced_field(WorkspaceCwd, 'sideEffects', null) :- - WorkspaceCwd \= 'packages/shims'. + WorkspaceCwd \= 'packages/shims', + WorkspaceCwd \= 'packages/streams', workspace_field(WorkspaceCwd, 'private', true). % The list of files included in published packages must only include files diff --git a/packages/extension/package.json b/packages/extension/package.json index 192af388f..b3cce458e 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -31,6 +31,7 @@ "@metamask/snaps-utils": "^7.8.0", "@metamask/utils": "^9.1.0", "@ocap/shims": "workspace:^", + "@ocap/streams": "workspace:^", "ses": "^1.7.0" }, "devDependencies": { diff --git a/packages/extension/src/iframe-manager.test.ts b/packages/extension/src/iframe-manager.test.ts index c2a00c5e5..3413b8177 100644 --- a/packages/extension/src/iframe-manager.test.ts +++ b/packages/extension/src/iframe-manager.test.ts @@ -1,54 +1,22 @@ import type { makePromiseKit } from '@endo/promise-kit'; import * as snapsUtils from '@metamask/snaps-utils'; -import { vi, beforeEach, describe, it, expect } from 'vitest'; +import { delay, makePromiseKitMock } from '@ocap/test-utils'; +import { vi, describe, it, expect } from 'vitest'; +import { IframeManager } from './iframe-manager.js'; import { Command } from './shared.js'; -vi.mock('@endo/promise-kit', () => ({ - makePromiseKit: (): ReturnType => { - let resolve: (value: unknown) => void, reject: (reason?: unknown) => void; - const promise = new Promise((_resolve, _reject) => { - resolve = _resolve; - reject = _reject; - }); - // @ts-expect-error We have in fact assigned resolve and reject. - return { promise, resolve, reject }; - }, -})); +vi.mock('@endo/promise-kit', () => makePromiseKitMock()); vi.mock('@metamask/snaps-utils', () => ({ createWindow: vi.fn(), })); describe('IframeManager', () => { - // eslint-disable-next-line @typescript-eslint/consistent-type-imports - let IframeManager: typeof import('./iframe-manager.js').IframeManager; - - beforeEach(async () => { - vi.resetModules(); - IframeManager = (await import('./iframe-manager.js')).IframeManager; - }); - - describe('getInstance', () => { - it('is a singleton', () => { - expect(IframeManager.getInstance()).toBe(IframeManager.getInstance()); - }); - - it('sets up event listener on construction', () => { - const addEventListenerSpy = vi.spyOn(window, 'addEventListener'); - let manager = IframeManager.getInstance(); - - expect(manager).toBeInstanceOf(IframeManager); - expect(addEventListenerSpy).toHaveBeenCalledOnce(); - expect(addEventListenerSpy).toHaveBeenCalledWith( - 'message', - expect.any(Function), - ); - - manager = IframeManager.getInstance(); - expect(addEventListenerSpy).toHaveBeenCalledOnce(); - }); - }); + const makeGetPort = + (port: MessagePort = new MessageChannel().port1) => + async (_window: Window) => + Promise.resolve(port); describe('create', () => { it('creates a new iframe', async () => { @@ -56,12 +24,11 @@ describe('IframeManager', () => { vi.mocked(snapsUtils.createWindow).mockResolvedValueOnce( mockWindow as Window, ); - - const manager = IframeManager.getInstance(); + const manager = new IframeManager(); const sendMessageSpy = vi .spyOn(manager, 'sendMessage') .mockImplementation(vi.fn()); - const [newWindow, id] = await manager.create(); + const [newWindow, id] = await manager.create({ getPort: makeGetPort() }); expect(newWindow).toBe(mockWindow); expect(id).toBeTypeOf('string'); @@ -78,12 +45,15 @@ describe('IframeManager', () => { mockWindow as Window, ); - const manager = IframeManager.getInstance(); + const manager = new IframeManager(); const sendMessageSpy = vi .spyOn(manager, 'sendMessage') .mockImplementation(vi.fn()); const id = 'foo'; - const [newWindow, returnedId] = await manager.create(id); + const [newWindow, returnedId] = await manager.create({ + id, + getPort: makeGetPort(), + }); expect(newWindow).toBe(mockWindow); expect(returnedId).toBe(id); @@ -93,6 +63,35 @@ describe('IframeManager', () => { data: null, }); }); + + it('creates a new iframe with the default getPort function', async () => { + vi.resetModules(); + vi.doMock('@ocap/streams', () => ({ + initializeMessageChannel: vi.fn(), + MessagePortReader: class Mock1 {}, + MessagePortWriter: class Mock2 {}, + })); + 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: 'ping', + data: null, + }); + }); }); describe('delete', () => { @@ -107,22 +106,22 @@ describe('IframeManager', () => { return iframe.contentWindow as Window; }); - const manager = IframeManager.getInstance(); + const manager = new IframeManager(); vi.spyOn(manager, 'sendMessage').mockImplementation(vi.fn()); - await manager.create(id); - manager.delete(id); + 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 = IframeManager.getInstance(); + const manager = new IframeManager(); const iframe = document.createElement('iframe'); const removeSpy = vi.spyOn(iframe, 'remove'); - manager.delete(id); + await manager.delete(id); expect(removeSpy).not.toHaveBeenCalled(); }); @@ -130,72 +129,98 @@ describe('IframeManager', () => { describe('sendMessage', () => { it('sends a message to an iframe', async () => { - const iframeWindow = { postMessage: vi.fn() }; - vi.mocked(snapsUtils.createWindow).mockResolvedValueOnce( - iframeWindow as unknown as Window, - ); + vi.mocked(snapsUtils.createWindow).mockResolvedValueOnce({} as Window); - const manager = IframeManager.getInstance(); + 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); + await manager.create({ id, getPort: makeGetPort(port1) }); const message = { type: Command.Evaluate, data: '2+2' }; const messagePromise = manager.sendMessage(id, message); const messageId: string | undefined = - iframeWindow.postMessage.mock.lastCall?.[0]?.id; + portPostMessageSpy.mock.lastCall?.[0]?.id; expect(messageId).toBeTypeOf('string'); - window.dispatchEvent( - new MessageEvent('message', { - data: { - id: messageId, - message: { - type: Command.Evaluate, - data: '4', - }, - }, - }), - ); + port2.postMessage({ + id: messageId, + message: { + type: Command.Evaluate, + data: '4', + }, + }); - expect(iframeWindow.postMessage).toHaveBeenCalledOnce(); - expect(iframeWindow.postMessage).toHaveBeenCalledWith( - { id: messageId, message }, - '*', - ); + expect(portPostMessageSpy).toHaveBeenCalledOnce(); + expect(portPostMessageSpy).toHaveBeenCalledWith({ + id: messageId, + message, + }); expect(await messagePromise).toBe('4'); }); it('throws if iframe not found', async () => { - const manager = IframeManager.getInstance(); + const manager = new IframeManager(); const id = 'foo'; const message = { type: Command.Ping, data: null }; await expect(manager.sendMessage(id, message)).rejects.toThrow( - `No iframe with id "${id}"`, + `No vat with id "${id}"`, ); }); }); - describe('warnings', () => { - it('calls console.warn when receiving unexpected message', () => { - // Initialize manager - IframeManager.getInstance(); + 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()); - window.dispatchEvent( - new MessageEvent('message', { - data: 'foo', - }), - ); + const { port1, port2 } = new MessageChannel(); + await manager.create({ getPort: makeGetPort(port1) }); + + port2.postMessage('foo'); + await delay(10); expect(warnSpy).toHaveBeenCalledWith( 'Offscreen received message with unexpected format', '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({ + 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 index 4ccfd0f80..d19798b79 100644 --- a/packages/extension/src/iframe-manager.ts +++ b/packages/extension/src/iframe-manager.ts @@ -1,99 +1,108 @@ import type { PromiseKit } from '@endo/promise-kit'; import { makePromiseKit } from '@endo/promise-kit'; import { createWindow } from '@metamask/snaps-utils'; - -import type { IframeMessage } from './shared.js'; +import { + initializeMessageChannel, + MessagePortReader, + MessagePortWriter, +} from '@ocap/streams'; + +import type { + IframeMessage, + PortStreams, + WrappedIframeMessage, +} from './shared.js'; import { Command, isWrappedIframeMessage } from './shared.js'; const IFRAME_URI = 'iframe.html'; -// The actual