From 0ee2a9dd3cbc0baed8e6ea640beca20863e1cfb6 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Fri, 2 May 2025 14:03:41 +0200 Subject: [PATCH 1/2] Remove web worker executor and environment --- packages/snaps-controllers/coverage.json | 8 +- .../src/services/browser.test.ts | 1 - .../snaps-controllers/src/services/browser.ts | 1 - .../snaps-controllers/src/services/index.ts | 1 - .../WebWorkerExecutionService.test.browser.ts | 226 ------------------ .../webworker/WebWorkerExecutionService.ts | 122 ---------- .../src/services/webworker/index.ts | 1 - packages/snaps-controllers/vitest.config.mts | 20 -- .../coverage.json | 8 +- .../snaps-execution-environments/package.json | 1 - .../WebWorkerSnapExecutor.test.browser.ts | 172 ------------- .../executor/WebWorkerSnapExecutor.ts | 37 --- .../src/webworker/executor/index.ts | 9 - .../pool/WebWorkerPool.test.browser.ts | 188 --------------- .../src/webworker/pool/WebWorkerPool.ts | 211 ---------------- .../src/webworker/pool/index.ts | 9 - .../webpack.config.js | 18 -- yarn.lock | 25 +- 18 files changed, 31 insertions(+), 1027 deletions(-) delete mode 100644 packages/snaps-controllers/src/services/webworker/WebWorkerExecutionService.test.browser.ts delete mode 100644 packages/snaps-controllers/src/services/webworker/WebWorkerExecutionService.ts delete mode 100644 packages/snaps-controllers/src/services/webworker/index.ts delete mode 100644 packages/snaps-execution-environments/src/webworker/executor/WebWorkerSnapExecutor.test.browser.ts delete mode 100644 packages/snaps-execution-environments/src/webworker/executor/WebWorkerSnapExecutor.ts delete mode 100644 packages/snaps-execution-environments/src/webworker/executor/index.ts delete mode 100644 packages/snaps-execution-environments/src/webworker/pool/WebWorkerPool.test.browser.ts delete mode 100644 packages/snaps-execution-environments/src/webworker/pool/WebWorkerPool.ts delete mode 100644 packages/snaps-execution-environments/src/webworker/pool/index.ts diff --git a/packages/snaps-controllers/coverage.json b/packages/snaps-controllers/coverage.json index 3e58e6d076..f7f509342b 100644 --- a/packages/snaps-controllers/coverage.json +++ b/packages/snaps-controllers/coverage.json @@ -1,6 +1,6 @@ { - "branches": 93.54, - "functions": 97.38, - "lines": 98.34, - "statements": 98.08 + "branches": 93.51, + "functions": 97.36, + "lines": 98.33, + "statements": 98.17 } diff --git a/packages/snaps-controllers/src/services/browser.test.ts b/packages/snaps-controllers/src/services/browser.test.ts index 3a42acb126..b4cf203e56 100644 --- a/packages/snaps-controllers/src/services/browser.test.ts +++ b/packages/snaps-controllers/src/services/browser.test.ts @@ -6,7 +6,6 @@ describe('browser entrypoint', () => { 'setupMultiplex', 'IframeExecutionService', 'OffscreenExecutionService', - 'WebWorkerExecutionService', 'ProxyPostMessageStream', 'WebViewExecutionService', 'WebViewMessageStream', diff --git a/packages/snaps-controllers/src/services/browser.ts b/packages/snaps-controllers/src/services/browser.ts index 1b80e9e865..871061032c 100644 --- a/packages/snaps-controllers/src/services/browser.ts +++ b/packages/snaps-controllers/src/services/browser.ts @@ -5,4 +5,3 @@ export * from './ProxyPostMessageStream'; export * from './iframe'; export * from './offscreen'; export * from './webview'; -export { WebWorkerExecutionService } from './webworker'; diff --git a/packages/snaps-controllers/src/services/index.ts b/packages/snaps-controllers/src/services/index.ts index b72ca852ee..2e0ea1263b 100644 --- a/packages/snaps-controllers/src/services/index.ts +++ b/packages/snaps-controllers/src/services/index.ts @@ -3,4 +3,3 @@ export type * from './ExecutionService'; export * from './ProxyPostMessageStream'; export * from './iframe'; export * from './offscreen'; -export { WebWorkerExecutionService } from './webworker'; diff --git a/packages/snaps-controllers/src/services/webworker/WebWorkerExecutionService.test.browser.ts b/packages/snaps-controllers/src/services/webworker/WebWorkerExecutionService.test.browser.ts deleted file mode 100644 index 463bc275ee..0000000000 --- a/packages/snaps-controllers/src/services/webworker/WebWorkerExecutionService.test.browser.ts +++ /dev/null @@ -1,226 +0,0 @@ -import { HandlerType } from '@metamask/snaps-utils'; -import { - DEFAULT_SNAP_BUNDLE, - MOCK_LOCAL_SNAP_ID, - MOCK_ORIGIN, - MOCK_SNAP_ID, - spy, -} from '@metamask/snaps-utils/test-utils'; -import { describe, it, expect, afterEach } from 'vitest'; - -import { - WebWorkerExecutionService, - WORKER_POOL_ID, -} from './WebWorkerExecutionService'; -import { MOCK_BLOCK_NUMBER } from '../../test-utils/constants'; -import { createService } from '../../test-utils/service'; - -const WORKER_POOL_URL = 'http://localhost:63315/worker/pool/index.html'; - -describe('WebWorkerExecutionService', () => { - afterEach(() => { - document.getElementById(WORKER_POOL_ID)?.remove(); - }); - - it('can boot', async () => { - const { service } = createService(WebWorkerExecutionService, { - documentUrl: new URL(WORKER_POOL_URL), - }); - - expect(service).toBeDefined(); - await service.terminateAllSnaps(); - }); - - it('only creates a single iframe', async () => { - const { service } = createService(WebWorkerExecutionService, { - documentUrl: new URL(WORKER_POOL_URL), - }); - - await service.executeSnap({ - snapId: MOCK_SNAP_ID, - sourceCode: ` - module.exports.onRpcRequest = () => null; - `, - endowments: [], - }); - - await service.executeSnap({ - snapId: MOCK_LOCAL_SNAP_ID, - sourceCode: ` - module.exports.onRpcRequest = () => null; - `, - endowments: [], - }); - - expect(document.getElementById(WORKER_POOL_ID)).not.toBeNull(); - expect(document.getElementsByTagName('iframe')).toHaveLength(1); - - await service.terminateAllSnaps(); - }); - - it('can create a snap worker and start the snap', async () => { - const { service } = createService(WebWorkerExecutionService, { - documentUrl: new URL(WORKER_POOL_URL), - }); - - const response = await service.executeSnap({ - snapId: 'TestSnap', - sourceCode: ` - module.exports.onRpcRequest = () => null; - `, - endowments: [], - }); - - expect(response).toBe('OK'); - await service.terminateAllSnaps(); - }); - - it('executes a snap', async () => { - const { service } = createService(WebWorkerExecutionService, { - documentUrl: new URL(WORKER_POOL_URL), - }); - - await service.executeSnap({ - snapId: MOCK_SNAP_ID, - sourceCode: DEFAULT_SNAP_BUNDLE, - endowments: ['console'], - }); - - const result = await service.handleRpcRequest(MOCK_SNAP_ID, { - origin: MOCK_ORIGIN, - handler: HandlerType.OnRpcRequest, - request: { - jsonrpc: '2.0', - id: 1, - method: 'foo', - }, - }); - - expect(result).toBe('foo1'); - - await service.terminateAllSnaps(); - }); - - it('can handle a crashed snap', async () => { - expect.assertions(1); - const { service } = createService(WebWorkerExecutionService, { - documentUrl: new URL(WORKER_POOL_URL), - }); - - const action = async () => { - await service.executeSnap({ - snapId: MOCK_SNAP_ID, - sourceCode: ` - throw new Error("Crashed."); - `, - endowments: [], - }); - }; - - await expect(action()).rejects.toThrow( - `Error while running snap '${MOCK_SNAP_ID}': Crashed.`, - ); - await service.terminateAllSnaps(); - }); - - it('can detect outbound requests', async () => { - expect.assertions(5); - - const { service, messenger } = createService(WebWorkerExecutionService, { - documentUrl: new URL(WORKER_POOL_URL), - }); - - const publishSpy = spy(messenger, 'publish'); - - const executeResult = await service.executeSnap({ - snapId: MOCK_SNAP_ID, - sourceCode: ` - module.exports.onRpcRequest = () => ethereum.request({ method: 'eth_blockNumber', params: [] }); - `, - endowments: ['ethereum'], - }); - - expect(executeResult).toBe('OK'); - - const result = await service.handleRpcRequest(MOCK_SNAP_ID, { - origin: 'foo', - handler: HandlerType.OnRpcRequest, - request: { - jsonrpc: '2.0', - id: 1, - method: 'foobar', - params: [], - }, - }); - - expect(result).toBe(MOCK_BLOCK_NUMBER); - - expect(publishSpy.calls).toHaveLength(2); - expect(publishSpy.calls[0]).toStrictEqual({ - args: ['ExecutionService:outboundRequest', MOCK_SNAP_ID], - result: undefined, - }); - - expect(publishSpy.calls[1]).toStrictEqual({ - args: ['ExecutionService:outboundResponse', MOCK_SNAP_ID], - result: undefined, - }); - - await service.terminateAllSnaps(); - publishSpy.reset(); - }); - - it('confirms that events are secured', async () => { - // Check if the security critical properties of the Event object - // are unavailable. This will confirm that executeLockdownEvents works - // inside snaps-execution-environments - const { service } = createService(WebWorkerExecutionService, { - documentUrl: new URL(WORKER_POOL_URL), - }); - - await service.executeSnap({ - snapId: MOCK_SNAP_ID, - sourceCode: ` - module.exports.onRpcRequest = async ({ request }) => { - let result; - const promise = new Promise((resolve) => { - const xhr = new XMLHttpRequest(); - xhr.open('GET', 'https://metamask.io/'); - xhr.send(); - xhr.onreadystatechange = (ev) => { - result = ev; - resolve(); - }; - }); - await promise; - - return { - targetIsUndefined: result.target === undefined, - currentTargetIsUndefined: result.target === undefined, - srcElementIsUndefined: result.target === undefined, - composedPathIsUndefined: result.target === undefined - }; - }; - `, - endowments: ['console', 'XMLHttpRequest'], - }); - - const result = await service.handleRpcRequest(MOCK_SNAP_ID, { - origin: 'foo', - handler: HandlerType.OnRpcRequest, - request: { - jsonrpc: '2.0', - id: 1, - method: 'foobar', - params: [], - }, - }); - - expect(result).toStrictEqual({ - targetIsUndefined: true, - currentTargetIsUndefined: true, - srcElementIsUndefined: true, - composedPathIsUndefined: true, - }); - }); -}); diff --git a/packages/snaps-controllers/src/services/webworker/WebWorkerExecutionService.ts b/packages/snaps-controllers/src/services/webworker/WebWorkerExecutionService.ts deleted file mode 100644 index bd586543f2..0000000000 --- a/packages/snaps-controllers/src/services/webworker/WebWorkerExecutionService.ts +++ /dev/null @@ -1,122 +0,0 @@ -import type { BasePostMessageStream } from '@metamask/post-message-stream'; -import { WindowPostMessageStream } from '@metamask/post-message-stream'; -import { createWindow } from '@metamask/snaps-utils'; -import { assert } from '@metamask/utils'; -import { nanoid } from 'nanoid'; - -import type { - ExecutionServiceArgs, - TerminateJobArgs, -} from '../AbstractExecutionService'; -import { AbstractExecutionService } from '../AbstractExecutionService'; -import { ProxyPostMessageStream } from '../ProxyPostMessageStream'; - -type WebWorkerExecutionEnvironmentServiceArgs = { - documentUrl: URL; -} & ExecutionServiceArgs; - -export const WORKER_POOL_ID = 'snaps-worker-pool'; - -export class WebWorkerExecutionService extends AbstractExecutionService { - readonly #documentUrl: URL; - - #runtimeStream?: BasePostMessageStream; - - /** - * Create a new webworker execution service. - * - * @param args - The constructor arguments. - * @param args.documentUrl - The URL of the worker pool document to use as the - * execution environment. - * @param args.messenger - The messenger to use for communication with the - * `SnapController`. - * @param args.setupSnapProvider - The function to use to set up the snap - * provider. - */ - constructor({ - documentUrl, - messenger, - setupSnapProvider, - ...args - }: WebWorkerExecutionEnvironmentServiceArgs) { - super({ - ...args, - messenger, - setupSnapProvider, - }); - - this.#documentUrl = documentUrl; - } - - /** - * Send a termination command to the worker pool document. - * - * @param job - The job to terminate. - */ - // TODO: Either fix this lint violation or explain why it's necessary to - // ignore. - // eslint-disable-next-line @typescript-eslint/no-misused-promises - protected async terminateJob(job: TerminateJobArgs) { - // The `AbstractExecutionService` will have already closed the job stream, - // so we write to the runtime stream directly. - assert(this.#runtimeStream, 'Runtime stream not initialized.'); - this.#runtimeStream.write({ - jobId: job.id, - data: { - jsonrpc: '2.0', - method: 'terminateJob', - id: nanoid(), - }, - }); - } - - /** - * Create a new stream for the specified job. This wraps the runtime stream - * in a stream specific to the job. - * - * @param jobId - The job ID. - * @returns An object with the worker ID and stream. - */ - protected async initEnvStream(jobId: string) { - // Lazily create the worker pool document. - await this.createDocument(); - - // `createDocument` should have initialized the runtime stream. - assert(this.#runtimeStream, 'Runtime stream not initialized.'); - - const stream = new ProxyPostMessageStream({ - stream: this.#runtimeStream, - jobId, - }); - - return { worker: jobId, stream }; - } - - /** - * Creates the worker pool document to be used as the execution environment. - * - * If the document already exists, this does nothing. - */ - // TODO: Either fix this lint violation or explain why it's necessary to - // ignore. - // eslint-disable-next-line no-restricted-syntax - private async createDocument() { - // We only want to create a single pool. - if (document.getElementById(WORKER_POOL_ID)) { - return; - } - - const window = await createWindow({ - uri: this.#documentUrl.href, - id: WORKER_POOL_ID, - sandbox: false, - }); - - this.#runtimeStream = new WindowPostMessageStream({ - name: 'parent', - target: 'child', - targetWindow: window, - targetOrigin: '*', - }); - } -} diff --git a/packages/snaps-controllers/src/services/webworker/index.ts b/packages/snaps-controllers/src/services/webworker/index.ts deleted file mode 100644 index 6c1383c319..0000000000 --- a/packages/snaps-controllers/src/services/webworker/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './WebWorkerExecutionService'; diff --git a/packages/snaps-controllers/vitest.config.mts b/packages/snaps-controllers/vitest.config.mts index a05377b1dd..3e905cd16e 100644 --- a/packages/snaps-controllers/vitest.config.mts +++ b/packages/snaps-controllers/vitest.config.mts @@ -14,16 +14,6 @@ const IFRAME_TEST_PATH = join( './src/services/iframe/test', ); -const WORKER_EXECUTOR_PATH = join( - import.meta.dirname, - '../snaps-execution-environments/dist/webpack/worker-executor', -); - -const WORKER_POOL_PATH = join( - import.meta.dirname, - '../snaps-execution-environments/dist/webpack/worker-pool', -); - export default defineConfig({ plugins: [tsconfigPaths(), nodePolyfills()], @@ -38,16 +28,6 @@ export default defineConfig({ target: `http://localhost:63315/@fs${IFRAME_TEST_PATH}`, rewrite: (path) => path.replace(/^\/iframe\/test/u, ''), }, - - '/worker/executor': { - target: `http://localhost:63315/@fs${WORKER_EXECUTOR_PATH}`, - rewrite: (path) => path.replace(/^\/worker\/executor/u, ''), - }, - - '/worker/pool': { - target: `http://localhost:63315/@fs${WORKER_POOL_PATH}`, - rewrite: (path) => path.replace(/^\/worker\/pool/u, ''), - }, }, fs: { diff --git a/packages/snaps-execution-environments/coverage.json b/packages/snaps-execution-environments/coverage.json index 31ccde4fe9..1e437e58f6 100644 --- a/packages/snaps-execution-environments/coverage.json +++ b/packages/snaps-execution-environments/coverage.json @@ -1,6 +1,6 @@ { - "branches": 90.78, - "functions": 94.96, - "lines": 90.84, - "statements": 90.25 + "branches": 90, + "functions": 94.57, + "lines": 90.13, + "statements": 89.5 } diff --git a/packages/snaps-execution-environments/package.json b/packages/snaps-execution-environments/package.json index 7f04d57b53..a794af00d6 100644 --- a/packages/snaps-execution-environments/package.json +++ b/packages/snaps-execution-environments/package.json @@ -75,7 +75,6 @@ "@metamask/snaps-utils": "workspace:^", "@metamask/superstruct": "^3.2.1", "@metamask/utils": "^11.4.0", - "nanoid": "^3.3.10", "readable-stream": "^3.6.2" }, "devDependencies": { diff --git a/packages/snaps-execution-environments/src/webworker/executor/WebWorkerSnapExecutor.test.browser.ts b/packages/snaps-execution-environments/src/webworker/executor/WebWorkerSnapExecutor.test.browser.ts deleted file mode 100644 index cbaf72b7e5..0000000000 --- a/packages/snaps-execution-environments/src/webworker/executor/WebWorkerSnapExecutor.test.browser.ts +++ /dev/null @@ -1,172 +0,0 @@ -// eslint-disable-next-line import-x/no-unassigned-import -import 'ses'; -import { HandlerType } from '@metamask/snaps-utils'; -import type { SpyFunction } from '@metamask/snaps-utils/test-utils'; -import { - MOCK_ORIGIN, - MOCK_SNAP_ID, - MockWindowPostMessageStream, - spy, -} from '@metamask/snaps-utils/test-utils'; -import { describe, expect, it, beforeAll, beforeEach } from 'vitest'; - -import { WebWorkerSnapExecutor } from './WebWorkerSnapExecutor'; - -/** - * Write a message to the stream, wrapped with the job ID. - * - * @param stream - The stream to write to. - * @param message - The message to write. - */ -function writeMessage( - stream: MockWindowPostMessageStream, - message: Record, -) { - stream.write(message); -} - -/** - * Wait for a response from the stream. - * - * @param stream - The stream to wait for a response on. - * @returns The raw JSON-RPC response object. - */ -async function getResponse( - stream: MockWindowPostMessageStream, -): Promise> { - return new Promise((resolve) => { - stream.once('response', (data) => { - resolve(data); - }); - }); -} - -describe('WebWorkerSnapExecutor', () => { - let consoleSpy: SpyFunction; - - beforeAll(() => { - // @ts-expect-error - `globalThis.process` is not optional. - delete globalThis.process; - - // SES makes the `console.error` property non-writable, so we have to - // create the spy before lockdown. - consoleSpy = spy(console, 'error'); - - lockdown({ - domainTaming: 'unsafe', - errorTaming: 'unsafe', - stackFiltering: 'verbose', - }); - }); - - beforeEach(() => { - consoleSpy.clear(); - }); - - it('receives and processes commands', async () => { - const mockStream = new MockWindowPostMessageStream(); - - // Initialize - WebWorkerSnapExecutor.initialize(mockStream); - - writeMessage(mockStream, { - name: 'command', - data: { - jsonrpc: '2.0', - id: 1, - method: 'ping', - }, - }); - - expect(await getResponse(mockStream)).toStrictEqual({ - jsonrpc: '2.0', - id: 1, - result: 'OK', - }); - - const CODE = ` - exports.onRpcRequest = () => { - return 'foobar'; - }; - `; - - writeMessage(mockStream, { - name: 'command', - data: { - jsonrpc: '2.0', - id: 2, - method: 'executeSnap', - params: [MOCK_SNAP_ID, CODE, []], - }, - }); - - expect(await getResponse(mockStream)).toStrictEqual({ - jsonrpc: '2.0', - id: 2, - result: 'OK', - }); - - writeMessage(mockStream, { - name: 'command', - data: { - jsonrpc: '2.0', - id: 3, - method: 'snapRpc', - params: [ - MOCK_SNAP_ID, - HandlerType.OnRpcRequest, - MOCK_ORIGIN, - { jsonrpc: '2.0', method: '' }, - ], - }, - }); - - expect(await getResponse(mockStream)).toStrictEqual({ - result: 'foobar', - id: 3, - jsonrpc: '2.0', - }); - }); - - // TODO: Re-enable this test after investigating error handling further. - - it.skip('handles closing the stream', async () => { - const mockStream = new MockWindowPostMessageStream(); - - // We have to mock close, because otherwise WebDriverIO will break. - const closeSpy = spy(globalThis, 'close').mockImplementation(() => { - // Do nothing - }); - - WebWorkerSnapExecutor.initialize(mockStream); - mockStream.destroy(); - - await new Promise((resolve) => setTimeout(resolve, 1)); - - expect(closeSpy.calls).toHaveLength(1); - - closeSpy.reset(); - }); - - // TODO: Re-enable this test after investigating error handling further. - - it.skip('handles stream errors', async () => { - const mockStream = new MockWindowPostMessageStream(); - - // We have to mock close, because otherwise WebDriverIO will break. - const closeSpy = spy(globalThis, 'close').mockImplementation(() => { - // Do nothing - }); - - WebWorkerSnapExecutor.initialize(mockStream); - mockStream.emit('error', new Error('test error')); - - expect(closeSpy.calls).toHaveLength(1); - expect(consoleSpy.calls).toHaveLength(3); - expect(consoleSpy.calls[0].args[0]).toBe( - 'Parent stream failure, closing worker.', - ); - - closeSpy.reset(); - }); -}); diff --git a/packages/snaps-execution-environments/src/webworker/executor/WebWorkerSnapExecutor.ts b/packages/snaps-execution-environments/src/webworker/executor/WebWorkerSnapExecutor.ts deleted file mode 100644 index 2f13f81d81..0000000000 --- a/packages/snaps-execution-environments/src/webworker/executor/WebWorkerSnapExecutor.ts +++ /dev/null @@ -1,37 +0,0 @@ -import ObjectMultiplex from '@metamask/object-multiplex'; -import type { BasePostMessageStream } from '@metamask/post-message-stream'; -import { WebWorkerPostMessageStream } from '@metamask/post-message-stream'; -import { logError, SNAP_STREAM_NAMES } from '@metamask/snaps-utils'; -import { pipeline } from 'readable-stream'; - -import { BaseSnapExecutor } from '../../common/BaseSnapExecutor'; -import { log } from '../../logging'; - -export class WebWorkerSnapExecutor extends BaseSnapExecutor { - /** - * Initialize the WebWorkerSnapExecutor. This creates a post message stream - * from and to the parent window, for two-way communication with the iframe. - * - * @param stream - The stream to use for communication. - * @returns An instance of `WebWorkerSnapExecutor`, with the initialized post - * message streams. - */ - static initialize( - stream: BasePostMessageStream = new WebWorkerPostMessageStream(), - ) { - log('Worker: Connecting to parent.'); - - const mux = new ObjectMultiplex(); - pipeline(stream, mux, stream, (error) => { - if (error) { - logError(`Parent stream failure, closing worker.`, error); - } - self.close(); - }); - - const commandStream = mux.createStream(SNAP_STREAM_NAMES.COMMAND); - const rpcStream = mux.createStream(SNAP_STREAM_NAMES.JSON_RPC); - - return new WebWorkerSnapExecutor(commandStream, rpcStream); - } -} diff --git a/packages/snaps-execution-environments/src/webworker/executor/index.ts b/packages/snaps-execution-environments/src/webworker/executor/index.ts deleted file mode 100644 index ec8f55ee81..0000000000 --- a/packages/snaps-execution-environments/src/webworker/executor/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { WebWorkerSnapExecutor } from './WebWorkerSnapExecutor'; -import { executeLockdownEvents } from '../../common/lockdown/lockdown-events'; -import { executeLockdownMore } from '../../common/lockdown/lockdown-more'; - -// Lockdown is already applied in LavaMoat -executeLockdownMore(); -executeLockdownEvents(); - -WebWorkerSnapExecutor.initialize(); diff --git a/packages/snaps-execution-environments/src/webworker/pool/WebWorkerPool.test.browser.ts b/packages/snaps-execution-environments/src/webworker/pool/WebWorkerPool.test.browser.ts deleted file mode 100644 index c1b62fa350..0000000000 --- a/packages/snaps-execution-environments/src/webworker/pool/WebWorkerPool.test.browser.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { MockPostMessageStream, spy } from '@metamask/snaps-utils/test-utils'; -import { assert } from '@metamask/utils'; -import { describe, expect, it } from 'vitest'; - -import { WebWorkerPool } from './WebWorkerPool'; - -const MOCK_JOB_ID = 'job-id'; -const WORKER_URL = 'http://localhost:63316/worker/executor/bundle.js'; - -/** - * Write a message to the stream, wrapped with the job ID. - * - * @param stream - The stream to write to. - * @param message - The message to write. - * @param jobId - The job ID. - */ -function writeMessage( - stream: MockPostMessageStream, - message: Record, - jobId = MOCK_JOB_ID, -) { - stream.write({ - jobId, - data: message, - }); -} - -/** - * Write a termination message to the stream. - * - * @param stream - The stream to write to. - * @returns A promise that resolves after 1 millisecond. - */ -async function terminateJob(stream: MockPostMessageStream) { - writeMessage(stream, { - jsonrpc: '2.0', - id: 2, - method: 'terminateJob', - }); - - return await new Promise((resolve) => setTimeout(resolve, 1)); -} - -/** - * Wait for a response from the stream. - * - * @param stream - The stream to wait for a response on. - * @returns The raw JSON-RPC response object. - */ -async function getResponse( - stream: MockPostMessageStream, -): Promise> { - return new Promise((resolve) => { - stream.once('response', (data) => { - resolve(data); - }); - }); -} - -describe('WebWorkerPool', () => { - it('forwards messages to the worker', async () => { - const mockStream = new MockPostMessageStream(); - - WebWorkerPool.initialize(mockStream, WORKER_URL); - - writeMessage(mockStream, { - name: 'command', - data: { - jsonrpc: '2.0', - id: 1, - method: 'ping', - }, - }); - - expect(await getResponse(mockStream)).toEqual({ - jsonrpc: '2.0', - id: 1, - result: 'OK', - }); - - expect(document.getElementById(MOCK_JOB_ID)).toBeDefined(); - - await terminateJob(mockStream); - }); - - it('terminates the worker', async () => { - const mockStream = new MockPostMessageStream(); - - const executor = WebWorkerPool.initialize(mockStream, WORKER_URL); - - // Send ping to ensure that the worker is created. - writeMessage(mockStream, { - name: 'command', - data: { - jsonrpc: '2.0', - id: 1, - method: 'ping', - }, - }); - - // Wait for the response, so that we know the worker is created. - await getResponse(mockStream); - - const worker = executor.jobs.get(MOCK_JOB_ID); - - assert(worker); - const terminateSpy = spy(worker.worker, 'terminate'); - - await terminateJob(mockStream); - - expect(executor.jobs.get(MOCK_JOB_ID)).toBeUndefined(); - expect(terminateSpy.calls).toHaveLength(1); - }); - - it('creates a worker pool', async () => { - const mockStream = new MockPostMessageStream(); - - const executor = WebWorkerPool.initialize(mockStream, WORKER_URL); - expect(executor.pool).toHaveLength(0); - - // Send ping to ensure that the worker is created. - writeMessage(mockStream, { - name: 'command', - data: { - jsonrpc: '2.0', - id: 1, - method: 'ping', - }, - }); - - // Wait for the response, so that we know the worker is created. - await getResponse(mockStream); - - expect(executor.pool).toHaveLength(3); - const nextWorker = executor.pool[0]; - - writeMessage( - mockStream, - { - name: 'command', - data: { - jsonrpc: '2.0', - id: 2, - method: 'ping', - }, - }, - 'job-id-2', - ); - - // Wait for the response, so that we know the worker is created. - await getResponse(mockStream); - - expect(executor.pool).toHaveLength(3); - expect(executor.pool[0]).not.toBe(nextWorker); - }); - - it('handles errors', async () => { - const mockStream = new MockPostMessageStream(); - - const fetchSpy = spy(globalThis, 'fetch').mockImplementation(() => { - throw new Error('Failed to fetch.'); - }); - - WebWorkerPool.initialize(mockStream, WORKER_URL); - - writeMessage(mockStream, { - name: 'command', - data: { - jsonrpc: '2.0', - id: 1, - method: 'ping', - }, - }); - - // Wait for the response, so that we know the worker is created. - const response = await getResponse(mockStream); - expect(response).toStrictEqual({ - jsonrpc: '2.0', - id: null, - error: { - code: -32000, - message: 'Internal error', - }, - }); - - expect(fetchSpy.calls).toHaveLength(1); - }); -}); diff --git a/packages/snaps-execution-environments/src/webworker/pool/WebWorkerPool.ts b/packages/snaps-execution-environments/src/webworker/pool/WebWorkerPool.ts deleted file mode 100644 index ffdb165687..0000000000 --- a/packages/snaps-execution-environments/src/webworker/pool/WebWorkerPool.ts +++ /dev/null @@ -1,211 +0,0 @@ -import type { BasePostMessageStream } from '@metamask/post-message-stream'; -import { - WebWorkerParentPostMessageStream, - WindowPostMessageStream, -} from '@metamask/post-message-stream'; -import { logError } from '@metamask/snaps-utils'; -import type { JsonRpcRequest } from '@metamask/utils'; -import { assert } from '@metamask/utils'; -import { nanoid } from 'nanoid/non-secure'; - -type ExecutorJob = { - id: string; - worker: Worker; - stream: WebWorkerParentPostMessageStream; -}; - -/** - * A snap executor using the WebWorker API. - * - * This is not a traditional snap executor, as it does not execute snaps itself. - * Instead, it creates a pool of webworkers for each snap execution, and sends - * the snap execution request to the webworker. The webworker is responsible for - * executing the snap. - */ -export class WebWorkerPool { - readonly #poolSize; - - readonly #stream: BasePostMessageStream; - - readonly #url: string; - - readonly pool: Worker[] = []; - - readonly jobs: Map = new Map(); - - #workerSourceURL?: string; - - /* istanbul ignore next - Constructor arguments. */ - static initialize( - stream: BasePostMessageStream = new WindowPostMessageStream({ - name: 'child', - target: 'parent', - targetWindow: self.parent, - targetOrigin: '*', - }), - url = '../executor/bundle.js', - poolSize?: number, - ) { - return new WebWorkerPool(stream, url, poolSize); - } - - constructor(stream: BasePostMessageStream, url: string, poolSize = 3) { - this.#stream = stream; - this.#url = url; - this.#poolSize = poolSize; - - this.#stream.on('data', this.#onData.bind(this)); - } - - /** - * Handle an incoming message from the `WebWorkerExecutionService`. This - * assumes that the message contains a `jobId` property, and a JSON-RPC - * request in the `data` property. - * - * @param data - The message data. - * @param data.data - The JSON-RPC request. - * @param data.jobId - The job ID. - */ - #onData(data: { data: JsonRpcRequest; jobId: string }) { - const { jobId, data: request } = data; - - const job = this.jobs.get(jobId); - if (!job) { - // This ensures that a job is initialized before it is used. To avoid - // code duplication, we call the `#onData` method again, which will - // run the rest of the logic after initialization. - this.#initializeJob(jobId) - .then(() => { - this.#onData(data); - }) - .catch((error) => { - logError('[Worker] Error initializing job:', error.toString()); - - this.#stream.write({ - jobId, - data: { - name: 'command', - data: { - jsonrpc: '2.0', - id: request.id ?? null, - error: { - code: -32000, - message: 'Internal error', - }, - }, - }, - }); - }); - - return; - } - - // This is a method specific to the `WebWorkerPool`, as the service itself - // does not have access to the workers directly. - if (request.method === 'terminateJob') { - this.#terminateJob(jobId); - return; - } - - job.stream.write(request); - } - - /** - * Create a new worker and set up a stream to communicate with it. - * - * @param jobId - The job ID. - * @returns The job. - */ - async #initializeJob(jobId: string): Promise { - const worker = await this.#getWorker(); - const jobStream = new WebWorkerParentPostMessageStream({ - worker, - }); - - // Write messages from the worker to the parent, wrapped with the job ID. - jobStream.on('data', (data) => { - this.#stream.write({ data, jobId }); - }); - - const job = { id: jobId, worker, stream: jobStream }; - this.jobs.set(jobId, job); - return job; - } - - /** - * Terminate the job with the given ID. This will close the worker and delete - * the job from the internal job map. - * - * @param jobId - The job ID. - */ - #terminateJob(jobId: string) { - const job = this.jobs.get(jobId); - assert(job, `Job "${jobId}" not found.`); - - job.stream.destroy(); - job.worker.terminate(); - - this.jobs.delete(jobId); - } - - /** - * Get a worker from the pool. A new worker will be created automatically. - * - * @returns The worker. - */ - async #getWorker() { - // Lazily create the pool of workers. - if (this.pool.length === 0) { - await this.#updatePool(); - } - - const worker = this.pool.shift(); - assert(worker, 'Worker not found.'); - - await this.#updatePool(); - - return worker; - } - - /** - * Update the pool of workers. This will create new workers if the pool is - * below the minimum size. - */ - async #updatePool() { - while (this.pool.length < this.#poolSize) { - const worker = await this.#createWorker(); - this.pool.push(worker); - } - } - - /** - * Create a new worker. This will fetch the worker source if it has not - * already been fetched. - * - * @returns The worker. - */ - async #createWorker() { - return new Worker(await this.#getWorkerURL(), { - name: `worker-${nanoid()}`, - }); - } - - /** - * Get the URL of the worker source. This will fetch the worker source if it - * has not already been fetched. - * - * @returns The worker source URL, as a `blob:` URL. - */ - async #getWorkerURL() { - if (this.#workerSourceURL) { - return this.#workerSourceURL; - } - - const blob = await fetch(this.#url) - .then(async (response) => response.blob()) - .then(URL.createObjectURL.bind(URL)); - - this.#workerSourceURL = blob; - return blob; - } -} diff --git a/packages/snaps-execution-environments/src/webworker/pool/index.ts b/packages/snaps-execution-environments/src/webworker/pool/index.ts deleted file mode 100644 index 7d85d1c642..0000000000 --- a/packages/snaps-execution-environments/src/webworker/pool/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { WebWorkerPool } from './WebWorkerPool'; -import { executeLockdownEvents } from '../../common/lockdown/lockdown-events'; -import { executeLockdownMore } from '../../common/lockdown/lockdown-more'; - -// Lockdown is already applied in LavaMoat -executeLockdownMore(); -executeLockdownEvents(); - -WebWorkerPool.initialize(); diff --git a/packages/snaps-execution-environments/webpack.config.js b/packages/snaps-execution-environments/webpack.config.js index 79e76313fa..da124f8855 100644 --- a/packages/snaps-execution-environments/webpack.config.js +++ b/packages/snaps-execution-environments/webpack.config.js @@ -133,24 +133,6 @@ const ENTRY_POINTS = [ ], }, }, - - { - name: 'worker-executor', - entry: './src/webworker/executor/index.ts', - inline: true, - - config: { - target: 'webworker', - }, - }, - - { - name: 'worker-pool', - entry: './src/webworker/pool/index.ts', - scuttleGlobalThis: true, - - config: DEFAULT_WEB_CONFIG, - }, ]; /** diff --git a/yarn.lock b/yarn.lock index 2dfd8aef4f..8cb9c52106 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4321,7 +4321,6 @@ __metadata: jest-fetch-mock: "npm:^3.0.3" jest-silent-reporter: "npm:^0.6.0" lavamoat: "npm:^9.0.8" - nanoid: "npm:^3.3.10" prettier: "npm:^3.3.3" readable-stream: "npm:^3.6.2" rimraf: "npm:^4.1.2" @@ -11603,6 +11602,18 @@ __metadata: languageName: node linkType: hard +"fdir@npm:^6.4.2": + version: 6.4.3 + resolution: "fdir@npm:6.4.3" + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + checksum: 10/8e6d20f4590dc168de1374a9cadaa37e20ca6e0b822aa247c230e7ea1d9e9674a68cd816146435e4ecc98f9285091462ab7e5e56eebc9510931a1794e4db68b2 + languageName: node + linkType: hard + "fdir@npm:^6.4.4": version: 6.4.4 resolution: "fdir@npm:6.4.4" @@ -18455,7 +18466,7 @@ __metadata: languageName: node linkType: hard -"tinyglobby@npm:^0.2.13, tinyglobby@npm:^0.2.9": +"tinyglobby@npm:^0.2.13": version: 0.2.13 resolution: "tinyglobby@npm:0.2.13" dependencies: @@ -18465,6 +18476,16 @@ __metadata: languageName: node linkType: hard +"tinyglobby@npm:^0.2.9": + version: 0.2.10 + resolution: "tinyglobby@npm:0.2.10" + dependencies: + fdir: "npm:^6.4.2" + picomatch: "npm:^4.0.2" + checksum: 10/10c976866d849702edc47fc3fef27d63f074c40f75ef17171ecc1452967900699fa1e62373681dd58e673ddff2e3f6094bcd0a2101e3e4b30f4c2b9da41397f2 + languageName: node + linkType: hard + "tinypool@npm:^1.0.2": version: 1.0.2 resolution: "tinypool@npm:1.0.2" From 9861942d30420885946622522b3d3816ee314828 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Fri, 2 May 2025 14:07:50 +0200 Subject: [PATCH 2/2] Dedupe lockfile --- yarn.lock | 24 +----------------------- 1 file changed, 1 insertion(+), 23 deletions(-) diff --git a/yarn.lock b/yarn.lock index 8cb9c52106..3c893d678c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11602,18 +11602,6 @@ __metadata: languageName: node linkType: hard -"fdir@npm:^6.4.2": - version: 6.4.3 - resolution: "fdir@npm:6.4.3" - peerDependencies: - picomatch: ^3 || ^4 - peerDependenciesMeta: - picomatch: - optional: true - checksum: 10/8e6d20f4590dc168de1374a9cadaa37e20ca6e0b822aa247c230e7ea1d9e9674a68cd816146435e4ecc98f9285091462ab7e5e56eebc9510931a1794e4db68b2 - languageName: node - linkType: hard - "fdir@npm:^6.4.4": version: 6.4.4 resolution: "fdir@npm:6.4.4" @@ -18466,7 +18454,7 @@ __metadata: languageName: node linkType: hard -"tinyglobby@npm:^0.2.13": +"tinyglobby@npm:^0.2.13, tinyglobby@npm:^0.2.9": version: 0.2.13 resolution: "tinyglobby@npm:0.2.13" dependencies: @@ -18476,16 +18464,6 @@ __metadata: languageName: node linkType: hard -"tinyglobby@npm:^0.2.9": - version: 0.2.10 - resolution: "tinyglobby@npm:0.2.10" - dependencies: - fdir: "npm:^6.4.2" - picomatch: "npm:^4.0.2" - checksum: 10/10c976866d849702edc47fc3fef27d63f074c40f75ef17171ecc1452967900699fa1e62373681dd58e673ddff2e3f6094bcd0a2101e3e4b30f4c2b9da41397f2 - languageName: node - linkType: hard - "tinypool@npm:^1.0.2": version: 1.0.2 resolution: "tinypool@npm:1.0.2"