diff --git a/.depcheckrc.yml b/.depcheckrc.yml index 1cdc2b13a..4e4381fda 100644 --- a/.depcheckrc.yml +++ b/.depcheckrc.yml @@ -10,6 +10,7 @@ ignores: - '@types/node' - '@yarnpkg/*' - 'jest-silent-reporter' + - 'jsdom' - 'prettier-plugin-*' - 'ts-jest' - 'typedoc' diff --git a/.eslintrc.js b/.eslintrc.js index b008601f1..3d4aa04b7 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -33,7 +33,7 @@ module.exports = { overrides: [ { - files: ['*.js'], + files: ['*.js', '*.cjs'], parserOptions: { sourceType: 'script', ecmaVersion: '2020', @@ -41,21 +41,36 @@ module.exports = { }, { - files: ['*.ts'], + files: ['*.mjs'], + parserOptions: { + sourceType: 'module', + ecmaVersion: '2020', + }, + }, + + { + files: ['**/scripts/*.mjs'], + parserOptions: { + ecmaVersion: '2022', + }, + rules: { + 'import/extensions': 'off', + 'import/no-unassigned-import': 'off', + }, + }, + + { + files: ['*.ts', '*.cts', '*.mts'], extends: ['@metamask/eslint-config-typescript'], parserOptions: { tsconfigRootDir: __dirname, project: ['./tsconfig.packages.json'], }, rules: { - // Enable rules that are disabled in `@metamask/eslint-config-typescript` '@typescript-eslint/no-explicit-any': 'error', - // TODO: auto-fix breaks stuff - '@typescript-eslint/promise-function-async': 'off', - - // Without the `allowAny` option, this rule causes a lot of false - // positives. + // This rule is broken, and without the `allowAny` option, it causes + // a lot of false positives. '@typescript-eslint/restrict-template-expressions': [ 'error', { diff --git a/README.md b/README.md index b22da4f87..8477fa668 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,14 @@ # Ocap Kernel Monorepo -Welcome to the Ocap Kernel team's monorepo. It is a work in progress. +Welcome to the Ocap Kernel team's monorepo! It is a work in progress. + +## Contributing + +To get started: + +- `yarn install` +- `yarn build` + - This will build the entire monorepo in the correct order. + You may need to re-run it if multiple packages have changed. + +Lint using `yarn lint` or `yarn lint:fix` from the root. diff --git a/constraints.pro b/constraints.pro index ae27a8921..b3e8fb617 100644 --- a/constraints.pro +++ b/constraints.pro @@ -99,6 +99,7 @@ workspace_basename(WorkspaceCwd, WorkspaceBasename) :- % and is not private. workspace_package_name(WorkspaceCwd, WorkspacePackageName) :- workspace_basename(WorkspaceCwd, WorkspaceBasename), + workspace_field(WorkspaceCwd, 'private', false), atom_concat('@metamask/', WorkspaceBasename, WorkspacePackageName). % True if RepoName can be unified with the repository name part of RepoUrl, a @@ -207,8 +208,10 @@ 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', workspace_field(WorkspaceCwd, 'private', true). gen_enforced_field(WorkspaceCwd, 'module', null) :- + WorkspaceCwd \= 'packages/shims', workspace_field(WorkspaceCwd, 'private', true). % The type definitions entrypoint for all publishable packages must be the same. @@ -216,6 +219,7 @@ 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', workspace_field(WorkspaceCwd, 'private', true). % The exports for all published packages must be the same. @@ -234,6 +238,7 @@ gen_enforced_field(WorkspaceCwd, 'exports["./package.json"]', './package.json') \+ workspace_field(WorkspaceCwd, 'private', true). % Non-published packages must not specify exports. gen_enforced_field(WorkspaceCwd, 'exports', null) :- + WorkspaceCwd \= 'packages/shims', workspace_field(WorkspaceCwd, 'private', true). % Published packages must not have side effects. @@ -241,6 +246,7 @@ 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', workspace_field(WorkspaceCwd, 'private', true). % The list of files included in published packages must only include files @@ -253,12 +259,15 @@ gen_enforced_field(WorkspaceCwd, 'files', ['dist/']) :- gen_enforced_field(WorkspaceCwd, 'files', []) :- WorkspaceCwd = '.'. -% All non-root packages must have the same "build" script. -gen_enforced_field(WorkspaceCwd, 'scripts.build', 'ts-bridge --project tsconfig.build.json --clean') :- - WorkspaceCwd \= '.'. +% TODO: Add constraint enforcing the existence of _any_ build script in all packages, +% including the root. +% % All packages must have some "build" script. +% gen_enforced_field(WorkspaceCwd, 'scripts.build', '') :- +% WorkspaceCwd \= '.'. -% All non-root packages must have the same "build:docs" script. +% All packages except the root and extension must have the same "build:docs" script. gen_enforced_field(WorkspaceCwd, 'scripts.build:docs', 'typedoc') :- + WorkspaceCwd \= 'packages/extension', WorkspaceCwd \= '.'. % All published packages must have the same "publish:preview" script. @@ -291,18 +300,28 @@ gen_enforced_field(WorkspaceCwd, 'scripts.changelog:update', CorrectChangelogUpd % All non-root packages must have the same "test" script. gen_enforced_field(WorkspaceCwd, 'scripts.test', 'jest --reporters=jest-silent-reporter') :- + WorkspaceCwd \= 'packages/extension', + WorkspaceCwd \= 'packages/shims', WorkspaceCwd \= '.'. % All non-root packages must have the same "test:clean" script. gen_enforced_field(WorkspaceCwd, 'scripts.test:clean', 'jest --clearCache') :- + WorkspaceCwd \= 'packages/extension', WorkspaceCwd \= '.'. % All non-root packages must have the same "test:verbose" script. gen_enforced_field(WorkspaceCwd, 'scripts.test:verbose', 'jest --verbose') :- + WorkspaceCwd \= 'packages/extension', + WorkspaceCwd \= '.'. + +% All non-root packages must have the same "test:verbose" script. +gen_enforced_field(WorkspaceCwd, 'scripts.test:dev', 'jest --verbose --coverage false') :- + WorkspaceCwd \= 'packages/extension', WorkspaceCwd \= '.'. % All non-root packages must have the same "test:watch" script. gen_enforced_field(WorkspaceCwd, 'scripts.test:watch', 'jest --watch') :- + WorkspaceCwd \= 'packages/extension', WorkspaceCwd \= '.'. % All dependency ranges must be recognizable (this makes it possible to apply diff --git a/package.json b/package.json index 90bd16198..e995d3878 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "ocap-kernel-monorepo", + "name": "@ocap/monorepo", "version": "0.0.0", "private": true, "repository": { @@ -11,10 +11,10 @@ "packages/*" ], "scripts": { - "build": "yarn run build:source && yarn run build:types", + "build": "yarn run build:source && yarn build:types", "build:clean": "yarn clean && yarn build", - "build:docs": "yarn workspaces foreach --all --exclude ocap-kernel-monorepo --parallel --interlaced --verbose run build:docs", - "build:source": "yarn workspaces foreach --all --parallel --exclude ocap-kernel-monorepo --interlaced --verbose run build", + "build:docs": "yarn workspaces foreach --all --exclude @ocap/monorepo --exclude @ocap/extension --parallel --interlaced --verbose run build:docs", + "build:source": "yarn workspaces foreach --all --topological --parallel --interlaced --exclude @ocap/monorepo --verbose run build", "build:types": "tsc --build tsconfig.build.json --verbose", "build:watch": "yarn run build --watch", "changelog:update": "yarn workspaces foreach --all --no-private --parallel --interlaced --verbose run changelog:update", @@ -23,11 +23,11 @@ "lint": "yarn lint:eslint && yarn lint:misc --check && yarn constraints && yarn lint:dependencies", "lint:dependencies": "depcheck && yarn dedupe --check", "lint:dependencies:fix": "depcheck && yarn dedupe", - "lint:eslint": "eslint . --cache --ext js,cjs,ts", + "lint:eslint": "eslint . --cache --ext js,mjs,cjs,ts,mts,cts", "lint:fix": "yarn lint:eslint --fix && yarn lint:misc --write && yarn constraints --fix && yarn lint:dependencies:fix", "lint:misc": "prettier '**/*.json' '**/*.md' '!**/CHANGELOG.old.md' '**/*.yml' '!.yarnrc.yml' '!merged-packages/**' --ignore-path .gitignore", "prepack": "./scripts/prepack.sh", - "test": "yarn test:verbose --silent --collectCoverage=false --reporters=jest-silent-reporter", + "test": "yarn workspaces foreach --all --parallel --verbose run test", "test:clean": "yarn workspaces foreach --all --parallel --verbose run test:clean && yarn test", "test:verbose": "yarn workspaces foreach --all --parallel --verbose run test:verbose" }, @@ -58,9 +58,10 @@ "eslint-plugin-promise": "^6.1.1", "jest": "^28.1.3", "jest-silent-reporter": "^0.6.0", + "jsdom": "^24.1.1", "prettier": "^2.7.1", "prettier-plugin-packagejson": "^2.3.0", - "rimraf": "^6.0.0", + "rimraf": "^6.0.1", "ts-jest": "^28.0.7", "typedoc": "^0.24.8", "typescript": "~4.9.5" diff --git a/packages/extension/.eslintrc.js b/packages/extension/.eslintrc.js new file mode 100644 index 000000000..13900fb6d --- /dev/null +++ b/packages/extension/.eslintrc.js @@ -0,0 +1,32 @@ +module.exports = { + extends: ['../../.eslintrc.js'], + + overrides: [ + { + files: ['src/**/*.ts'], + globals: { + chrome: 'readonly', + clients: 'readonly', + Compartment: 'readonly', + }, + }, + + { + files: ['vite.config.mts'], + parserOptions: { + sourceType: 'module', + tsconfigRootDir: __dirname, + project: ['./tsconfig.scripts.json'], + }, + }, + + { + files: ['test/setup.mjs'], + rules: { + 'import/extensions': 'off', + 'import/no-unassigned-import': 'off', + 'import/no-unresolved': 'off', + }, + }, + ], +}; diff --git a/packages/extension/README.md b/packages/extension/README.md new file mode 100644 index 000000000..6603aeb37 --- /dev/null +++ b/packages/extension/README.md @@ -0,0 +1,26 @@ +# `@ocap/extension` + +For running Ocap Kernel experiments in an extension environment. + +## Usage + +`yarn build` creates a production build of the extension, while `yarn start` runs a dev server with hot reloading. + +To use the extension, load the `dist` directory as an unpacked extension in your +Chromium browser of choice. You may have to manually reload the extension on changes, +event with the dev server running. + +The extension has no UI. To start the background service worker, click the extension's +action button in the browser bar. Once the service worker is running, inspect it via +`chrome://extensions`. With the console open, you can evaluate arbitrary strings in a +SES compartment: + +```text +> await kernel.evaluate('[1, 2, 3].join(", ");') +< undefined +"1, 2, 3" +``` + +## Contributing + +This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/ocap-kernel#readme). diff --git a/packages/extension/package.json b/packages/extension/package.json new file mode 100644 index 000000000..6bb742e09 --- /dev/null +++ b/packages/extension/package.json @@ -0,0 +1,49 @@ +{ + "name": "@ocap/extension", + "version": "0.0.0", + "private": true, + "description": "For running Ocap Kernel experiments in an extension environment", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/ocap-kernel.git" + }, + "files": [ + "dist/" + ], + "scripts": { + "build": "yarn build:types && yarn build:vite", + "build:types": "tsc --project tsconfig.build.json", + "build:vite": "vite build --config vite.config.mts", + "changelog:validate": "../../scripts/validate-changelog.sh @ocap/extension", + "publish:preview": "yarn npm publish --tag preview", + "start": "vite --config vite.config.mts & vite build --watch --config vite.config.mts", + "test": "vitest run --config vite.config.mts", + "test:dev": "vitest run --config vite.config.mts --coverage false", + "test:verbose": "yarn test", + "test:watch": "vitest --config vite.config.mts" + }, + "dependencies": { + "@endo/lockdown": "^1.0.7", + "@endo/promise-kit": "^1.1.2", + "@metamask/snaps-utils": "^7.8.0", + "@metamask/utils": "^9.1.0", + "@ocap/shims": "^0.0.0", + "ses": "^1.5.0" + }, + "devDependencies": { + "@arethetypeswrong/cli": "^0.15.3", + "@metamask/auto-changelog": "^3.4.4", + "@types/chrome": "^0.0.268", + "@vitest/coverage-v8": "^2.0.4", + "deepmerge": "^4.3.1", + "typedoc": "^0.24.8", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~4.9.5", + "vite": "^5.3.4", + "vite-plugin-static-copy": "^1.0.6", + "vitest": "^2.0.4" + }, + "engines": { + "node": "^18.18 || >=20" + } +} diff --git a/packages/extension/src/background.ts b/packages/extension/src/background.ts new file mode 100644 index 000000000..d04913a8d --- /dev/null +++ b/packages/extension/src/background.ts @@ -0,0 +1,91 @@ +/* eslint-disable import/extensions,import/no-unassigned-import */ +import './dev-console.mjs'; +import './endoify.mjs'; +/* eslint-enable import/extensions,import/no-unassigned-import */ + +import type { ExtensionMessage } from './shared'; +import { Command, makeHandledCallback } from './shared'; + +// globalThis.kernel will exist due to dev-console.mjs +Object.defineProperties(globalThis.kernel, { + sendMessage: { + value: sendMessage, + }, + evaluate: { + value: async (source: string) => sendMessage(Command.Evaluate, source), + }, + ping: { + value: async () => sendMessage(Command.Ping), + }, +}); + +const OFFSCREEN_DOCUMENT_PATH = '/offscreen.html'; + +// With this we can click the extension action button to wake up the service worker. +chrome.action.onClicked.addListener(() => { + sendMessage(Command.Ping).catch(console.error); +}); + +/** + * Send a message to the offscreen document. + * @param type - The message type. + * @param data - The message data. + * @param data.name - The name to include in the message. + */ +async function sendMessage(type: string, data?: string) { + await provideOffScreenDocument(); + + await chrome.runtime.sendMessage({ + type, + target: 'offscreen', + data: data ?? null, + }); +} + +/** + * Create the offscreen document if it doesn't already exist. + */ +async function provideOffScreenDocument() { + if (!(await chrome.offscreen.hasDocument())) { + await chrome.offscreen.createDocument({ + url: OFFSCREEN_DOCUMENT_PATH, + reasons: [chrome.offscreen.Reason.IFRAME_SCRIPTING], + justification: `Surely you won't object to our capabilities?`, + }); + } +} + +// Handle replies from the offscreen document +chrome.runtime.onMessage.addListener( + makeHandledCallback(async (message: ExtensionMessage) => { + if (message.target !== 'background') { + console.warn( + `Background received message with unexpected target: "${message.target}"`, + ); + return; + } + + switch (message.type) { + case Command.Evaluate: + case Command.Ping: + console.log(message.data); + await closeOffscreenDocument(); + break; + default: + console.error( + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `Background received unexpected message type: "${message.type}"`, + ); + } + }), +); + +/** + * Close the offscreen document if it exists. + */ +async function closeOffscreenDocument() { + if (!(await chrome.offscreen.hasDocument())) { + return; + } + await chrome.offscreen.closeDocument(); +} diff --git a/packages/extension/src/dev-console.mjs b/packages/extension/src/dev-console.mjs new file mode 100644 index 000000000..275afbcb6 --- /dev/null +++ b/packages/extension/src/dev-console.mjs @@ -0,0 +1,8 @@ +/* eslint-disable import/unambiguous */ +// We set this property on globalThis in the background before lockdown. +Object.defineProperty(globalThis, 'kernel', { + configurable: false, + enumerable: true, + writable: false, + value: {}, +}); diff --git a/packages/extension/src/iframe-manager.test.ts b/packages/extension/src/iframe-manager.test.ts new file mode 100644 index 000000000..a5c621570 --- /dev/null +++ b/packages/extension/src/iframe-manager.test.ts @@ -0,0 +1,200 @@ +import * as snapsUtils from '@metamask/snaps-utils'; +import { vi, beforeEach, describe, it, expect } from 'vitest'; + +import { Command } from './shared'; + +vi.mock('@endo/promise-kit', () => ({ + makePromiseKit: () => { + 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('@metamask/snaps-utils', () => ({ + createWindow: vi.fn(), +})); + +describe('IframeManager', () => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + let IframeManager: typeof import('./iframe-manager').IframeManager; + + beforeEach(async () => { + vi.resetModules(); + IframeManager = (await import('./iframe-manager')).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(); + }); + }); + + describe('create', () => { + it('creates a new iframe', async () => { + const mockWindow = {}; + vi.mocked(snapsUtils.createWindow).mockResolvedValueOnce( + mockWindow as Window, + ); + + const manager = IframeManager.getInstance(); + 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, + }); + }); + + it('creates a new iframe with a specified id', async () => { + const mockWindow = {}; + vi.mocked(snapsUtils.createWindow).mockResolvedValueOnce( + mockWindow as Window, + ); + + const manager = IframeManager.getInstance(); + const sendMessageSpy = vi + .spyOn(manager, 'sendMessage') + .mockImplementation(vi.fn()); + const id = 'foo'; + const [newWindow, returnedId] = await manager.create(id); + + expect(newWindow).toBe(mockWindow); + expect(returnedId).toBe(id); + expect(sendMessageSpy).toHaveBeenCalledOnce(); + expect(sendMessageSpy).toHaveBeenCalledWith(id, { + type: 'ping', + data: null, + }); + }); + }); + + describe('delete', () => { + it('deletes an iframe', async () => { + const id = 'foo'; + const iframe = document.createElement('iframe'); + iframe.id = `ocap-iframe-${id}`; + const removeSpy = vi.spyOn(iframe, 'remove'); + + vi.mocked(snapsUtils.createWindow).mockImplementationOnce(async () => { + document.body.appendChild(iframe); + return iframe.contentWindow as Window; + }); + + const manager = IframeManager.getInstance(); + vi.spyOn(manager, 'sendMessage').mockImplementation(vi.fn()); + + await manager.create(id); + manager.delete(id); + + expect(removeSpy).toHaveBeenCalledOnce(); + }); + + it('ignores attempt to delete unrecognized iframe', async () => { + const id = 'foo'; + const manager = IframeManager.getInstance(); + const iframe = document.createElement('iframe'); + + const removeSpy = vi.spyOn(iframe, 'remove'); + manager.delete(id); + + expect(removeSpy).not.toHaveBeenCalled(); + }); + }); + + 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, + ); + + const manager = IframeManager.getInstance(); + const sendMessageSpy = vi.spyOn(manager, 'sendMessage'); + sendMessageSpy.mockImplementationOnce(async () => Promise.resolve()); + + const id = 'foo'; + await manager.create(id); + + const message = { type: Command.Evaluate, data: '2+2' }; + + const messagePromise = manager.sendMessage(id, message); + const messageId: string | undefined = + iframeWindow.postMessage.mock.lastCall?.[0]?.id; + expect(messageId).toBeTypeOf('string'); + + window.dispatchEvent( + new MessageEvent('message', { + data: { + id: messageId, + message: { + type: Command.Evaluate, + data: '4', + }, + }, + }), + ); + + expect(iframeWindow.postMessage).toHaveBeenCalledOnce(); + expect(iframeWindow.postMessage).toHaveBeenCalledWith( + { id: messageId, message }, + '*', + ); + expect(await messagePromise).toBe('4'); + }); + + it('throws if iframe not found', async () => { + const manager = IframeManager.getInstance(); + const id = 'foo'; + const message = { type: Command.Ping, data: null }; + + await expect(manager.sendMessage(id, message)).rejects.toThrow( + `No iframe with id "${id}"`, + ); + }); + }); + + describe('warnings', () => { + it('calls console.warn when receiving unexpected message', () => { + // Initialize manager + IframeManager.getInstance(); + const warnSpy = vi.spyOn(console, 'warn'); + + window.dispatchEvent( + new MessageEvent('message', { + data: 'foo', + }), + ); + + expect(warnSpy).toHaveBeenCalledWith( + 'Offscreen received message with unexpected format', + 'foo', + ); + }); + }); +}); diff --git a/packages/extension/src/iframe-manager.ts b/packages/extension/src/iframe-manager.ts new file mode 100644 index 000000000..6e7148b2c --- /dev/null +++ b/packages/extension/src/iframe-manager.ts @@ -0,0 +1,147 @@ +import type { PromiseKit } from '@endo/promise-kit'; +import { makePromiseKit } from '@endo/promise-kit'; +import { createWindow } from '@metamask/snaps-utils'; + +import type { IframeMessage } from './shared'; +import { Command, isWrappedIframeMessage } from './shared'; + +const IFRAME_URI = 'iframe.html'; + +// The actual