From e9c31dba663288248135fb1608d76032ebe2a20c Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Fri, 13 Feb 2026 14:35:09 +0100 Subject: [PATCH 01/11] feat(ocap-kernel): add QUIC direct transport support for Node.js connections Add direct QUIC (UDP) transport for peer-to-peer Node.js kernel communication without requiring a relay server. Users declare `directListenAddresses` in `initRemoteComms` options and the platform layer auto-detects and injects the `@chainsafe/libp2p-quic` transport. Key changes: - ConnectionFactory: options bag API, direct transport merging, conditional bootstrap (allows relay-free operation), getListenAddresses() - Kernel: expose listenAddresses in status, add registerLocationHints() - NodejsPlatformServices: auto-detect QUIC from address strings, dynamic import of @chainsafe/libp2p-quic - E2E tests proving direct QUIC works without any relay Closes #645 Co-Authored-By: Claude Opus 4.6 --- .gitignore | 2 +- .../src/PlatformServicesClient.ts | 11 + packages/nodejs/package.json | 1 + .../src/kernel/PlatformServices.test.ts | 97 +++++++ .../nodejs/src/kernel/PlatformServices.ts | 43 ++- .../nodejs/test/e2e/quic-transport.test.ts | 267 ++++++++++++++++++ packages/ocap-kernel/src/Kernel.ts | 12 + .../platform/connection-factory.test.ts | 193 +++++++++++-- .../remotes/platform/connection-factory.ts | 132 ++++++--- .../src/remotes/platform/transport.test.ts | 48 +++- .../src/remotes/platform/transport.ts | 10 +- packages/ocap-kernel/src/remotes/types.ts | 37 +++ packages/ocap-kernel/src/types.ts | 9 + yarn.lock | 102 ++++++- 14 files changed, 870 insertions(+), 94 deletions(-) create mode 100644 packages/nodejs/test/e2e/quic-transport.test.ts diff --git a/.gitignore b/.gitignore index 6171d4a15..98db10d42 100644 --- a/.gitignore +++ b/.gitignore @@ -91,5 +91,5 @@ test-results .turbo # Claude -.claude/settings.local.json +**/.claude/settings.local.json .playwright-mcp/ \ No newline at end of file diff --git a/packages/kernel-browser-runtime/src/PlatformServicesClient.ts b/packages/kernel-browser-runtime/src/PlatformServicesClient.ts index fc1e8f8bf..ec0037d7e 100644 --- a/packages/kernel-browser-runtime/src/PlatformServicesClient.ts +++ b/packages/kernel-browser-runtime/src/PlatformServicesClient.ts @@ -290,6 +290,17 @@ export class PlatformServicesClient implements PlatformServices { await this.#rpcClient.call('resetAllBackoffs', []); } + /** + * Get the actual listen addresses of the libp2p node. + * In the browser runtime, this always returns an empty array since + * direct transport (QUIC) is only supported in Node.js. + * + * @returns An empty array. + */ + getListenAddresses(): string[] { + return []; + } + /** * Handle a remote message from a peer. * diff --git a/packages/nodejs/package.json b/packages/nodejs/package.json index db394666d..64e85b4a8 100644 --- a/packages/nodejs/package.json +++ b/packages/nodejs/package.json @@ -49,6 +49,7 @@ "test:dev:quiet": "yarn test:dev --reporter @ocap/repo-tools/vitest-reporters/silent" }, "dependencies": { + "@chainsafe/libp2p-quic": "^1.1.8", "@endo/eventual-send": "^1.3.4", "@endo/promise-kit": "^1.1.13", "@libp2p/interface": "2.11.0", diff --git a/packages/nodejs/src/kernel/PlatformServices.test.ts b/packages/nodejs/src/kernel/PlatformServices.test.ts index a1cc433c1..8371b66a0 100644 --- a/packages/nodejs/src/kernel/PlatformServices.test.ts +++ b/packages/nodejs/src/kernel/PlatformServices.test.ts @@ -73,6 +73,10 @@ vi.mock('node:worker_threads', () => ({ }), })); +vi.mock('@chainsafe/libp2p-quic', () => ({ + quic: () => ({ tag: 'mock-quic-transport' }), +})); + vi.mock('@metamask/ocap-kernel', async (importOriginal) => { const actual = await importOriginal(); return { @@ -83,6 +87,10 @@ vi.mock('@metamask/ocap-kernel', async (importOriginal) => { closeConnection: mockCloseConnection, registerLocationHints: mockRegisterLocationHints, reconnectPeer: mockReconnectPeer, + resetAllBackoffs: vi.fn(), + getListenAddresses: vi.fn(() => [ + '/ip4/127.0.0.1/udp/12345/quic-v1/p2p/mock-peer-id', + ]), })), }; }); @@ -385,6 +393,95 @@ describe('NodejsPlatformServices', () => { // This is tested through integration tests expect(service).toBeInstanceOf(NodejsPlatformServices); }); + + it('injects QUIC directTransport when directListenAddresses contain /quic-v1', async () => { + const service = new NodejsPlatformServices({ workerFilePath }); + const keySeed = '0x1234567890abcdef'; + const relays = ['/dns4/relay.example/tcp/443/wss/p2p/relayPeer']; + const remoteHandler = vi.fn(async () => 'response'); + + await service.initializeRemoteComms( + keySeed, + { + relays, + directListenAddresses: ['/ip4/0.0.0.0/udp/0/quic-v1'], + }, + remoteHandler, + ); + + const { initTransport } = await import('@metamask/ocap-kernel'); + expect(initTransport).toHaveBeenCalledWith( + keySeed, + expect.objectContaining({ + relays, + directTransport: { + transport: expect.any(Object), + listenAddresses: ['/ip4/0.0.0.0/udp/0/quic-v1'], + }, + }), + expect.any(Function), + undefined, + undefined, + undefined, + ); + }); + + it('does not inject directTransport when no directListenAddresses provided', async () => { + const service = new NodejsPlatformServices({ workerFilePath }); + const keySeed = '0x1234567890abcdef'; + const relays = ['/dns4/relay.example/tcp/443/wss/p2p/relayPeer']; + const remoteHandler = vi.fn(async () => 'response'); + + await service.initializeRemoteComms(keySeed, { relays }, remoteHandler); + + const { initTransport } = await import('@metamask/ocap-kernel'); + const callArgs = ( + initTransport as unknown as ReturnType + ).mock.calls.at(-1); + // Second argument is the options + expect(callArgs?.[1]).not.toHaveProperty('directTransport'); + }); + + it('throws error for direct TCP listen addresses', async () => { + const service = new NodejsPlatformServices({ workerFilePath }); + const remoteHandler = vi.fn(async () => 'response'); + + await expect( + service.initializeRemoteComms( + '0xtest', + { + relays: [], + directListenAddresses: ['/ip4/0.0.0.0/tcp/4001'], + }, + remoteHandler, + ), + ).rejects.toThrowError( + 'Direct TCP listen addresses are not yet supported', + ); + }); + }); + + describe('getListenAddresses', () => { + it('returns listen addresses after initialization', async () => { + const service = new NodejsPlatformServices({ workerFilePath }); + const remoteHandler = vi.fn(async () => ''); + + await service.initializeRemoteComms( + '0xabcd', + { relays: [] }, + remoteHandler, + ); + + const addresses = service.getListenAddresses(); + expect(addresses).toStrictEqual([ + '/ip4/127.0.0.1/udp/12345/quic-v1/p2p/mock-peer-id', + ]); + }); + + it('returns empty array before initialization', () => { + const service = new NodejsPlatformServices({ workerFilePath }); + expect(service.getListenAddresses()).toStrictEqual([]); + }); }); describe('sendRemoteMessage', () => { diff --git a/packages/nodejs/src/kernel/PlatformServices.ts b/packages/nodejs/src/kernel/PlatformServices.ts index efe9e8e38..fdb2058d2 100644 --- a/packages/nodejs/src/kernel/PlatformServices.ts +++ b/packages/nodejs/src/kernel/PlatformServices.ts @@ -47,6 +47,8 @@ export class NodejsPlatformServices implements PlatformServices { #resetAllBackoffsFunc: (() => void) | null = null; + #getListenAddressesFunc: (() => string[]) | null = null; + #remoteMessageHandler: RemoteMessageHandler | undefined = undefined; readonly #workerFilePath: string; @@ -253,6 +255,29 @@ export class NodejsPlatformServices implements PlatformServices { throw Error('remote comms already initialized'); } this.#remoteMessageHandler = remoteMessageHandler; + + const { directListenAddresses, ...restOptions } = options; + + if (directListenAddresses?.some((addr) => addr.includes('/tcp/'))) { + throw new Error('Direct TCP listen addresses are not yet supported'); + } + + const hasQuic = directListenAddresses?.some((addr) => + addr.includes('/quic-v1'), + ); + + let enhancedOptions: RemoteCommsOptions = restOptions; + if (hasQuic && directListenAddresses) { + const { quic } = await import('@chainsafe/libp2p-quic'); + enhancedOptions = { + ...restOptions, + directTransport: { + transport: quic(), + listenAddresses: directListenAddresses, + }, + }; + } + const { sendRemoteMessage, stop, @@ -260,9 +285,10 @@ export class NodejsPlatformServices implements PlatformServices { registerLocationHints, reconnectPeer, resetAllBackoffs, + getListenAddresses, } = await initTransport( keySeed, - options, + enhancedOptions, this.#handleRemoteMessage.bind(this), onRemoteGiveUp, incarnationId, @@ -274,6 +300,7 @@ export class NodejsPlatformServices implements PlatformServices { this.#registerLocationHintsFunc = registerLocationHints; this.#reconnectPeerFunc = reconnectPeer; this.#resetAllBackoffsFunc = resetAllBackoffs; + this.#getListenAddressesFunc = getListenAddresses; } /** @@ -293,6 +320,7 @@ export class NodejsPlatformServices implements PlatformServices { this.#registerLocationHintsFunc = null; this.#reconnectPeerFunc = null; this.#resetAllBackoffsFunc = null; + this.#getListenAddressesFunc = null; } /** @@ -347,5 +375,18 @@ export class NodejsPlatformServices implements PlatformServices { } this.#resetAllBackoffsFunc(); } + + /** + * Get the actual listen addresses of the libp2p node. + * Returns multiaddr strings that other peers can use to dial this node directly. + * + * @returns The listen address strings, or empty array if remote comms not initialized. + */ + getListenAddresses(): string[] { + if (!this.#getListenAddressesFunc) { + return []; + } + return this.#getListenAddressesFunc(); + } } harden(NodejsPlatformServices); diff --git a/packages/nodejs/test/e2e/quic-transport.test.ts b/packages/nodejs/test/e2e/quic-transport.test.ts new file mode 100644 index 000000000..53a32beaa --- /dev/null +++ b/packages/nodejs/test/e2e/quic-transport.test.ts @@ -0,0 +1,267 @@ +import { makeSQLKernelDatabase } from '@metamask/kernel-store/sqlite/nodejs'; +import { Kernel, makeKernelStore } from '@metamask/ocap-kernel'; +import { delay } from '@ocap/repo-tools/test-utils'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; + +import { makeTestKernel } from '../helpers/kernel.ts'; +import { + getVatRootRef, + launchVatAndGetURL, + makeRemoteVatConfig, + sendRemoteMessage, +} from '../helpers/remote-comms.ts'; + +// Increase timeout for network operations +const NETWORK_TIMEOUT = 30_000; + +/** + * Stop an operation with a timeout to prevent hangs during cleanup. + * + * @param stopFn - The stop function to call. + * @param timeoutMs - The timeout in milliseconds. + * @param label - A label for logging. + */ +async function stopWithTimeout( + stopFn: () => Promise, + timeoutMs: number, + label: string, +): Promise { + try { + await Promise.race([ + stopFn(), + new Promise((_resolve, reject) => + setTimeout(() => reject(new Error(`${label} timed out`)), timeoutMs), + ), + ]); + } catch { + // Ignore timeout errors during cleanup + } +} + +// QUIC listen addresses for each kernel (port 0 = OS-assigned) +const quicListenAddress = '/ip4/127.0.0.1/udp/0/quic-v1'; + +/** + * Get the connected remote comms info from a kernel's status. + * + * @param kernel - The kernel to get info from. + * @returns The peer ID and listen addresses. + */ +async function getConnectedInfo(kernel: Kernel): Promise<{ + peerId: string; + listenAddresses: string[]; + quicAddresses: string[]; +}> { + const status = await kernel.getStatus(); + if (status.remoteComms?.state !== 'connected') { + throw new Error('Remote comms not connected'); + } + const { peerId, listenAddresses } = status.remoteComms; + return { + peerId, + listenAddresses, + quicAddresses: listenAddresses.filter((addr) => addr.includes('/quic-v1/')), + }; +} + +describe.sequential('QUIC Transport E2E', () => { + let kernel1: Kernel; + let kernel2: Kernel; + let kernelDatabase1: Awaited>; + let kernelDatabase2: Awaited>; + let kernelStore1: ReturnType; + let kernelStore2: ReturnType; + + beforeEach(async () => { + kernelDatabase1 = await makeSQLKernelDatabase({ dbFilename: ':memory:' }); + kernelStore1 = makeKernelStore(kernelDatabase1); + + kernelDatabase2 = await makeSQLKernelDatabase({ dbFilename: ':memory:' }); + kernelStore2 = makeKernelStore(kernelDatabase2); + + kernel1 = await makeTestKernel(kernelDatabase1); + kernel2 = await makeTestKernel(kernelDatabase2); + }); + + afterEach(async () => { + const STOP_TIMEOUT = 3000; + await Promise.all([ + kernel1 && + stopWithTimeout( + async () => kernel1.stop(), + STOP_TIMEOUT, + 'kernel1.stop', + ), + kernel2 && + stopWithTimeout( + async () => kernel2.stop(), + STOP_TIMEOUT, + 'kernel2.stop', + ), + ]); + if (kernelDatabase1) { + kernelDatabase1.close(); + } + if (kernelDatabase2) { + kernelDatabase2.close(); + } + await delay(200); + }); + + describe('Initialization', () => { + it( + 'initializes remote comms with QUIC transport without a relay', + async () => { + await kernel1.initRemoteComms({ + directListenAddresses: [quicListenAddress], + }); + await kernel2.initRemoteComms({ + directListenAddresses: [quicListenAddress], + }); + + const info1 = await getConnectedInfo(kernel1); + const info2 = await getConnectedInfo(kernel2); + + // Each kernel should have QUIC listen addresses + expect(info1.quicAddresses.length).toBeGreaterThan(0); + expect(info2.quicAddresses.length).toBeGreaterThan(0); + + // Peer IDs should be distinct + expect(info1.peerId).not.toBe(info2.peerId); + }, + NETWORK_TIMEOUT, + ); + + it( + 'rejects direct TCP listen addresses', + async () => { + await expect( + kernel1.initRemoteComms({ + directListenAddresses: ['/ip4/0.0.0.0/tcp/4001'], + }), + ).rejects.toThrow('Direct TCP listen addresses are not yet supported'); + }, + NETWORK_TIMEOUT, + ); + }); + + describe('Direct Connectivity', () => { + it( + 'sends a message via direct QUIC', + async () => { + // Initialize both kernels with QUIC only — no relays + await kernel1.initRemoteComms({ + directListenAddresses: [quicListenAddress], + }); + await kernel2.initRemoteComms({ + directListenAddresses: [quicListenAddress], + }); + + // Get kernel2's QUIC addresses and register them on kernel1 + const info2 = await getConnectedInfo(kernel2); + await kernel1.registerLocationHints(info2.peerId, info2.quicAddresses); + + // Launch vats + const aliceConfig = makeRemoteVatConfig('Alice'); + const bobConfig = makeRemoteVatConfig('Bob'); + await launchVatAndGetURL(kernel1, aliceConfig); + const bobURL = await launchVatAndGetURL(kernel2, bobConfig); + + const aliceRef = getVatRootRef(kernel1, kernelStore1, 'Alice'); + + const response = await sendRemoteMessage( + kernel1, + aliceRef, + bobURL, + 'hello', + ['Alice'], + ); + expect(response).toContain('vat Bob got "hello" from Alice'); + }, + NETWORK_TIMEOUT, + ); + + it( + 'establishes bidirectional QUIC communication', + async () => { + await kernel1.initRemoteComms({ + directListenAddresses: [quicListenAddress], + }); + await kernel2.initRemoteComms({ + directListenAddresses: [quicListenAddress], + }); + + // Exchange QUIC addresses + const info1 = await getConnectedInfo(kernel1); + const info2 = await getConnectedInfo(kernel2); + + await kernel1.registerLocationHints(info2.peerId, info2.quicAddresses); + await kernel2.registerLocationHints(info1.peerId, info1.quicAddresses); + + // Launch vats + const aliceConfig = makeRemoteVatConfig('Alice'); + const bobConfig = makeRemoteVatConfig('Bob'); + const aliceURL = await launchVatAndGetURL(kernel1, aliceConfig); + const bobURL = await launchVatAndGetURL(kernel2, bobConfig); + + const aliceRef = getVatRootRef(kernel1, kernelStore1, 'Alice'); + const bobRef = getVatRootRef(kernel2, kernelStore2, 'Bob'); + + // Alice → Bob + const aliceToBob = await sendRemoteMessage( + kernel1, + aliceRef, + bobURL, + 'hello', + ['Alice'], + ); + expect(aliceToBob).toContain('vat Bob got "hello" from Alice'); + + // Bob → Alice + const bobToAlice = await sendRemoteMessage( + kernel2, + bobRef, + aliceURL, + 'hello', + ['Bob'], + ); + expect(bobToAlice).toContain('vat Alice got "hello" from Bob'); + }, + NETWORK_TIMEOUT, + ); + + it( + 'sends multiple sequential messages via QUIC', + async () => { + await kernel1.initRemoteComms({ + directListenAddresses: [quicListenAddress], + }); + await kernel2.initRemoteComms({ + directListenAddresses: [quicListenAddress], + }); + + const info2 = await getConnectedInfo(kernel2); + await kernel1.registerLocationHints(info2.peerId, info2.quicAddresses); + + const aliceConfig = makeRemoteVatConfig('Alice'); + const bobConfig = makeRemoteVatConfig('Bob'); + await launchVatAndGetURL(kernel1, aliceConfig); + const bobURL = await launchVatAndGetURL(kernel2, bobConfig); + + const aliceRef = getVatRootRef(kernel1, kernelStore1, 'Alice'); + + for (let i = 0; i < 5; i++) { + const response = await sendRemoteMessage( + kernel1, + aliceRef, + bobURL, + 'hello', + ['Alice'], + ); + expect(response).toContain('vat Bob got "hello" from Alice'); + } + }, + NETWORK_TIMEOUT, + ); + }); +}); diff --git a/packages/ocap-kernel/src/Kernel.ts b/packages/ocap-kernel/src/Kernel.ts index 6b864b03b..e66e535a2 100644 --- a/packages/ocap-kernel/src/Kernel.ts +++ b/packages/ocap-kernel/src/Kernel.ts @@ -327,6 +327,17 @@ export class Kernel { await this.#remoteManager.reconnectPeer(peerId, hints); } + /** + * Register location hints for a remote peer. + * Hints are multiaddr strings that can be used to dial the peer directly. + * + * @param peerId - The peer ID to register hints for. + * @param hints - The location hint multiaddr strings. + */ + async registerLocationHints(peerId: string, hints: string[]): Promise { + await this.#remoteManager.registerLocationHints(peerId, hints); + } + /** * Send a message from the kernel to an object in a vat. * @@ -583,6 +594,7 @@ export class Kernel { status.remoteComms = { state: 'connected', peerId: this.#remoteManager.getPeerId(), + listenAddresses: this.#platformServices.getListenAddresses(), }; } else if (this.#remoteManager.isIdentityInitialized()) { status.remoteComms = { diff --git a/packages/ocap-kernel/src/remotes/platform/connection-factory.test.ts b/packages/ocap-kernel/src/remotes/platform/connection-factory.test.ts index 054e0e6a2..1e171a2fa 100644 --- a/packages/ocap-kernel/src/remotes/platform/connection-factory.test.ts +++ b/packages/ocap-kernel/src/remotes/platform/connection-factory.test.ts @@ -162,6 +162,10 @@ describe('ConnectionFactory', () => { toString: () => 'test-peer-id', }, addEventListener: vi.fn(), + getMultiaddrs: vi.fn(() => [ + { toString: () => '/ip4/127.0.0.1/udp/12345/quic-v1/p2p/test-peer-id' }, + { toString: () => '/ip4/127.0.0.1/tcp/9001/ws/p2p/test-peer-id' }, + ]), dialProtocol: vi.fn( async ( addr: string, @@ -194,14 +198,22 @@ describe('ConnectionFactory', () => { /** * Create a new ConnectionFactory. * - * @param signal - The signal to use for the ConnectionFactory. - * @param maxRetryAttempts - Maximum number of retry attempts. + * @param options - Options for the factory. + * @param options.signal - The signal to use for the ConnectionFactory. + * @param options.maxRetryAttempts - Maximum number of retry attempts. + * @param options.directTransport - Optional direct transport with listen addresses. + * @param options.directTransport.transport - The transport implementation. + * @param options.directTransport.listenAddresses - Addresses to listen on. * @returns The ConnectionFactory. */ - async function createFactory( - signal?: AbortSignal, - maxRetryAttempts?: number, - ): Promise< + async function createFactory(options?: { + signal?: AbortSignal; + maxRetryAttempts?: number; + directTransport?: { + transport: unknown; + listenAddresses: string[]; + }; + }): Promise< Awaited< ReturnType< typeof import('./connection-factory.ts').ConnectionFactory.make @@ -210,13 +222,14 @@ describe('ConnectionFactory', () => { > { const { ConnectionFactory } = await import('./connection-factory.ts'); const { Logger } = await import('@metamask/logger'); - return ConnectionFactory.make( + return ConnectionFactory.make({ keySeed, knownRelays, - new Logger(), - signal ?? new AbortController().signal, - maxRetryAttempts, - ); + logger: new Logger(), + signal: options?.signal ?? new AbortController().signal, + maxRetryAttempts: options?.maxRetryAttempts, + directTransport: options?.directTransport, + }); } describe('initialize', () => { @@ -258,9 +271,22 @@ describe('ConnectionFactory', () => { expect(callArgs.peerDiscovery).toBeDefined(); }); + it('omits bootstrap when no relays are provided', async () => { + const { ConnectionFactory } = await import('./connection-factory.ts'); + factory = await ConnectionFactory.make({ + keySeed, + knownRelays: [], + logger: new (await import('@metamask/logger')).Logger(), + signal: new AbortController().signal, + }); + + const callArgs = createLibp2p.mock.calls[0]?.[0]; + expect(callArgs.peerDiscovery).toBeUndefined(); + }); + it('accepts maxRetryAttempts parameter', async () => { const maxRetryAttempts = 5; - factory = await createFactory(undefined, maxRetryAttempts); + factory = await createFactory({ maxRetryAttempts }); expect(createLibp2p).toHaveBeenCalledOnce(); expect(libp2pState.startCalled).toBe(true); @@ -442,7 +468,7 @@ describe('ConnectionFactory', () => { const controller = new AbortController(); controller.abort(); - factory = await createFactory(controller.signal); + factory = await createFactory({ signal: controller.signal }); await expect(factory.openChannelOnce('peer123')).rejects.toThrow( AbortError, @@ -472,7 +498,7 @@ describe('ConnectionFactory', () => { handle: vi.fn(), })); - factory = await createFactory(controller.signal); + factory = await createFactory({ signal: controller.signal }); // The error is caught, then on retry signal.aborted is checked and AbortError is thrown await expect(factory.openChannelOnce('peer123')).rejects.toThrow( @@ -698,12 +724,12 @@ describe('ConnectionFactory', () => { vi.resetModules(); const { ConnectionFactory } = await import('./connection-factory.ts'); const { Logger } = await import('@metamask/logger'); - factory = await ConnectionFactory.make( + factory = await ConnectionFactory.make({ keySeed, knownRelays, - new Logger(), - new AbortController().signal, - ); + logger: new Logger(), + signal: new AbortController().signal, + }); await factory.openChannelWithRetry('peer123'); @@ -746,12 +772,12 @@ describe('ConnectionFactory', () => { vi.resetModules(); const { ConnectionFactory } = await import('./connection-factory.ts'); const { Logger } = await import('@metamask/logger'); - factory = await ConnectionFactory.make( + factory = await ConnectionFactory.make({ keySeed, knownRelays, - new Logger(), - new AbortController().signal, - ); + logger: new Logger(), + signal: new AbortController().signal, + }); await factory.openChannelWithRetry('peer123'); @@ -797,13 +823,13 @@ describe('ConnectionFactory', () => { vi.resetModules(); const { ConnectionFactory } = await import('./connection-factory.ts'); const { Logger } = await import('@metamask/logger'); - factory = await ConnectionFactory.make( + factory = await ConnectionFactory.make({ keySeed, knownRelays, - new Logger(), - new AbortController().signal, + logger: new Logger(), + signal: new AbortController().signal, maxRetryAttempts, - ); + }); await factory.openChannelWithRetry('peer123'); @@ -1239,4 +1265,119 @@ describe('ConnectionFactory', () => { expect(outboundChannel.peerId).toBe('outbound-peer'); }); }); + + describe('directTransport', () => { + it('includes direct transport in libp2p config when provided', async () => { + const mockTransport = { tag: 'quic-transport' }; + factory = await createFactory({ + directTransport: { + transport: mockTransport, + listenAddresses: ['/ip4/0.0.0.0/udp/0/quic-v1'], + }, + }); + + const callArgs = createLibp2p.mock.calls[0]?.[0]; + expect(callArgs.transports).toHaveLength(5); // 4 default + 1 direct + expect(callArgs.transports[4]).toBe(mockTransport); + }); + + it('merges direct listen addresses with default addresses', async () => { + factory = await createFactory({ + directTransport: { + transport: {}, + listenAddresses: ['/ip4/0.0.0.0/udp/0/quic-v1'], + }, + }); + + const callArgs = createLibp2p.mock.calls[0]?.[0]; + expect(callArgs.addresses.listen).toStrictEqual([ + '/webrtc', + '/p2p-circuit', + '/ip4/0.0.0.0/udp/0/quic-v1', + ]); + }); + + it('does not add direct transport when not provided', async () => { + factory = await createFactory(); + + const callArgs = createLibp2p.mock.calls[0]?.[0]; + expect(callArgs.transports).toHaveLength(4); + expect(callArgs.addresses.listen).toStrictEqual([ + '/webrtc', + '/p2p-circuit', + ]); + }); + }); + + describe('getListenAddresses', () => { + it('returns multiaddr strings from libp2p', async () => { + factory = await createFactory(); + + const addresses = factory.getListenAddresses(); + + expect(addresses).toStrictEqual([ + '/ip4/127.0.0.1/udp/12345/quic-v1/p2p/test-peer-id', + '/ip4/127.0.0.1/tcp/9001/ws/p2p/test-peer-id', + ]); + }); + + it('returns empty array after stop', async () => { + factory = await createFactory(); + await factory.stop(); + + const addresses = factory.getListenAddresses(); + + expect(addresses).toStrictEqual([]); + }); + }); + + describe('candidateAddressStrings with direct addresses', () => { + it('places direct address hints first', async () => { + factory = await createFactory(); + + const directHint = '/ip4/192.168.1.1/udp/4001/quic-v1/p2p/peer123'; + const addresses = factory.candidateAddressStrings('peer123', [ + directHint, + ]); + + expect(addresses[0]).toBe(directHint); + // Relay addresses follow + expect(addresses[1]).toContain('/p2p-circuit/'); + }); + + it('does not wrap direct address hints in relay pattern', async () => { + factory = await createFactory(); + + const directHint = '/ip4/192.168.1.1/udp/4001/quic-v1/p2p/peer123'; + const addresses = factory.candidateAddressStrings('peer123', [ + directHint, + ]); + + // The direct address should appear exactly as provided + expect(addresses).toContain(directHint); + // It should NOT be wrapped in a relay circuit + const wrappedDirectAddresses = addresses.filter( + (addr: string) => + addr.includes('/p2p-circuit/') && addr.includes('quic-v1'), + ); + expect(wrappedDirectAddresses).toHaveLength(0); + }); + + it('handles mix of direct and relay hints', async () => { + factory = await createFactory(); + + const directHint = '/ip4/192.168.1.1/udp/4001/quic-v1/p2p/peer123'; + const relayHint = '/dns4/hint.example/tcp/443/wss/p2p/hint'; + const addresses = factory.candidateAddressStrings('peer123', [ + directHint, + relayHint, + ]); + + // Direct addresses come first + expect(addresses[0]).toBe(directHint); + // Relay hint addresses follow + expect(addresses[1]).toContain('hint.example'); + expect(addresses[1]).toContain('/p2p-circuit/'); + }); + }); }); diff --git a/packages/ocap-kernel/src/remotes/platform/connection-factory.ts b/packages/ocap-kernel/src/remotes/platform/connection-factory.ts index 796196ac7..17753ca35 100644 --- a/packages/ocap-kernel/src/remotes/platform/connection-factory.ts +++ b/packages/ocap-kernel/src/remotes/platform/connection-factory.ts @@ -17,7 +17,11 @@ import { multiaddr } from '@multiformats/multiaddr'; import { byteStream } from 'it-byte-stream'; import { createLibp2p } from 'libp2p'; -import type { Channel, InboundConnectionHandler } from '../types.ts'; +import type { + Channel, + ConnectionFactoryOptions, + InboundConnectionHandler, +} from '../types.ts'; /** * Connection factory for libp2p network operations. @@ -38,56 +42,52 @@ export class ConnectionFactory { readonly #maxRetryAttempts: number; + readonly #directTransport: + | { + transport: unknown; + listenAddresses: string[]; + } + | undefined; + #inboundHandler?: InboundConnectionHandler; /** * Constructor for the ConnectionFactory. * - * @param keySeed - The key seed to use for the libp2p node. - * @param knownRelays - The known relays to use for the libp2p node. - * @param logger - The logger to use for the libp2p node. - * @param signal - The signal to use for the libp2p node. - * @param maxRetryAttempts - Maximum number of reconnection attempts. 0 = infinite (default). + * @param options - The options for the ConnectionFactory. + * @param options.keySeed - The key seed to use for the libp2p node. + * @param options.knownRelays - The known relays to use for the libp2p node. + * @param options.logger - The logger to use for the libp2p node. + * @param options.signal - The signal to use for the libp2p node. + * @param options.maxRetryAttempts - Maximum number of reconnection attempts. 0 = infinite (default). + * @param options.directTransport - Optional direct transport (e.g. QUIC) with listen addresses. */ // eslint-disable-next-line no-restricted-syntax - private constructor( - keySeed: string, - knownRelays: string[], - logger: Logger, - signal: AbortSignal, - maxRetryAttempts?: number, - ) { - this.#keySeed = keySeed; - this.#knownRelays = knownRelays; - this.#logger = logger; - this.#signal = signal; - this.#maxRetryAttempts = maxRetryAttempts ?? 0; + private constructor(options: ConnectionFactoryOptions) { + this.#keySeed = options.keySeed; + this.#knownRelays = options.knownRelays; + this.#logger = options.logger; + this.#signal = options.signal; + this.#maxRetryAttempts = options.maxRetryAttempts ?? 0; + this.#directTransport = options.directTransport; } /** * Create a new ConnectionFactory instance. * - * @param keySeed - The key seed to use for the libp2p node. - * @param knownRelays - The known relays to use for the libp2p node. - * @param logger - The logger to use for the libp2p node. - * @param signal - The signal to use for the libp2p node. - * @param maxRetryAttempts - Maximum number of reconnection attempts. 0 = infinite (default). + * @param options - The options for the ConnectionFactory. + * @param options.keySeed - The key seed to use for the libp2p node. + * @param options.knownRelays - The known relays to use for the libp2p node. + * @param options.logger - The logger to use for the libp2p node. + * @param options.signal - The signal to use for the libp2p node. + * @param options.maxRetryAttempts - Maximum number of reconnection attempts. 0 = infinite (default). + * @param options.directTransport - Optional direct transport (e.g. QUIC) with listen addresses. * @returns A promise for the new ConnectionFactory instance. */ static async make( - keySeed: string, - knownRelays: string[], - logger: Logger, - signal: AbortSignal, - maxRetryAttempts?: number, + options: ConnectionFactoryOptions, ): Promise { - const factory = new ConnectionFactory( - keySeed, - knownRelays, - logger, - signal, - maxRetryAttempts, - ); + const factory = new ConnectionFactory(options); await factory.#init(); return factory; } @@ -101,7 +101,11 @@ export class ConnectionFactory { this.#libp2p = await createLibp2p({ privateKey, addresses: { - listen: ['/webrtc', '/p2p-circuit'], + listen: [ + '/webrtc', + '/p2p-circuit', + ...(this.#directTransport?.listenAddresses ?? []), + ], appendAnnounce: ['/webrtc'], }, transports: [ @@ -120,6 +124,9 @@ export class ConnectionFactory { }, }), circuitRelayTransport(), + ...(this.#directTransport + ? [this.#directTransport.transport as ReturnType] + : []), ], connectionEncrypters: [noise()], streamMuxers: [yamux()], @@ -127,11 +134,15 @@ export class ConnectionFactory { // Allow private addresses for local testing denyDialMultiaddr: async () => false, }, - peerDiscovery: [ - bootstrap({ - list: this.#knownRelays, - }), - ], + ...(this.#knownRelays.length > 0 + ? { + peerDiscovery: [ + bootstrap({ + list: this.#knownRelays, + }), + ], + } + : {}), services: { identify: identify(), ping: ping(), @@ -174,6 +185,19 @@ export class ConnectionFactory { this.#inboundHandler = handler; } + /** + * Get the actual listen addresses of the libp2p node. + * These are the multiaddr strings that other peers can use to dial this node. + * + * @returns The listen address strings. + */ + getListenAddresses(): string[] { + if (!this.#libp2p) { + return []; + } + return this.#libp2p.getMultiaddrs().map((ma) => ma.toString()); + } + /** * Generate key info from the seed. * @@ -190,19 +214,37 @@ export class ConnectionFactory { /** * Get candidate address strings for dialing a peer. * + * Hints that already contain `/p2p/{peerId}` are treated as direct addresses + * (e.g. QUIC multiaddrs) and are used as-is. Other hints are relay addresses + * and are expanded with circuit-relay patterns. Direct addresses are tried first. + * * @param peerId - The peer ID to get candidate address strings for. * @param hints - The hints to get candidate address strings for. * @returns The candidate address strings. */ candidateAddressStrings(peerId: string, hints: string[]): string[] { - const possibleContacts = hints.concat( - ...this.#knownRelays.filter((relay) => !hints.includes(relay)), + const directAddresses: string[] = []; + const relayHints: string[] = []; + + for (const hint of hints) { + if (hint.includes(`/p2p/${peerId}`)) { + directAddresses.push(hint); + } else { + relayHints.push(hint); + } + } + + const possibleRelays = relayHints.concat( + ...this.#knownRelays.filter((relay) => !relayHints.includes(relay)), ); - // Try WebRTC via relay first, then WebSocket via relay. - return possibleContacts.flatMap((relay) => [ + + // Direct addresses first, then WebRTC via relay, then WebSocket via relay. + const relayAddresses = possibleRelays.flatMap((relay) => [ `${relay}/p2p-circuit/webrtc/p2p/${peerId}`, `${relay}/p2p-circuit/p2p/${peerId}`, ]); + + return [...directAddresses, ...relayAddresses]; } /** diff --git a/packages/ocap-kernel/src/remotes/platform/transport.test.ts b/packages/ocap-kernel/src/remotes/platform/transport.test.ts index 439f1449b..58b5b8f39 100644 --- a/packages/ocap-kernel/src/remotes/platform/transport.test.ts +++ b/packages/ocap-kernel/src/remotes/platform/transport.test.ts @@ -80,6 +80,7 @@ const mockConnectionFactory = { onInboundConnection: vi.fn(), stop: vi.fn().mockResolvedValue(undefined), closeChannel: vi.fn().mockResolvedValue(undefined), + getListenAddresses: vi.fn().mockReturnValue([]), }; vi.mock('./connection-factory.ts', () => { @@ -241,13 +242,14 @@ describe('transport.initTransport', () => { await initTransport(keySeed, { relays: knownRelays }, vi.fn()); - expect(ConnectionFactory.make).toHaveBeenCalledWith( + expect(ConnectionFactory.make).toHaveBeenCalledWith({ keySeed, knownRelays, - expect.any(Object), // Logger instance - expect.any(AbortSignal), // signal from AbortController - undefined, // maxRetryAttempts (optional) - ); + logger: expect.any(Object), + signal: expect.any(AbortSignal), + maxRetryAttempts: undefined, + directTransport: undefined, + }); }); it('passes maxRetryAttempts to ConnectionFactory.make', async () => { @@ -257,16 +259,38 @@ describe('transport.initTransport', () => { await initTransport(keySeed, { relays: [], maxRetryAttempts }, vi.fn()); - expect(ConnectionFactory.make).toHaveBeenCalledWith( + expect(ConnectionFactory.make).toHaveBeenCalledWith({ keySeed, - [], - expect.any(Object), - expect.any(AbortSignal), + knownRelays: [], + logger: expect.any(Object), + signal: expect.any(AbortSignal), maxRetryAttempts, - ); + directTransport: undefined, + }); + }); + + it('passes directTransport to ConnectionFactory.make', async () => { + const { ConnectionFactory } = await import('./connection-factory.ts'); + const keySeed = '0xabcd'; + const mockTransport = { tag: 'quic' }; + const directTransport = { + transport: mockTransport, + listenAddresses: ['/ip4/0.0.0.0/udp/0/quic-v1'], + }; + + await initTransport(keySeed, { relays: [], directTransport }, vi.fn()); + + expect(ConnectionFactory.make).toHaveBeenCalledWith({ + keySeed, + knownRelays: [], + logger: expect.any(Object), + signal: expect.any(AbortSignal), + maxRetryAttempts: undefined, + directTransport, + }); }); - it('returns sendRemoteMessage, stop, closeConnection, registerLocationHints, and reconnectPeer', async () => { + it('returns sendRemoteMessage, stop, closeConnection, registerLocationHints, reconnectPeer, and getListenAddresses', async () => { const result = await initTransport('0x1234', {}, vi.fn()); expect(result).toHaveProperty('sendRemoteMessage'); @@ -274,11 +298,13 @@ describe('transport.initTransport', () => { expect(result).toHaveProperty('closeConnection'); expect(result).toHaveProperty('registerLocationHints'); expect(result).toHaveProperty('reconnectPeer'); + expect(result).toHaveProperty('getListenAddresses'); expect(typeof result.sendRemoteMessage).toBe('function'); expect(typeof result.stop).toBe('function'); expect(typeof result.closeConnection).toBe('function'); expect(typeof result.registerLocationHints).toBe('function'); expect(typeof result.reconnectPeer).toBe('function'); + expect(typeof result.getListenAddresses).toBe('function'); }); }); diff --git a/packages/ocap-kernel/src/remotes/platform/transport.ts b/packages/ocap-kernel/src/remotes/platform/transport.ts index 015b54413..92a3d7df2 100644 --- a/packages/ocap-kernel/src/remotes/platform/transport.ts +++ b/packages/ocap-kernel/src/remotes/platform/transport.ts @@ -76,6 +76,7 @@ export async function initTransport( registerLocationHints: (peerId: string, hints: string[]) => void; reconnectPeer: (peerId: string, hints?: string[]) => Promise; resetAllBackoffs: () => void; + getListenAddresses: () => string[]; }> { const { relays = [], @@ -86,6 +87,7 @@ export async function initTransport( stalePeerTimeoutMs = DEFAULT_STALE_PEER_TIMEOUT_MS, maxMessagesPerSecond = DEFAULT_MESSAGE_RATE_LIMIT, maxConnectionAttemptsPerMinute = DEFAULT_CONNECTION_RATE_LIMIT, + directTransport, } = options; let cleanupWakeDetector: (() => void) | undefined; const stopController = new AbortController(); @@ -115,13 +117,14 @@ export async function initTransport( } connectionLossHolder.impl(peerId); }; - const connectionFactory = await ConnectionFactory.make( + const connectionFactory = await ConnectionFactory.make({ keySeed, - relays, + knownRelays: relays, logger, signal, maxRetryAttempts, - ); + directTransport, + }); // Create handshake dependencies (only if incarnation ID is configured). // The incarnation ID is optional primarily for unit tests that don't need @@ -728,5 +731,6 @@ export async function initTransport( registerLocationHints, reconnectPeer, resetAllBackoffs, + getListenAddresses: () => connectionFactory.getListenAddresses(), }; } diff --git a/packages/ocap-kernel/src/remotes/types.ts b/packages/ocap-kernel/src/remotes/types.ts index 84b8ddc93..0a604d651 100644 --- a/packages/ocap-kernel/src/remotes/types.ts +++ b/packages/ocap-kernel/src/remotes/types.ts @@ -1,3 +1,4 @@ +import type { Logger } from '@metamask/logger'; import type { ByteStream } from 'it-byte-stream'; export type InboundConnectionHandler = (channel: Channel) => void; @@ -99,6 +100,42 @@ export type RemoteCommsOptions = { * Uses a sliding 1-minute window. */ maxConnectionAttemptsPerMinute?: number | undefined; + /** + * Direct listen addresses for non-relay transports (e.g. QUIC). + * Example: `['/ip4/0.0.0.0/udp/0/quic-v1']` + * + * The platform layer detects the required transport from the address strings + * and injects it automatically. Users never need to import transport packages. + */ + directListenAddresses?: string[] | undefined; + /** + * Internal option injected by platform services. Bundles a direct transport + * implementation with its listen addresses. Users should use + * `directListenAddresses` instead. + * + * @internal + */ + directTransport?: { + transport: unknown; + listenAddresses: string[]; + }; +}; + +/** + * Options for creating a ConnectionFactory instance. + */ +export type ConnectionFactoryOptions = { + keySeed: string; + knownRelays: string[]; + logger: Logger; + signal: AbortSignal; + maxRetryAttempts?: number | undefined; + directTransport?: + | { + transport: unknown; + listenAddresses: string[]; + } + | undefined; }; export type RemoteInfo = { diff --git a/packages/ocap-kernel/src/types.ts b/packages/ocap-kernel/src/types.ts index 83e52362e..5b7e644a0 100644 --- a/packages/ocap-kernel/src/types.ts +++ b/packages/ocap-kernel/src/types.ts @@ -377,6 +377,14 @@ export type PlatformServices = { * @returns A promise that resolves when backoffs have been reset. */ resetAllBackoffs: () => Promise; + /** + * Get the actual listen addresses of the libp2p node. + * Returns multiaddr strings that other peers can use to dial this node directly. + * Returns an empty array if remote comms is not initialized. + * + * @returns The listen address strings. + */ + getListenAddresses: () => string[]; }; // Cluster configuration @@ -469,6 +477,7 @@ const RemoteCommsIdentityOnlyStruct = object({ const RemoteCommsConnectedStruct = object({ state: literal('connected'), peerId: string(), + listenAddresses: array(string()), }); export const KernelStatusStruct = type({ diff --git a/yarn.lock b/yarn.lock index e3b364372..e43403629 100644 --- a/yarn.lock +++ b/yarn.lock @@ -480,6 +480,93 @@ __metadata: languageName: node linkType: hard +"@chainsafe/libp2p-quic-darwin-arm64@npm:1.1.8": + version: 1.1.8 + resolution: "@chainsafe/libp2p-quic-darwin-arm64@npm:1.1.8" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@chainsafe/libp2p-quic-darwin-x64@npm:1.1.8": + version: 1.1.8 + resolution: "@chainsafe/libp2p-quic-darwin-x64@npm:1.1.8" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@chainsafe/libp2p-quic-linux-arm64-gnu@npm:1.1.8": + version: 1.1.8 + resolution: "@chainsafe/libp2p-quic-linux-arm64-gnu@npm:1.1.8" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@chainsafe/libp2p-quic-linux-arm64-musl@npm:1.1.8": + version: 1.1.8 + resolution: "@chainsafe/libp2p-quic-linux-arm64-musl@npm:1.1.8" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@chainsafe/libp2p-quic-linux-x64-gnu@npm:1.1.8": + version: 1.1.8 + resolution: "@chainsafe/libp2p-quic-linux-x64-gnu@npm:1.1.8" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@chainsafe/libp2p-quic-linux-x64-musl@npm:1.1.8": + version: 1.1.8 + resolution: "@chainsafe/libp2p-quic-linux-x64-musl@npm:1.1.8" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@chainsafe/libp2p-quic-win32-x64-msvc@npm:1.1.8": + version: 1.1.8 + resolution: "@chainsafe/libp2p-quic-win32-x64-msvc@npm:1.1.8" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@chainsafe/libp2p-quic@npm:^1.1.8": + version: 1.1.8 + resolution: "@chainsafe/libp2p-quic@npm:1.1.8" + dependencies: + "@chainsafe/libp2p-quic-darwin-arm64": "npm:1.1.8" + "@chainsafe/libp2p-quic-darwin-x64": "npm:1.1.8" + "@chainsafe/libp2p-quic-linux-arm64-gnu": "npm:1.1.8" + "@chainsafe/libp2p-quic-linux-arm64-musl": "npm:1.1.8" + "@chainsafe/libp2p-quic-linux-x64-gnu": "npm:1.1.8" + "@chainsafe/libp2p-quic-linux-x64-musl": "npm:1.1.8" + "@chainsafe/libp2p-quic-win32-x64-msvc": "npm:1.1.8" + "@libp2p/crypto": "npm:^5.1.7" + "@libp2p/interface": "npm:^2.10.5" + "@libp2p/utils": "npm:^6.7.1" + "@multiformats/multiaddr": "npm:^12.4.0" + "@multiformats/multiaddr-matcher": "npm:^2.0.1" + it-stream-types: "npm:^2.0.2" + race-signal: "npm:^1.1.3" + uint8arraylist: "npm:^2.4.8" + dependenciesMeta: + "@chainsafe/libp2p-quic-darwin-arm64": + optional: true + "@chainsafe/libp2p-quic-darwin-x64": + optional: true + "@chainsafe/libp2p-quic-linux-arm64-gnu": + optional: true + "@chainsafe/libp2p-quic-linux-arm64-musl": + optional: true + "@chainsafe/libp2p-quic-linux-x64-gnu": + optional: true + "@chainsafe/libp2p-quic-linux-x64-musl": + optional: true + "@chainsafe/libp2p-quic-win32-x64-msvc": + optional: true + checksum: 10/7db59436903a62788c99b1be076231cbc21f7742f177d61757730a39b97037c002713e239a6f7d3f80088252c87e3048e684eeb9fba33b6de8e6a0374d380dd0 + languageName: node + linkType: hard + "@chainsafe/libp2p-yamux@npm:7.0.4": version: 7.0.4 resolution: "@chainsafe/libp2p-yamux@npm:7.0.4" @@ -1672,18 +1759,18 @@ __metadata: languageName: node linkType: hard -"@libp2p/crypto@npm:^5.0.0, @libp2p/crypto@npm:^5.1.8": - version: 5.1.12 - resolution: "@libp2p/crypto@npm:5.1.12" +"@libp2p/crypto@npm:^5.0.0, @libp2p/crypto@npm:^5.1.7, @libp2p/crypto@npm:^5.1.8": + version: 5.1.13 + resolution: "@libp2p/crypto@npm:5.1.13" dependencies: - "@libp2p/interface": "npm:^3.0.2" + "@libp2p/interface": "npm:^3.1.0" "@noble/curves": "npm:^2.0.1" "@noble/hashes": "npm:^2.0.1" multiformats: "npm:^13.4.0" protons-runtime: "npm:^5.6.0" uint8arraylist: "npm:^2.4.8" uint8arrays: "npm:^5.1.0" - checksum: 10/ff88cca89b27087654415054b54aca9e1c13b59ce3739f759a528b431956b59b19aa1259ed64887eda48d9390ba633a5f5dc89864d90c2fcf9722348a52931ee + checksum: 10/8e4a248ab34f668d2ad55e7c579df6043750e13ab2008e488a6ece7a6b3c4271728691da0f28eee52d134a6044fa8b8dc662af7c0a4a17fac8fc0a17b5bf19e3 languageName: node linkType: hard @@ -1883,7 +1970,7 @@ __metadata: languageName: node linkType: hard -"@libp2p/utils@npm:^6.0.0, @libp2p/utils@npm:^6.7.2": +"@libp2p/utils@npm:^6.0.0, @libp2p/utils@npm:^6.7.1, @libp2p/utils@npm:^6.7.2": version: 6.7.2 resolution: "@libp2p/utils@npm:6.7.2" dependencies: @@ -3062,7 +3149,7 @@ __metadata: languageName: node linkType: hard -"@multiformats/multiaddr-matcher@npm:^2.0.0": +"@multiformats/multiaddr-matcher@npm:^2.0.0, @multiformats/multiaddr-matcher@npm:^2.0.1": version: 2.0.2 resolution: "@multiformats/multiaddr-matcher@npm:2.0.2" dependencies: @@ -3872,6 +3959,7 @@ __metadata: resolution: "@ocap/nodejs@workspace:packages/nodejs" dependencies: "@arethetypeswrong/cli": "npm:^0.17.4" + "@chainsafe/libp2p-quic": "npm:^1.1.8" "@endo/eventual-send": "npm:^1.3.4" "@endo/promise-kit": "npm:^1.1.13" "@libp2p/interface": "npm:2.11.0" From 6859e2c5f126eaca593fd35230f7dc968d2ceb60 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Fri, 13 Feb 2026 14:45:40 +0100 Subject: [PATCH 02/11] feat(ocap-kernel): add TCP direct transport support alongside QUIC Pluralize directTransport to directTransports (array) to support multiple simultaneous transports. Add TCP transport auto-detection in NodejsPlatformServices alongside existing QUIC detection. Users can now pass both QUIC and TCP addresses: directListenAddresses: ['/ip4/0.0.0.0/udp/0/quic-v1', '/ip4/0.0.0.0/tcp/0'] Co-Authored-By: Claude Opus 4.6 --- packages/nodejs/package.json | 1 + .../src/kernel/PlatformServices.test.ts | 107 ++++++++++++++---- .../nodejs/src/kernel/PlatformServices.ts | 47 +++++--- .../nodejs/test/e2e/quic-transport.test.ts | 79 +++++++++++-- .../platform/connection-factory.test.ts | 81 ++++++++++--- .../remotes/platform/connection-factory.ts | 24 ++-- .../src/remotes/platform/transport.test.ts | 27 +++-- .../src/remotes/platform/transport.ts | 4 +- packages/ocap-kernel/src/remotes/types.ts | 20 ++-- .../ocap-kernel/src/vats/VatSupervisor.ts | 1 - yarn.lock | 1 + 11 files changed, 290 insertions(+), 102 deletions(-) diff --git a/packages/nodejs/package.json b/packages/nodejs/package.json index 64e85b4a8..b84481062 100644 --- a/packages/nodejs/package.json +++ b/packages/nodejs/package.json @@ -53,6 +53,7 @@ "@endo/eventual-send": "^1.3.4", "@endo/promise-kit": "^1.1.13", "@libp2p/interface": "2.11.0", + "@libp2p/tcp": "10.1.19", "@libp2p/webrtc": "5.2.24", "@metamask/kernel-shims": "workspace:^", "@metamask/kernel-store": "workspace:^", diff --git a/packages/nodejs/src/kernel/PlatformServices.test.ts b/packages/nodejs/src/kernel/PlatformServices.test.ts index 8371b66a0..fa68cdd30 100644 --- a/packages/nodejs/src/kernel/PlatformServices.test.ts +++ b/packages/nodejs/src/kernel/PlatformServices.test.ts @@ -77,6 +77,10 @@ vi.mock('@chainsafe/libp2p-quic', () => ({ quic: () => ({ tag: 'mock-quic-transport' }), })); +vi.mock('@libp2p/tcp', () => ({ + tcp: () => ({ tag: 'mock-tcp-transport' }), +})); + vi.mock('@metamask/ocap-kernel', async (importOriginal) => { const actual = await importOriginal(); return { @@ -394,7 +398,7 @@ describe('NodejsPlatformServices', () => { expect(service).toBeInstanceOf(NodejsPlatformServices); }); - it('injects QUIC directTransport when directListenAddresses contain /quic-v1', async () => { + it('injects QUIC directTransports when directListenAddresses contain /quic-v1', async () => { const service = new NodejsPlatformServices({ workerFilePath }); const keySeed = '0x1234567890abcdef'; const relays = ['/dns4/relay.example/tcp/443/wss/p2p/relayPeer']; @@ -414,10 +418,12 @@ describe('NodejsPlatformServices', () => { keySeed, expect.objectContaining({ relays, - directTransport: { - transport: expect.any(Object), - listenAddresses: ['/ip4/0.0.0.0/udp/0/quic-v1'], - }, + directTransports: [ + { + transport: { tag: 'mock-quic-transport' }, + listenAddresses: ['/ip4/0.0.0.0/udp/0/quic-v1'], + }, + ], }), expect.any(Function), undefined, @@ -426,39 +432,90 @@ describe('NodejsPlatformServices', () => { ); }); - it('does not inject directTransport when no directListenAddresses provided', async () => { + it('injects TCP directTransports when directListenAddresses contain /tcp/', async () => { const service = new NodejsPlatformServices({ workerFilePath }); const keySeed = '0x1234567890abcdef'; - const relays = ['/dns4/relay.example/tcp/443/wss/p2p/relayPeer']; const remoteHandler = vi.fn(async () => 'response'); - await service.initializeRemoteComms(keySeed, { relays }, remoteHandler); + await service.initializeRemoteComms( + keySeed, + { + directListenAddresses: ['/ip4/0.0.0.0/tcp/4001'], + }, + remoteHandler, + ); const { initTransport } = await import('@metamask/ocap-kernel'); - const callArgs = ( - initTransport as unknown as ReturnType - ).mock.calls.at(-1); - // Second argument is the options - expect(callArgs?.[1]).not.toHaveProperty('directTransport'); + expect(initTransport).toHaveBeenCalledWith( + keySeed, + expect.objectContaining({ + directTransports: [ + { + transport: { tag: 'mock-tcp-transport' }, + listenAddresses: ['/ip4/0.0.0.0/tcp/4001'], + }, + ], + }), + expect.any(Function), + undefined, + undefined, + undefined, + ); }); - it('throws error for direct TCP listen addresses', async () => { + it('injects both QUIC and TCP when directListenAddresses contain both', async () => { const service = new NodejsPlatformServices({ workerFilePath }); + const keySeed = '0x1234567890abcdef'; const remoteHandler = vi.fn(async () => 'response'); - await expect( - service.initializeRemoteComms( - '0xtest', - { - relays: [], - directListenAddresses: ['/ip4/0.0.0.0/tcp/4001'], - }, - remoteHandler, - ), - ).rejects.toThrowError( - 'Direct TCP listen addresses are not yet supported', + await service.initializeRemoteComms( + keySeed, + { + directListenAddresses: [ + '/ip4/0.0.0.0/udp/0/quic-v1', + '/ip4/0.0.0.0/tcp/4001', + ], + }, + remoteHandler, + ); + + const { initTransport } = await import('@metamask/ocap-kernel'); + expect(initTransport).toHaveBeenCalledWith( + keySeed, + expect.objectContaining({ + directTransports: [ + { + transport: { tag: 'mock-quic-transport' }, + listenAddresses: ['/ip4/0.0.0.0/udp/0/quic-v1'], + }, + { + transport: { tag: 'mock-tcp-transport' }, + listenAddresses: ['/ip4/0.0.0.0/tcp/4001'], + }, + ], + }), + expect.any(Function), + undefined, + undefined, + undefined, ); }); + + it('does not inject directTransports when no directListenAddresses provided', async () => { + const service = new NodejsPlatformServices({ workerFilePath }); + const keySeed = '0x1234567890abcdef'; + const relays = ['/dns4/relay.example/tcp/443/wss/p2p/relayPeer']; + const remoteHandler = vi.fn(async () => 'response'); + + await service.initializeRemoteComms(keySeed, { relays }, remoteHandler); + + const { initTransport } = await import('@metamask/ocap-kernel'); + const callArgs = ( + initTransport as unknown as ReturnType + ).mock.calls.at(-1); + // Second argument is the options + expect(callArgs?.[1]).not.toHaveProperty('directTransports'); + }); }); describe('getListenAddresses', () => { diff --git a/packages/nodejs/src/kernel/PlatformServices.ts b/packages/nodejs/src/kernel/PlatformServices.ts index fdb2058d2..79f510f6a 100644 --- a/packages/nodejs/src/kernel/PlatformServices.ts +++ b/packages/nodejs/src/kernel/PlatformServices.ts @@ -258,26 +258,41 @@ export class NodejsPlatformServices implements PlatformServices { const { directListenAddresses, ...restOptions } = options; - if (directListenAddresses?.some((addr) => addr.includes('/tcp/'))) { - throw new Error('Direct TCP listen addresses are not yet supported'); - } - - const hasQuic = directListenAddresses?.some((addr) => - addr.includes('/quic-v1'), - ); + const directTransports: { + transport: unknown; + listenAddresses: string[]; + }[] = []; + + if (directListenAddresses && directListenAddresses.length > 0) { + const quicAddresses = directListenAddresses.filter((addr) => + addr.includes('/quic-v1'), + ); + const tcpAddresses = directListenAddresses.filter((addr) => + addr.includes('/tcp/'), + ); - let enhancedOptions: RemoteCommsOptions = restOptions; - if (hasQuic && directListenAddresses) { - const { quic } = await import('@chainsafe/libp2p-quic'); - enhancedOptions = { - ...restOptions, - directTransport: { + if (quicAddresses.length > 0) { + const { quic } = await import('@chainsafe/libp2p-quic'); + directTransports.push({ transport: quic(), - listenAddresses: directListenAddresses, - }, - }; + listenAddresses: quicAddresses, + }); + } + + if (tcpAddresses.length > 0) { + const { tcp } = await import('@libp2p/tcp'); + directTransports.push({ + transport: tcp(), + listenAddresses: tcpAddresses, + }); + } } + const enhancedOptions: RemoteCommsOptions = { + ...restOptions, + ...(directTransports.length > 0 ? { directTransports } : {}), + }; + const { sendRemoteMessage, stop, diff --git a/packages/nodejs/test/e2e/quic-transport.test.ts b/packages/nodejs/test/e2e/quic-transport.test.ts index 53a32beaa..8bb4f55eb 100644 --- a/packages/nodejs/test/e2e/quic-transport.test.ts +++ b/packages/nodejs/test/e2e/quic-transport.test.ts @@ -38,8 +38,9 @@ async function stopWithTimeout( } } -// QUIC listen addresses for each kernel (port 0 = OS-assigned) +// Listen addresses for each kernel (port 0 = OS-assigned) const quicListenAddress = '/ip4/127.0.0.1/udp/0/quic-v1'; +const tcpListenAddress = '/ip4/127.0.0.1/tcp/0'; /** * Get the connected remote comms info from a kernel's status. @@ -51,6 +52,7 @@ async function getConnectedInfo(kernel: Kernel): Promise<{ peerId: string; listenAddresses: string[]; quicAddresses: string[]; + tcpAddresses: string[]; }> { const status = await kernel.getStatus(); if (status.remoteComms?.state !== 'connected') { @@ -61,10 +63,13 @@ async function getConnectedInfo(kernel: Kernel): Promise<{ peerId, listenAddresses, quicAddresses: listenAddresses.filter((addr) => addr.includes('/quic-v1/')), + tcpAddresses: listenAddresses.filter( + (addr) => addr.includes('/tcp/') && !addr.includes('/ws'), + ), }; } -describe.sequential('QUIC Transport E2E', () => { +describe.sequential('Direct Transport E2E', () => { let kernel1: Kernel; let kernel2: Kernel; let kernelDatabase1: Awaited>; @@ -133,13 +138,39 @@ describe.sequential('QUIC Transport E2E', () => { ); it( - 'rejects direct TCP listen addresses', + 'initializes remote comms with TCP transport without a relay', async () => { - await expect( - kernel1.initRemoteComms({ - directListenAddresses: ['/ip4/0.0.0.0/tcp/4001'], - }), - ).rejects.toThrow('Direct TCP listen addresses are not yet supported'); + await kernel1.initRemoteComms({ + directListenAddresses: [tcpListenAddress], + }); + await kernel2.initRemoteComms({ + directListenAddresses: [tcpListenAddress], + }); + + const info1 = await getConnectedInfo(kernel1); + const info2 = await getConnectedInfo(kernel2); + + // Each kernel should have TCP listen addresses + expect(info1.tcpAddresses.length).toBeGreaterThan(0); + expect(info2.tcpAddresses.length).toBeGreaterThan(0); + + // Peer IDs should be distinct + expect(info1.peerId).not.toBe(info2.peerId); + }, + NETWORK_TIMEOUT, + ); + + it( + 'initializes remote comms with both QUIC and TCP', + async () => { + await kernel1.initRemoteComms({ + directListenAddresses: [quicListenAddress, tcpListenAddress], + }); + + const info1 = await getConnectedInfo(kernel1); + + expect(info1.quicAddresses.length).toBeGreaterThan(0); + expect(info1.tcpAddresses.length).toBeGreaterThan(0); }, NETWORK_TIMEOUT, ); @@ -263,5 +294,37 @@ describe.sequential('QUIC Transport E2E', () => { }, NETWORK_TIMEOUT, ); + + it( + 'sends a message via direct TCP', + async () => { + await kernel1.initRemoteComms({ + directListenAddresses: [tcpListenAddress], + }); + await kernel2.initRemoteComms({ + directListenAddresses: [tcpListenAddress], + }); + + const info2 = await getConnectedInfo(kernel2); + await kernel1.registerLocationHints(info2.peerId, info2.tcpAddresses); + + const aliceConfig = makeRemoteVatConfig('Alice'); + const bobConfig = makeRemoteVatConfig('Bob'); + await launchVatAndGetURL(kernel1, aliceConfig); + const bobURL = await launchVatAndGetURL(kernel2, bobConfig); + + const aliceRef = getVatRootRef(kernel1, kernelStore1, 'Alice'); + + const response = await sendRemoteMessage( + kernel1, + aliceRef, + bobURL, + 'hello', + ['Alice'], + ); + expect(response).toContain('vat Bob got "hello" from Alice'); + }, + NETWORK_TIMEOUT, + ); }); }); diff --git a/packages/ocap-kernel/src/remotes/platform/connection-factory.test.ts b/packages/ocap-kernel/src/remotes/platform/connection-factory.test.ts index 1e171a2fa..15742bd3d 100644 --- a/packages/ocap-kernel/src/remotes/platform/connection-factory.test.ts +++ b/packages/ocap-kernel/src/remotes/platform/connection-factory.test.ts @@ -201,18 +201,16 @@ describe('ConnectionFactory', () => { * @param options - Options for the factory. * @param options.signal - The signal to use for the ConnectionFactory. * @param options.maxRetryAttempts - Maximum number of retry attempts. - * @param options.directTransport - Optional direct transport with listen addresses. - * @param options.directTransport.transport - The transport implementation. - * @param options.directTransport.listenAddresses - Addresses to listen on. + * @param options.directTransports - Optional direct transports with listen addresses. * @returns The ConnectionFactory. */ async function createFactory(options?: { signal?: AbortSignal; maxRetryAttempts?: number; - directTransport?: { + directTransports?: { transport: unknown; listenAddresses: string[]; - }; + }[]; }): Promise< Awaited< ReturnType< @@ -228,7 +226,7 @@ describe('ConnectionFactory', () => { logger: new Logger(), signal: options?.signal ?? new AbortController().signal, maxRetryAttempts: options?.maxRetryAttempts, - directTransport: options?.directTransport, + directTransports: options?.directTransports, }); } @@ -1266,14 +1264,16 @@ describe('ConnectionFactory', () => { }); }); - describe('directTransport', () => { - it('includes direct transport in libp2p config when provided', async () => { + describe('directTransports', () => { + it('includes a single direct transport in libp2p config', async () => { const mockTransport = { tag: 'quic-transport' }; factory = await createFactory({ - directTransport: { - transport: mockTransport, - listenAddresses: ['/ip4/0.0.0.0/udp/0/quic-v1'], - }, + directTransports: [ + { + transport: mockTransport, + listenAddresses: ['/ip4/0.0.0.0/udp/0/quic-v1'], + }, + ], }); const callArgs = createLibp2p.mock.calls[0]?.[0]; @@ -1281,12 +1281,58 @@ describe('ConnectionFactory', () => { expect(callArgs.transports[4]).toBe(mockTransport); }); + it('includes multiple direct transports in libp2p config', async () => { + const mockQuic = { tag: 'quic-transport' }; + const mockTcp = { tag: 'tcp-transport' }; + factory = await createFactory({ + directTransports: [ + { + transport: mockQuic, + listenAddresses: ['/ip4/0.0.0.0/udp/0/quic-v1'], + }, + { + transport: mockTcp, + listenAddresses: ['/ip4/0.0.0.0/tcp/4001'], + }, + ], + }); + + const callArgs = createLibp2p.mock.calls[0]?.[0]; + expect(callArgs.transports).toHaveLength(6); // 4 default + 2 direct + expect(callArgs.transports[4]).toBe(mockQuic); + expect(callArgs.transports[5]).toBe(mockTcp); + }); + it('merges direct listen addresses with default addresses', async () => { factory = await createFactory({ - directTransport: { - transport: {}, - listenAddresses: ['/ip4/0.0.0.0/udp/0/quic-v1'], - }, + directTransports: [ + { + transport: {}, + listenAddresses: ['/ip4/0.0.0.0/udp/0/quic-v1'], + }, + ], + }); + + const callArgs = createLibp2p.mock.calls[0]?.[0]; + expect(callArgs.addresses.listen).toStrictEqual([ + '/webrtc', + '/p2p-circuit', + '/ip4/0.0.0.0/udp/0/quic-v1', + ]); + }); + + it('merges multiple transport listen addresses', async () => { + factory = await createFactory({ + directTransports: [ + { + transport: {}, + listenAddresses: ['/ip4/0.0.0.0/udp/0/quic-v1'], + }, + { + transport: {}, + listenAddresses: ['/ip4/0.0.0.0/tcp/4001'], + }, + ], }); const callArgs = createLibp2p.mock.calls[0]?.[0]; @@ -1294,10 +1340,11 @@ describe('ConnectionFactory', () => { '/webrtc', '/p2p-circuit', '/ip4/0.0.0.0/udp/0/quic-v1', + '/ip4/0.0.0.0/tcp/4001', ]); }); - it('does not add direct transport when not provided', async () => { + it('does not add direct transports when not provided', async () => { factory = await createFactory(); const callArgs = createLibp2p.mock.calls[0]?.[0]; diff --git a/packages/ocap-kernel/src/remotes/platform/connection-factory.ts b/packages/ocap-kernel/src/remotes/platform/connection-factory.ts index 17753ca35..336c7fddf 100644 --- a/packages/ocap-kernel/src/remotes/platform/connection-factory.ts +++ b/packages/ocap-kernel/src/remotes/platform/connection-factory.ts @@ -42,12 +42,10 @@ export class ConnectionFactory { readonly #maxRetryAttempts: number; - readonly #directTransport: - | { - transport: unknown; - listenAddresses: string[]; - } - | undefined; + readonly #directTransports: { + transport: unknown; + listenAddresses: string[]; + }[]; #inboundHandler?: InboundConnectionHandler; @@ -60,7 +58,7 @@ export class ConnectionFactory { * @param options.logger - The logger to use for the libp2p node. * @param options.signal - The signal to use for the libp2p node. * @param options.maxRetryAttempts - Maximum number of reconnection attempts. 0 = infinite (default). - * @param options.directTransport - Optional direct transport (e.g. QUIC) with listen addresses. + * @param options.directTransports - Optional direct transports (e.g. QUIC, TCP) with listen addresses. */ // eslint-disable-next-line no-restricted-syntax private constructor(options: ConnectionFactoryOptions) { @@ -69,7 +67,7 @@ export class ConnectionFactory { this.#logger = options.logger; this.#signal = options.signal; this.#maxRetryAttempts = options.maxRetryAttempts ?? 0; - this.#directTransport = options.directTransport; + this.#directTransports = options.directTransports ?? []; } /** @@ -81,7 +79,7 @@ export class ConnectionFactory { * @param options.logger - The logger to use for the libp2p node. * @param options.signal - The signal to use for the libp2p node. * @param options.maxRetryAttempts - Maximum number of reconnection attempts. 0 = infinite (default). - * @param options.directTransport - Optional direct transport (e.g. QUIC) with listen addresses. + * @param options.directTransports - Optional direct transports (e.g. QUIC, TCP) with listen addresses. * @returns A promise for the new ConnectionFactory instance. */ static async make( @@ -104,7 +102,7 @@ export class ConnectionFactory { listen: [ '/webrtc', '/p2p-circuit', - ...(this.#directTransport?.listenAddresses ?? []), + ...this.#directTransports.flatMap((dt) => dt.listenAddresses), ], appendAnnounce: ['/webrtc'], }, @@ -124,9 +122,9 @@ export class ConnectionFactory { }, }), circuitRelayTransport(), - ...(this.#directTransport - ? [this.#directTransport.transport as ReturnType] - : []), + ...this.#directTransports.map( + (dt) => dt.transport as ReturnType, + ), ], connectionEncrypters: [noise()], streamMuxers: [yamux()], diff --git a/packages/ocap-kernel/src/remotes/platform/transport.test.ts b/packages/ocap-kernel/src/remotes/platform/transport.test.ts index 58b5b8f39..3c28b6667 100644 --- a/packages/ocap-kernel/src/remotes/platform/transport.test.ts +++ b/packages/ocap-kernel/src/remotes/platform/transport.test.ts @@ -248,7 +248,7 @@ describe('transport.initTransport', () => { logger: expect.any(Object), signal: expect.any(AbortSignal), maxRetryAttempts: undefined, - directTransport: undefined, + directTransports: undefined, }); }); @@ -265,20 +265,27 @@ describe('transport.initTransport', () => { logger: expect.any(Object), signal: expect.any(AbortSignal), maxRetryAttempts, - directTransport: undefined, + directTransports: undefined, }); }); - it('passes directTransport to ConnectionFactory.make', async () => { + it('passes directTransports to ConnectionFactory.make', async () => { const { ConnectionFactory } = await import('./connection-factory.ts'); const keySeed = '0xabcd'; - const mockTransport = { tag: 'quic' }; - const directTransport = { - transport: mockTransport, - listenAddresses: ['/ip4/0.0.0.0/udp/0/quic-v1'], - }; + const mockQuic = { tag: 'quic' }; + const mockTcp = { tag: 'tcp' }; + const directTransports = [ + { + transport: mockQuic, + listenAddresses: ['/ip4/0.0.0.0/udp/0/quic-v1'], + }, + { + transport: mockTcp, + listenAddresses: ['/ip4/0.0.0.0/tcp/4001'], + }, + ]; - await initTransport(keySeed, { relays: [], directTransport }, vi.fn()); + await initTransport(keySeed, { relays: [], directTransports }, vi.fn()); expect(ConnectionFactory.make).toHaveBeenCalledWith({ keySeed, @@ -286,7 +293,7 @@ describe('transport.initTransport', () => { logger: expect.any(Object), signal: expect.any(AbortSignal), maxRetryAttempts: undefined, - directTransport, + directTransports, }); }); diff --git a/packages/ocap-kernel/src/remotes/platform/transport.ts b/packages/ocap-kernel/src/remotes/platform/transport.ts index 92a3d7df2..312e1b59b 100644 --- a/packages/ocap-kernel/src/remotes/platform/transport.ts +++ b/packages/ocap-kernel/src/remotes/platform/transport.ts @@ -87,7 +87,7 @@ export async function initTransport( stalePeerTimeoutMs = DEFAULT_STALE_PEER_TIMEOUT_MS, maxMessagesPerSecond = DEFAULT_MESSAGE_RATE_LIMIT, maxConnectionAttemptsPerMinute = DEFAULT_CONNECTION_RATE_LIMIT, - directTransport, + directTransports, } = options; let cleanupWakeDetector: (() => void) | undefined; const stopController = new AbortController(); @@ -123,7 +123,7 @@ export async function initTransport( logger, signal, maxRetryAttempts, - directTransport, + directTransports, }); // Create handshake dependencies (only if incarnation ID is configured). diff --git a/packages/ocap-kernel/src/remotes/types.ts b/packages/ocap-kernel/src/remotes/types.ts index 0a604d651..2cb986ba5 100644 --- a/packages/ocap-kernel/src/remotes/types.ts +++ b/packages/ocap-kernel/src/remotes/types.ts @@ -101,24 +101,24 @@ export type RemoteCommsOptions = { */ maxConnectionAttemptsPerMinute?: number | undefined; /** - * Direct listen addresses for non-relay transports (e.g. QUIC). - * Example: `['/ip4/0.0.0.0/udp/0/quic-v1']` + * Direct listen addresses for non-relay transports (e.g. QUIC, TCP). + * Example: `['/ip4/0.0.0.0/udp/0/quic-v1', '/ip4/0.0.0.0/tcp/4001']` * - * The platform layer detects the required transport from the address strings - * and injects it automatically. Users never need to import transport packages. + * The platform layer detects the required transports from the address strings + * and injects them automatically. Users never need to import transport packages. */ directListenAddresses?: string[] | undefined; /** - * Internal option injected by platform services. Bundles a direct transport - * implementation with its listen addresses. Users should use + * Internal option injected by platform services. Bundles direct transport + * implementations with their listen addresses. Users should use * `directListenAddresses` instead. * * @internal */ - directTransport?: { + directTransports?: { transport: unknown; listenAddresses: string[]; - }; + }[]; }; /** @@ -130,11 +130,11 @@ export type ConnectionFactoryOptions = { logger: Logger; signal: AbortSignal; maxRetryAttempts?: number | undefined; - directTransport?: + directTransports?: | { transport: unknown; listenAddresses: string[]; - } + }[] | undefined; }; diff --git a/packages/ocap-kernel/src/vats/VatSupervisor.ts b/packages/ocap-kernel/src/vats/VatSupervisor.ts index c8ddb25ce..98ae4f51d 100644 --- a/packages/ocap-kernel/src/vats/VatSupervisor.ts +++ b/packages/ocap-kernel/src/vats/VatSupervisor.ts @@ -43,7 +43,6 @@ import { isVatConfig, coerceVatSyscallObject } from '../types.ts'; const makeLiveSlots: MakeLiveSlotsFn = localMakeLiveSlots; -// eslint-disable-next-line n/no-unsupported-features/node-builtins export type FetchBlob = (bundleURL: string) => Promise; type SupervisorRpcClient = Pick< diff --git a/yarn.lock b/yarn.lock index e43403629..cc2c6e1c8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3963,6 +3963,7 @@ __metadata: "@endo/eventual-send": "npm:^1.3.4" "@endo/promise-kit": "npm:^1.1.13" "@libp2p/interface": "npm:2.11.0" + "@libp2p/tcp": "npm:10.1.19" "@libp2p/webrtc": "npm:5.2.24" "@metamask/auto-changelog": "npm:^5.3.0" "@metamask/eslint-config": "npm:^15.0.0" From 0e81173f519bf6d3ac42725dfbdb0fdcddc60978 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Fri, 13 Feb 2026 15:07:01 +0100 Subject: [PATCH 03/11] fix: add getListenAddresses to all PlatformServices mocks Add missing getListenAddresses method to PlatformServices mocks in kernel-test, ocap-kernel test helpers, Kernel.test, and PlatformServicesServer.test to match the updated type. Co-Authored-By: Claude Opus 4.6 --- .../kernel-browser-runtime/src/PlatformServicesClient.ts | 4 ++-- .../src/PlatformServicesServer.test.ts | 1 + packages/kernel-test/src/remote-comms.test.ts | 4 ++++ packages/nodejs/src/kernel/PlatformServices.ts | 2 +- packages/ocap-kernel/src/Kernel.test.ts | 1 + .../ocap-kernel/src/remotes/platform/connection-factory.ts | 6 +----- packages/ocap-kernel/src/types.ts | 2 +- packages/ocap-kernel/test/remotes-mocks.ts | 1 + 8 files changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/kernel-browser-runtime/src/PlatformServicesClient.ts b/packages/kernel-browser-runtime/src/PlatformServicesClient.ts index ec0037d7e..c56dee697 100644 --- a/packages/kernel-browser-runtime/src/PlatformServicesClient.ts +++ b/packages/kernel-browser-runtime/src/PlatformServicesClient.ts @@ -291,9 +291,9 @@ export class PlatformServicesClient implements PlatformServices { } /** - * Get the actual listen addresses of the libp2p node. + * Get the listen addresses of the libp2p node. * In the browser runtime, this always returns an empty array since - * direct transport (QUIC) is only supported in Node.js. + * direct transport is only supported in Node.js. * * @returns An empty array. */ diff --git a/packages/kernel-browser-runtime/src/PlatformServicesServer.test.ts b/packages/kernel-browser-runtime/src/PlatformServicesServer.test.ts index 6b6eba5e3..abe83cf05 100644 --- a/packages/kernel-browser-runtime/src/PlatformServicesServer.test.ts +++ b/packages/kernel-browser-runtime/src/PlatformServicesServer.test.ts @@ -50,6 +50,7 @@ vi.mock('@metamask/ocap-kernel', () => ({ closeConnection: mockCloseConnection, registerLocationHints: mockRegisterLocationHints, reconnectPeer: mockReconnectPeer, + getListenAddresses: vi.fn(() => []), }; }, ), diff --git a/packages/kernel-test/src/remote-comms.test.ts b/packages/kernel-test/src/remote-comms.test.ts index 63ae1badf..6d57d8d11 100644 --- a/packages/kernel-test/src/remote-comms.test.ts +++ b/packages/kernel-test/src/remote-comms.test.ts @@ -139,6 +139,10 @@ class DirectNetworkService { // Mock implementation - in direct network, connections are always available return Promise.resolve(); }, + + getListenAddresses() { + return []; + }, }; } } diff --git a/packages/nodejs/src/kernel/PlatformServices.ts b/packages/nodejs/src/kernel/PlatformServices.ts index 79f510f6a..38aa140b1 100644 --- a/packages/nodejs/src/kernel/PlatformServices.ts +++ b/packages/nodejs/src/kernel/PlatformServices.ts @@ -392,7 +392,7 @@ export class NodejsPlatformServices implements PlatformServices { } /** - * Get the actual listen addresses of the libp2p node. + * Get the listen addresses of the libp2p node. * Returns multiaddr strings that other peers can use to dial this node directly. * * @returns The listen address strings, or empty array if remote comms not initialized. diff --git a/packages/ocap-kernel/src/Kernel.test.ts b/packages/ocap-kernel/src/Kernel.test.ts index 021f52fc6..d39aa526e 100644 --- a/packages/ocap-kernel/src/Kernel.test.ts +++ b/packages/ocap-kernel/src/Kernel.test.ts @@ -111,6 +111,7 @@ describe('Kernel', () => { terminateAll: async () => undefined, stopRemoteComms: vi.fn(async () => undefined), resetAllBackoffs: vi.fn(async () => undefined), + getListenAddresses: vi.fn(() => []), } as unknown as PlatformServices; launchWorkerMock = vi diff --git a/packages/ocap-kernel/src/remotes/platform/connection-factory.ts b/packages/ocap-kernel/src/remotes/platform/connection-factory.ts index 336c7fddf..85d34be40 100644 --- a/packages/ocap-kernel/src/remotes/platform/connection-factory.ts +++ b/packages/ocap-kernel/src/remotes/platform/connection-factory.ts @@ -184,7 +184,7 @@ export class ConnectionFactory { } /** - * Get the actual listen addresses of the libp2p node. + * Get the listen addresses of the libp2p node. * These are the multiaddr strings that other peers can use to dial this node. * * @returns The listen address strings. @@ -212,10 +212,6 @@ export class ConnectionFactory { /** * Get candidate address strings for dialing a peer. * - * Hints that already contain `/p2p/{peerId}` are treated as direct addresses - * (e.g. QUIC multiaddrs) and are used as-is. Other hints are relay addresses - * and are expanded with circuit-relay patterns. Direct addresses are tried first. - * * @param peerId - The peer ID to get candidate address strings for. * @param hints - The hints to get candidate address strings for. * @returns The candidate address strings. diff --git a/packages/ocap-kernel/src/types.ts b/packages/ocap-kernel/src/types.ts index 5b7e644a0..c77d6ff89 100644 --- a/packages/ocap-kernel/src/types.ts +++ b/packages/ocap-kernel/src/types.ts @@ -378,7 +378,7 @@ export type PlatformServices = { */ resetAllBackoffs: () => Promise; /** - * Get the actual listen addresses of the libp2p node. + * Get the listen addresses of the libp2p node. * Returns multiaddr strings that other peers can use to dial this node directly. * Returns an empty array if remote comms is not initialized. * diff --git a/packages/ocap-kernel/test/remotes-mocks.ts b/packages/ocap-kernel/test/remotes-mocks.ts index 56bfa3659..5e27e9c62 100644 --- a/packages/ocap-kernel/test/remotes-mocks.ts +++ b/packages/ocap-kernel/test/remotes-mocks.ts @@ -62,6 +62,7 @@ export class MockRemotesFactory { registerLocationHints: vi.fn(), reconnectPeer: vi.fn(), resetAllBackoffs: vi.fn(), + getListenAddresses: vi.fn().mockReturnValue([]), }; } From af93bc58fe1093f7f55db7df7df4eb24c73d97b2 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Fri, 13 Feb 2026 15:10:28 +0100 Subject: [PATCH 04/11] fix test names --- packages/nodejs/test/e2e/quic-transport.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/nodejs/test/e2e/quic-transport.test.ts b/packages/nodejs/test/e2e/quic-transport.test.ts index 8bb4f55eb..3fec07e3b 100644 --- a/packages/nodejs/test/e2e/quic-transport.test.ts +++ b/packages/nodejs/test/e2e/quic-transport.test.ts @@ -115,7 +115,7 @@ describe.sequential('Direct Transport E2E', () => { describe('Initialization', () => { it( - 'initializes remote comms with QUIC transport without a relay', + 'initializes remote comms with QUIC transport', async () => { await kernel1.initRemoteComms({ directListenAddresses: [quicListenAddress], @@ -138,7 +138,7 @@ describe.sequential('Direct Transport E2E', () => { ); it( - 'initializes remote comms with TCP transport without a relay', + 'initializes remote comms with TCP transport', async () => { await kernel1.initRemoteComms({ directListenAddresses: [tcpListenAddress], @@ -180,7 +180,7 @@ describe.sequential('Direct Transport E2E', () => { it( 'sends a message via direct QUIC', async () => { - // Initialize both kernels with QUIC only — no relays + // Initialize both kernels with QUIC only await kernel1.initRemoteComms({ directListenAddresses: [quicListenAddress], }); From da41b7a1f87d5862261b27e4b7e8352f4ba7a249 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Fri, 13 Feb 2026 15:19:25 +0100 Subject: [PATCH 05/11] fix: address review findings for direct transport support - Wrap dynamic QUIC/TCP imports with actionable error messages - Validate unrecognized direct listen addresses (throw instead of silently dropping) - Fix timer leak in ConnectionFactory.stop() via clearTimeout in .finally() - Remove unnecessary `undefined as unknown as Libp2p` type assertion - Add missing listenAddresses to kernel-test status assertion - Add registerLocationHints to mock RemoteManager and unit test in Kernel.test - Add tests for dynamic import failures, empty relays+hints, unsupported addresses Co-Authored-By: Claude Opus 4.6 --- packages/kernel-test/src/remote-comms.test.ts | 2 + .../src/kernel/PlatformServices.test.ts | 62 +++++++++++++++++++ .../nodejs/src/kernel/PlatformServices.ts | 60 +++++++++++++----- packages/ocap-kernel/src/Kernel.test.ts | 20 ++++++ .../platform/connection-factory.test.ts | 28 +++++++++ .../remotes/platform/connection-factory.ts | 13 ++-- 6 files changed, 163 insertions(+), 22 deletions(-) diff --git a/packages/kernel-test/src/remote-comms.test.ts b/packages/kernel-test/src/remote-comms.test.ts index 6d57d8d11..aae2e1250 100644 --- a/packages/kernel-test/src/remote-comms.test.ts +++ b/packages/kernel-test/src/remote-comms.test.ts @@ -290,10 +290,12 @@ describe('Remote Communications (Integration Tests)', () => { expect(status1.remoteComms).toStrictEqual({ state: 'connected', peerId: expect.any(String), + listenAddresses: [], }); expect(status2.remoteComms).toStrictEqual({ state: 'connected', peerId: expect.any(String), + listenAddresses: [], }); // Each kernel should have a unique peer ID const peerId1 = diff --git a/packages/nodejs/src/kernel/PlatformServices.test.ts b/packages/nodejs/src/kernel/PlatformServices.test.ts index fa68cdd30..7a9dfc3d1 100644 --- a/packages/nodejs/src/kernel/PlatformServices.test.ts +++ b/packages/nodejs/src/kernel/PlatformServices.test.ts @@ -501,6 +501,68 @@ describe('NodejsPlatformServices', () => { ); }); + it('throws for unsupported direct listen addresses', async () => { + const service = new NodejsPlatformServices({ workerFilePath }); + const remoteHandler = vi.fn(async () => 'response'); + + await expect( + service.initializeRemoteComms( + '0xtest', + { + directListenAddresses: ['/ip4/0.0.0.0/udp/0/webrtc'], + }, + remoteHandler, + ), + ).rejects.toThrowError('Unsupported direct listen address'); + }); + + it('wraps QUIC import failure with actionable error', async () => { + vi.doMock('@chainsafe/libp2p-quic', () => { + throw new Error('native module not found'); + }); + + // Re-import PlatformServices to pick up the new mock + vi.resetModules(); + const { NodejsPlatformServices: FreshServices } = await import( + './PlatformServices.ts' + ); + const service = new FreshServices({ workerFilePath }); + const remoteHandler = vi.fn(async () => 'response'); + + await expect( + service.initializeRemoteComms( + '0xtest', + { + directListenAddresses: ['/ip4/0.0.0.0/udp/0/quic-v1'], + }, + remoteHandler, + ), + ).rejects.toThrowError('Failed to load QUIC transport'); + }); + + it('wraps TCP import failure with actionable error', async () => { + vi.doMock('@libp2p/tcp', () => { + throw new Error('module not found'); + }); + + vi.resetModules(); + const { NodejsPlatformServices: FreshServices } = await import( + './PlatformServices.ts' + ); + const service = new FreshServices({ workerFilePath }); + const remoteHandler = vi.fn(async () => 'response'); + + await expect( + service.initializeRemoteComms( + '0xtest', + { + directListenAddresses: ['/ip4/0.0.0.0/tcp/4001'], + }, + remoteHandler, + ), + ).rejects.toThrowError('Failed to load TCP transport'); + }); + it('does not inject directTransports when no directListenAddresses provided', async () => { const service = new NodejsPlatformServices({ workerFilePath }); const keySeed = '0x1234567890abcdef'; diff --git a/packages/nodejs/src/kernel/PlatformServices.ts b/packages/nodejs/src/kernel/PlatformServices.ts index 38aa140b1..529b61327 100644 --- a/packages/nodejs/src/kernel/PlatformServices.ts +++ b/packages/nodejs/src/kernel/PlatformServices.ts @@ -264,27 +264,55 @@ export class NodejsPlatformServices implements PlatformServices { }[] = []; if (directListenAddresses && directListenAddresses.length > 0) { - const quicAddresses = directListenAddresses.filter((addr) => - addr.includes('/quic-v1'), - ); - const tcpAddresses = directListenAddresses.filter((addr) => - addr.includes('/tcp/'), - ); + const quicAddresses: string[] = []; + const tcpAddresses: string[] = []; + + for (const addr of directListenAddresses) { + const isQuic = addr.includes('/quic-v1'); + const isTcp = addr.includes('/tcp/'); + + if (isQuic) { + quicAddresses.push(addr); + } else if (isTcp) { + tcpAddresses.push(addr); + } else { + throw new Error( + `Unsupported direct listen address: ${addr}. ` + + `Only QUIC (/quic-v1) and TCP (/tcp/) addresses are supported.`, + ); + } + } if (quicAddresses.length > 0) { - const { quic } = await import('@chainsafe/libp2p-quic'); - directTransports.push({ - transport: quic(), - listenAddresses: quicAddresses, - }); + try { + const { quic } = await import('@chainsafe/libp2p-quic'); + directTransports.push({ + transport: quic(), + listenAddresses: quicAddresses, + }); + } catch (error) { + throw new Error( + `Failed to load QUIC transport for addresses: ${quicAddresses.join(', ')}. ` + + `Ensure @chainsafe/libp2p-quic is installed and compatible with your platform.`, + { cause: error }, + ); + } } if (tcpAddresses.length > 0) { - const { tcp } = await import('@libp2p/tcp'); - directTransports.push({ - transport: tcp(), - listenAddresses: tcpAddresses, - }); + try { + const { tcp } = await import('@libp2p/tcp'); + directTransports.push({ + transport: tcp(), + listenAddresses: tcpAddresses, + }); + } catch (error) { + throw new Error( + `Failed to load TCP transport for addresses: ${tcpAddresses.join(', ')}. ` + + `Ensure @libp2p/tcp is installed.`, + { cause: error }, + ); + } } } diff --git a/packages/ocap-kernel/src/Kernel.test.ts b/packages/ocap-kernel/src/Kernel.test.ts index d39aa526e..336398c71 100644 --- a/packages/ocap-kernel/src/Kernel.test.ts +++ b/packages/ocap-kernel/src/Kernel.test.ts @@ -54,6 +54,8 @@ const mocks = vi.hoisted(() => { reconnectPeer = vi.fn().mockResolvedValue(undefined); + registerLocationHints = vi.fn().mockResolvedValue(undefined); + getPeerId = vi.fn().mockReturnValue('mock-peer-id'); constructor() { @@ -1051,5 +1053,23 @@ describe('Kernel', () => { ); }); }); + + describe('registerLocationHints()', () => { + it('registers location hints via RemoteManager', async () => { + const kernel = await Kernel.make( + mockPlatformServices, + mockKernelDatabase, + ); + const remoteManagerInstance = mocks.RemoteManager.lastInstance; + await kernel.registerLocationHints('peer-123', [ + '/ip4/192.168.1.1/udp/4001/quic-v1/p2p/peer-123', + ]); + expect( + remoteManagerInstance.registerLocationHints, + ).toHaveBeenCalledWith('peer-123', [ + '/ip4/192.168.1.1/udp/4001/quic-v1/p2p/peer-123', + ]); + }); + }); }); }); diff --git a/packages/ocap-kernel/src/remotes/platform/connection-factory.test.ts b/packages/ocap-kernel/src/remotes/platform/connection-factory.test.ts index 15742bd3d..4d79bfa89 100644 --- a/packages/ocap-kernel/src/remotes/platform/connection-factory.test.ts +++ b/packages/ocap-kernel/src/remotes/platform/connection-factory.test.ts @@ -416,6 +416,20 @@ describe('ConnectionFactory', () => { ); expect(relay1Addresses).toHaveLength(2); // Just WebRTC and circuit }); + + it('returns empty array when no relays and no hints', async () => { + const { ConnectionFactory } = await import('./connection-factory.ts'); + factory = await ConnectionFactory.make({ + keySeed, + knownRelays: [], + logger: new (await import('@metamask/logger')).Logger(), + signal: new AbortController().signal, + }); + + const addresses = factory.candidateAddressStrings('peer123', []); + + expect(addresses).toStrictEqual([]); + }); }); describe('openChannelOnce', () => { @@ -611,6 +625,20 @@ describe('ConnectionFactory', () => { expect.stringContaining('opened channel to peer123'), ); }); + + it('throws fallback error when no relays and no hints', async () => { + const { ConnectionFactory } = await import('./connection-factory.ts'); + factory = await ConnectionFactory.make({ + keySeed, + knownRelays: [], + logger: new (await import('@metamask/logger')).Logger(), + signal: new AbortController().signal, + }); + + await expect(factory.openChannelOnce('peer123')).rejects.toThrow( + 'unable to open channel to peer123', + ); + }); }); describe('openChannelWithRetry', () => { diff --git a/packages/ocap-kernel/src/remotes/platform/connection-factory.ts b/packages/ocap-kernel/src/remotes/platform/connection-factory.ts index 85d34be40..4027c3ab1 100644 --- a/packages/ocap-kernel/src/remotes/platform/connection-factory.ts +++ b/packages/ocap-kernel/src/remotes/platform/connection-factory.ts @@ -428,20 +428,21 @@ export class ConnectionFactory { try { // Add a timeout to prevent hanging if libp2p.stop() doesn't complete const STOP_TIMEOUT_MS = 2000; + let timeoutId: ReturnType; await Promise.race([ this.#libp2p.stop(), - new Promise((_resolve, reject) => - setTimeout( + new Promise((_resolve, reject) => { + timeoutId = setTimeout( () => reject(new Error('libp2p.stop() timed out')), STOP_TIMEOUT_MS, - ), - ), - ]); + ); + }), + ]).finally(() => clearTimeout(timeoutId)); } catch (error) { this.#logger.error('libp2p.stop() failed or timed out:', error); // Continue anyway - we'll clear the reference } - this.#libp2p = undefined as unknown as Libp2p; + this.#libp2p = undefined; } } } From 9b0a5a9f1597ae4a49155100db7a2ddf5f6cef08 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Fri, 13 Feb 2026 15:20:20 +0100 Subject: [PATCH 06/11] fix: restore type assertion for exactOptionalPropertyTypes compatibility Co-Authored-By: Claude Opus 4.6 --- packages/ocap-kernel/src/remotes/platform/connection-factory.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ocap-kernel/src/remotes/platform/connection-factory.ts b/packages/ocap-kernel/src/remotes/platform/connection-factory.ts index 4027c3ab1..3e858d230 100644 --- a/packages/ocap-kernel/src/remotes/platform/connection-factory.ts +++ b/packages/ocap-kernel/src/remotes/platform/connection-factory.ts @@ -442,7 +442,7 @@ export class ConnectionFactory { this.#logger.error('libp2p.stop() failed or timed out:', error); // Continue anyway - we'll clear the reference } - this.#libp2p = undefined; + this.#libp2p = undefined as unknown as Libp2p; } } } From de0ed1aa13ca8b9efe22c819771bb8e0be929d9c Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Fri, 13 Feb 2026 15:30:10 +0100 Subject: [PATCH 07/11] refactor: remove unnecessary try/catch around dynamic transport imports Both @chainsafe/libp2p-quic and @libp2p/tcp are hard dependencies that ship with @ocap/nodejs. Wrapping their imports adds noise without value. Co-Authored-By: Claude Opus 4.6 --- .../src/kernel/PlatformServices.test.ts | 47 ------------------- .../nodejs/src/kernel/PlatformServices.ts | 36 ++++---------- 2 files changed, 10 insertions(+), 73 deletions(-) diff --git a/packages/nodejs/src/kernel/PlatformServices.test.ts b/packages/nodejs/src/kernel/PlatformServices.test.ts index 7a9dfc3d1..17bcd3f8e 100644 --- a/packages/nodejs/src/kernel/PlatformServices.test.ts +++ b/packages/nodejs/src/kernel/PlatformServices.test.ts @@ -516,53 +516,6 @@ describe('NodejsPlatformServices', () => { ).rejects.toThrowError('Unsupported direct listen address'); }); - it('wraps QUIC import failure with actionable error', async () => { - vi.doMock('@chainsafe/libp2p-quic', () => { - throw new Error('native module not found'); - }); - - // Re-import PlatformServices to pick up the new mock - vi.resetModules(); - const { NodejsPlatformServices: FreshServices } = await import( - './PlatformServices.ts' - ); - const service = new FreshServices({ workerFilePath }); - const remoteHandler = vi.fn(async () => 'response'); - - await expect( - service.initializeRemoteComms( - '0xtest', - { - directListenAddresses: ['/ip4/0.0.0.0/udp/0/quic-v1'], - }, - remoteHandler, - ), - ).rejects.toThrowError('Failed to load QUIC transport'); - }); - - it('wraps TCP import failure with actionable error', async () => { - vi.doMock('@libp2p/tcp', () => { - throw new Error('module not found'); - }); - - vi.resetModules(); - const { NodejsPlatformServices: FreshServices } = await import( - './PlatformServices.ts' - ); - const service = new FreshServices({ workerFilePath }); - const remoteHandler = vi.fn(async () => 'response'); - - await expect( - service.initializeRemoteComms( - '0xtest', - { - directListenAddresses: ['/ip4/0.0.0.0/tcp/4001'], - }, - remoteHandler, - ), - ).rejects.toThrowError('Failed to load TCP transport'); - }); - it('does not inject directTransports when no directListenAddresses provided', async () => { const service = new NodejsPlatformServices({ workerFilePath }); const keySeed = '0x1234567890abcdef'; diff --git a/packages/nodejs/src/kernel/PlatformServices.ts b/packages/nodejs/src/kernel/PlatformServices.ts index 529b61327..ee2fd161a 100644 --- a/packages/nodejs/src/kernel/PlatformServices.ts +++ b/packages/nodejs/src/kernel/PlatformServices.ts @@ -284,35 +284,19 @@ export class NodejsPlatformServices implements PlatformServices { } if (quicAddresses.length > 0) { - try { - const { quic } = await import('@chainsafe/libp2p-quic'); - directTransports.push({ - transport: quic(), - listenAddresses: quicAddresses, - }); - } catch (error) { - throw new Error( - `Failed to load QUIC transport for addresses: ${quicAddresses.join(', ')}. ` + - `Ensure @chainsafe/libp2p-quic is installed and compatible with your platform.`, - { cause: error }, - ); - } + const { quic } = await import('@chainsafe/libp2p-quic'); + directTransports.push({ + transport: quic(), + listenAddresses: quicAddresses, + }); } if (tcpAddresses.length > 0) { - try { - const { tcp } = await import('@libp2p/tcp'); - directTransports.push({ - transport: tcp(), - listenAddresses: tcpAddresses, - }); - } catch (error) { - throw new Error( - `Failed to load TCP transport for addresses: ${tcpAddresses.join(', ')}. ` + - `Ensure @libp2p/tcp is installed.`, - { cause: error }, - ); - } + const { tcp } = await import('@libp2p/tcp'); + directTransports.push({ + transport: tcp(), + listenAddresses: tcpAddresses, + }); } } From 958d67d00a59cd24a7532fa2d6d04ff9a10a4313 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Fri, 13 Feb 2026 15:31:18 +0100 Subject: [PATCH 08/11] refactor: use static imports for QUIC and TCP transports Both packages are hard dependencies of @ocap/nodejs. Dynamic imports added unnecessary indirection with no benefit. Co-Authored-By: Claude Opus 4.6 --- packages/nodejs/src/kernel/PlatformServices.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/nodejs/src/kernel/PlatformServices.ts b/packages/nodejs/src/kernel/PlatformServices.ts index ee2fd161a..dc99fa637 100644 --- a/packages/nodejs/src/kernel/PlatformServices.ts +++ b/packages/nodejs/src/kernel/PlatformServices.ts @@ -1,4 +1,6 @@ +import { quic } from '@chainsafe/libp2p-quic'; import { makePromiseKit } from '@endo/promise-kit'; +import { tcp } from '@libp2p/tcp'; import { isJsonRpcMessage } from '@metamask/kernel-utils'; import type { JsonRpcMessage } from '@metamask/kernel-utils'; import { Logger } from '@metamask/logger'; @@ -284,7 +286,6 @@ export class NodejsPlatformServices implements PlatformServices { } if (quicAddresses.length > 0) { - const { quic } = await import('@chainsafe/libp2p-quic'); directTransports.push({ transport: quic(), listenAddresses: quicAddresses, @@ -292,7 +293,6 @@ export class NodejsPlatformServices implements PlatformServices { } if (tcpAddresses.length > 0) { - const { tcp } = await import('@libp2p/tcp'); directTransports.push({ transport: tcp(), listenAddresses: tcpAddresses, From 77512a80ec875e96cf0ae0f245b817d972e961b7 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Fri, 13 Feb 2026 19:33:50 +0100 Subject: [PATCH 09/11] fix: address PR review comments - Add clarifying comment for conditional peer discovery - Use multiaddr().getPeerId() instead of string matching for direct address detection in candidateAddressStrings() Co-Authored-By: Claude Opus 4.6 --- .../src/remotes/platform/connection-factory.test.ts | 11 +++++++++-- .../src/remotes/platform/connection-factory.ts | 3 ++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/ocap-kernel/src/remotes/platform/connection-factory.test.ts b/packages/ocap-kernel/src/remotes/platform/connection-factory.test.ts index 4d79bfa89..14a491b35 100644 --- a/packages/ocap-kernel/src/remotes/platform/connection-factory.test.ts +++ b/packages/ocap-kernel/src/remotes/platform/connection-factory.test.ts @@ -98,7 +98,14 @@ vi.mock('@metamask/logger', () => ({ })); vi.mock('@multiformats/multiaddr', () => ({ - multiaddr: (addr: string) => addr, + multiaddr: (addr: string) => { + // Extract the last /p2p/ segment if present + const peerIdMatch = addr.match(/\/p2p\/([^/]+)$/u); + return { + toString: () => addr, + getPeerId: () => peerIdMatch?.[1] ?? null, + }; + }, })); // Simple ByteStream mock @@ -1261,7 +1268,7 @@ describe('ConnectionFactory', () => { // Verify dial was made expect(libp2pState.dials).toHaveLength(1); - expect(libp2pState.dials[0]?.addr).toContain('hint.example'); + expect(libp2pState.dials[0]?.addr.toString()).toContain('hint.example'); // Clean up await factory.stop(); diff --git a/packages/ocap-kernel/src/remotes/platform/connection-factory.ts b/packages/ocap-kernel/src/remotes/platform/connection-factory.ts index 3e858d230..1740fd254 100644 --- a/packages/ocap-kernel/src/remotes/platform/connection-factory.ts +++ b/packages/ocap-kernel/src/remotes/platform/connection-factory.ts @@ -132,6 +132,7 @@ export class ConnectionFactory { // Allow private addresses for local testing denyDialMultiaddr: async () => false, }, + // No peer discovery in direct connection mode ...(this.#knownRelays.length > 0 ? { peerDiscovery: [ @@ -221,7 +222,7 @@ export class ConnectionFactory { const relayHints: string[] = []; for (const hint of hints) { - if (hint.includes(`/p2p/${peerId}`)) { + if (multiaddr(hint).getPeerId() === peerId) { directAddresses.push(hint); } else { relayHints.push(hint); From ed82cf09ffec87388b58090a3f4562c23da561b7 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Fri, 13 Feb 2026 19:39:23 +0100 Subject: [PATCH 10/11] refactor: reuse DirectTransport type from types.ts Co-Authored-By: Claude Opus 4.6 --- .../nodejs/src/kernel/PlatformServices.ts | 6 ++---- packages/ocap-kernel/src/index.ts | 1 + .../platform/connection-factory.test.ts | 5 +---- .../remotes/platform/connection-factory.ts | 6 ++---- packages/ocap-kernel/src/remotes/types.ts | 20 +++++++++---------- 5 files changed, 16 insertions(+), 22 deletions(-) diff --git a/packages/nodejs/src/kernel/PlatformServices.ts b/packages/nodejs/src/kernel/PlatformServices.ts index dc99fa637..71fcf80bf 100644 --- a/packages/nodejs/src/kernel/PlatformServices.ts +++ b/packages/nodejs/src/kernel/PlatformServices.ts @@ -5,6 +5,7 @@ import { isJsonRpcMessage } from '@metamask/kernel-utils'; import type { JsonRpcMessage } from '@metamask/kernel-utils'; import { Logger } from '@metamask/logger'; import type { + DirectTransport, PlatformServices, VatId, RemoteMessageHandler, @@ -260,10 +261,7 @@ export class NodejsPlatformServices implements PlatformServices { const { directListenAddresses, ...restOptions } = options; - const directTransports: { - transport: unknown; - listenAddresses: string[]; - }[] = []; + const directTransports: DirectTransport[] = []; if (directListenAddresses && directListenAddresses.length > 0) { const quicAddresses: string[] = []; diff --git a/packages/ocap-kernel/src/index.ts b/packages/ocap-kernel/src/index.ts index 4e402dca9..c9e1b804b 100644 --- a/packages/ocap-kernel/src/index.ts +++ b/packages/ocap-kernel/src/index.ts @@ -18,6 +18,7 @@ export type { SystemSubclusterConfig, } from './types.ts'; export type { + DirectTransport, RemoteIdentity, RemoteMessageHandler, SendRemoteMessage, diff --git a/packages/ocap-kernel/src/remotes/platform/connection-factory.test.ts b/packages/ocap-kernel/src/remotes/platform/connection-factory.test.ts index 14a491b35..8ffef30e5 100644 --- a/packages/ocap-kernel/src/remotes/platform/connection-factory.test.ts +++ b/packages/ocap-kernel/src/remotes/platform/connection-factory.test.ts @@ -214,10 +214,7 @@ describe('ConnectionFactory', () => { async function createFactory(options?: { signal?: AbortSignal; maxRetryAttempts?: number; - directTransports?: { - transport: unknown; - listenAddresses: string[]; - }[]; + directTransports?: import('../types.ts').DirectTransport[]; }): Promise< Awaited< ReturnType< diff --git a/packages/ocap-kernel/src/remotes/platform/connection-factory.ts b/packages/ocap-kernel/src/remotes/platform/connection-factory.ts index 1740fd254..2a35f38d4 100644 --- a/packages/ocap-kernel/src/remotes/platform/connection-factory.ts +++ b/packages/ocap-kernel/src/remotes/platform/connection-factory.ts @@ -20,6 +20,7 @@ import { createLibp2p } from 'libp2p'; import type { Channel, ConnectionFactoryOptions, + DirectTransport, InboundConnectionHandler, } from '../types.ts'; @@ -42,10 +43,7 @@ export class ConnectionFactory { readonly #maxRetryAttempts: number; - readonly #directTransports: { - transport: unknown; - listenAddresses: string[]; - }[]; + readonly #directTransports: DirectTransport[]; #inboundHandler?: InboundConnectionHandler; diff --git a/packages/ocap-kernel/src/remotes/types.ts b/packages/ocap-kernel/src/remotes/types.ts index 2cb986ba5..93d474f38 100644 --- a/packages/ocap-kernel/src/remotes/types.ts +++ b/packages/ocap-kernel/src/remotes/types.ts @@ -115,10 +115,15 @@ export type RemoteCommsOptions = { * * @internal */ - directTransports?: { - transport: unknown; - listenAddresses: string[]; - }[]; + directTransports?: DirectTransport[]; +}; + +/** + * A direct transport implementation bundled with its listen addresses. + */ +export type DirectTransport = { + transport: unknown; + listenAddresses: string[]; }; /** @@ -130,12 +135,7 @@ export type ConnectionFactoryOptions = { logger: Logger; signal: AbortSignal; maxRetryAttempts?: number | undefined; - directTransports?: - | { - transport: unknown; - listenAddresses: string[]; - }[] - | undefined; + directTransports?: DirectTransport[] | undefined; }; export type RemoteInfo = { From 8e56a5eadb8451226b25d78a6afd74735ff13d92 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Fri, 13 Feb 2026 19:40:31 +0100 Subject: [PATCH 11/11] fix: skip malformed hints in candidateAddressStrings to preserve relay fallback A malformed hint passed to multiaddr() would throw and abort address generation before relay addresses were built. Now invalid hints are caught and skipped so relay fallback dialing still works. Co-Authored-By: Claude Opus 4.6 --- .../remotes/platform/connection-factory.test.ts | 17 +++++++++++++++++ .../src/remotes/platform/connection-factory.ts | 13 +++++++++---- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/packages/ocap-kernel/src/remotes/platform/connection-factory.test.ts b/packages/ocap-kernel/src/remotes/platform/connection-factory.test.ts index 8ffef30e5..0fc082abe 100644 --- a/packages/ocap-kernel/src/remotes/platform/connection-factory.test.ts +++ b/packages/ocap-kernel/src/remotes/platform/connection-factory.test.ts @@ -99,6 +99,10 @@ vi.mock('@metamask/logger', () => ({ vi.mock('@multiformats/multiaddr', () => ({ multiaddr: (addr: string) => { + // Real multiaddr() throws on malformed addresses + if (!addr.startsWith('/')) { + throw new Error(`invalid multiaddr "${addr}"`); + } // Extract the last /p2p/ segment if present const peerIdMatch = addr.match(/\/p2p\/([^/]+)$/u); return { @@ -1458,5 +1462,18 @@ describe('ConnectionFactory', () => { expect(addresses[1]).toContain('hint.example'); expect(addresses[1]).toContain('/p2p-circuit/'); }); + + it('skips malformed hints and still generates relay addresses', async () => { + factory = await createFactory(); + + const addresses = factory.candidateAddressStrings('peer123', [ + 'not-a-valid-multiaddr', + ]); + + // Malformed hint is skipped, relay addresses still generated + expect(addresses).not.toContain('not-a-valid-multiaddr'); + expect(addresses.length).toBeGreaterThan(0); + expect(addresses.every((a) => a.includes('/p2p-circuit/'))).toBe(true); + }); }); }); diff --git a/packages/ocap-kernel/src/remotes/platform/connection-factory.ts b/packages/ocap-kernel/src/remotes/platform/connection-factory.ts index 2a35f38d4..eece4d3fb 100644 --- a/packages/ocap-kernel/src/remotes/platform/connection-factory.ts +++ b/packages/ocap-kernel/src/remotes/platform/connection-factory.ts @@ -220,10 +220,15 @@ export class ConnectionFactory { const relayHints: string[] = []; for (const hint of hints) { - if (multiaddr(hint).getPeerId() === peerId) { - directAddresses.push(hint); - } else { - relayHints.push(hint); + try { + if (multiaddr(hint).getPeerId() === peerId) { + directAddresses.push(hint); + } else { + relayHints.push(hint); + } + } catch { + // Skip malformed hints so relay fallback still works. + this.#logger.log(`skipping malformed hint: ${hint}`); } }