diff --git a/eslint.config.mjs b/eslint.config.mjs index 80099c044..72ca9637b 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -129,6 +129,13 @@ const config = createConfig([ }, }, + { + files: ['**/test/**/*', '**/*.test.ts', '**/*.test.tsx'], + rules: { + '@typescript-eslint/explicit-function-return-type': 'off', + }, + }, + { files: ['**/*.types.test.ts'], rules: { diff --git a/packages/cli/test/bundles.ts b/packages/cli/test/bundles.ts index 6758aa19e..da178151f 100644 --- a/packages/cli/test/bundles.ts +++ b/packages/cli/test/bundles.ts @@ -11,7 +11,6 @@ export const invalidTestBundleNames = ['bad-vat.fails']; const testRoot = new URL('.', import.meta.url).pathname; -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type const makeTestBundleRoot = async () => { const stageRoot = resolve(tmpdir(), 'test'); @@ -32,7 +31,6 @@ const makeTestBundleRoot = async () => { return stageBundleRoot; }; -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type export const makeTestBundleStage = async () => { const stageBundleRoot = await makeTestBundleRoot(); @@ -44,7 +42,6 @@ export const makeTestBundleStage = async () => { return join(stageBundleRoot, `${bundleName}.js`); }; - // eslint-disable-next-line @typescript-eslint/explicit-function-return-type const getTestBundleSpecs = (testBundleNames: string[]) => testBundleNames.map((bundleName) => ({ name: bundleName, diff --git a/packages/cli/test/integration/serve.test.ts b/packages/cli/test/integration/serve.test.ts index 304ef2d09..4bf59efa3 100644 --- a/packages/cli/test/integration/serve.test.ts +++ b/packages/cli/test/integration/serve.test.ts @@ -53,7 +53,6 @@ describe('serve', async () => { }); describe('server', () => { - // eslint-disable-next-line @typescript-eslint/explicit-function-return-type const makeServer = (root: string = testBundleRoot) => { const port = getServerPort(); const { listen } = getServer({ diff --git a/packages/create-package/src/cli.test.ts b/packages/create-package/src/cli.test.ts index 614e6113b..26fd64e13 100644 --- a/packages/create-package/src/cli.test.ts +++ b/packages/create-package/src/cli.test.ts @@ -74,9 +74,7 @@ describe('create-package/cli', () => { tsConfig: {}, tsConfigBuild: {}, nodeVersions: '>=18.0.0', - // TODO: Replace `any` with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any); + } as utils.MonorepoFileData); vi.spyOn(utils, 'finalizeAndWriteData').mockResolvedValue(); expect( @@ -100,9 +98,7 @@ describe('create-package/cli', () => { tsConfig: {}, tsConfigBuild: {}, nodeVersions: '>=18.0.0', - // TODO: Replace `any` with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any); + } as utils.MonorepoFileData); vi.spyOn(utils, 'finalizeAndWriteData').mockResolvedValue(); expect( diff --git a/packages/extension/package.json b/packages/extension/package.json index a7b5814b6..b73c30132 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -50,6 +50,7 @@ "@metamask/utils": "^11.4.0", "@ocap/errors": "workspace:^", "@ocap/kernel": "workspace:^", + "@ocap/rpc-methods": "workspace:^", "@ocap/shims": "workspace:^", "@ocap/store": "workspace:^", "@ocap/streams": "workspace:^", diff --git a/packages/extension/src/kernel-integration/VatWorkerClient.ts b/packages/extension/src/kernel-integration/VatWorkerClient.ts index 48559142a..92db270ab 100644 --- a/packages/extension/src/kernel-integration/VatWorkerClient.ts +++ b/packages/extension/src/kernel-integration/VatWorkerClient.ts @@ -1,5 +1,4 @@ import { makePromiseKit } from '@endo/promise-kit'; -import type { PromiseKit } from '@endo/promise-kit'; import { isObject } from '@metamask/utils'; import { isVatCommandReply, @@ -24,15 +23,13 @@ import type { PostMessageEnvelope, PostMessageTarget, } from '@ocap/streams/browser'; -import type { Logger } from '@ocap/utils'; +import type { Logger, PromiseCallbacks } from '@ocap/utils'; import { makeCounter, makeLogger } from '@ocap/utils'; // Appears in the docs. // eslint-disable-next-line @typescript-eslint/no-unused-vars import type { ExtensionVatWorkerServer } from './VatWorkerServer.ts'; -type PromiseCallbacks = Omit, 'promise'>; - export type VatWorkerClientStream = PostMessageDuplexStream< MessageEvent, PostMessageEnvelope diff --git a/packages/extension/src/kernel-integration/command-registry.test.ts b/packages/extension/src/kernel-integration/command-registry.test.ts deleted file mode 100644 index cfe9373c8..000000000 --- a/packages/extension/src/kernel-integration/command-registry.test.ts +++ /dev/null @@ -1,275 +0,0 @@ -import type { Kernel, KernelCommand, VatId, VatConfig } from '@ocap/kernel'; -import type { KernelDatabase } from '@ocap/store'; -import { setupOcapKernelMock } from '@ocap/test-utils'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -import { KernelCommandRegistry } from './command-registry.ts'; -import type { CommandHandler } from './command-registry.ts'; -import { handlers } from './handlers/index.ts'; - -// Mock logger -vi.mock('@ocap/utils', async (importOriginal) => ({ - ...(await importOriginal()), - makeLogger: () => ({ - error: vi.fn(), - debug: vi.fn(), - }), -})); - -const { setMockBehavior, resetMocks } = setupOcapKernelMock(); - -describe('KernelCommandRegistry', () => { - let registry: KernelCommandRegistry; - let mockKernel: Kernel; - let mockKernelDatabase: KernelDatabase; - - beforeEach(() => { - vi.resetModules(); - resetMocks(); - - mockKernelDatabase = { - kernelKVStore: { - get: vi.fn(), - getRequired: vi.fn(), - getNextKey: vi.fn(), - set: vi.fn(), - delete: vi.fn(), - }, - clear: vi.fn(), - executeQuery: vi.fn(), - makeVatStore: vi.fn(), - }; - - // Create mock kernel - mockKernel = { - launchVat: vi.fn().mockResolvedValue(undefined), - restartVat: vi.fn().mockResolvedValue(undefined), - terminateVat: vi.fn().mockResolvedValue(undefined), - terminateAllVats: vi.fn().mockResolvedValue(undefined), - clearStorage: vi.fn().mockResolvedValue(undefined), - getVatIds: vi.fn().mockReturnValue(['v0', 'v1']), - getVats: vi.fn().mockReturnValue([ - { - id: 'v0', - config: { bundleSpec: 'http://localhost:3000/sample-vat.bundle' }, - }, - { - id: 'v1', - config: { bundleSpec: 'http://localhost:3000/sample-vat.bundle' }, - }, - ]), - sendVatCommand: vi.fn((id: VatId, _message: KernelCommand) => { - if (id === 'v0') { - return 'success'; - } - return { error: 'Unknown vat ID' }; - }), - reset: vi.fn().mockResolvedValue(undefined), - } as unknown as Kernel; - - registry = new KernelCommandRegistry(); - handlers.forEach((handler) => { - registry.register(handler as CommandHandler); - }); - }); - - describe('vat management commands', () => { - it('should handle launchVat command', async () => { - const result = await registry.execute( - mockKernel, - mockKernelDatabase, - 'launchVat', - { - sourceSpec: 'bogus.js', - }, - ); - - expect(mockKernel.launchVat).toHaveBeenCalledWith({ - sourceSpec: 'bogus.js', - }); - expect(result).toBeNull(); - }); - - it('should handle invalid vat configuration', async () => { - setMockBehavior({ isVatConfig: false }); - - await expect( - registry.execute(mockKernel, mockKernelDatabase, 'launchVat', { - bogus: 'bogus.js', - } as unknown as VatConfig), - ).rejects.toThrow(/Expected a value of type `VatConfig`/u); - }); - - it('should handle restartVat command', async () => { - const result = await registry.execute( - mockKernel, - mockKernelDatabase, - 'restartVat', - { - id: 'v0', - }, - ); - - expect(mockKernel.restartVat).toHaveBeenCalledWith('v0'); - expect(result).toBeNull(); - }); - - it('should handle invalid vat ID for restartVat command', async () => { - setMockBehavior({ isVatId: false }); - - await expect( - registry.execute(mockKernel, mockKernelDatabase, 'restartVat', { - id: 'invalid', - }), - ).rejects.toThrow(/Expected a value of type `VatId`/u); - }); - - it('should handle terminateVat command', async () => { - const result = await registry.execute( - mockKernel, - mockKernelDatabase, - 'terminateVat', - { - id: 'v0', - }, - ); - - expect(mockKernel.terminateVat).toHaveBeenCalledWith('v0'); - expect(result).toBeNull(); - }); - - it('should handle terminateAllVats command', async () => { - const result = await registry.execute( - mockKernel, - mockKernelDatabase, - 'terminateAllVats', - [], - ); - - expect(mockKernel.terminateAllVats).toHaveBeenCalled(); - expect(result).toBeNull(); - }); - }); - - describe('status command', () => { - it('should handle getStatus command', async () => { - const result = await registry.execute( - mockKernel, - mockKernelDatabase, - 'getStatus', - [], - ); - - expect(mockKernel.getVats).toHaveBeenCalled(); - expect(result).toStrictEqual({ - clusterConfig: undefined, - vats: [ - { - id: 'v0', - config: { - bundleSpec: 'http://localhost:3000/sample-vat.bundle', - }, - }, - { - id: 'v1', - config: { - bundleSpec: 'http://localhost:3000/sample-vat.bundle', - }, - }, - ], - }); - }); - }); - - describe('sendVatCommand command', () => { - it('should handle vat commands', async () => { - const result = await registry.execute( - mockKernel, - mockKernelDatabase, - 'sendVatCommand', - { - id: 'v0', - payload: { method: 'ping', params: [] }, - }, - ); - - expect(mockKernel.sendVatCommand).toHaveBeenCalledWith('v0', { - method: 'ping', - params: [], - }); - expect(result).toStrictEqual({ result: 'success' }); - }); - - it('should handle invalid command payload', async () => { - setMockBehavior({ isKernelCommand: false }); - - await expect( - registry.execute(mockKernel, mockKernelDatabase, 'sendVatCommand', { - id: 'v0', - payload: { invalid: 'command' }, - }), - ).rejects.toThrow('Invalid command payload'); - }); - - it('should handle missing vat ID', async () => { - setMockBehavior({ isVatId: false }); - - await expect( - registry.execute(mockKernel, mockKernelDatabase, 'sendVatCommand', { - id: null, - payload: { method: 'ping', params: [] }, - }), - ).rejects.toThrow('Vat ID required for this command'); - }); - }); - - describe('error handling', () => { - it('should handle unknown method', async () => { - await expect( - // @ts-expect-error Testing invalid method - registry.execute(mockKernel, mockKernelDatabase, 'unknownMethod', null), - ).rejects.toThrow('Unknown method: unknownMethod'); - }); - - it('should handle kernel errors', async () => { - const error = new Error('Kernel error'); - vi.mocked(mockKernel.launchVat).mockRejectedValue(error); - - await expect( - registry.execute(mockKernel, mockKernelDatabase, 'launchVat', { - sourceSpec: 'bogus.js', - }), - ).rejects.toThrow('Kernel error'); - - vi.mocked(mockKernel.launchVat).mockRejectedValue('error'); - - await expect( - registry.execute(mockKernel, mockKernelDatabase, 'launchVat', { - sourceSpec: 'bogus.js', - }), - ).rejects.toThrow('error'); - }); - }); - - describe('clearState command', () => { - it('should handle clearState command', async () => { - const result = await registry.execute( - mockKernel, - mockKernelDatabase, - 'clearState', - [], - ); - - expect(mockKernel.reset).toHaveBeenCalled(); - expect(result).toBeNull(); - }); - - it('should handle clearState errors', async () => { - vi.mocked(mockKernel.reset).mockRejectedValue(new Error('Reset failed')); - - await expect( - registry.execute(mockKernel, mockKernelDatabase, 'clearState', []), - ).rejects.toThrow('Reset failed'); - }); - }); -}); diff --git a/packages/extension/src/kernel-integration/command-registry.ts b/packages/extension/src/kernel-integration/command-registry.ts deleted file mode 100644 index 6a15a066e..000000000 --- a/packages/extension/src/kernel-integration/command-registry.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { assert } from '@metamask/superstruct'; -import type { Infer, Struct } from '@metamask/superstruct'; -import type { Json } from '@metamask/utils'; -import type { Kernel } from '@ocap/kernel'; -import type { KernelDatabase } from '@ocap/store'; - -import type { KernelControlMethod } from './handlers/index.ts'; -import type { KernelCommandPayloadStructs } from './messages.ts'; - -export type CommandParams = { - [Method in KernelControlMethod]: Infer< - (typeof KernelCommandPayloadStructs)[Method] - >['params']; -}; - -export type CommandHandler = { - method: Method; - - /** - * Validation schema for the parameters. - */ - schema: Struct; - - /** - * Implementation of the command. - * - * @param kernel - The kernel instance. - * @param kernelDatabase - The kernel database instance. - * @param params - The parameters. - * @returns The result of the command. - */ - implementation: ( - kernel: Kernel, - kernelDatabase: KernelDatabase, - params: CommandParams[Method], - ) => Promise; -}; - -/** - * A registry for kernel commands. - */ -export class KernelCommandRegistry { - readonly #handlers = new Map< - KernelControlMethod, - CommandHandler - >(); - - /** - * Register a command handler. - * - * @param handler - The command handler. - */ - register( - handler: CommandHandler, - ): void; - - register(handler: CommandHandler): void { - this.#handlers.set(handler.method, handler); - } - - /** - * Execute a command. - * - * @param kernel - The kernel. - * @param kernelDatabase - The kernel database. - * @param method - The method name. - * @param params - The parameters. - * @returns The result. - */ - async execute( - kernel: Kernel, - kernelDatabase: KernelDatabase, - method: Method, - params: CommandParams[Method], - ): Promise { - const handler = this.#handlers.get(method); - if (!handler) { - throw new Error(`Unknown method: ${method}`); - } - - assert(params, handler.schema); - return handler.implementation(kernel, kernelDatabase, params); - } -} diff --git a/packages/extension/src/kernel-integration/handlers/clear-state.test.ts b/packages/extension/src/kernel-integration/handlers/clear-state.test.ts index 6d480b14f..81eb39b5d 100644 --- a/packages/extension/src/kernel-integration/handlers/clear-state.test.ts +++ b/packages/extension/src/kernel-integration/handlers/clear-state.test.ts @@ -1,39 +1,34 @@ import type { Kernel } from '@ocap/kernel'; -import type { KernelDatabase } from '@ocap/store'; -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import { clearStateHandler } from './clear-state.ts'; describe('clearStateHandler', () => { - const mockKernel = { - reset: vi.fn().mockResolvedValue(undefined), - } as unknown as Kernel; + let mockKernel: Kernel; - const mockKernelDatabase = {} as unknown as KernelDatabase; - - it('should have the correct method', () => { - expect(clearStateHandler.method).toBe('clearState'); - }); - - it('should have a schema', () => { - expect(clearStateHandler.schema).toBeDefined(); + beforeEach(() => { + mockKernel = { + reset: vi.fn(), + } as unknown as Kernel; }); - it('should call kernel.reset() and return null', async () => { + it('clears state', async () => { const result = await clearStateHandler.implementation( - mockKernel, - mockKernelDatabase, + { kernel: mockKernel }, [], ); - expect(mockKernel.reset).toHaveBeenCalledOnce(); + + expect(mockKernel.reset).toHaveBeenCalledTimes(1); expect(result).toBeNull(); }); - it('should propagate errors from kernel.reset()', async () => { - const error = new Error('Reset failed'); - vi.mocked(mockKernel.reset).mockRejectedValueOnce(error); + it('should propagate errors from clearState', async () => { + const error = new Error('Clear state failed'); + vi.mocked(mockKernel.reset).mockImplementationOnce(() => { + throw error; + }); await expect( - clearStateHandler.implementation(mockKernel, mockKernelDatabase, []), + clearStateHandler.implementation({ kernel: mockKernel }, []), ).rejects.toThrow(error); }); }); diff --git a/packages/extension/src/kernel-integration/handlers/clear-state.ts b/packages/extension/src/kernel-integration/handlers/clear-state.ts index 86a4263b3..49be4f013 100644 --- a/packages/extension/src/kernel-integration/handlers/clear-state.ts +++ b/packages/extension/src/kernel-integration/handlers/clear-state.ts @@ -1,13 +1,27 @@ +import { literal } from '@metamask/superstruct'; import type { Json } from '@metamask/utils'; import type { Kernel } from '@ocap/kernel'; +import type { MethodSpec, Handler } from '@ocap/rpc-methods'; +import { EmptyJsonArray } from '@ocap/utils'; -import type { CommandHandler } from '../command-registry.ts'; -import { KernelCommandPayloadStructs } from '../messages.ts'; +export const clearStateSpec: MethodSpec<'clearState', Json[], null> = { + method: 'clearState', + params: EmptyJsonArray, + result: literal(null), +}; + +export type ClearStateHooks = { kernel: Pick }; -export const clearStateHandler: CommandHandler<'clearState'> = { +export const clearStateHandler: Handler< + 'clearState', + Json[], + null, + ClearStateHooks +> = { + ...clearStateSpec, method: 'clearState', - schema: KernelCommandPayloadStructs.clearState.schema.params, - implementation: async (kernel: Kernel): Promise => { + hooks: { kernel: true }, + implementation: async ({ kernel }: ClearStateHooks): Promise => { await kernel.reset(); return null; }, diff --git a/packages/extension/src/kernel-integration/handlers/execute-db-query.test.ts b/packages/extension/src/kernel-integration/handlers/execute-db-query.test.ts index 590e9690a..e9808e16c 100644 --- a/packages/extension/src/kernel-integration/handlers/execute-db-query.test.ts +++ b/packages/extension/src/kernel-integration/handlers/execute-db-query.test.ts @@ -1,40 +1,32 @@ -import type { Kernel } from '@ocap/kernel'; -import type { KernelDatabase } from '@ocap/store'; import { describe, it, expect, vi } from 'vitest'; import { executeDBQueryHandler } from './execute-db-query.ts'; describe('executeDBQueryHandler', () => { - const mockKernelDatabase = { - executeQuery: vi.fn(() => 'test'), - } as unknown as KernelDatabase; + it('executes a database query', async () => { + const mockExecuteDBQuery = vi.fn().mockReturnValueOnce([{ key: 'value' }]); - const mockKernel = {} as unknown as Kernel; - - it('should have the correct method', () => { - expect(executeDBQueryHandler.method).toBe('executeDBQuery'); - }); - - it('should execute query and return result', async () => { - const params = { sql: 'SELECT * FROM test' }; const result = await executeDBQueryHandler.implementation( - mockKernel, - mockKernelDatabase, - params, + { executeDBQuery: mockExecuteDBQuery }, + { + sql: 'test-query', + }, ); - expect(mockKernelDatabase.executeQuery).toHaveBeenCalledWith(params.sql); - expect(result).toBe('test'); + + expect(mockExecuteDBQuery).toHaveBeenCalledWith('test-query'); + expect(result).toStrictEqual([{ key: 'value' }]); }); - it('should propagate errors from executeQuery', async () => { + it('should propagate errors from executeDBQuery', async () => { const error = new Error('Query failed'); - vi.mocked(mockKernelDatabase.executeQuery).mockRejectedValueOnce(error); - const params = { sql: 'SELECT * FROM test' }; + const mockExecuteDBQuery = vi.fn().mockImplementationOnce(() => { + throw error; + }); + await expect( executeDBQueryHandler.implementation( - mockKernel, - mockKernelDatabase, - params, + { executeDBQuery: mockExecuteDBQuery }, + { sql: 'test-query' }, ), ).rejects.toThrow(error); }); diff --git a/packages/extension/src/kernel-integration/handlers/execute-db-query.ts b/packages/extension/src/kernel-integration/handlers/execute-db-query.ts index 619741871..ab52071b9 100644 --- a/packages/extension/src/kernel-integration/handlers/execute-db-query.ts +++ b/packages/extension/src/kernel-integration/handlers/execute-db-query.ts @@ -1,18 +1,34 @@ -import type { Json } from '@metamask/utils'; -import type { Kernel } from '@ocap/kernel'; -import type { KernelDatabase } from '@ocap/store'; +import { array, object, record, string } from '@metamask/superstruct'; +import type { MethodSpec, Handler } from '@ocap/rpc-methods'; -import type { CommandHandler, CommandParams } from '../command-registry.ts'; -import { KernelCommandPayloadStructs } from '../messages.ts'; - -export const executeDBQueryHandler: CommandHandler<'executeDBQuery'> = { +export const executeDBQuerySpec: MethodSpec< + 'executeDBQuery', + { sql: string }, + Record[] +> = { method: 'executeDBQuery', - schema: KernelCommandPayloadStructs.executeDBQuery.schema.params, + params: object({ + sql: string(), + }), + result: array(record(string(), string())), +} as const; + +export type ExecuteDBQueryHooks = { + executeDBQuery: (sql: string) => Record[]; +}; + +export const executeDBQueryHandler: Handler< + 'executeDBQuery', + { sql: string }, + Record[], + ExecuteDBQueryHooks +> = { + ...executeDBQuerySpec, + hooks: { executeDBQuery: true }, implementation: async ( - _kernel: Kernel, - kdb: KernelDatabase, - params: CommandParams['executeDBQuery'], - ): Promise => { - return kdb.executeQuery(params.sql); + { executeDBQuery }: ExecuteDBQueryHooks, + params: { sql: string }, + ): Promise[]> => { + return executeDBQuery(params.sql); }, }; diff --git a/packages/extension/src/kernel-integration/handlers/get-status.test.ts b/packages/extension/src/kernel-integration/handlers/get-status.test.ts index f6f15cdad..2a1a24850 100644 --- a/packages/extension/src/kernel-integration/handlers/get-status.test.ts +++ b/packages/extension/src/kernel-integration/handlers/get-status.test.ts @@ -1,38 +1,40 @@ import type { Kernel } from '@ocap/kernel'; -import type { KernelDatabase } from '@ocap/store'; -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import { getStatusHandler } from './get-status.ts'; -import clusterConfig from '../../vats/default-cluster.json'; describe('getStatusHandler', () => { - const mockVats = [ - { id: 'v0', config: { sourceSpec: 'test.js' } }, - { id: 'v1', config: { sourceSpec: 'test2.js' } }, - ]; + let mockKernel: Kernel; - const mockKernel = { - clusterConfig, - getVats: vi.fn(() => mockVats), - } as unknown as Kernel; - - const mockKernelDatabase = {} as unknown as KernelDatabase; - - it('should have the correct method', () => { - expect(getStatusHandler.method).toBe('getStatus'); - }); - - it('should have a schema', () => { - expect(getStatusHandler.schema).toBeDefined(); + beforeEach(() => { + mockKernel = { + getVats: vi.fn(), + clusterConfig: undefined, + } as unknown as Kernel; + Object.defineProperty(mockKernel, 'clusterConfig', { + get: vi.fn(() => ({ foo: 'bar' })), + }); }); it('should return vats status and cluster config', async () => { + vi.mocked(mockKernel.getVats).mockReturnValueOnce([]); + const result = await getStatusHandler.implementation( - mockKernel, - mockKernelDatabase, + { kernel: mockKernel }, [], ); - expect(mockKernel.getVats).toHaveBeenCalledOnce(); - expect(result).toStrictEqual({ vats: mockVats, clusterConfig }); + + expect(mockKernel.getVats).toHaveBeenCalledTimes(1); + expect(result).toStrictEqual({ vats: [], clusterConfig: { foo: 'bar' } }); + }); + + it('should propagate errors from getVats', async () => { + const error = new Error('Status check failed'); + vi.mocked(mockKernel.getVats).mockImplementationOnce(() => { + throw error; + }); + await expect( + getStatusHandler.implementation({ kernel: mockKernel }, []), + ).rejects.toThrow(error); }); }); diff --git a/packages/extension/src/kernel-integration/handlers/get-status.ts b/packages/extension/src/kernel-integration/handlers/get-status.ts index 0378092d5..810122ed3 100644 --- a/packages/extension/src/kernel-integration/handlers/get-status.ts +++ b/packages/extension/src/kernel-integration/handlers/get-status.ts @@ -1,18 +1,52 @@ -import type { Json } from '@metamask/utils'; +import { nullable, type, array, object } from '@metamask/superstruct'; +import type { Infer } from '@metamask/superstruct'; +import { + ClusterConfigStruct, + VatConfigStruct, + VatIdStruct, +} from '@ocap/kernel'; import type { Kernel } from '@ocap/kernel'; +import type { MethodSpec, Handler } from '@ocap/rpc-methods'; +import { EmptyJsonArray } from '@ocap/utils'; -import type { CommandHandler } from '../command-registry.ts'; -import { KernelCommandPayloadStructs } from '../messages.ts'; +const KernelStatusStruct = type({ + clusterConfig: nullable(ClusterConfigStruct), + vats: array( + object({ + id: VatIdStruct, + config: VatConfigStruct, + }), + ), +}); -type GetStatusMethod = 'getStatus'; +export type KernelStatus = Infer; -export const getStatusHandler: CommandHandler = { +export const getStatusSpec: MethodSpec< + 'getStatus', + EmptyJsonArray, + Infer +> = { method: 'getStatus', - schema: KernelCommandPayloadStructs.getStatus.schema.params, - implementation: async (kernel: Kernel): Promise => { - return { - vats: kernel.getVats(), - clusterConfig: kernel.clusterConfig, - } as Json; - }, + params: EmptyJsonArray, + result: KernelStatusStruct, +}; + +export type GetStatusHooks = { + kernel: Pick; +}; + +export const getStatusHandler: Handler< + 'getStatus', + EmptyJsonArray, + KernelStatus, + GetStatusHooks +> = { + ...getStatusSpec, + hooks: { kernel: true }, + implementation: async ({ + kernel, + }: GetStatusHooks): Promise => ({ + vats: kernel.getVats(), + clusterConfig: kernel.clusterConfig, + }), }; diff --git a/packages/extension/src/kernel-integration/handlers/index.ts b/packages/extension/src/kernel-integration/handlers/index.ts index 974c1c601..221e185c4 100644 --- a/packages/extension/src/kernel-integration/handlers/index.ts +++ b/packages/extension/src/kernel-integration/handlers/index.ts @@ -1,25 +1,60 @@ -import { clearStateHandler } from './clear-state.ts'; -import { executeDBQueryHandler } from './execute-db-query.ts'; -import { getStatusHandler } from './get-status.ts'; -import { launchVatHandler } from './launch-vat.ts'; -import { reloadConfigHandler } from './reload-config.ts'; -import { restartVatHandler } from './restart-vat.ts'; -import { sendVatCommandHandler } from './send-vat-command.ts'; -import { terminateAllVatsHandler } from './terminate-all-vats.ts'; -import { terminateVatHandler } from './terminate-vat.ts'; -import { updateClusterConfigHandler } from './update-cluster-config.ts'; - -export const handlers = [ - getStatusHandler, - clearStateHandler, - sendVatCommandHandler, +import { clearStateHandler, clearStateSpec } from './clear-state.ts'; +import { executeDBQueryHandler, - launchVatHandler, - reloadConfigHandler, - restartVatHandler, - terminateVatHandler, + executeDBQuerySpec, +} from './execute-db-query.ts'; +import { getStatusHandler, getStatusSpec } from './get-status.ts'; +import { launchVatHandler, launchVatSpec } from './launch-vat.ts'; +import { reloadConfigHandler, reloadConfigSpec } from './reload-config.ts'; +import { restartVatHandler, restartVatSpec } from './restart-vat.ts'; +import { + sendVatCommandHandler, + sendVatCommandSpec, +} from './send-vat-command.ts'; +import { terminateAllVatsHandler, + terminateAllVatsSpec, +} from './terminate-all-vats.ts'; +import { terminateVatHandler, terminateVatSpec } from './terminate-vat.ts'; +import { updateClusterConfigHandler, -] as const; + updateClusterConfigSpec, +} from './update-cluster-config.ts'; + +/** + * Call-ee side handlers for the kernel control methods. + */ +export const handlers = { + clearState: clearStateHandler, + executeDBQuery: executeDBQueryHandler, + getStatus: getStatusHandler, + launchVat: launchVatHandler, + reload: reloadConfigHandler, + restartVat: restartVatHandler, + sendVatCommand: sendVatCommandHandler, + terminateAllVats: terminateAllVatsHandler, + terminateVat: terminateVatHandler, + updateClusterConfig: updateClusterConfigHandler, +} as const; + +/** + * Call-er side method specs for the kernel control methods. + */ +export const methodSpecs = { + clearState: clearStateSpec, + executeDBQuery: executeDBQuerySpec, + getStatus: getStatusSpec, + launchVat: launchVatSpec, + reload: reloadConfigSpec, + restartVat: restartVatSpec, + sendVatCommand: sendVatCommandSpec, + terminateAllVats: terminateAllVatsSpec, + terminateVat: terminateVatSpec, + updateClusterConfig: updateClusterConfigSpec, +} as const; + +type Handlers = (typeof handlers)[keyof typeof handlers]; + +export type KernelControlMethod = Handlers['method']; -export type KernelControlMethod = (typeof handlers)[number]['method']; +export type { KernelStatus } from './get-status.ts'; diff --git a/packages/extension/src/kernel-integration/handlers/launch-vat.test.ts b/packages/extension/src/kernel-integration/handlers/launch-vat.test.ts index 433a049a9..4d9d23ef4 100644 --- a/packages/extension/src/kernel-integration/handlers/launch-vat.test.ts +++ b/packages/extension/src/kernel-integration/handlers/launch-vat.test.ts @@ -1,29 +1,21 @@ import type { Kernel } from '@ocap/kernel'; -import type { KernelDatabase } from '@ocap/store'; -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import { launchVatHandler } from './launch-vat.ts'; describe('launchVatHandler', () => { - const mockKernel = { - launchVat: vi.fn().mockResolvedValue(undefined), - } as unknown as Kernel; + let mockKernel: Kernel; - const mockKernelDatabase = {} as unknown as KernelDatabase; - - it('should have the correct method', () => { - expect(launchVatHandler.method).toBe('launchVat'); - }); - - it('should have a schema', () => { - expect(launchVatHandler.schema).toBeDefined(); + beforeEach(() => { + mockKernel = { + launchVat: vi.fn().mockResolvedValue(undefined), + } as unknown as Kernel; }); it('should launch vat and return null', async () => { const params = { sourceSpec: 'test.js' }; const result = await launchVatHandler.implementation( - mockKernel, - mockKernelDatabase, + { kernel: mockKernel }, params, ); expect(mockKernel.launchVat).toHaveBeenCalledWith(params); @@ -35,7 +27,7 @@ describe('launchVatHandler', () => { vi.mocked(mockKernel.launchVat).mockRejectedValueOnce(error); const params = { sourceSpec: 'test.js' }; await expect( - launchVatHandler.implementation(mockKernel, mockKernelDatabase, params), + launchVatHandler.implementation({ kernel: mockKernel }, params), ).rejects.toThrow(error); }); }); diff --git a/packages/extension/src/kernel-integration/handlers/launch-vat.ts b/packages/extension/src/kernel-integration/handlers/launch-vat.ts index cff4fde8c..fe4c7a0ac 100644 --- a/packages/extension/src/kernel-integration/handlers/launch-vat.ts +++ b/packages/extension/src/kernel-integration/handlers/launch-vat.ts @@ -1,18 +1,30 @@ -import type { Json } from '@metamask/utils'; -import type { Kernel } from '@ocap/kernel'; -import type { KernelDatabase } from '@ocap/store'; +import { literal } from '@metamask/superstruct'; +import { VatConfigStruct } from '@ocap/kernel'; +import type { Kernel, VatConfig } from '@ocap/kernel'; +import type { MethodSpec, Handler } from '@ocap/rpc-methods'; -import type { CommandHandler, CommandParams } from '../command-registry.ts'; -import { KernelCommandPayloadStructs } from '../messages.ts'; - -export const launchVatHandler: CommandHandler<'launchVat'> = { +export const launchVatSpec: MethodSpec<'launchVat', VatConfig, null> = { method: 'launchVat', - schema: KernelCommandPayloadStructs.launchVat.schema.params, + params: VatConfigStruct, + result: literal(null), +}; + +export type LaunchVatHooks = { + kernel: Pick; +}; + +export const launchVatHandler: Handler< + 'launchVat', + VatConfig, + null, + LaunchVatHooks +> = { + ...launchVatSpec, + hooks: { kernel: true }, implementation: async ( - kernel: Kernel, - _kdb: KernelDatabase, - params: CommandParams['launchVat'], - ): Promise => { + { kernel }: LaunchVatHooks, + params: VatConfig, + ): Promise => { await kernel.launchVat(params); return null; }, diff --git a/packages/extension/src/kernel-integration/handlers/reload-config.test.ts b/packages/extension/src/kernel-integration/handlers/reload-config.test.ts index cf918c89a..1efa91ed8 100644 --- a/packages/extension/src/kernel-integration/handlers/reload-config.test.ts +++ b/packages/extension/src/kernel-integration/handlers/reload-config.test.ts @@ -1,24 +1,19 @@ import type { Kernel } from '@ocap/kernel'; -import type { KernelDatabase } from '@ocap/store'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { reloadConfigHandler } from './reload-config.ts'; describe('reloadConfigHandler', () => { - const mockKernel = { - reload: vi.fn().mockResolvedValue(undefined), - } as Partial; - - const mockKernelDatabase = {} as KernelDatabase; - + let mockKernel: Kernel; beforeEach(() => { - vi.clearAllMocks(); + mockKernel = { + reload: vi.fn().mockResolvedValue(undefined), + } as unknown as Kernel; }); it('should call kernel.reload() and return null', async () => { const result = await reloadConfigHandler.implementation( - mockKernel as Kernel, - mockKernelDatabase, + { kernel: mockKernel }, [], ); @@ -26,24 +21,12 @@ describe('reloadConfigHandler', () => { expect(result).toBeNull(); }); - it('should use the correct method name', () => { - expect(reloadConfigHandler.method).toBe('reload'); - }); - - it('should use the clearState schema for params', () => { - expect(reloadConfigHandler.schema).toBeDefined(); - }); - it('should propagate errors from kernel.reload()', async () => { const error = new Error('Reload failed'); - vi.mocked(mockKernel.reload)?.mockRejectedValueOnce(error); + vi.mocked(mockKernel.reload).mockRejectedValueOnce(error); await expect( - reloadConfigHandler.implementation( - mockKernel as Kernel, - mockKernelDatabase, - [], - ), + reloadConfigHandler.implementation({ kernel: mockKernel }, []), ).rejects.toThrow(error); }); }); diff --git a/packages/extension/src/kernel-integration/handlers/reload-config.ts b/packages/extension/src/kernel-integration/handlers/reload-config.ts index fabb3c31a..ec77fdd3d 100644 --- a/packages/extension/src/kernel-integration/handlers/reload-config.ts +++ b/packages/extension/src/kernel-integration/handlers/reload-config.ts @@ -1,13 +1,25 @@ -import type { Json } from '@metamask/utils'; +import { literal } from '@metamask/superstruct'; import type { Kernel } from '@ocap/kernel'; +import type { MethodSpec, Handler } from '@ocap/rpc-methods'; +import { EmptyJsonArray } from '@ocap/utils'; -import type { CommandHandler } from '../command-registry.ts'; -import { KernelCommandPayloadStructs } from '../messages.ts'; - -export const reloadConfigHandler: CommandHandler<'reload'> = { +export const reloadConfigSpec: MethodSpec<'reload', EmptyJsonArray, null> = { method: 'reload', - schema: KernelCommandPayloadStructs.clearState.schema.params, - implementation: async (kernel: Kernel): Promise => { + params: EmptyJsonArray, + result: literal(null), +}; + +export type ReloadConfigHooks = { kernel: Pick }; + +export const reloadConfigHandler: Handler< + 'reload', + EmptyJsonArray, + null, + ReloadConfigHooks +> = { + ...reloadConfigSpec, + hooks: { kernel: true }, + implementation: async ({ kernel }: ReloadConfigHooks): Promise => { await kernel.reload(); return null; }, diff --git a/packages/extension/src/kernel-integration/handlers/restart-vat.test.ts b/packages/extension/src/kernel-integration/handlers/restart-vat.test.ts index c5eb0f137..da7a3edac 100644 --- a/packages/extension/src/kernel-integration/handlers/restart-vat.test.ts +++ b/packages/extension/src/kernel-integration/handlers/restart-vat.test.ts @@ -1,29 +1,20 @@ import type { Kernel } from '@ocap/kernel'; -import type { KernelDatabase } from '@ocap/store'; -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import { restartVatHandler } from './restart-vat.ts'; describe('restartVatHandler', () => { - const mockKernel = { - restartVat: vi.fn().mockResolvedValue(undefined), - } as unknown as Kernel; - - const mockKernelDatabase = {} as unknown as KernelDatabase; - - it('should have the correct method', () => { - expect(restartVatHandler.method).toBe('restartVat'); - }); - - it('should have a schema', () => { - expect(restartVatHandler.schema).toBeDefined(); + let mockKernel: Kernel; + beforeEach(() => { + mockKernel = { + restartVat: vi.fn().mockResolvedValue(undefined), + } as unknown as Kernel; }); it('should restart vat and return null', async () => { const params = { id: 'v0' } as const; const result = await restartVatHandler.implementation( - mockKernel, - mockKernelDatabase, + { kernel: mockKernel }, params, ); expect(mockKernel.restartVat).toHaveBeenCalledWith(params.id); @@ -35,7 +26,7 @@ describe('restartVatHandler', () => { vi.mocked(mockKernel.restartVat).mockRejectedValueOnce(error); const params = { id: 'v0' } as const; await expect( - restartVatHandler.implementation(mockKernel, mockKernelDatabase, params), + restartVatHandler.implementation({ kernel: mockKernel }, params), ).rejects.toThrow(error); }); }); diff --git a/packages/extension/src/kernel-integration/handlers/restart-vat.ts b/packages/extension/src/kernel-integration/handlers/restart-vat.ts index 53a3a0162..c10e31fa7 100644 --- a/packages/extension/src/kernel-integration/handlers/restart-vat.ts +++ b/packages/extension/src/kernel-integration/handlers/restart-vat.ts @@ -1,18 +1,28 @@ -import type { Json } from '@metamask/utils'; -import type { Kernel } from '@ocap/kernel'; -import type { KernelDatabase } from '@ocap/store'; +import { object, literal } from '@metamask/superstruct'; +import { VatIdStruct } from '@ocap/kernel'; +import type { Kernel, VatId } from '@ocap/kernel'; +import type { MethodSpec, Handler } from '@ocap/rpc-methods'; -import type { CommandHandler, CommandParams } from '../command-registry.ts'; -import { KernelCommandPayloadStructs } from '../messages.ts'; - -export const restartVatHandler: CommandHandler<'restartVat'> = { +export const restartVatSpec: MethodSpec<'restartVat', { id: VatId }, null> = { method: 'restartVat', - schema: KernelCommandPayloadStructs.restartVat.schema.params, + params: object({ id: VatIdStruct }), + result: literal(null), +}; + +export type RestartVatHooks = { kernel: Pick }; + +export const restartVatHandler: Handler< + 'restartVat', + { id: VatId }, + null, + RestartVatHooks +> = { + ...restartVatSpec, + hooks: { kernel: true }, implementation: async ( - kernel: Kernel, - _kdb: KernelDatabase, - params: CommandParams['restartVat'], - ): Promise => { + { kernel }: RestartVatHooks, + params: { id: VatId }, + ): Promise => { await kernel.restartVat(params.id); return null; }, diff --git a/packages/extension/src/kernel-integration/handlers/send-vat-command.test.ts b/packages/extension/src/kernel-integration/handlers/send-vat-command.test.ts index fe8b4b2b3..ab89fdd7d 100644 --- a/packages/extension/src/kernel-integration/handlers/send-vat-command.test.ts +++ b/packages/extension/src/kernel-integration/handlers/send-vat-command.test.ts @@ -1,43 +1,47 @@ -import type { Kernel } from '@ocap/kernel'; -import type { KernelDatabase } from '@ocap/store'; -import { describe, it, expect, vi } from 'vitest'; +import type { VatId, Kernel } from '@ocap/kernel'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import { sendVatCommandHandler } from './send-vat-command.ts'; describe('sendVatCommandHandler', () => { - const mockKernel = { - sendVatCommand: vi.fn(() => 'success'), - } as unknown as Kernel; - - const mockKernelDatabase = {} as unknown as KernelDatabase; - - it('should have the correct method', () => { - expect(sendVatCommandHandler.method).toBe('sendVatCommand'); + let mockKernel: Kernel; + beforeEach(() => { + mockKernel = { + sendVatCommand: vi.fn(), + } as unknown as Kernel; }); - it('should handle vat messages', async () => { - const params = { - id: 'v0', - payload: { method: 'ping', params: [] }, - }; + it('sends a command to a vat', async () => { + const vatId = 'vat1' as VatId; + vi.mocked(mockKernel.sendVatCommand).mockResolvedValueOnce('foo'); + const result = await sendVatCommandHandler.implementation( - mockKernel, - mockKernelDatabase, - params, + { kernel: mockKernel }, + { + id: vatId, + payload: { method: 'ping', params: [] }, + }, ); - expect(mockKernel.sendVatCommand).toHaveBeenCalledWith('v0', { + + expect(mockKernel.sendVatCommand).toHaveBeenCalledWith(vatId, { method: 'ping', params: [], }); - expect(result).toStrictEqual({ result: 'success' }); + expect(result).toStrictEqual({ result: 'foo' }); }); - it('should throw error when vat ID is missing', async () => { + it('throws if payload is not a valid kernel command', async () => { + const vatId = 'vat1' as VatId; + await expect( - sendVatCommandHandler.implementation(mockKernel, mockKernelDatabase, { - id: null, - payload: { method: 'ping', params: [] }, - }), - ).rejects.toThrow('Vat ID required for this command'); + sendVatCommandHandler.implementation( + { kernel: mockKernel }, + { + id: vatId, + payload: { notACommand: true }, + }, + ), + ).rejects.toThrow('Invalid command payload'); + expect(mockKernel.sendVatCommand).not.toHaveBeenCalled(); }); }); diff --git a/packages/extension/src/kernel-integration/handlers/send-vat-command.ts b/packages/extension/src/kernel-integration/handlers/send-vat-command.ts index ee6b787fe..3b45a64fd 100644 --- a/packages/extension/src/kernel-integration/handlers/send-vat-command.ts +++ b/packages/extension/src/kernel-integration/handlers/send-vat-command.ts @@ -1,27 +1,38 @@ +import { object } from '@metamask/superstruct'; +import { UnsafeJsonStruct } from '@metamask/utils'; import type { Json } from '@metamask/utils'; -import { isKernelCommand } from '@ocap/kernel'; -import type { Kernel } from '@ocap/kernel'; -import type { KernelDatabase } from '@ocap/store'; +import { isKernelCommand, VatIdStruct } from '@ocap/kernel'; +import type { Kernel, VatId } from '@ocap/kernel'; +import type { MethodSpec, Handler } from '@ocap/rpc-methods'; -import type { CommandHandler, CommandParams } from '../command-registry.ts'; -import { KernelCommandPayloadStructs } from '../messages.ts'; - -export const sendVatCommandHandler: CommandHandler<'sendVatCommand'> = { +export const sendVatCommandSpec: MethodSpec< + 'sendVatCommand', + { id: VatId; payload: Json }, + { result: Json } +> = { method: 'sendVatCommand', - schema: KernelCommandPayloadStructs.sendVatCommand.schema.params, - implementation: async ( - kernel: Kernel, - _kdb: KernelDatabase, - params: CommandParams['sendVatCommand'], - ): Promise => { + // TODO:rekm Use a more specific struct for the payload + params: object({ id: VatIdStruct, payload: UnsafeJsonStruct }), + result: object({ result: UnsafeJsonStruct }), +}; + +export type SendVatCommandHooks = { + kernel: Pick; +}; + +export const sendVatCommandHandler: Handler< + 'sendVatCommand', + { id: VatId; payload: Json }, + { result: Json }, + SendVatCommandHooks +> = { + ...sendVatCommandSpec, + hooks: { kernel: true }, + implementation: async ({ kernel }, params): Promise<{ result: Json }> => { if (!isKernelCommand(params.payload)) { throw new Error('Invalid command payload'); } - if (!params.id) { - throw new Error('Vat ID required for this command'); - } - const result = await kernel.sendVatCommand(params.id, params.payload); return { result }; }, diff --git a/packages/extension/src/kernel-integration/handlers/terminate-all-vats.test.ts b/packages/extension/src/kernel-integration/handlers/terminate-all-vats.test.ts index 80107a686..b2ac29184 100644 --- a/packages/extension/src/kernel-integration/handlers/terminate-all-vats.test.ts +++ b/packages/extension/src/kernel-integration/handlers/terminate-all-vats.test.ts @@ -1,27 +1,25 @@ import type { Kernel } from '@ocap/kernel'; -import type { KernelDatabase } from '@ocap/store'; -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import { terminateAllVatsHandler } from './terminate-all-vats.ts'; describe('terminateAllVatsHandler', () => { - const mockKernel = { - terminateAllVats: vi.fn().mockResolvedValue(undefined), - } as unknown as Kernel; - - const mockKernelDatabase = {} as unknown as KernelDatabase; - - it('should have the correct method', () => { - expect(terminateAllVatsHandler.method).toBe('terminateAllVats'); + let mockKernel: Kernel; + beforeEach(() => { + mockKernel = { + terminateAllVats: vi.fn(), + } as unknown as Kernel; }); - it('should terminate all vats and return null', async () => { + it('terminates all vats', async () => { + vi.mocked(mockKernel.terminateAllVats).mockResolvedValueOnce(undefined); + const result = await terminateAllVatsHandler.implementation( - mockKernel, - mockKernelDatabase, + { kernel: mockKernel }, [], ); - expect(mockKernel.terminateAllVats).toHaveBeenCalledOnce(); + + expect(mockKernel.terminateAllVats).toHaveBeenCalledTimes(1); expect(result).toBeNull(); }); @@ -29,11 +27,7 @@ describe('terminateAllVatsHandler', () => { const error = new Error('Termination failed'); vi.mocked(mockKernel.terminateAllVats).mockRejectedValueOnce(error); await expect( - terminateAllVatsHandler.implementation( - mockKernel, - mockKernelDatabase, - [], - ), + terminateAllVatsHandler.implementation({ kernel: mockKernel }, []), ).rejects.toThrow(error); }); }); diff --git a/packages/extension/src/kernel-integration/handlers/terminate-all-vats.ts b/packages/extension/src/kernel-integration/handlers/terminate-all-vats.ts index a683bb4ba..18f1a2037 100644 --- a/packages/extension/src/kernel-integration/handlers/terminate-all-vats.ts +++ b/packages/extension/src/kernel-integration/handlers/terminate-all-vats.ts @@ -1,13 +1,32 @@ +import { literal } from '@metamask/superstruct'; import type { Json } from '@metamask/utils'; import type { Kernel } from '@ocap/kernel'; +import type { MethodSpec, Handler } from '@ocap/rpc-methods'; +import { EmptyJsonArray } from '@ocap/utils'; -import type { CommandHandler } from '../command-registry.ts'; -import { KernelCommandPayloadStructs } from '../messages.ts'; - -export const terminateAllVatsHandler: CommandHandler<'terminateAllVats'> = { +export const terminateAllVatsSpec: MethodSpec< + 'terminateAllVats', + Json[], + null +> = { method: 'terminateAllVats', - schema: KernelCommandPayloadStructs.terminateAllVats.schema.params, - implementation: async (kernel: Kernel): Promise => { + params: EmptyJsonArray, + result: literal(null), +}; + +export type TerminateAllVatsHooks = { + kernel: Pick; +}; + +export const terminateAllVatsHandler: Handler< + 'terminateAllVats', + Json[], + null, + TerminateAllVatsHooks +> = { + ...terminateAllVatsSpec, + hooks: { kernel: true }, + implementation: async ({ kernel }: TerminateAllVatsHooks): Promise => { await kernel.terminateAllVats(); return null; }, diff --git a/packages/extension/src/kernel-integration/handlers/terminate-vat.test.ts b/packages/extension/src/kernel-integration/handlers/terminate-vat.test.ts index 5f6b98701..9a67046b8 100644 --- a/packages/extension/src/kernel-integration/handlers/terminate-vat.test.ts +++ b/packages/extension/src/kernel-integration/handlers/terminate-vat.test.ts @@ -1,25 +1,20 @@ import type { Kernel } from '@ocap/kernel'; -import type { KernelDatabase } from '@ocap/store'; -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import { terminateVatHandler } from './terminate-vat.ts'; describe('terminateVatHandler', () => { - const mockKernel = { - terminateVat: vi.fn().mockResolvedValue(undefined), - } as unknown as Kernel; - - const mockKernelDatabase = {} as unknown as KernelDatabase; - - it('should have the correct method', () => { - expect(terminateVatHandler.method).toBe('terminateVat'); + let mockKernel: Kernel; + beforeEach(() => { + mockKernel = { + terminateVat: vi.fn().mockResolvedValue(undefined), + } as unknown as Kernel; }); it('should terminate vat and return null', async () => { const params = { id: 'v0' } as const; const result = await terminateVatHandler.implementation( - mockKernel, - mockKernelDatabase, + { kernel: mockKernel }, params, ); expect(mockKernel.terminateVat).toHaveBeenCalledWith(params.id); @@ -31,11 +26,7 @@ describe('terminateVatHandler', () => { vi.mocked(mockKernel.terminateVat).mockRejectedValueOnce(error); const params = { id: 'v0' } as const; await expect( - terminateVatHandler.implementation( - mockKernel, - mockKernelDatabase, - params, - ), + terminateVatHandler.implementation({ kernel: mockKernel }, params), ).rejects.toThrow(error); }); }); diff --git a/packages/extension/src/kernel-integration/handlers/terminate-vat.ts b/packages/extension/src/kernel-integration/handlers/terminate-vat.ts index dacf11b9a..e1d60f88a 100644 --- a/packages/extension/src/kernel-integration/handlers/terminate-vat.ts +++ b/packages/extension/src/kernel-integration/handlers/terminate-vat.ts @@ -1,18 +1,26 @@ -import type { Json } from '@metamask/utils'; -import type { Kernel } from '@ocap/kernel'; -import type { KernelDatabase } from '@ocap/store'; +import { object, literal } from '@metamask/superstruct'; +import type { Kernel, VatId } from '@ocap/kernel'; +import { VatIdStruct } from '@ocap/kernel'; +import type { MethodSpec, Handler } from '@ocap/rpc-methods'; -import type { CommandHandler, CommandParams } from '../command-registry.ts'; -import { KernelCommandPayloadStructs } from '../messages.ts'; +export const terminateVatSpec: MethodSpec<'terminateVat', { id: VatId }, null> = + { + method: 'terminateVat', + params: object({ id: VatIdStruct }), + result: literal(null), + }; -export const terminateVatHandler: CommandHandler<'terminateVat'> = { - method: 'terminateVat', - schema: KernelCommandPayloadStructs.terminateVat.schema.params, - implementation: async ( - kernel: Kernel, - _kdb: KernelDatabase, - params: CommandParams['terminateVat'], - ): Promise => { +export type TerminateVatHooks = { kernel: Pick }; + +export const terminateVatHandler: Handler< + 'terminateVat', + { id: VatId }, + null, + TerminateVatHooks +> = { + ...terminateVatSpec, + hooks: { kernel: true }, + implementation: async ({ kernel }, params): Promise => { await kernel.terminateVat(params.id); return null; }, diff --git a/packages/extension/src/kernel-integration/handlers/update-cluster-config.test.ts b/packages/extension/src/kernel-integration/handlers/update-cluster-config.test.ts index 6514bbc6f..694d9d93c 100644 --- a/packages/extension/src/kernel-integration/handlers/update-cluster-config.test.ts +++ b/packages/extension/src/kernel-integration/handlers/update-cluster-config.test.ts @@ -1,17 +1,10 @@ -import type { ClusterConfig, Kernel } from '@ocap/kernel'; -import type { KernelDatabase } from '@ocap/store'; -import { describe, it, expect } from 'vitest'; +import type { ClusterConfig } from '@ocap/kernel'; +import { describe, it, expect, vi } from 'vitest'; import { updateClusterConfigHandler } from './update-cluster-config.ts'; describe('updateClusterConfigHandler', () => { - const mockKernel = { - clusterConfig: null, - } as Partial; - - const mockKernelDatabase = {} as KernelDatabase; - - const testConfig: ClusterConfig = { + const makeTestConfig = (): ClusterConfig => ({ bootstrap: 'testVat', forceReset: true, vats: { @@ -19,24 +12,30 @@ describe('updateClusterConfigHandler', () => { sourceSpec: 'test-source', }, }, - }; + }); it('should update kernel cluster config', async () => { + const updateClusterConfig = vi.fn(); + const testConfig = makeTestConfig(); const result = await updateClusterConfigHandler.implementation( - mockKernel as Kernel, - mockKernelDatabase, + { updateClusterConfig }, { config: testConfig }, ); - expect(mockKernel.clusterConfig).toStrictEqual(testConfig); + expect(updateClusterConfig).toHaveBeenCalledWith(testConfig); expect(result).toBeNull(); }); - it('should use the correct method name', () => { - expect(updateClusterConfigHandler.method).toBe('updateClusterConfig'); - }); - - it('should validate the config using the correct schema', () => { - expect(updateClusterConfigHandler.schema).toBeDefined(); + it('should propagate errors from updateClusterConfig', async () => { + const error = new Error('Update failed'); + const updateClusterConfig = vi.fn(() => { + throw error; + }); + await expect( + updateClusterConfigHandler.implementation( + { updateClusterConfig }, + { config: makeTestConfig() }, + ), + ).rejects.toThrow(error); }); }); diff --git a/packages/extension/src/kernel-integration/handlers/update-cluster-config.ts b/packages/extension/src/kernel-integration/handlers/update-cluster-config.ts index f87190e65..bf12ce344 100644 --- a/packages/extension/src/kernel-integration/handlers/update-cluster-config.ts +++ b/packages/extension/src/kernel-integration/handlers/update-cluster-config.ts @@ -1,20 +1,32 @@ -import type { Json } from '@metamask/utils'; -import type { ClusterConfig, Kernel } from '@ocap/kernel'; -import type { KernelDatabase } from '@ocap/store'; +import { object, literal } from '@metamask/superstruct'; +import type { ClusterConfig } from '@ocap/kernel'; +import { ClusterConfigStruct } from '@ocap/kernel'; +import type { MethodSpec, Handler } from '@ocap/rpc-methods'; -import type { CommandHandler } from '../command-registry.ts'; -import { KernelCommandPayloadStructs } from '../messages.ts'; +export const updateClusterConfigSpec: MethodSpec< + 'updateClusterConfig', + { config: ClusterConfig }, + null +> = { + method: 'updateClusterConfig', + params: object({ config: ClusterConfigStruct }), + result: literal(null), +}; -export const updateClusterConfigHandler: CommandHandler<'updateClusterConfig'> = - { - method: 'updateClusterConfig', - schema: KernelCommandPayloadStructs.updateClusterConfig.schema.params, - implementation: async ( - kernel: Kernel, - _kdb: KernelDatabase, - params: { config: ClusterConfig }, - ): Promise => { - kernel.clusterConfig = params.config; - return null; - }, - }; +export type UpdateClusterConfigHooks = { + updateClusterConfig: (config: ClusterConfig) => void; +}; + +export const updateClusterConfigHandler: Handler< + 'updateClusterConfig', + { config: ClusterConfig }, + null, + UpdateClusterConfigHooks +> = { + ...updateClusterConfigSpec, + hooks: { updateClusterConfig: true }, + implementation: async ({ updateClusterConfig }, params): Promise => { + updateClusterConfig(params.config); + return null; + }, +}; diff --git a/packages/extension/src/kernel-integration/messages.test.ts b/packages/extension/src/kernel-integration/messages.test.ts deleted file mode 100644 index 4370ed2a8..000000000 --- a/packages/extension/src/kernel-integration/messages.test.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { describe, it, expect } from 'vitest'; - -import { - isKernelControlCommand, - isKernelControlReply, - isKernelStatus, -} from './messages.ts'; -import clusterConfig from '../vats/default-cluster.json'; - -describe('isKernelControlCommand', () => { - it.each([ - [ - 'launch vat command', - { - method: 'launchVat', - params: { sourceSpec: 'test.js' }, - }, - true, - ], - [ - 'restart vat command', - { - method: 'restartVat', - params: { id: 'v0' }, - }, - true, - ], - [ - 'terminate vat command', - { - method: 'terminateVat', - params: { id: 'v0' }, - }, - true, - ], - [ - 'terminate all vats command', - { - method: 'terminateAllVats', - params: [], - }, - true, - ], - [ - 'get status command', - { - method: 'getStatus', - params: [], - }, - true, - ], - [ - 'send message command', - { - method: 'sendVatCommand', - params: { - id: 'v0', - payload: { test: 'data' }, - }, - }, - true, - ], - [ - 'clear state command', - { - method: 'clearState', - params: [], - }, - true, - ], - [ - 'execute DB query command', - { - method: 'executeDBQuery', - params: { - sql: 'SELECT * FROM test', - }, - }, - true, - ], - ['null value', null, false], - ['undefined value', undefined, false], - ['empty object', {}, false], - ['missing payload', { id: 'test' }, false], - ['missing id', { payload: {} }, false], - [ - 'invalid method', - { - id: 'test', - payload: { method: 'invalidMethod' }, - }, - false, - ], - [ - 'invalid params', - { - id: 'test', - payload: { - method: 'launchVat', - params: 'invalid', - }, - }, - false, - ], - ])('should validate %s', (_, command, expected) => { - expect(isKernelControlCommand(command)).toBe(expected); - }); -}); - -describe('isKernelControlReply', () => { - it.each([ - [ - 'launch vat success reply', - { - method: 'launchVat', - result: null, - }, - true, - ], - [ - 'launch vat error reply', - { - method: 'launchVat', - result: { error: 'Failed to launch vat' }, - }, - true, - ], - [ - 'get status reply', - { - method: 'getStatus', - result: { - clusterConfig, - vats: [ - { - id: 'v0', - config: { sourceSpec: 'test.js' }, - }, - ], - }, - }, - true, - ], - [ - 'send message reply', - { - method: 'sendVatCommand', - result: { result: 'success' }, - }, - true, - ], - ['null value', null, false], - ['undefined value', undefined, false], - ['empty object', {}, false], - ['missing payload', { id: 'test' }, false], - ['missing id', { payload: {} }, false], - [ - 'invalid method', - { - method: 'invalidMethod', - result: null, - }, - false, - ], - [ - 'invalid result', - { - method: 'launchVat', - result: 'invalid', - }, - false, - ], - ])('should validate %s', (_, reply, expected) => { - expect(isKernelControlReply(reply)).toBe(expected); - }); -}); - -describe('isKernelStatus', () => { - it.each([ - [ - 'valid kernel status', - { - clusterConfig, - vats: [ - { - id: 'v0', - config: { sourceSpec: 'test.js' }, - }, - ], - }, - true, - ], - ['empty vats array', { vats: [], clusterConfig }, true], - ['null value', null, false], - ['undefined value', undefined, false], - ['empty object', {}, false], - ['null vats', { vats: null, clusterConfig }, false], - ['invalid vats type', { vats: 'invalid', clusterConfig }, false], - ['invalid vat object', { vats: [{ invalid: true }], clusterConfig }, false], - [ - 'invalid vat id type', - { vats: [{ id: 123, config: {} }], clusterConfig }, - false, - ], - ['invalid cluster config', { vats: [], clusterConfig: 'invalid' }, false], - ['invalid cluster config type', { vats: [], clusterConfig: 123 }, false], - ['invalid cluster config object', { vats: [], clusterConfig: {} }, false], - ['invalid cluster config array', { vats: [], clusterConfig: [] }, false], - [ - 'invalid cluster config boolean', - { vats: [], clusterConfig: true }, - false, - ], - ['invalid cluster config number', { vats: [], clusterConfig: 123 }, false], - [ - 'invalid cluster config string', - { vats: [], clusterConfig: 'test' }, - false, - ], - ])('should validate %s', (_, status, expected) => { - expect(isKernelStatus(status)).toBe(expected); - }); -}); diff --git a/packages/extension/src/kernel-integration/messages.ts b/packages/extension/src/kernel-integration/messages.ts deleted file mode 100644 index 96ee42425..000000000 --- a/packages/extension/src/kernel-integration/messages.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { - object, - union, - literal, - array, - type, - is, - string, - record, -} from '@metamask/superstruct'; -import type { Infer } from '@metamask/superstruct'; -import { UnsafeJsonStruct } from '@metamask/utils'; -import { - ClusterConfigStruct, - VatConfigStruct, - VatIdStruct, -} from '@ocap/kernel'; -import { EmptyJsonArray } from '@ocap/utils'; -import type { TypeGuard } from '@ocap/utils'; - -const KernelStatusStruct = type({ - clusterConfig: ClusterConfigStruct, - vats: array( - object({ - id: VatIdStruct, - config: VatConfigStruct, - }), - ), -}); - -export type KernelStatus = Infer; - -// Command payload structs -export const KernelCommandPayloadStructs = { - launchVat: object({ - method: literal('launchVat'), - params: VatConfigStruct, - }), - restartVat: object({ - method: literal('restartVat'), - params: object({ id: VatIdStruct }), - }), - terminateVat: object({ - method: literal('terminateVat'), - params: object({ id: VatIdStruct }), - }), - terminateAllVats: object({ - method: literal('terminateAllVats'), - params: EmptyJsonArray, - }), - getStatus: object({ - method: literal('getStatus'), - params: EmptyJsonArray, - }), - reload: object({ - method: literal('reload'), - params: EmptyJsonArray, - }), - sendVatCommand: object({ - method: literal('sendVatCommand'), - params: object({ - id: union([VatIdStruct, literal(null)]), - payload: UnsafeJsonStruct, - }), - }), - clearState: object({ - method: literal('clearState'), - params: EmptyJsonArray, - }), - executeDBQuery: object({ - method: literal('executeDBQuery'), - params: object({ - sql: string(), - }), - }), - updateClusterConfig: object({ - method: literal('updateClusterConfig'), - params: object({ - config: ClusterConfigStruct, - }), - }), -} as const; - -export const KernelReplyPayloadStructs = { - launchVat: object({ - method: literal('launchVat'), - result: union([literal(null), object({ error: string() })]), - }), - restartVat: object({ - method: literal('restartVat'), - result: union([literal(null), object({ error: string() })]), - }), - terminateVat: object({ - method: literal('terminateVat'), - result: union([literal(null), object({ error: string() })]), - }), - terminateAllVats: object({ - method: literal('terminateAllVats'), - result: union([literal(null), object({ error: string() })]), - }), - getStatus: object({ - method: literal('getStatus'), - result: union([KernelStatusStruct, object({ error: string() })]), - }), - reload: object({ - method: literal('reload'), - result: union([literal(null), object({ error: string() })]), - }), - sendVatCommand: object({ - method: literal('sendVatCommand'), - result: UnsafeJsonStruct, - }), - clearState: object({ - method: literal('clearState'), - result: literal(null), - }), - executeDBQuery: object({ - method: literal('executeDBQuery'), - result: union([ - array(record(string(), string())), - object({ error: string() }), - ]), - }), - updateClusterConfig: object({ - method: literal('updateClusterConfig'), - result: literal(null), - }), -} as const; - -const KernelControlCommandStruct = union([ - KernelCommandPayloadStructs.launchVat, - KernelCommandPayloadStructs.restartVat, - KernelCommandPayloadStructs.terminateVat, - KernelCommandPayloadStructs.terminateAllVats, - KernelCommandPayloadStructs.getStatus, - KernelCommandPayloadStructs.reload, - KernelCommandPayloadStructs.sendVatCommand, - KernelCommandPayloadStructs.clearState, - KernelCommandPayloadStructs.executeDBQuery, - KernelCommandPayloadStructs.updateClusterConfig, -]); - -const KernelControlReplyStruct = union([ - KernelReplyPayloadStructs.launchVat, - KernelReplyPayloadStructs.restartVat, - KernelReplyPayloadStructs.terminateVat, - KernelReplyPayloadStructs.terminateAllVats, - KernelReplyPayloadStructs.getStatus, - KernelReplyPayloadStructs.reload, - KernelReplyPayloadStructs.sendVatCommand, - KernelReplyPayloadStructs.clearState, - KernelReplyPayloadStructs.executeDBQuery, - KernelReplyPayloadStructs.updateClusterConfig, -]); - -export type KernelControlCommand = Infer; -export type KernelControlReply = Infer; - -export type KernelControlResult< - Method extends keyof typeof KernelReplyPayloadStructs, -> = Infer<(typeof KernelReplyPayloadStructs)[Method]>['result']; - -export type KernelControlReturnType = { - [Method in keyof typeof KernelReplyPayloadStructs]: KernelControlResult; -}; - -export const isKernelControlCommand: TypeGuard = ( - value: unknown, -): value is KernelControlCommand => is(value, KernelControlCommandStruct); - -export const isKernelControlReply: TypeGuard = ( - value: unknown, -): value is KernelControlReply => is(value, KernelControlReplyStruct); - -export const isKernelStatus: TypeGuard = ( - value, -): value is KernelStatus => is(value, KernelStatusStruct); - -export const UiControlMethod = { - init: 'init', -} as const; - -export type UiControlMethod = keyof typeof UiControlMethod; - -const UiControlCommandStruct = object({ - method: literal(UiControlMethod.init), - params: string(), // The UI instance's BroadcastChannel name -}); - -export type UiControlCommand = Infer; - -export const isUiControlCommand: TypeGuard = ( - value: unknown, -): value is UiControlCommand => is(value, UiControlCommandStruct); diff --git a/packages/extension/src/kernel-integration/middleware/panel-message.test.ts b/packages/extension/src/kernel-integration/middleware/panel-message.test.ts index a6fb85d8d..5fe2c1b0d 100644 --- a/packages/extension/src/kernel-integration/middleware/panel-message.test.ts +++ b/packages/extension/src/kernel-integration/middleware/panel-message.test.ts @@ -6,21 +6,24 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { createPanelMessageMiddleware } from './panel-message.ts'; -const { mockRegister, mockExecute } = vi.hoisted(() => ({ - mockRegister: vi.fn(), +const { mockAssertHasMethod, mockExecute } = vi.hoisted(() => ({ + mockAssertHasMethod: vi.fn(), mockExecute: vi.fn(), })); -vi.mock('../command-registry.ts', () => ({ - KernelCommandRegistry: vi.fn().mockImplementation(() => ({ - register: mockRegister, - execute: mockExecute, - })), +vi.mock('@ocap/rpc-methods', () => ({ + RpcService: class MockRpcService { + assertHasMethod = mockAssertHasMethod; + + execute = mockExecute; + }, })); -// Mock the handlers vi.mock('../handlers/index.ts', () => ({ - handlers: [{ method: 'testMethod1' }, { method: 'testMethod2' }], + handlers: { + testMethod1: { method: 'testMethod1' }, + testMethod2: { method: 'testMethod2' }, + }, })); describe('createPanelMessageMiddleware', () => { @@ -53,14 +56,10 @@ describe('createPanelMessageMiddleware', () => { // Process the request const response = await engine.handle(request); + console.log('response', response); // Verify the middleware called execute with the right parameters - expect(mockExecute).toHaveBeenCalledWith( - mockKernel, - mockKernelDatabase, - 'testMethod1', - { foo: 'bar' }, - ); + expect(mockExecute).toHaveBeenCalledWith('testMethod1', { foo: 'bar' }); // Verify the response contains the expected result expect(response).toStrictEqual({ @@ -86,12 +85,7 @@ describe('createPanelMessageMiddleware', () => { const response = await engine.handle(request); // Verify the middleware called execute with the right parameters - expect(mockExecute).toHaveBeenCalledWith( - mockKernel, - mockKernelDatabase, - 'testMethod2', - [], - ); + expect(mockExecute).toHaveBeenCalledWith('testMethod2', []); // Verify the response contains the expected result expect(response).toStrictEqual({ @@ -118,12 +112,7 @@ describe('createPanelMessageMiddleware', () => { const response = await engine.handle(request); // Verify the middleware called execute - expect(mockExecute).toHaveBeenCalledWith( - mockKernel, - mockKernelDatabase, - 'testMethod1', - { foo: 'bar' }, - ); + expect(mockExecute).toHaveBeenCalledWith('testMethod1', { foo: 'bar' }); // Verify the response contains the error expect(response).toStrictEqual({ @@ -156,12 +145,7 @@ describe('createPanelMessageMiddleware', () => { const response = await engine.handle(request); // Verify the middleware called execute with the array params - expect(mockExecute).toHaveBeenCalledWith( - mockKernel, - mockKernelDatabase, - 'testMethod1', - ['item1', 'item2'], - ); + expect(mockExecute).toHaveBeenCalledWith('testMethod1', ['item1', 'item2']); // Verify the response contains the expected result expect(response).toStrictEqual({ @@ -187,12 +171,7 @@ describe('createPanelMessageMiddleware', () => { const response = await engine.handle(request); // Verify the middleware called execute with undefined params - expect(mockExecute).toHaveBeenCalledWith( - mockKernel, - mockKernelDatabase, - 'testMethod2', - undefined, - ); + expect(mockExecute).toHaveBeenCalledWith('testMethod2', undefined); // Verify the response contains the expected result expect(response).toStrictEqual({ @@ -201,4 +180,34 @@ describe('createPanelMessageMiddleware', () => { result: { status: 'ok' }, }); }); + + it('rejects unknown methods', async () => { + const request = { + id: 6, + jsonrpc: '2.0', + method: 'unknownMethod', + } as JsonRpcRequest; + + mockAssertHasMethod.mockImplementation(() => { + throw new Error('The method does not exist / is not available.'); + }); + + const response = await engine.handle(request); + + expect(mockExecute).not.toHaveBeenCalled(); + + // Verify the response contains the error + expect(response).toStrictEqual({ + id: 6, + jsonrpc: '2.0', + error: expect.objectContaining({ + code: -32603, // Internal error + data: expect.objectContaining({ + cause: expect.objectContaining({ + message: 'The method does not exist / is not available.', + }), + }), + }), + }); + }); }); diff --git a/packages/extension/src/kernel-integration/middleware/panel-message.ts b/packages/extension/src/kernel-integration/middleware/panel-message.ts index 21e277195..9228f9414 100644 --- a/packages/extension/src/kernel-integration/middleware/panel-message.ts +++ b/packages/extension/src/kernel-integration/middleware/panel-message.ts @@ -1,22 +1,11 @@ import { createAsyncMiddleware } from '@metamask/json-rpc-engine'; import type { JsonRpcMiddleware } from '@metamask/json-rpc-engine'; -import type { Json } from '@metamask/utils'; -import type { Kernel } from '@ocap/kernel'; +import type { Json, JsonRpcParams } from '@metamask/utils'; +import type { ClusterConfig, Kernel } from '@ocap/kernel'; +import { RpcService } from '@ocap/rpc-methods'; import type { KernelDatabase } from '@ocap/store'; -import { KernelCommandRegistry } from '../command-registry.ts'; -import type { CommandHandler } from '../command-registry.ts'; import { handlers } from '../handlers/index.ts'; -import type { KernelControlCommand } from '../messages.ts'; - -const registry = new KernelCommandRegistry(); - -// Register handlers -handlers.forEach((handler) => - registry.register(handler as CommandHandler), -); - -type KernelControlParams = KernelControlCommand['params']; /** * Creates a middleware function that handles panel messages. @@ -28,9 +17,17 @@ type KernelControlParams = KernelControlCommand['params']; export const createPanelMessageMiddleware = ( kernel: Kernel, kernelDatabase: KernelDatabase, -): JsonRpcMiddleware => - createAsyncMiddleware(async (req, res, _next) => { +): JsonRpcMiddleware => { + const rpcService: RpcService = new RpcService(handlers, { + kernel, + executeDBQuery: (sql: string) => kernelDatabase.executeQuery(sql), + updateClusterConfig: (config: ClusterConfig) => + (kernel.clusterConfig = config), + }); + + return createAsyncMiddleware(async (req, res, _next) => { const { method, params } = req; - // @ts-expect-error - TODO:rekm execute() should probably just expect a string "method" - res.result = await registry.execute(kernel, kernelDatabase, method, params); + rpcService.assertHasMethod(method); + res.result = await rpcService.execute(method, params); }); +}; diff --git a/packages/extension/src/kernel-integration/ui-connections.test.ts b/packages/extension/src/kernel-integration/ui-connections.test.ts index 24f42396f..a7b86153d 100644 --- a/packages/extension/src/kernel-integration/ui-connections.test.ts +++ b/packages/extension/src/kernel-integration/ui-connections.test.ts @@ -4,7 +4,6 @@ import { delay } from '@ocap/test-utils'; import { TestDuplexStream } from '@ocap/test-utils/streams'; import { describe, it, expect, vi, beforeEach } from 'vitest'; -import type { KernelControlReply } from './messages.ts'; import { establishKernelConnection, receiveUiConnections, @@ -85,7 +84,6 @@ class MockBroadcastChannel { vi.stubGlobal('BroadcastChannel', MockBroadcastChannel); -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type const makeMockLogger = () => ({ ...console, @@ -163,9 +161,7 @@ describe('ui-connections', () => { const logger = makeMockLogger(); const mockHandleMessage = vi.fn( - async ( - _request: JsonRpcRequest, - ): Promise> => ({ + async (_request: JsonRpcRequest): Promise => ({ id: 'foo', jsonrpc: '2.0' as const, result: { vats: [], clusterConfig }, diff --git a/packages/extension/src/kernel-integration/ui-connections.ts b/packages/extension/src/kernel-integration/ui-connections.ts index cfc1ec3c0..1239282c2 100644 --- a/packages/extension/src/kernel-integration/ui-connections.ts +++ b/packages/extension/src/kernel-integration/ui-connections.ts @@ -5,8 +5,8 @@ import { stringify } from '@ocap/utils'; import type { Logger } from '@ocap/utils'; import { nanoid } from 'nanoid'; -import { isUiControlCommand } from './messages.ts'; -import type { KernelControlReply, UiControlCommand } from './messages.ts'; +import { isUiControlCommand } from './ui-control-command.ts'; +import type { UiControlCommand } from './ui-control-command.ts'; export const UI_CONTROL_CHANNEL_NAME = 'ui-control'; @@ -22,7 +22,7 @@ export type KernelControlReplyStream = PostMessageDuplexStream< type HandleInstanceMessage = ( request: JsonRpcRequest, -) => Promise>; +) => Promise; /** * Establishes a connection between a UI instance and the kernel. Should be called diff --git a/packages/extension/src/kernel-integration/ui-control-command.test.ts b/packages/extension/src/kernel-integration/ui-control-command.test.ts new file mode 100644 index 000000000..303a19b90 --- /dev/null +++ b/packages/extension/src/kernel-integration/ui-control-command.test.ts @@ -0,0 +1,21 @@ +import { describe, it, expect } from 'vitest'; + +import { isUiControlCommand } from './ui-control-command.ts'; + +describe('isUiControlCommand', () => { + it('should return true for a valid ui control command', () => { + const command = { + method: 'init', + params: 'test-channel', + }; + expect(isUiControlCommand(command)).toBe(true); + }); + + it('should return false for an invalid ui control command', () => { + const command = { + method: 'invalid', + params: 'test-channel', + }; + expect(isUiControlCommand(command)).toBe(false); + }); +}); diff --git a/packages/extension/src/kernel-integration/ui-control-command.ts b/packages/extension/src/kernel-integration/ui-control-command.ts new file mode 100644 index 000000000..2db01380c --- /dev/null +++ b/packages/extension/src/kernel-integration/ui-control-command.ts @@ -0,0 +1,20 @@ +import { object, literal, is, string } from '@metamask/superstruct'; +import type { Infer } from '@metamask/superstruct'; +import type { TypeGuard } from '@ocap/utils'; + +export const UiControlMethod = { + init: 'init', +} as const; + +export type UiControlMethod = keyof typeof UiControlMethod; + +const UiControlCommandStruct = object({ + method: literal(UiControlMethod.init), + params: string(), // The UI instance's BroadcastChannel name +}); + +export type UiControlCommand = Infer; + +export const isUiControlCommand: TypeGuard = ( + value: unknown, +): value is UiControlCommand => is(value, UiControlCommandStruct); diff --git a/packages/extension/src/ui/App.test.tsx b/packages/extension/src/ui/App.test.tsx index 870b9c80a..82e95e396 100644 --- a/packages/extension/src/ui/App.test.tsx +++ b/packages/extension/src/ui/App.test.tsx @@ -28,7 +28,7 @@ describe('App', () => { it('renders an error message if there is an error connecting to the kernel', async () => { const { useStream } = await import('./hooks/useStream.ts'); vi.mocked(useStream).mockReturnValue({ - sendMessage: undefined, + callKernelMethod: undefined, error: new Error('Test kernel connection error'), } as unknown as StreamState); const { App } = await import('./App.tsx'); @@ -41,10 +41,10 @@ describe('App', () => { ).toBeInTheDocument(); }); - it('renders "Connecting to kernel..." when sendMessage is not yet available and no error is present', async () => { + it('renders "Connecting to kernel..." when callKernelMethod is not yet available and no error is present', async () => { const { useStream } = await import('./hooks/useStream.ts'); vi.mocked(useStream).mockReturnValue({ - sendMessage: undefined, + callKernelMethod: undefined, error: undefined, } as unknown as StreamState); const { App } = await import('./App.tsx'); @@ -52,10 +52,10 @@ describe('App', () => { expect(screen.getByText('Connecting to kernel...')).toBeInTheDocument(); }); - it('renders the main UI when sendMessage is available and no error is present', async () => { + it('renders the main UI when callKernelMethod is available and no error is present', async () => { const { useStream } = await import('./hooks/useStream.ts'); vi.mocked(useStream).mockReturnValue({ - sendMessage: vi.fn(), + callKernelMethod: vi.fn(), error: undefined, } as unknown as StreamState); const { App } = await import('./App.tsx'); diff --git a/packages/extension/src/ui/App.tsx b/packages/extension/src/ui/App.tsx index 5be82be7a..f7d920ee5 100644 --- a/packages/extension/src/ui/App.tsx +++ b/packages/extension/src/ui/App.tsx @@ -9,7 +9,7 @@ import { PanelProvider } from './context/PanelContext.tsx'; import { useStream } from './hooks/useStream.ts'; export const App: React.FC = () => { - const { sendMessage, error } = useStream(); + const { callKernelMethod, error } = useStream(); const [activeTab, setActiveTab] = useState('vats'); if (error) { @@ -22,7 +22,7 @@ export const App: React.FC = () => { ); } - if (!sendMessage) { + if (!callKernelMethod) { return (
Connecting to kernel...
@@ -31,7 +31,7 @@ export const App: React.FC = () => { } return ( - +
({ diff --git a/packages/extension/src/ui/components/ConfigEditor.tsx b/packages/extension/src/ui/components/ConfigEditor.tsx index a818e01e8..41c27d07a 100644 --- a/packages/extension/src/ui/components/ConfigEditor.tsx +++ b/packages/extension/src/ui/components/ConfigEditor.tsx @@ -1,7 +1,7 @@ import type { ClusterConfig } from '@ocap/kernel'; import { useCallback, useEffect, useMemo, useState } from 'react'; -import type { KernelStatus } from '../../kernel-integration/messages.ts'; +import type { KernelStatus } from '../../kernel-integration/handlers/index.ts'; import defaultConfig from '../../vats/default-cluster.json'; import minimalConfig from '../../vats/minimal-cluster.json'; import styles from '../App.module.css'; diff --git a/packages/extension/src/ui/components/KernelControls.test.tsx b/packages/extension/src/ui/components/KernelControls.test.tsx index df141bb99..b9617c641 100644 --- a/packages/extension/src/ui/components/KernelControls.test.tsx +++ b/packages/extension/src/ui/components/KernelControls.test.tsx @@ -38,8 +38,6 @@ const mockUseKernelActions = (overrides = {}): void => { const mockUseVats = (vats: VatRecord[] = []): void => { vi.mocked(useVats).mockReturnValue({ vats, - selectedVatId: undefined, - setSelectedVatId: vi.fn(), pingVat: vi.fn(), restartVat: vi.fn(), terminateVat: vi.fn(), diff --git a/packages/extension/src/ui/context/PanelContext.test.tsx b/packages/extension/src/ui/context/PanelContext.test.tsx index 417a5c20f..023cca5a9 100644 --- a/packages/extension/src/ui/context/PanelContext.test.tsx +++ b/packages/extension/src/ui/context/PanelContext.test.tsx @@ -35,13 +35,13 @@ describe('PanelContext', () => { ).isJsonRpcFailure.mockReturnValue(false); const { result } = renderHook(() => usePanelContext(), { wrapper: ({ children }) => ( - + {children} ), }); - // @ts-expect-error - we are testing the sendMessage function - const actualResponse = await result.current.sendMessage(payload); + // @ts-expect-error - we are testing the callKernelMethod function + const actualResponse = await result.current.callKernelMethod(payload); expect(mockSendMessage).toHaveBeenCalledWith(payload); expect(actualResponse).toBe(response); }); @@ -58,13 +58,13 @@ describe('PanelContext', () => { ).isJsonRpcFailure.mockReturnValue(true); const { result } = renderHook(() => usePanelContext(), { wrapper: ({ children }) => ( - + {children} ), }); - // @ts-expect-error - we are testing the sendMessage function - await expect(result.current.sendMessage(payload)).rejects.toThrow( + // @ts-expect-error - we are testing the callKernelMethod function + await expect(result.current.callKernelMethod(payload)).rejects.toThrow( JSON.stringify(errorResponse.error), ); expect( @@ -84,13 +84,15 @@ describe('PanelContext', () => { mockSendMessage.mockRejectedValueOnce(error); const { result } = renderHook(() => usePanelContext(), { wrapper: ({ children }) => ( - + {children} ), }); - // @ts-expect-error - we are testing the sendMessage function - await expect(result.current.sendMessage(payload)).rejects.toThrow(error); + // @ts-expect-error - we are testing the callKernelMethod function + await expect(result.current.callKernelMethod(payload)).rejects.toThrow( + error, + ); expect( vi.mocked(await import('../services/logger.ts')).logger.error, ).toHaveBeenCalledWith(`Error: ${error.message}`, 'error'); @@ -104,7 +106,7 @@ describe('PanelContext', () => { ); const { result } = renderHook(() => usePanelContext(), { wrapper: ({ children }) => ( - + {children} ), diff --git a/packages/extension/src/ui/context/PanelContext.tsx b/packages/extension/src/ui/context/PanelContext.tsx index 1862201b6..78ea3a3f4 100644 --- a/packages/extension/src/ui/context/PanelContext.tsx +++ b/packages/extension/src/ui/context/PanelContext.tsx @@ -9,10 +9,10 @@ import { } from 'react'; import type { ReactNode } from 'react'; -import type { KernelStatus } from '../../kernel-integration/messages.ts'; +import type { KernelStatus } from '../../kernel-integration/handlers/index.ts'; import { useStatusPolling } from '../hooks/useStatusPolling.ts'; import { logger } from '../services/logger.ts'; -import type { SendMessageFunction } from '../services/stream.ts'; +import type { CallKernelMethod } from '../services/stream.ts'; export type OutputType = 'sent' | 'received' | 'error' | 'success'; @@ -22,7 +22,7 @@ type PanelLog = { }; export type PanelContextType = { - sendMessage: SendMessageFunction; + callKernelMethod: CallKernelMethod; status: KernelStatus | undefined; logMessage: (message: string, type?: OutputType) => void; messageContent: string; @@ -36,8 +36,8 @@ const PanelContext = createContext(undefined); export const PanelProvider: React.FC<{ children: ReactNode; - sendMessage: SendMessageFunction; -}> = ({ children, sendMessage }) => { + callKernelMethod: CallKernelMethod; +}> = ({ children, callKernelMethod }) => { const [panelLogs, setPanelLogs] = useState([]); const [messageContent, setMessageContent] = useState(''); const [isLoading, setIsLoading] = useState(false); @@ -54,7 +54,7 @@ export const PanelProvider: React.FC<{ setPanelLogs([]); }, []); - const sendMessageWrapper: SendMessageFunction = useCallback( + const sendMessageWrapper: CallKernelMethod = useCallback( async (payload) => { if (isRequestInProgress.current) { throw new Error('A request is already in progress'); @@ -70,7 +70,7 @@ export const PanelProvider: React.FC<{ setIsLoading(true); logMessage(stringify(payload, 2), 'sent'); - const response = await sendMessage(payload); + const response = await callKernelMethod(payload); if (isJsonRpcFailure(response)) { throw new Error(stringify(response.error, 0)); } @@ -82,15 +82,15 @@ export const PanelProvider: React.FC<{ cleanup(); } }, - [sendMessage], + [callKernelMethod], ); - const status = useStatusPolling(sendMessage, isRequestInProgress); + const status = useStatusPolling(callKernelMethod, isRequestInProgress); return ( { beforeEach(() => { vi.clearAllMocks(); vi.mocked(usePanelContext).mockReturnValue({ - sendMessage: mockSendMessage, + callKernelMethod: mockSendMessage, logMessage: mockLogMessage, } as unknown as ReturnType); }); diff --git a/packages/extension/src/ui/hooks/useDatabaseInspector.ts b/packages/extension/src/ui/hooks/useDatabaseInspector.ts index b51a6d5b5..253e0fb10 100644 --- a/packages/extension/src/ui/hooks/useDatabaseInspector.ts +++ b/packages/extension/src/ui/hooks/useDatabaseInspector.ts @@ -17,7 +17,7 @@ export function useDatabaseInspector(): { refreshData: () => void; executeQuery: (sql: string) => void; } { - const { sendMessage, logMessage } = usePanelContext(); + const { callKernelMethod, logMessage } = usePanelContext(); const [tables, setTables] = useState([]); const [selectedTable, setSelectedTable] = useState(''); const [tableData, setTableData] = useState[]>([]); @@ -25,7 +25,7 @@ export function useDatabaseInspector(): { // Execute a query and set the result as table data const executeQuery = useCallback( (sql: string): void => { - sendMessage({ + callKernelMethod({ method: 'executeDBQuery', params: { sql }, }) @@ -40,12 +40,12 @@ export function useDatabaseInspector(): { logMessage(`Failed to execute query: ${error}`, 'error'); }); }, - [logMessage, sendMessage], + [logMessage, callKernelMethod], ); // Fetch available tables const fetchTables = useCallback(async (): Promise => { - const result = await sendMessage({ + const result = await callKernelMethod({ method: 'executeDBQuery', params: { sql: "SELECT name FROM sqlite_master WHERE type='table'" }, }); @@ -59,12 +59,12 @@ export function useDatabaseInspector(): { setSelectedTable(tableNames[0] ?? ''); } } - }, [logMessage, sendMessage]); + }, [logMessage, callKernelMethod]); // Fetch data for selected table const fetchTableData = useCallback( async (tableName: string): Promise => { - const result = await sendMessage({ + const result = await callKernelMethod({ method: 'executeDBQuery', params: { sql: `SELECT * FROM ${tableName}` }, }); @@ -73,7 +73,7 @@ export function useDatabaseInspector(): { setTableData(result); } }, - [logMessage, sendMessage], + [logMessage, callKernelMethod], ); // Refresh data for selected table diff --git a/packages/extension/src/ui/hooks/useKernelActions.test.ts b/packages/extension/src/ui/hooks/useKernelActions.test.ts index 7b95cedce..d9c2c291a 100644 --- a/packages/extension/src/ui/hooks/useKernelActions.test.ts +++ b/packages/extension/src/ui/hooks/useKernelActions.test.ts @@ -19,7 +19,7 @@ describe('useKernelActions', () => { beforeEach(async () => { const { usePanelContext } = await import('../context/PanelContext.tsx'); vi.mocked(usePanelContext).mockReturnValue({ - sendMessage: mockSendMessage, + callKernelMethod: mockSendMessage, logMessage: mockLogMessage, messageContent: mockMessageContent, setMessageContent: vi.fn(), diff --git a/packages/extension/src/ui/hooks/useKernelActions.ts b/packages/extension/src/ui/hooks/useKernelActions.ts index bbef6d259..f4e3a8c22 100644 --- a/packages/extension/src/ui/hooks/useKernelActions.ts +++ b/packages/extension/src/ui/hooks/useKernelActions.ts @@ -16,62 +16,62 @@ export function useKernelActions(): { launchVat: (bundleUrl: string, vatName: string) => void; updateClusterConfig: (config: ClusterConfig) => Promise; } { - const { sendMessage, logMessage, messageContent } = usePanelContext(); + const { callKernelMethod, logMessage, messageContent } = usePanelContext(); /** * Sends a kernel command. */ const sendKernelCommand = useCallback(() => { - sendMessage({ + callKernelMethod({ method: 'sendVatCommand', params: JSON.parse(messageContent), }) .then((result) => logMessage(stringify(result, 0), 'received')) .catch((error) => logMessage(error.message, 'error')); - }, [messageContent, sendMessage, logMessage]); + }, [messageContent, callKernelMethod, logMessage]); /** * Terminates all vats. */ const terminateAllVats = useCallback(() => { - sendMessage({ + callKernelMethod({ method: 'terminateAllVats', params: [], }) .then(() => logMessage('All vats terminated', 'success')) .catch(() => logMessage('Failed to terminate all vats', 'error')); - }, [sendMessage, logMessage]); + }, [callKernelMethod, logMessage]); /** * Clears the kernel state. */ const clearState = useCallback(() => { - sendMessage({ + callKernelMethod({ method: 'clearState', params: [], }) .then(() => logMessage('State cleared', 'success')) .catch(() => logMessage('Failed to clear state', 'error')); - }, [sendMessage, logMessage]); + }, [callKernelMethod, logMessage]); /** * Reloads the kernel default sub-cluster. */ const reload = useCallback(() => { - sendMessage({ + callKernelMethod({ method: 'reload', params: [], }) .then(() => logMessage('Default sub-cluster reloaded', 'success')) .catch(() => logMessage('Failed to reload', 'error')); - }, [sendMessage, logMessage]); + }, [callKernelMethod, logMessage]); /** * Launches a vat. */ const launchVat = useCallback( (bundleUrl: string, vatName: string) => { - sendMessage({ + callKernelMethod({ method: 'launchVat', params: { bundleSpec: bundleUrl, @@ -83,7 +83,7 @@ export function useKernelActions(): { .then(() => logMessage(`Launched vat "${vatName}"`, 'success')) .catch(() => logMessage(`Failed to launch vat "${vatName}":`, 'error')); }, - [sendMessage, logMessage], + [callKernelMethod, logMessage], ); /** @@ -91,14 +91,14 @@ export function useKernelActions(): { */ const updateClusterConfig = useCallback( async (config: ClusterConfig) => { - return sendMessage({ + return callKernelMethod({ method: 'updateClusterConfig', params: { config }, }) .then(() => logMessage('Config updated', 'success')) .catch(() => logMessage('Failed to update config', 'error')); }, - [sendMessage, logMessage], + [callKernelMethod, logMessage], ); return { diff --git a/packages/extension/src/ui/hooks/useStatusPolling.ts b/packages/extension/src/ui/hooks/useStatusPolling.ts index 4617137bc..d7f862511 100644 --- a/packages/extension/src/ui/hooks/useStatusPolling.ts +++ b/packages/extension/src/ui/hooks/useStatusPolling.ts @@ -3,19 +3,19 @@ import { stringify } from '@ocap/utils'; import { useEffect, useRef, useState } from 'react'; import type { StreamState } from './useStream.ts'; -import type { KernelStatus } from '../../kernel-integration/messages.ts'; +import type { KernelStatus } from '../../kernel-integration/handlers/index.ts'; import { logger } from '../services/logger.ts'; /** * Hook to start polling for kernel status * - * @param sendMessage - Function to send a message to the kernel + * @param callKernelMethod - Function to send a message to the kernel * @param isRequestInProgress - Ref to track if a request is in progress * @param interval - Polling interval in milliseconds * @returns The kernel status */ export const useStatusPolling = ( - sendMessage: StreamState['sendMessage'], + callKernelMethod: StreamState['callKernelMethod'], isRequestInProgress: React.RefObject, interval: number = 1000, ): KernelStatus | undefined => { @@ -27,11 +27,11 @@ export const useStatusPolling = ( */ useEffect(() => { const fetchStatus = async (): Promise => { - if (!sendMessage || isRequestInProgress.current) { + if (!callKernelMethod || isRequestInProgress.current) { return; } try { - const result = await sendMessage({ + const result = await callKernelMethod({ method: 'getStatus', params: [], }); @@ -55,7 +55,7 @@ export const useStatusPolling = ( clearInterval(pollingRef.current); } }; - }, [sendMessage, interval, isRequestInProgress]); + }, [callKernelMethod, interval, isRequestInProgress]); return status; }; diff --git a/packages/extension/src/ui/hooks/useStream.test.ts b/packages/extension/src/ui/hooks/useStream.test.ts index d662cc899..bb7a055da 100644 --- a/packages/extension/src/ui/hooks/useStream.test.ts +++ b/packages/extension/src/ui/hooks/useStream.test.ts @@ -11,16 +11,16 @@ vi.mock('../services/stream.ts', () => ({ describe('useStream', () => { const mockSendMessage = vi.fn(); - it('should set sendMessage function when stream setup succeeds', async () => { + it('should set callKernelMethod function when stream setup succeeds', async () => { vi.mocked(setupStream).mockResolvedValueOnce({ - sendMessage: mockSendMessage, + callKernelMethod: mockSendMessage, }); const { result } = renderHook(() => useStream()); await waitFor(() => { - expect(result.current.sendMessage).toBeDefined(); + expect(result.current.callKernelMethod).toBeDefined(); }); expect(result.current).toStrictEqual({ - sendMessage: mockSendMessage, + callKernelMethod: mockSendMessage, }); expect(setupStream).toHaveBeenCalledTimes(1); }); diff --git a/packages/extension/src/ui/hooks/useStream.ts b/packages/extension/src/ui/hooks/useStream.ts index eab175ea7..70028c67d 100644 --- a/packages/extension/src/ui/hooks/useStream.ts +++ b/packages/extension/src/ui/hooks/useStream.ts @@ -1,15 +1,15 @@ import { useState, useEffect } from 'react'; import { setupStream } from '../services/stream.ts'; -import type { SendMessageFunction } from '../services/stream.ts'; +import type { CallKernelMethod } from '../services/stream.ts'; export type StreamState = { - sendMessage?: SendMessageFunction; + callKernelMethod?: CallKernelMethod; error?: Error; }; /** - * Hook to setup the stream and provide a sendMessage function. + * Hook to setup the stream and provide a callKernelMethod function. * * @returns The stream state. */ @@ -17,7 +17,7 @@ export function useStream(): StreamState { const [state, setState] = useState({}); /** - * Effect to setup the stream and provide a sendMessage function. + * Effect to setup the stream and provide a callKernelMethod function. */ useEffect(() => { setupStream() diff --git a/packages/extension/src/ui/hooks/useVats.test.ts b/packages/extension/src/ui/hooks/useVats.test.ts index 13934252d..3e0063123 100644 --- a/packages/extension/src/ui/hooks/useVats.test.ts +++ b/packages/extension/src/ui/hooks/useVats.test.ts @@ -37,7 +37,7 @@ describe('useVats', () => { beforeEach(async () => { const { usePanelContext } = await import('../context/PanelContext.tsx'); vi.mocked(usePanelContext).mockReturnValue({ - sendMessage: mockSendMessage, + callKernelMethod: mockSendMessage, status: mockStatus, selectedVatId: mockVatId, setSelectedVatId: mockSetSelectedVatId, @@ -62,7 +62,7 @@ describe('useVats', () => { it('should handle missing vat config gracefully', async () => { const { usePanelContext } = await import('../context/PanelContext.tsx'); vi.mocked(usePanelContext).mockReturnValue({ - sendMessage: mockSendMessage, + callKernelMethod: mockSendMessage, status: { vats: [{ id: mockVatId, config: {} as VatConfig }] }, selectedVatId: mockVatId, setSelectedVatId: mockSetSelectedVatId, diff --git a/packages/extension/src/ui/hooks/useVats.ts b/packages/extension/src/ui/hooks/useVats.ts index 7b798a4f5..3c971acff 100644 --- a/packages/extension/src/ui/hooks/useVats.ts +++ b/packages/extension/src/ui/hooks/useVats.ts @@ -17,7 +17,7 @@ export const useVats = (): { restartVat: (id: VatId) => void; terminateVat: (id: VatId) => void; } => { - const { sendMessage, status, logMessage } = usePanelContext(); + const { callKernelMethod, status, logMessage } = usePanelContext(); const vats = useMemo(() => { return ( @@ -39,7 +39,7 @@ export const useVats = (): { */ const pingVat = useCallback( (id: VatId) => { - sendMessage({ + callKernelMethod({ method: 'sendVatCommand', params: { id, @@ -52,7 +52,7 @@ export const useVats = (): { .then((result) => logMessage(stringify(result, 0), 'received')) .catch((error) => logMessage(error.message, 'error')); }, - [sendMessage, logMessage], + [callKernelMethod, logMessage], ); /** @@ -60,14 +60,14 @@ export const useVats = (): { */ const restartVat = useCallback( (id: VatId) => { - sendMessage({ + callKernelMethod({ method: 'restartVat', params: { id }, }) .then(() => logMessage(`Restarted vat "${id}"`, 'success')) .catch(() => logMessage(`Failed to restart vat "${id}"`, 'error')); }, - [sendMessage, logMessage], + [callKernelMethod, logMessage], ); /** @@ -75,14 +75,14 @@ export const useVats = (): { */ const terminateVat = useCallback( (id: VatId) => { - sendMessage({ + callKernelMethod({ method: 'terminateVat', params: { id }, }) .then(() => logMessage(`Terminated vat "${id}"`, 'success')) .catch(() => logMessage(`Failed to terminate vat "${id}"`, 'error')); }, - [sendMessage, logMessage], + [callKernelMethod, logMessage], ); return { diff --git a/packages/extension/src/ui/services/stream.ts b/packages/extension/src/ui/services/stream.ts index 03a8e2bd4..800e0166e 100644 --- a/packages/extension/src/ui/services/stream.ts +++ b/packages/extension/src/ui/services/stream.ts @@ -1,17 +1,15 @@ -import { isJsonRpcSuccess } from '@metamask/utils'; -import { MessageResolver } from '@ocap/kernel'; +import { RpcClient } from '@ocap/rpc-methods'; +import type { ExtractParams, ExtractResult } from '@ocap/rpc-methods'; import { logger } from './logger.ts'; +import { methodSpecs } from '../../kernel-integration/handlers/index.ts'; import type { KernelControlMethod } from '../../kernel-integration/handlers/index.ts'; -import type { - KernelControlCommand, - KernelControlReturnType, -} from '../../kernel-integration/messages.ts'; import { establishKernelConnection } from '../../kernel-integration/ui-connections.ts'; -export type SendMessageFunction = ( - command: Extract, -) => Promise; +export type CallKernelMethod = (command: { + method: Method; + params: ExtractParams; +}) => Promise>; /** * Setup the stream for sending and receiving messages. @@ -19,14 +17,20 @@ export type SendMessageFunction = ( * @returns A function for sending messages. */ export async function setupStream(): Promise<{ - sendMessage: SendMessageFunction; + callKernelMethod: CallKernelMethod; }> { const kernelStream = await establishKernelConnection(logger); - const resolver = new MessageResolver('kernel'); + const rpcClient = new RpcClient( + methodSpecs, + async (request) => { + await kernelStream.write(request); + }, + 'panel', + ); const cleanup = (): void => { - resolver.terminateAll(new Error('Stream disconnected')); + rpcClient.rejectAll(new Error('Stream disconnected')); // Explicitly _do not_ return the stream, as the connection will be // re-established when the panel is reloaded. If we return the stream, // the remote end will be closed and the connection irrevocably lost. @@ -40,27 +44,17 @@ export async function setupStream(): Promise<{ throw new Error('Invalid response id'); } - if (isJsonRpcSuccess(response)) { - resolver.handleResponse(response.id, response.result); - } else { - resolver.handleResponse(response.id, response); - } + rpcClient.handleResponse(response.id, response); }) .catch((error) => { logger.error('error draining kernel stream', error); }) .finally(cleanup); - const sendMessage: SendMessageFunction = async (payload) => { + const callKernelMethod: CallKernelMethod = async (payload) => { logger.log('sending message', payload); - return resolver.createMessage(async (messageId) => { - await kernelStream.write({ - ...payload, - id: messageId, - jsonrpc: '2.0', - }); - }); + return await rpcClient.call(payload.method, payload.params); }; - return { sendMessage }; + return { callKernelMethod }; } diff --git a/packages/extension/tsconfig.build.json b/packages/extension/tsconfig.build.json index ae4bb837a..a71b15760 100644 --- a/packages/extension/tsconfig.build.json +++ b/packages/extension/tsconfig.build.json @@ -14,6 +14,7 @@ }, "references": [ { "path": "../kernel/tsconfig.build.json" }, + { "path": "../rpc-methods/tsconfig.build.json" }, { "path": "../shims/tsconfig.build.json" }, { "path": "../store/tsconfig.build.json" }, { "path": "../streams/tsconfig.build.json" }, diff --git a/packages/extension/tsconfig.json b/packages/extension/tsconfig.json index b6fc060fd..b9dc824a5 100644 --- a/packages/extension/tsconfig.json +++ b/packages/extension/tsconfig.json @@ -20,12 +20,13 @@ ] }, "references": [ + { "path": "../kernel" }, + { "path": "../rpc-methods" }, { "path": "../shims" }, + { "path": "../store" }, { "path": "../streams" }, { "path": "../test-utils" }, - { "path": "../utils" }, - { "path": "../kernel" }, - { "path": "../store" } + { "path": "../utils" } ], "include": [ "../../vitest.config.ts", diff --git a/packages/kernel-test/src/supervisor.test.ts b/packages/kernel-test/src/supervisor.test.ts index 864dd9a9a..569b623ae 100644 --- a/packages/kernel-test/src/supervisor.test.ts +++ b/packages/kernel-test/src/supervisor.test.ts @@ -14,7 +14,6 @@ const makeVatSupervisor = async ({ }: { handleWrite?: (input: unknown) => void | Promise; vatPowers?: Record; - // eslint-disable-next-line @typescript-eslint/explicit-function-return-type }) => { const commandStream = await TestDuplexStream.make< VatCommand, diff --git a/packages/kernel/src/VatHandle.ts b/packages/kernel/src/VatHandle.ts index 8bbeb244b..5b1488632 100644 --- a/packages/kernel/src/VatHandle.ts +++ b/packages/kernel/src/VatHandle.ts @@ -8,7 +8,7 @@ import { makePromiseKit } from '@endo/promise-kit'; import { VatDeletedError, StreamReadError } from '@ocap/errors'; import type { VatStore } from '@ocap/store'; import type { DuplexStream } from '@ocap/streams'; -import type { Logger } from '@ocap/utils'; +import type { Logger, PromiseCallbacks } from '@ocap/utils'; import { makeLogger, makeCounter } from '@ocap/utils'; import type { Kernel } from './Kernel.ts'; @@ -22,7 +22,6 @@ import { kser } from './services/kernel-marshal.ts'; import type { KernelStore } from './store/index.ts'; import { parseRef } from './store/utils/parse-ref.ts'; import type { - PromiseCallbacks, VatId, VatConfig, VRef, diff --git a/packages/kernel/src/index.test.ts b/packages/kernel/src/index.test.ts index e55ac1253..0feabd913 100644 --- a/packages/kernel/src/index.test.ts +++ b/packages/kernel/src/index.test.ts @@ -9,7 +9,6 @@ describe('index', () => { 'Kernel', 'KernelCommandMethod', 'KernelSendVatCommandStruct', - 'MessageResolver', 'VatCommandMethod', 'VatConfigStruct', 'VatHandle', diff --git a/packages/kernel/src/messages/index.ts b/packages/kernel/src/messages/index.ts index 4a43ba022..56ab8d687 100644 --- a/packages/kernel/src/messages/index.ts +++ b/packages/kernel/src/messages/index.ts @@ -28,6 +28,3 @@ export type { VatWorkerServiceCommand, VatWorkerServiceReply, } from './vat-worker-service.ts'; - -// Message resolver. -export { MessageResolver } from './message-resolver.ts'; diff --git a/packages/kernel/src/messages/message-resolver.test.ts b/packages/kernel/src/messages/message-resolver.test.ts deleted file mode 100644 index 9f744ab78..000000000 --- a/packages/kernel/src/messages/message-resolver.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; - -import { MessageResolver } from './message-resolver.ts'; - -describe('MessageResolver', () => { - it('resolves the promise when handleResponse is called', async () => { - const prefix = 'test'; - const resolver = new MessageResolver(prefix); - - const callback = vi.fn(async (messageId: string) => { - // Simulate some asynchronous operation - setTimeout(() => { - resolver.handleResponse(messageId, 'response value'); - }, 10); - }); - - const promise = resolver.createMessage(callback); - const result = await promise; - - expect(result).toBe('response value'); - expect(callback).toHaveBeenCalled(); - }); - - it('logs an error if handleResponse is called with an unknown messageId', () => { - const prefix = 'test'; - const resolver = new MessageResolver(prefix); - - const consoleErrorSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => { - // Do nothing - }); - - resolver.handleResponse('unknown-id', 'value'); - - expect(consoleErrorSpy).toHaveBeenCalledWith( - 'No unresolved message with id "unknown-id".', - ); - - consoleErrorSpy.mockRestore(); - }); - - it('generates unique message IDs', async () => { - const prefix = 'test'; - const resolver = new MessageResolver(prefix); - - const messageIds: string[] = []; - - const callback1 = vi.fn(async (messageId: string) => { - messageIds.push(messageId); - resolver.handleResponse(messageId, 'response1'); - }); - - const callback2 = vi.fn(async (messageId: string) => { - messageIds.push(messageId); - resolver.handleResponse(messageId, 'response2'); - }); - - const promise1 = resolver.createMessage(callback1); - const promise2 = resolver.createMessage(callback2); - - const result1 = await promise1; - const result2 = await promise2; - - expect(result1).toBe('response1'); - expect(result2).toBe('response2'); - - expect(messageIds).toHaveLength(2); - expect(messageIds[0]).toBe(`${prefix}:1`); - expect(messageIds[1]).toBe(`${prefix}:2`); - expect(messageIds[0]).not.toBe(messageIds[1]); - }); - - it('rejects all pending messages with provided error', async () => { - const resolver = new MessageResolver('test'); - const callback1 = vi.fn().mockResolvedValue(undefined); - const callback2 = vi.fn().mockResolvedValue(undefined); - - const promise1 = resolver.createMessage(callback1); - const promise2 = resolver.createMessage(callback2); - - const error = new Error('Termination error'); - resolver.terminateAll(error); - - await expect(promise1).rejects.toThrow(error); - await expect(promise2).rejects.toThrow(error); - }); - - it('clears all unresolved messages after termination', () => { - const resolver = new MessageResolver('test'); - const callback = vi.fn().mockResolvedValue(undefined); - - resolver.createMessage(callback).catch(vi.fn()); - resolver.createMessage(callback).catch(vi.fn()); - - expect(resolver.unresolvedMessages.size).toBe(2); - - resolver.terminateAll(new Error('test')); - - expect(resolver.unresolvedMessages.size).toBe(0); - }); -}); diff --git a/packages/kernel/src/messages/message-resolver.ts b/packages/kernel/src/messages/message-resolver.ts deleted file mode 100644 index f83531c85..000000000 --- a/packages/kernel/src/messages/message-resolver.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { makePromiseKit } from '@endo/promise-kit'; -import { makeCounter } from '@ocap/utils'; - -import type { PromiseCallbacks } from '../types.ts'; - -export class MessageResolver { - readonly #prefix: string; - - readonly unresolvedMessages = new Map(); - - readonly #messageCounter = makeCounter(); - - constructor(prefix: string) { - this.#prefix = prefix; - } - - async createMessage( - sendMessage: (messageId: string) => Promise, - ): Promise { - const { promise, reject, resolve } = makePromiseKit(); - const messageId = this.#nextMessageId(); - - this.unresolvedMessages.set(messageId, { - resolve: resolve as (value: unknown) => void, - reject, - }); - - sendMessage(messageId).catch(console.error); - return promise; - } - - handleResponse(messageId: string, value: unknown): void { - const promiseCallbacks = this.unresolvedMessages.get(messageId); - if (promiseCallbacks === undefined) { - console.error(`No unresolved message with id "${messageId}".`); - } else { - this.unresolvedMessages.delete(messageId); - promiseCallbacks.resolve(value); - } - } - - terminateAll(error: Error): void { - for (const [messageId, promiseCallback] of this.unresolvedMessages) { - promiseCallback?.reject(error); - this.unresolvedMessages.delete(messageId); - } - } - - #nextMessageId(): string { - return `${this.#prefix}:${this.#messageCounter()}`; - } -} diff --git a/packages/kernel/src/types.ts b/packages/kernel/src/types.ts index db21b27d4..e1a6135a6 100644 --- a/packages/kernel/src/types.ts +++ b/packages/kernel/src/types.ts @@ -1,6 +1,5 @@ import type { Message } from '@agoric/swingset-liveslots'; import type { CapData } from '@endo/marshal'; -import type { PromiseKit } from '@endo/promise-kit'; import { define, is, @@ -200,11 +199,6 @@ export const VatMessageIdStruct = define( isVatMessageId, ); -export type PromiseCallbacks = Omit< - PromiseKit, - 'promise' ->; - export type VatWorkerService = { /** * Launch a new worker with a specific vat id. diff --git a/packages/rpc-methods/CHANGELOG.md b/packages/rpc-methods/CHANGELOG.md new file mode 100644 index 000000000..0c82cb1ed --- /dev/null +++ b/packages/rpc-methods/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +[Unreleased]: https://github.com/MetaMask/ocap-kernel/ diff --git a/packages/rpc-methods/README.md b/packages/rpc-methods/README.md new file mode 100644 index 000000000..188b469e8 --- /dev/null +++ b/packages/rpc-methods/README.md @@ -0,0 +1,15 @@ +# `@ocap/rpc-methods` + +Utilities for implementing JSON-RPC methods + +## Installation + +`yarn add @ocap/rpc-methods` + +or + +`npm install @ocap/rpc-methods` + +## 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/rpc-methods/package.json b/packages/rpc-methods/package.json new file mode 100644 index 000000000..6ded786d3 --- /dev/null +++ b/packages/rpc-methods/package.json @@ -0,0 +1,88 @@ +{ + "name": "@ocap/rpc-methods", + "version": "0.0.0", + "private": true, + "description": "Utilities for implementing JSON-RPC methods", + "homepage": "https://github.com/MetaMask/ocap-kernel/tree/main/packages/rpc-methods#readme", + "bugs": { + "url": "https://github.com/MetaMask/ocap-kernel/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/ocap-kernel.git" + }, + "type": "module", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "files": [ + "dist/" + ], + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --clean", + "build:docs": "typedoc", + "changelog:validate": "../../scripts/validate-changelog.sh @ocap/rpc-methods", + "clean": "rimraf --glob './*.tsbuildinfo' ./.eslintcache ./coverage ./dist", + "lint": "yarn lint:eslint && yarn lint:misc --check && yarn constraints && yarn lint:dependencies", + "lint:dependencies": "depcheck", + "lint:eslint": "eslint . --cache", + "lint:fix": "yarn lint:eslint --fix && yarn lint:misc --write && yarn constraints --fix && yarn lint:dependencies", + "lint:misc": "prettier --no-error-on-unmatched-pattern '**/*.json' '**/*.md' '**/*.html' '!**/CHANGELOG.old.md' '**/*.yml' '!.yarnrc.yml' '!merged-packages/**' --ignore-path ../../.gitignore", + "publish:preview": "yarn npm publish --tag preview", + "test": "vitest run --config vitest.config.ts", + "test:clean": "yarn test --no-cache --coverage.clean", + "test:dev": "yarn test --mode development", + "test:verbose": "yarn test --reporter verbose", + "test:watch": "vitest --config vitest.config.ts" + }, + "dependencies": { + "@endo/promise-kit": "^1.1.10", + "@metamask/rpc-errors": "^7.0.2", + "@metamask/superstruct": "^3.2.0", + "@metamask/utils": "^11.4.0", + "@ocap/utils": "workspace:^" + }, + "devDependencies": { + "@arethetypeswrong/cli": "^0.17.4", + "@metamask/auto-changelog": "^5.0.1", + "@metamask/eslint-config": "^14.0.0", + "@metamask/eslint-config-nodejs": "^14.0.0", + "@metamask/eslint-config-typescript": "^14.0.0", + "@ocap/test-utils": "workspace:^", + "@ts-bridge/cli": "^0.6.3", + "@ts-bridge/shims": "^0.1.1", + "@typescript-eslint/eslint-plugin": "^8.29.0", + "@typescript-eslint/parser": "^8.29.0", + "@typescript-eslint/utils": "^8.29.0", + "@vitest/eslint-plugin": "^1.1.39", + "depcheck": "^1.4.7", + "eslint": "^9.23.0", + "eslint-config-prettier": "^10.1.1", + "eslint-import-resolver-typescript": "^4.3.1", + "eslint-plugin-import-x": "^4.10.0", + "eslint-plugin-jsdoc": "^50.6.9", + "eslint-plugin-n": "^17.17.0", + "eslint-plugin-prettier": "^5.2.6", + "eslint-plugin-promise": "^7.2.1", + "prettier": "^3.5.3", + "rimraf": "^6.0.1", + "typedoc": "^0.28.1", + "typescript": "~5.8.2", + "typescript-eslint": "^8.29.0", + "vite": "^6.2.5", + "vitest": "^3.1.1" + }, + "engines": { + "node": "^20 || >=22" + } +} diff --git a/packages/rpc-methods/src/RpcClient.test.ts b/packages/rpc-methods/src/RpcClient.test.ts new file mode 100644 index 000000000..c07f4d6ea --- /dev/null +++ b/packages/rpc-methods/src/RpcClient.test.ts @@ -0,0 +1,92 @@ +import { jsonrpc2 } from '@metamask/utils'; +import { describe, it, vi, expect } from 'vitest'; + +import { RpcClient } from './RpcClient.ts'; +import { getMethods } from '../test/methods.ts'; + +describe('RpcClient', () => { + describe('constructor', () => { + it('should create a new RpcClient', () => { + const client = new RpcClient(getMethods(), vi.fn(), 'test'); + expect(client).toBeInstanceOf(RpcClient); + }); + }); + + describe('call', () => { + it('should call a method', async () => { + const sendMessage = vi.fn(); + const client = new RpcClient(getMethods(), sendMessage, 'test'); + const resultP = client.call('method1', ['test']); + client.handleResponse('test:1', { + jsonrpc: jsonrpc2, + id: 'test:1', + result: null, + }); + + expect(sendMessage).toHaveBeenCalledWith({ + jsonrpc: jsonrpc2, + id: 'test:1', + method: 'method1', + params: ['test'], + }); + expect(await resultP).toBeNull(); + }); + + it('should throw an error for error responses', async () => { + const client = new RpcClient(getMethods(), vi.fn(), 'test'); + const resultP = client.call('method1', ['test']); + client.handleResponse('test:1', { + jsonrpc: jsonrpc2, + id: 'test:1', + error: { + code: -32000, + message: 'test error', + }, + }); + + await expect(resultP).rejects.toThrow('test error'); + }); + + it('should throw an error for invalid results', async () => { + const client = new RpcClient(getMethods(), vi.fn(), 'test'); + const resultP = client.call('method1', ['test']); + client.handleResponse('test:1', { + jsonrpc: jsonrpc2, + id: 'test:1', + result: 42, + }); + await expect(resultP).rejects.toThrow( + 'Invalid result: Expected the literal `null`, but received: 42', + ); + }); + + it('should throw an error for invalid responses', async () => { + const client = new RpcClient(getMethods(), vi.fn(), 'test'); + const resultP = client.call('method1', ['test']); + client.handleResponse('test:1', 'invalid'); + await expect(resultP).rejects.toThrow('Invalid JSON-RPC response:'); + }); + }); + + describe('handleResponse', () => { + it('should log an error if the message id is not found', () => { + const client = new RpcClient(getMethods(), vi.fn(), 'test'); + const logError = vi.spyOn(console, 'error'); + client.handleResponse('test:1', 'test'); + expect(logError).toHaveBeenCalledWith( + 'No unresolved message with id "test:1".', + ); + }); + }); + + describe('rejectAll', () => { + it('should reject all unresolved messages', async () => { + const client = new RpcClient(getMethods(), vi.fn(), 'test'); + const p1 = client.call('method1', ['test']); + const p2 = client.call('method1', ['test']); + client.rejectAll(new Error('test error')); + await expect(p1).rejects.toThrow('test error'); + await expect(p2).rejects.toThrow('test error'); + }); + }); +}); diff --git a/packages/rpc-methods/src/RpcClient.ts b/packages/rpc-methods/src/RpcClient.ts new file mode 100644 index 000000000..7f4aee3b1 --- /dev/null +++ b/packages/rpc-methods/src/RpcClient.ts @@ -0,0 +1,112 @@ +import { makePromiseKit } from '@endo/promise-kit'; +import { assert as assertStruct } from '@metamask/superstruct'; +import { isJsonRpcFailure, isJsonRpcSuccess } from '@metamask/utils'; +import type { JsonRpcRequest, JsonRpcSuccess } from '@metamask/utils'; +import { makeCounter, stringify } from '@ocap/utils'; +import type { PromiseCallbacks } from '@ocap/utils'; + +import type { + MethodSpec, + ExtractParams, + ExtractResult, + ExtractMethod, + MethodSpecRecord, +} from './types.ts'; + +export type SendMessage = (payload: JsonRpcRequest) => Promise; + +export class RpcClient< + // The class picks up its type from the `methods` argument, + // so using `any` in this constraint is safe. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Methods extends MethodSpecRecord>, +> { + readonly #methods: Methods; + + readonly #prefix: string; + + readonly #unresolvedMessages = new Map(); + + readonly #messageCounter = makeCounter(); + + readonly #sendMessage: SendMessage; + + constructor(methods: Methods, sendMessage: SendMessage, prefix: string) { + this.#methods = methods; + this.#sendMessage = sendMessage; + this.#prefix = prefix; + } + + async call>( + method: Method, + params: ExtractParams, + ): Promise> { + const id = this.#nextMessageId(); + const response = await this.#createMessage(id, { + id, + jsonrpc: '2.0', + method, + params, + }); + + this.#assertResult(method, response.result); + return response.result; + } + + #assertResult>( + method: Method, + result: unknown, + ): asserts result is ExtractResult { + try { + // @ts-expect-error - TODO: For unknown reasons, TypeScript fails to recognize that + // `Method` must be a key of `this.#methods`. + assertStruct(result, this.#methods[method].result); + } catch (error) { + throw new Error(`Invalid result: ${(error as Error).message}`); + } + } + + async #createMessage( + messageId: string, + payload: JsonRpcRequest, + ): Promise { + const { promise, reject, resolve } = makePromiseKit(); + + this.#unresolvedMessages.set(messageId, { + resolve: resolve as (value: unknown) => void, + reject, + }); + + await this.#sendMessage(payload); + return promise; + } + + handleResponse(messageId: string, response: unknown): void { + const requestCallbacks = this.#unresolvedMessages.get(messageId); + if (requestCallbacks === undefined) { + console.error(`No unresolved message with id "${messageId}".`); + } else { + this.#unresolvedMessages.delete(messageId); + if (isJsonRpcSuccess(response)) { + requestCallbacks.resolve(response); + } else if (isJsonRpcFailure(response)) { + requestCallbacks.reject(response.error); + } else { + requestCallbacks.reject( + new Error(`Invalid JSON-RPC response: ${stringify(response)}`), + ); + } + } + } + + rejectAll(error: Error): void { + for (const [messageId, promiseCallback] of this.#unresolvedMessages) { + promiseCallback?.reject(error); + this.#unresolvedMessages.delete(messageId); + } + } + + #nextMessageId(): string { + return `${this.#prefix}:${this.#messageCounter()}`; + } +} diff --git a/packages/rpc-methods/src/RpcService.test.ts b/packages/rpc-methods/src/RpcService.test.ts new file mode 100644 index 000000000..a2d929461 --- /dev/null +++ b/packages/rpc-methods/src/RpcService.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from 'vitest'; + +import { RpcService } from './RpcService.ts'; +import { getHandlers, getHooks } from '../test/methods.ts'; + +describe('RpcService', () => { + describe('constructor', () => { + it('should construct an instance', () => { + expect(new RpcService(getHandlers(), getHooks())).toBeInstanceOf( + RpcService, + ); + }); + }); + + describe('assertHasMethod', () => { + it('should not throw if the method is found', () => { + const service = new RpcService(getHandlers(), getHooks()); + expect(() => service.assertHasMethod('method1')).not.toThrow(); + }); + + it('should throw if the method is not found', () => { + const service = new RpcService(getHandlers(), getHooks()); + expect(() => service.assertHasMethod('method3')).toThrow( + 'The method does not exist / is not available.', + ); + }); + }); + + describe('execute', () => { + it('should execute a method', async () => { + const service = new RpcService(getHandlers(), getHooks()); + expect(await service.execute('method1', ['test'])).toBeNull(); + }); + + it('should be able to execute a method that uses a hook', async () => { + const service = new RpcService(getHandlers(), getHooks()); + expect(await service.execute('method2', [2])).toBe(4); + }); + + it('should throw an error if the method is not found', async () => { + const service = new RpcService(getHandlers(), getHooks()); + // @ts-expect-error Intentional destructive testing + await expect(service.execute('method3', [2])).rejects.toThrow( + // This is not a _good_ error, but we only care about type safety in this instance. + "Cannot read properties of undefined (reading 'params')", + ); + }); + + it('should throw if passed invalid params', async () => { + const service = new RpcService(getHandlers(), getHooks()); + await expect(service.execute('method1', [2])).rejects.toThrow( + 'Invalid params', + ); + }); + }); +}); diff --git a/packages/rpc-methods/src/RpcService.ts b/packages/rpc-methods/src/RpcService.ts new file mode 100644 index 000000000..a5cc86916 --- /dev/null +++ b/packages/rpc-methods/src/RpcService.ts @@ -0,0 +1,160 @@ +import { rpcErrors } from '@metamask/rpc-errors'; +import type { Struct } from '@metamask/superstruct'; +import { assert as assertStruct } from '@metamask/superstruct'; +import { hasProperty } from '@metamask/utils'; +import type { Json, JsonRpcParams } from '@metamask/utils'; + +import type { Handler } from './types.ts'; + +type ExtractHooks = + // We only use this type to extract the hooks from the handlers, + // so we can safely use `any` for the generic constraints. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Handlers extends Handler ? Hooks : never; + +type ExtractMethods = + // We only use this type to extract the hooks from the handlers, + // so we can safely use `any` for the generic constraints. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Handlers extends Handler ? Methods : never; + +type HandlerRecord< + Handlers extends Handler< + string, + JsonRpcParams, + Json, + Record + >, +> = Record; + +/** + * A registry for RPC method handlers that provides type-safe registration and execution. + */ +export class RpcService< + // The class picks up its type from the `handlers` argument, + // so using `any` in this constraint is safe. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Handlers extends HandlerRecord>, +> { + readonly #handlers: Handlers; + + readonly #hooks: ExtractHooks; + + /** + * Create a new HandlerRegistry with the specified method handlers. + * + * @param handlers - A record mapping method names to their handler implementations. + * @param hooks - The hooks to pass to the method implementation. + */ + constructor( + handlers: Handlers, + hooks: ExtractHooks, + ) { + this.#handlers = handlers; + this.#hooks = hooks; + } + + /** + * Assert that a method is registered in this registry. + * + * @param method - The method name to check. + * @throws If the method is not registered. + */ + assertHasMethod( + method: string, + ): asserts method is ExtractMethods { + if (!this.#hasMethod(method as ExtractMethods)) { + throw rpcErrors.methodNotFound(); + } + } + + /** + * Execute a method with the provided parameters. Only the hooks specified in the + * handler's `hooks` array will be passed to the implementation. + * + * @param method - The method name to execute. + * @param params - The parameters to pass to the method implementation. + * @returns The result of the method execution. + * @throws If the parameters are invalid. + */ + async execute>( + method: Method, + params: unknown, + ): Promise> { + const handler = this.#getHandler(method); + assertParams(params, handler.params); + + // Select only the hooks that the handler needs + const selectedHooks = selectHooks(this.#hooks, handler.hooks); + + // Execute the handler with the selected hooks + return await handler.implementation(selectedHooks, params); + } + + /** + * Check if a method is registered in this registry. + * + * @param method - The method name to check. + * @returns Whether the method is registered. + */ + #hasMethod>( + method: Method, + ): boolean { + return hasProperty(this.#handlers, method); + } + + /** + * Get a handler for a specific method. + * + * @param method - The method name to get the handler for. + * @returns The handler for the specified method. + * @throws If the method is not registered. + */ + #getHandler>( + method: Method, + ): Handlers[Method] { + return this.#handlers[method]; + } +} + +/** + * @param params - The parameters to assert. + * @param struct - The struct to assert the parameters against. + * @throws If the parameters are invalid. + */ +function assertParams( + params: unknown, + struct: Struct, +): asserts params is Params { + try { + assertStruct(params, struct); + } catch (error) { + throw new Error(`Invalid params: ${(error as Error).message}`); + } +} + +/** + * Returns the subset of the specified `hooks` that are included in the + * `hookNames` array. This is a Principle of Least Authority (POLA) measure + * to ensure that each RPC method implementation only has access to the + * API "hooks" it needs to do its job. + * + * @param hooks - The hooks to select from. + * @param hookNames - The names of the hooks to select. + * @returns The selected hooks. + * @template Hooks - The hooks to select from. + * @template HookName - The names of the hooks to select. + */ +function selectHooks< + Hooks extends Record, + HookName extends keyof Hooks, +>(hooks: Hooks, hookNames: { [Key in HookName]: true }): Pick { + return Object.keys(hookNames).reduce>>( + (hookSubset, hookName) => { + const key = hookName as HookName; + hookSubset[key] = hooks[key]; + return hookSubset; + }, + {}, + ) as Pick; +} diff --git a/packages/rpc-methods/src/index.test.ts b/packages/rpc-methods/src/index.test.ts new file mode 100644 index 000000000..f4e887299 --- /dev/null +++ b/packages/rpc-methods/src/index.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest'; + +import * as indexModule from './index.ts'; + +describe('index', () => { + it('has the expected exports', () => { + expect(Object.keys(indexModule).sort()).toStrictEqual([ + 'RpcClient', + 'RpcService', + ]); + }); +}); diff --git a/packages/rpc-methods/src/index.ts b/packages/rpc-methods/src/index.ts new file mode 100644 index 000000000..ccf017417 --- /dev/null +++ b/packages/rpc-methods/src/index.ts @@ -0,0 +1,3 @@ +export * from './RpcClient.ts'; +export * from './RpcService.ts'; +export type * from './types.ts'; diff --git a/packages/rpc-methods/src/types.ts b/packages/rpc-methods/src/types.ts new file mode 100644 index 000000000..83418e24b --- /dev/null +++ b/packages/rpc-methods/src/types.ts @@ -0,0 +1,74 @@ +import type { Infer, Struct } from '@metamask/superstruct'; +import type { JsonRpcParams, Json } from '@metamask/utils'; + +// Client-side types + +export type MethodSignature< + Method extends string, + Params extends JsonRpcParams, + Result extends Json, +> = (method: Method, params: Params) => Promise; + +export type MethodSpec< + Method extends string, + Params extends JsonRpcParams, + Result extends Json, +> = { + method: Method; + params: Struct; + result: Struct; +}; + +// `any` can safely be used in constraints. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type SpecConstraint = MethodSpec; + +export type MethodSpecRecord = Record< + Methods['method'], + Methods +>; + +type SpecRecordConstraint = MethodSpecRecord; + +export type ExtractMethodSignature = Spec extends ( + method: infer Method extends string, + params: infer Params extends JsonRpcParams, +) => Promise + ? MethodSignature + : never; + +export type ExtractMethodSpec< + Specs extends SpecRecordConstraint, + Key extends keyof Specs = keyof Specs, +> = Specs[Key]; + +export type ExtractMethod = + ExtractMethodSpec['method']; + +export type ExtractParams< + Method extends string, + Specs extends SpecRecordConstraint, +> = Infer['params']>; + +export type ExtractResult< + Method extends string, + Specs extends SpecRecordConstraint, +> = Infer['result']>; + +export type HandlerFunction< + Params extends JsonRpcParams, + Result extends Json, + Hooks extends Record, +> = (hooks: Hooks, params: Params) => Promise; + +// Service-side types + +export type Handler< + Method extends string, + Params extends JsonRpcParams, + Result extends Json, + Hooks extends Record, +> = MethodSpec & { + hooks: { [Key in keyof Hooks]: true }; + implementation: HandlerFunction; +}; diff --git a/packages/rpc-methods/test/methods.ts b/packages/rpc-methods/test/methods.ts new file mode 100644 index 000000000..7c684ce66 --- /dev/null +++ b/packages/rpc-methods/test/methods.ts @@ -0,0 +1,52 @@ +import { literal, number, string, tuple } from '@metamask/superstruct'; + +import type { Handler, MethodSpec } from '../src/types.ts'; + +export const getHooks = () => + ({ + hook1: () => undefined, + hook2: () => undefined, + hook3: () => undefined, + }) as const; + +export type Hooks = ReturnType; + +export const getMethods = () => + ({ + method1: { + method: 'method1', + params: tuple([string()]), + result: literal(null), + } as MethodSpec<'method1', [string], null>, + method2: { + method: 'method2', + params: tuple([number()]), + result: number(), + } as MethodSpec<'method2', [number], number>, + }) as const; + +export const getHandlers = () => { + const methods = getMethods(); + return { + method1: { + ...methods.method1, + hooks: { hook1: true, hook2: true } as const, + implementation: async (hooks, [_value]) => { + hooks.hook1(); + return null; + }, + } as Handler<'method1', [string], null, Pick>, + method2: { + ...methods.method2, + hooks: { hook3: true } as const, + implementation: async (hooks, [value]) => { + hooks.hook3(); + return value * 2; + }, + } as Handler<'method2', [number], number, Pick>, + }; +}; + +type MethodNames = keyof ReturnType; + +export type Methods = ReturnType[MethodNames]; diff --git a/packages/rpc-methods/test/setup.ts b/packages/rpc-methods/test/setup.ts new file mode 100644 index 000000000..a84e5f7ef --- /dev/null +++ b/packages/rpc-methods/test/setup.ts @@ -0,0 +1 @@ +import '@ocap/test-utils/mock-endoify'; diff --git a/packages/rpc-methods/tsconfig.build.json b/packages/rpc-methods/tsconfig.build.json new file mode 100644 index 000000000..846b1db52 --- /dev/null +++ b/packages/rpc-methods/tsconfig.build.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "types": [] + }, + "references": [{ "path": "../utils/tsconfig.build.json" }], + "files": [], + "include": ["./src"] +} diff --git a/packages/rpc-methods/tsconfig.json b/packages/rpc-methods/tsconfig.json new file mode 100644 index 000000000..5b3a91664 --- /dev/null +++ b/packages/rpc-methods/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./", + "lib": ["ES2022"], + "types": ["vitest"] + }, + "references": [{ "path": "../test-utils" }, { "path": "../utils" }], + "include": [ + "../../vitest.config.ts", + "./src", + "./test", + "./vite.config.ts", + "./vitest.config.ts" + ] +} diff --git a/packages/rpc-methods/typedoc.json b/packages/rpc-methods/typedoc.json new file mode 100644 index 000000000..f8eb78ae1 --- /dev/null +++ b/packages/rpc-methods/typedoc.json @@ -0,0 +1,8 @@ +{ + "entryPoints": [], + "excludePrivate": true, + "hideGenerator": true, + "out": "docs", + "tsconfig": "./tsconfig.build.json", + "projectDocuments": ["documents/*.md"] +} diff --git a/packages/rpc-methods/vitest.config.ts b/packages/rpc-methods/vitest.config.ts new file mode 100644 index 000000000..859e7cd5e --- /dev/null +++ b/packages/rpc-methods/vitest.config.ts @@ -0,0 +1,17 @@ +import { mergeConfig } from '@ocap/test-utils/vitest-config'; +import { defineConfig, defineProject } from 'vitest/config'; + +import defaultConfig from '../../vitest.config.ts'; + +export default defineConfig((args) => { + return mergeConfig( + args, + defaultConfig, + defineProject({ + test: { + name: 'rpc-methods', + setupFiles: ['./test/setup.ts'], + }, + }), + ); +}); diff --git a/packages/streams/src/browser/ChromeRuntimeStream.test.ts b/packages/streams/src/browser/ChromeRuntimeStream.test.ts index 09a39b0ec..9ffbd7a25 100644 --- a/packages/streams/src/browser/ChromeRuntimeStream.test.ts +++ b/packages/streams/src/browser/ChromeRuntimeStream.test.ts @@ -31,8 +31,6 @@ const makeEnvelope = ( const EXTENSION_ID = 'test-extension-id'; -// This function declares its own return type. -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type const makeRuntime = (extensionId: string = EXTENSION_ID) => { const listeners: ((...args: unknown[]) => void)[] = []; const dispatchRuntimeMessage = ( @@ -304,7 +302,6 @@ describe.concurrent('ChromeRuntimeWriter', () => { }); describe.concurrent('ChromeRuntimeDuplexStream', () => { - // eslint-disable-next-line @typescript-eslint/explicit-function-return-type const makeDuplexStream = async (validateInput?: ValidateInput) => { const { runtime, dispatchRuntimeMessage } = makeRuntime(); const duplexStreamP = ChromeRuntimeDuplexStream.make( diff --git a/packages/streams/src/browser/PostMessageStream.test.ts b/packages/streams/src/browser/PostMessageStream.test.ts index 69319d3ec..6ef82605c 100644 --- a/packages/streams/src/browser/PostMessageStream.test.ts +++ b/packages/streams/src/browser/PostMessageStream.test.ts @@ -17,7 +17,6 @@ import { makeStreamErrorSignal, } from '../utils.ts'; -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type const makeMockMessageTarget = () => { const listeners: ((payload: unknown) => void)[] = []; const postMessage = vi.fn((message: unknown, _transfer?: Transferable[]) => { @@ -208,7 +207,6 @@ describe('PostMessageDuplexStream', () => { postRemoteMessage?: PostMessage; validateInput?: ValidateInput; onEnd?: () => Promise; - // eslint-disable-next-line @typescript-eslint/explicit-function-return-type } = {}) => { const postLocalMessage = messageTarget.postMessage; // @ts-expect-error In reality you have to be explicit about `messageEventMode` diff --git a/tsconfig.build.json b/tsconfig.build.json index a8a341561..0b2f2fc1d 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -7,6 +7,7 @@ { "path": "./packages/kernel-test/tsconfig.build.json" }, { "path": "./packages/kernel/tsconfig.build.json" }, { "path": "./packages/nodejs/tsconfig.build.json" }, + { "path": "./packages/rpc-methods/tsconfig.build.json" }, { "path": "./packages/store/tsconfig.build.json" }, { "path": "./packages/streams/tsconfig.build.json" }, { "path": "./packages/utils/tsconfig.build.json" } diff --git a/tsconfig.json b/tsconfig.json index f9d8802b8..e47d180f5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -20,6 +20,7 @@ { "path": "./packages/kernel" }, { "path": "./packages/kernel-test" }, { "path": "./packages/nodejs" }, + { "path": "./packages/rpc-methods" }, { "path": "./packages/shims" }, { "path": "./packages/store" }, { "path": "./packages/streams" }, diff --git a/vitest.config.ts b/vitest.config.ts index 6a186a730..578d57855 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -82,16 +82,16 @@ export default defineConfig({ lines: 100, }, 'packages/extension/**': { - statements: 81.02, - functions: 83.85, - branches: 76.76, - lines: 81.03, + statements: 80.33, + functions: 82.44, + branches: 77.2, + lines: 80.33, }, 'packages/kernel/**': { - statements: 86.59, - functions: 92.88, - branches: 71.29, - lines: 86.56, + statements: 86.41, + functions: 92.76, + branches: 71.18, + lines: 86.38, }, 'packages/nodejs/**': { statements: 72.91, @@ -99,6 +99,12 @@ export default defineConfig({ branches: 63.63, lines: 74.46, }, + 'packages/rpc-methods/**': { + statements: 100, + functions: 100, + branches: 100, + lines: 100, + }, 'packages/shims/**': { statements: 0, functions: 0, diff --git a/yarn.lock b/yarn.lock index 99136a271..829a533b6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -997,14 +997,14 @@ __metadata: languageName: node linkType: hard -"@eslint/config-array@npm:^0.19.2": - version: 0.19.2 - resolution: "@eslint/config-array@npm:0.19.2" +"@eslint/config-array@npm:^0.20.0": + version: 0.20.0 + resolution: "@eslint/config-array@npm:0.20.0" dependencies: "@eslint/object-schema": "npm:^2.1.6" debug: "npm:^4.3.1" minimatch: "npm:^3.1.2" - checksum: 10/a6809720908f7dd8536e1a73b2369adf802fe61335536ed0592bca9543c476956e0c0a20fef8001885da8026e2445dc9bf3e471bb80d32c3be7bcdabb7628fd1 + checksum: 10/9db7f6cbb5363f2f98ee4805ce09d1a95c4349e86f3f456f2c23a0849b7a6aa8d2be4c25e376ee182af062762e15a101844881c89b566eea0856c481ffcb2090 languageName: node linkType: hard @@ -1041,10 +1041,10 @@ __metadata: languageName: node linkType: hard -"@eslint/js@npm:9.23.0, @eslint/js@npm:^9.11.0": - version: 9.23.0 - resolution: "@eslint/js@npm:9.23.0" - checksum: 10/d1d38fa2c4234f6ebed8e202530c9dccf565c47283f4e3c53955a47fed2bf8c59988f535672a32b53c14fed72e456c1c5cb050cd98a45474086b9693cbfa97d6 +"@eslint/js@npm:9.24.0, @eslint/js@npm:^9.11.0": + version: 9.24.0 + resolution: "@eslint/js@npm:9.24.0" + checksum: 10/d210114c147a1c1ebfaed5f32734e7c1f8ef551a5ea48ea67f9469668aa4079565ccd038412437bca87515d51dc9e8b8c788473dcf3d08e35dfb27e92cb3ce1b languageName: node linkType: hard @@ -1952,6 +1952,7 @@ __metadata: "@ocap/cli": "workspace:^" "@ocap/errors": "workspace:^" "@ocap/kernel": "workspace:^" + "@ocap/rpc-methods": "workspace:^" "@ocap/shims": "workspace:^" "@ocap/store": "workspace:^" "@ocap/streams": "workspace:^" @@ -2195,6 +2196,46 @@ __metadata: languageName: unknown linkType: soft +"@ocap/rpc-methods@workspace:^, @ocap/rpc-methods@workspace:packages/rpc-methods": + version: 0.0.0-use.local + resolution: "@ocap/rpc-methods@workspace:packages/rpc-methods" + dependencies: + "@arethetypeswrong/cli": "npm:^0.17.4" + "@endo/promise-kit": "npm:^1.1.10" + "@metamask/auto-changelog": "npm:^5.0.1" + "@metamask/eslint-config": "npm:^14.0.0" + "@metamask/eslint-config-nodejs": "npm:^14.0.0" + "@metamask/eslint-config-typescript": "npm:^14.0.0" + "@metamask/rpc-errors": "npm:^7.0.2" + "@metamask/superstruct": "npm:^3.2.0" + "@metamask/utils": "npm:^11.4.0" + "@ocap/test-utils": "workspace:^" + "@ocap/utils": "workspace:^" + "@ts-bridge/cli": "npm:^0.6.3" + "@ts-bridge/shims": "npm:^0.1.1" + "@typescript-eslint/eslint-plugin": "npm:^8.29.0" + "@typescript-eslint/parser": "npm:^8.29.0" + "@typescript-eslint/utils": "npm:^8.29.0" + "@vitest/eslint-plugin": "npm:^1.1.39" + depcheck: "npm:^1.4.7" + eslint: "npm:^9.23.0" + eslint-config-prettier: "npm:^10.1.1" + eslint-import-resolver-typescript: "npm:^4.3.1" + eslint-plugin-import-x: "npm:^4.10.0" + eslint-plugin-jsdoc: "npm:^50.6.9" + eslint-plugin-n: "npm:^17.17.0" + eslint-plugin-prettier: "npm:^5.2.6" + eslint-plugin-promise: "npm:^7.2.1" + prettier: "npm:^3.5.3" + rimraf: "npm:^6.0.1" + typedoc: "npm:^0.28.1" + typescript: "npm:~5.8.2" + typescript-eslint: "npm:^8.29.0" + vite: "npm:^6.2.5" + vitest: "npm:^3.1.1" + languageName: unknown + linkType: soft + "@ocap/shims@workspace:^, @ocap/shims@workspace:packages/shims": version: 0.0.0-use.local resolution: "@ocap/shims@workspace:packages/shims" @@ -5598,16 +5639,16 @@ __metadata: linkType: hard "eslint@npm:^9.23.0": - version: 9.23.0 - resolution: "eslint@npm:9.23.0" + version: 9.24.0 + resolution: "eslint@npm:9.24.0" dependencies: "@eslint-community/eslint-utils": "npm:^4.2.0" "@eslint-community/regexpp": "npm:^4.12.1" - "@eslint/config-array": "npm:^0.19.2" + "@eslint/config-array": "npm:^0.20.0" "@eslint/config-helpers": "npm:^0.2.0" "@eslint/core": "npm:^0.12.0" "@eslint/eslintrc": "npm:^3.3.1" - "@eslint/js": "npm:9.23.0" + "@eslint/js": "npm:9.24.0" "@eslint/plugin-kit": "npm:^0.2.7" "@humanfs/node": "npm:^0.16.6" "@humanwhocodes/module-importer": "npm:^1.0.1" @@ -5643,7 +5684,7 @@ __metadata: optional: true bin: eslint: bin/eslint.js - checksum: 10/fed63151adea5e4c732bc945dd8d30e6b670d0191b8aa4baff13a0826e29153499f7a59cb88a5a634f31d61c2bea2339ca4b9ff5976e9a61b2222abfb7431e4d + checksum: 10/05810e135c1f429be451a4be92283c0be204010bb0ea71edfeae1d25ff917cbc5a229144ee55853a085088c7e4092e59a28c0dae87a865ef9600ad4438861d4a languageName: node linkType: hard