From 8d123cf859d19004669e4b05bdb86a2f0e2b387b Mon Sep 17 00:00:00 2001 From: William Valentin Date: Mon, 16 Feb 2026 18:36:50 -0800 Subject: [PATCH] feat(companion): support runtime client autoConnect mode --- README.md | 2 +- docs/api/PROTOCOL.md | 2 +- docs/plans/state.json | 14 ++++++++++++++ src/companion/runtimeClient.test.ts | 27 +++++++++++++++++++++++++++ src/companion/runtimeClient.ts | 24 ++++++++++++++++++++++++ 5 files changed, 67 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3601884..09db157 100644 --- a/README.md +++ b/README.md @@ -1190,7 +1190,7 @@ Methods: - `system.capabilities` returns gateway protocol and node policy snapshot. Companion runtime helper: -- `src/companion/runtimeClient.ts` provides a typed Node/WebSocket client for companion runtimes (macOS/iOS/Android workers) with wrappers for `node.register`, `node.capabilities.get`, `node.location.set/get`, `node.status.set`, `node.push_token.set`, `system.capabilities`, `system.nodes`, and canvas artifact RPCs (`canvas.put/get/list/delete/clear`). +- `src/companion/runtimeClient.ts` provides a typed Node/WebSocket client for companion runtimes (macOS/iOS/Android workers) with wrappers for `node.register`, `node.capabilities.get`, `node.location.set/get`, `node.status.set`, `node.push_token.set`, `system.capabilities`, `system.nodes`, and canvas artifact RPCs (`canvas.put/get/list/delete/clear`), plus optional `autoConnect` mode for one-shot RPC calls. - `src/companion/platformClients.ts` provides platform-focused wrappers: - `MacOSCompanionClient` (`platform: "macos"`, APNs push registration) - `IOSCompanionClient` (`platform: "ios"`, APNs push registration) diff --git a/docs/api/PROTOCOL.md b/docs/api/PROTOCOL.md index 355d7c0..c1a3f38 100644 --- a/docs/api/PROTOCOL.md +++ b/docs/api/PROTOCOL.md @@ -1430,5 +1430,5 @@ For more implementation details, see: - Protocol types: `src/gateway/protocol.ts` - Handlers: `src/gateway/handlers/` - Gateway server: `src/gateway/server.ts` -- Companion runtime client helper: `src/companion/runtimeClient.ts` (node + system + `canvas.*` typed RPC wrappers) +- Companion runtime client helper: `src/companion/runtimeClient.ts` (node + system + `canvas.*` typed RPC wrappers, optional `autoConnect`) - Platform companion wrappers: `src/companion/platformClients.ts` diff --git a/docs/plans/state.json b/docs/plans/state.json index 483196d..c212aa5 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -232,6 +232,20 @@ ], "test_status": "pnpm test:run src/companion/platformClients.test.ts src/companion/platformClients.integration.test.ts + pnpm typecheck passing" }, + "companion-runtime-client-autoconnect": { + "status": "completed", + "date": "2026-02-17", + "updated": "2026-02-17", + "summary": "Added optional `autoConnect` support to `CompanionRuntimeClient` so RPC calls can establish connections on demand, including connect de-duplication to avoid concurrent open races, plus integration coverage.", + "files_modified": [ + "src/companion/runtimeClient.ts", + "src/companion/runtimeClient.test.ts", + "README.md", + "docs/api/PROTOCOL.md", + "docs/plans/state.json" + ], + "test_status": "pnpm test:run src/companion/runtimeClient.test.ts src/companion/platformClients.test.ts src/companion/platformClients.integration.test.ts + pnpm typecheck passing" + }, "browser-tools-activation-clarity": { "status": "completed", "date": "2026-02-17", diff --git a/src/companion/runtimeClient.test.ts b/src/companion/runtimeClient.test.ts index 9fad243..57c2762 100644 --- a/src/companion/runtimeClient.test.ts +++ b/src/companion/runtimeClient.test.ts @@ -268,4 +268,31 @@ describe('CompanionRuntimeClient', () => { client.disconnect(); } }); + + it('supports autoConnect mode for one-shot RPC usage', async () => { + if (!LISTEN_ALLOWED) { + return; + } + + const client = new CompanionRuntimeClient({ + url: `ws://127.0.0.1:${TEST_PORT}`, + token: TEST_TOKEN, + autoConnect: true, + }); + + expect(client.connected).toBe(false); + + try { + const register = await client.registerNode({ + nodeId: 'auto-connect-node', + role: 'companion', + capabilities: ['ui.canvas'], + }); + + expect(register.registered).toBe(true); + expect(client.connected).toBe(true); + } finally { + client.disconnect(); + } + }); }); diff --git a/src/companion/runtimeClient.ts b/src/companion/runtimeClient.ts index 6e0aca8..6b4c10f 100644 --- a/src/companion/runtimeClient.ts +++ b/src/companion/runtimeClient.ts @@ -37,6 +37,7 @@ export interface CompanionRuntimeClientOptions { url: string; token?: string; requestTimeoutMs?: number; + autoConnect?: boolean; websocketFactory?: (url: string) => WebSocket; } @@ -249,9 +250,11 @@ export class CompanionRuntimeClient { private readonly url: string; private readonly token?: string; private readonly requestTimeoutMs: number; + private readonly autoConnect: boolean; private readonly websocketFactory: (url: string) => WebSocket; private ws: WebSocket | null = null; + private connectPromise: Promise | null = null; private nextId = 1; private pending = new Map(); @@ -259,6 +262,7 @@ export class CompanionRuntimeClient { this.url = options.url; this.token = options.token; this.requestTimeoutMs = options.requestTimeoutMs ?? 15_000; + this.autoConnect = options.autoConnect ?? false; this.websocketFactory = options.websocketFactory ?? ((url) => new WebSocket(url)); } @@ -271,6 +275,19 @@ export class CompanionRuntimeClient { return; } + if (this.connectPromise) { + return this.connectPromise; + } + + this.connectPromise = this.openConnection(); + try { + await this.connectPromise; + } finally { + this.connectPromise = null; + } + } + + private async openConnection(): Promise { const ws = this.websocketFactory(withToken(this.url, this.token)); await new Promise((resolve, reject) => { @@ -326,6 +343,13 @@ export class CompanionRuntimeClient { } async call(method: string, params?: Record): Promise { + if (!this.connected) { + if (!this.autoConnect) { + throw new Error('WebSocket is not connected'); + } + await this.connect(); + } + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { throw new Error('WebSocket is not connected'); }