@livedesk/hub 0.1.41 → 0.1.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,100 +1,100 @@
1
- import readline from 'node:readline';
2
- import { AGENT_TOOL_DEFINITIONS } from './agent-tool-registry.js';
3
-
4
- const hubUrl = String(process.env.LIVEDESK_AGENT_MCP_URL || '').replace(/\/+$/, '');
5
- const token = String(process.env.LIVEDESK_AGENT_MCP_TOKEN || '');
6
- const protocolVersion = '2025-06-18';
7
-
8
- const toolDefinitions = AGENT_TOOL_DEFINITIONS.map(tool => ({
9
- name: tool.name,
10
- description: tool.description,
11
- inputSchema: { $schema: 'https://json-schema.org/draft/2020-12/schema', ...tool.inputSchema },
12
- annotations: {
13
- readOnlyHint: tool.readOnly === true,
14
- // Codex exec cannot service an interactive MCP approval prompt. Device
15
- // mutations are proposals at this boundary and remain fail-closed behind
16
- // LiveDesk's own signed permission policy and Hub approval workflow.
17
- destructiveHint: false,
18
- idempotentHint: tool.readOnly === true || tool.reversible === true,
19
- openWorldHint: false
20
- }
21
- }));
22
-
23
- function response(id, result) {
24
- process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id, result })}\n`);
25
- }
26
-
27
- function errorResponse(id, code, message) {
28
- process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id, error: { code, message } })}\n`);
29
- }
30
-
31
- async function callHub(name, args) {
32
- if (!hubUrl || !token) throw new Error('LiveDesk MCP session is not configured.');
33
- const result = await fetch(`${hubUrl}/api/internal/agent-mcp/tool`, {
34
- method: 'POST',
35
- headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
36
- body: JSON.stringify({ name, arguments: args && typeof args === 'object' ? args : {} })
37
- });
38
- const body = await result.json().catch(() => ({}));
39
- if (!result.ok || body.ok !== true) throw new Error(String(body.error || `LiveDesk tool failed (${result.status}).`));
40
- return body.result;
41
- }
42
-
43
- async function handle(message) {
44
- const id = message?.id;
45
- const method = String(message?.method || '');
46
- if (!method) return;
47
- if (method === 'initialize') {
48
- response(id, {
49
- protocolVersion,
50
- capabilities: { tools: { listChanged: false } },
51
- serverInfo: { name: 'livedesk', version: '1.0.0' }
52
- });
53
- return;
54
- }
55
- if (method === 'notifications/initialized' || method === 'ping') {
56
- if (id !== undefined) response(id, {});
57
- return;
58
- }
59
- if (method === 'tools/list') {
60
- response(id, { tools: toolDefinitions });
61
- return;
62
- }
63
- if (method === 'tools/call') {
64
- const name = String(message?.params?.name || '');
65
- if (!toolDefinitions.some(tool => tool.name === name)) {
66
- errorResponse(id, -32602, 'Unknown LiveDesk tool.');
67
- return;
68
- }
69
- try {
70
- const result = await callHub(name, message?.params?.arguments || {});
71
- response(id, {
72
- content: [{ type: 'text', text: JSON.stringify(result) }],
73
- structuredContent: result,
74
- isError: false
75
- });
76
- } catch (error) {
77
- response(id, {
78
- content: [{ type: 'text', text: String(error?.message || 'LiveDesk tool failed.') }],
79
- isError: true
80
- });
81
- }
82
- return;
83
- }
84
- if (id !== undefined) errorResponse(id, -32601, `Unsupported MCP method: ${method}`);
85
- }
86
-
87
- const input = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
88
- input.on('line', line => {
89
- const trimmed = String(line || '').trim();
90
- if (!trimmed) return;
91
- let message;
92
- try {
93
- message = JSON.parse(trimmed);
94
- } catch {
95
- return;
96
- }
97
- void handle(message).catch(error => {
98
- if (message?.id !== undefined) errorResponse(message.id, -32603, String(error?.message || 'MCP server error.'));
99
- });
100
- });
1
+ import readline from 'node:readline';
2
+ import { AGENT_TOOL_DEFINITIONS } from './agent-tool-registry.js';
3
+
4
+ const hubUrl = String(process.env.LIVEDESK_AGENT_MCP_URL || '').replace(/\/+$/, '');
5
+ const token = String(process.env.LIVEDESK_AGENT_MCP_TOKEN || '');
6
+ const protocolVersion = '2025-06-18';
7
+
8
+ const toolDefinitions = AGENT_TOOL_DEFINITIONS.map(tool => ({
9
+ name: tool.name,
10
+ description: tool.description,
11
+ inputSchema: { $schema: 'https://json-schema.org/draft/2020-12/schema', ...tool.inputSchema },
12
+ annotations: {
13
+ readOnlyHint: tool.readOnly === true,
14
+ // Codex exec cannot service an interactive MCP approval prompt. Device
15
+ // mutations are proposals at this boundary and remain fail-closed behind
16
+ // LiveDesk's own signed permission policy and Hub approval workflow.
17
+ destructiveHint: false,
18
+ idempotentHint: tool.readOnly === true || tool.reversible === true,
19
+ openWorldHint: false
20
+ }
21
+ }));
22
+
23
+ function response(id, result) {
24
+ process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id, result })}\n`);
25
+ }
26
+
27
+ function errorResponse(id, code, message) {
28
+ process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id, error: { code, message } })}\n`);
29
+ }
30
+
31
+ async function callHub(name, args) {
32
+ if (!hubUrl || !token) throw new Error('LiveDesk MCP session is not configured.');
33
+ const result = await fetch(`${hubUrl}/api/internal/agent-mcp/tool`, {
34
+ method: 'POST',
35
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
36
+ body: JSON.stringify({ name, arguments: args && typeof args === 'object' ? args : {} })
37
+ });
38
+ const body = await result.json().catch(() => ({}));
39
+ if (!result.ok || body.ok !== true) throw new Error(String(body.error || `LiveDesk tool failed (${result.status}).`));
40
+ return body.result;
41
+ }
42
+
43
+ async function handle(message) {
44
+ const id = message?.id;
45
+ const method = String(message?.method || '');
46
+ if (!method) return;
47
+ if (method === 'initialize') {
48
+ response(id, {
49
+ protocolVersion,
50
+ capabilities: { tools: { listChanged: false } },
51
+ serverInfo: { name: 'livedesk', version: '1.0.0' }
52
+ });
53
+ return;
54
+ }
55
+ if (method === 'notifications/initialized' || method === 'ping') {
56
+ if (id !== undefined) response(id, {});
57
+ return;
58
+ }
59
+ if (method === 'tools/list') {
60
+ response(id, { tools: toolDefinitions });
61
+ return;
62
+ }
63
+ if (method === 'tools/call') {
64
+ const name = String(message?.params?.name || '');
65
+ if (!toolDefinitions.some(tool => tool.name === name)) {
66
+ errorResponse(id, -32602, 'Unknown LiveDesk tool.');
67
+ return;
68
+ }
69
+ try {
70
+ const result = await callHub(name, message?.params?.arguments || {});
71
+ response(id, {
72
+ content: [{ type: 'text', text: JSON.stringify(result) }],
73
+ structuredContent: result,
74
+ isError: false
75
+ });
76
+ } catch (error) {
77
+ response(id, {
78
+ content: [{ type: 'text', text: String(error?.message || 'LiveDesk tool failed.') }],
79
+ isError: true
80
+ });
81
+ }
82
+ return;
83
+ }
84
+ if (id !== undefined) errorResponse(id, -32601, `Unsupported MCP method: ${method}`);
85
+ }
86
+
87
+ const input = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
88
+ input.on('line', line => {
89
+ const trimmed = String(line || '').trim();
90
+ if (!trimmed) return;
91
+ let message;
92
+ try {
93
+ message = JSON.parse(trimmed);
94
+ } catch {
95
+ return;
96
+ }
97
+ void handle(message).catch(error => {
98
+ if (message?.id !== undefined) errorResponse(message.id, -32603, String(error?.message || 'MCP server error.'));
99
+ });
100
+ });
@@ -0,0 +1,415 @@
1
+ import WebSocket from 'ws';
2
+
3
+ const DEFAULT_CONSOLE_RELAY_URL = 'https://livedesk-wake.lovecrdm.workers.dev';
4
+ const DEFAULT_RETRY_DELAYS_MS = [1_000, 2_000, 5_000, 10_000, 20_000];
5
+ const MAX_CHANNELS = 32;
6
+ const MAX_HTTP_REQUEST_BYTES = 1024 * 1024;
7
+ const MAX_HTTP_RESPONSE_BYTES = 4 * 1024 * 1024;
8
+ const MAX_RELAY_BUFFERED_BYTES = 4 * 1024 * 1024;
9
+ const HTTP_TIMEOUT_MS = 15_000;
10
+ const BINARY_HEADER_BYTES = 73;
11
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
12
+
13
+ function normalizeRelayUrl(value) {
14
+ const source = String(value || DEFAULT_CONSOLE_RELAY_URL).trim();
15
+ if (!source || /^(off|disabled|none)$/i.test(source)) return '';
16
+ try {
17
+ const url = new URL(source);
18
+ url.protocol = url.protocol === 'http:' ? 'ws:' : url.protocol === 'https:' ? 'wss:' : url.protocol;
19
+ url.pathname = '/v1/hub';
20
+ url.search = '';
21
+ url.hash = '';
22
+ return url;
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+
28
+ function allowedHttpRequest(method, path) {
29
+ if (!['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) return false;
30
+ const pathname = String(path || '').split('?')[0];
31
+ if (pathname === '/api/health'
32
+ || pathname === '/api/runtime/status'
33
+ || pathname === '/api/runtime/restart'
34
+ || pathname === '/api/auth/status'
35
+ || pathname === '/api/remote/status'
36
+ || pathname === '/api/hub/status'
37
+ || pathname === '/api/update/status'
38
+ || pathname === '/api/update/apply') {
39
+ return true;
40
+ }
41
+ if (/^\/api\/settings(?:\/|$)/.test(pathname)
42
+ || /^\/api\/captures(?:\/|$)/.test(pathname)
43
+ || /^\/api\/capture-sessions(?:\/|$)/.test(pathname)
44
+ || /^\/api\/remote\/devices(?:\/|$)/.test(pathname)
45
+ || /^\/api\/remote\/frames$/.test(pathname)
46
+ || /^\/api\/remote\/filesystem(?:\/|$)/.test(pathname)
47
+ || /^\/api\/remote\/files(?:\/|$)/.test(pathname)
48
+ || /^\/api\/remote\/tasks(?:\/|$)/.test(pathname)
49
+ || /^\/api\/remote\/license(?:\/sync)?$/.test(pathname)) {
50
+ return pathname !== '/api/remote/registry-credentials'
51
+ && pathname !== '/api/remote/pairing-pin'
52
+ && !pathname.startsWith('/api/remote/host-target')
53
+ && !pathname.startsWith('/api/remote/synthetic');
54
+ }
55
+ return false;
56
+ }
57
+
58
+ function allowedWebSocketPath(path) {
59
+ const pathname = String(path || '').split('?')[0];
60
+ return pathname === '/api/remote/frames/ws'
61
+ || pathname === '/api/remote/atlas/ws'
62
+ || pathname === '/api/remote/input/ws'
63
+ || pathname === '/api/remote/audio/ws';
64
+ }
65
+
66
+ function channelKey(consoleId, channelId) {
67
+ return `${consoleId}\n${channelId}`;
68
+ }
69
+
70
+ function parseMessage(raw) {
71
+ try {
72
+ return JSON.parse(String(raw || ''));
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+
78
+ function safeContentHeaders(headers) {
79
+ const result = {};
80
+ for (const name of ['accept', 'content-type', 'if-match', 'range']) {
81
+ const value = headers && typeof headers === 'object' ? headers[name] || headers[name.toUpperCase()] : '';
82
+ if (value) result[name] = String(value).slice(0, 512);
83
+ }
84
+ return result;
85
+ }
86
+
87
+ export function createHubConsoleRelay(options = {}) {
88
+ const relayUrl = normalizeRelayUrl(options.url);
89
+ const deviceId = String(options.deviceId || '').trim();
90
+ const httpBaseUrl = String(options.httpBaseUrl || '').replace(/\/+$/, '');
91
+ const WebSocketImpl = options.WebSocketImpl || WebSocket;
92
+ const fetchImpl = options.fetchImpl || globalThis.fetch;
93
+ const getAccessToken = typeof options.getAccessToken === 'function'
94
+ ? options.getAccessToken
95
+ : async () => String(options.accessToken || '').trim();
96
+ const logger = options.logger || console;
97
+ const retryDelaysMs = options.retryDelaysMs || DEFAULT_RETRY_DELAYS_MS;
98
+ const localSockets = new Map();
99
+ const pendingHttp = new Set();
100
+ let relaySocket = null;
101
+ let retryTimer = null;
102
+ let retryAttempt = 0;
103
+ let generation = 0;
104
+ let stopped = true;
105
+ let state = relayUrl ? 'idle' : 'disabled';
106
+ let lastConnectedAt = '';
107
+ let lastError = relayUrl instanceof URL ? '' : relayUrl === '' ? '' : 'invalid-relay-url';
108
+ let droppedBinaryMessages = 0;
109
+
110
+ const sendRelay = payload => {
111
+ if (!relaySocket || relaySocket.readyState !== WebSocketImpl.OPEN) return false;
112
+ if (Number(relaySocket.bufferedAmount || 0) > MAX_RELAY_BUFFERED_BYTES) return false;
113
+ relaySocket.send(typeof payload === 'string' || Buffer.isBuffer(payload) ? payload : JSON.stringify(payload));
114
+ return true;
115
+ };
116
+
117
+ const sendError = (consoleId, channelId, error, type = 'relay-error') => {
118
+ sendRelay({ type, consoleId, channelId, error: String(error || 'console-relay-error').slice(0, 240) });
119
+ };
120
+
121
+ const closeLocalSocket = (key, code = 1000, reason = 'console-channel-closed') => {
122
+ const socket = localSockets.get(key);
123
+ if (!socket) return;
124
+ localSockets.delete(key);
125
+ try { socket.close(code, reason); } catch { /* exact socket is already closed */ }
126
+ try { socket.terminate?.(); } catch { /* exact socket is already closed */ }
127
+ };
128
+
129
+ const closeConsoleChannels = consoleId => {
130
+ for (const key of [...localSockets.keys()]) {
131
+ if (key.startsWith(`${consoleId}\n`)) closeLocalSocket(key, 1001, 'console-detached');
132
+ }
133
+ };
134
+
135
+ const closeAllLocalSockets = reason => {
136
+ for (const key of [...localSockets.keys()]) closeLocalSocket(key, 1012, reason);
137
+ };
138
+
139
+ const handleHttpRequest = async payload => {
140
+ const consoleId = String(payload?.consoleId || '');
141
+ const channelId = String(payload?.channelId || '');
142
+ const method = String(payload?.method || 'GET').toUpperCase();
143
+ const path = String(payload?.path || '');
144
+ if (!UUID_PATTERN.test(consoleId) || !UUID_PATTERN.test(channelId) || !allowedHttpRequest(method, path)) {
145
+ sendError(consoleId, channelId, 'console-route-not-allowed', 'http-response');
146
+ return;
147
+ }
148
+ if (pendingHttp.size >= MAX_CHANNELS) {
149
+ sendError(consoleId, channelId, 'console-http-capacity-reached', 'http-response');
150
+ return;
151
+ }
152
+ let body;
153
+ try {
154
+ body = payload.bodyBase64 ? Buffer.from(String(payload.bodyBase64), 'base64') : undefined;
155
+ } catch {
156
+ sendError(consoleId, channelId, 'invalid-console-http-body', 'http-response');
157
+ return;
158
+ }
159
+ if (body && body.byteLength > MAX_HTTP_REQUEST_BYTES) {
160
+ sendError(consoleId, channelId, 'console-http-body-too-large', 'http-response');
161
+ return;
162
+ }
163
+
164
+ const requestKey = channelKey(consoleId, channelId);
165
+ pendingHttp.add(requestKey);
166
+ const controller = new AbortController();
167
+ const timeout = setTimeout(() => controller.abort(new Error('console-http-timeout')), HTTP_TIMEOUT_MS);
168
+ timeout.unref?.();
169
+ try {
170
+ const response = await fetchImpl(`${httpBaseUrl}${path}`, {
171
+ method,
172
+ headers: safeContentHeaders(payload.headers),
173
+ body: ['GET', 'HEAD'].includes(method) ? undefined : body,
174
+ signal: controller.signal
175
+ });
176
+ const responseBytes = Buffer.from(await response.arrayBuffer());
177
+ if (responseBytes.byteLength > MAX_HTTP_RESPONSE_BYTES) {
178
+ sendError(consoleId, channelId, 'console-http-response-too-large', 'http-response');
179
+ return;
180
+ }
181
+ sendRelay({
182
+ type: 'http-response',
183
+ consoleId,
184
+ channelId,
185
+ status: response.status,
186
+ statusText: response.statusText,
187
+ headers: {
188
+ 'content-type': String(response.headers.get('content-type') || '').slice(0, 256),
189
+ 'content-range': String(response.headers.get('content-range') || '').slice(0, 256)
190
+ },
191
+ bodyBase64: responseBytes.toString('base64')
192
+ });
193
+ } catch (error) {
194
+ sendError(consoleId, channelId, error instanceof Error ? error.message : error, 'http-response');
195
+ } finally {
196
+ clearTimeout(timeout);
197
+ pendingHttp.delete(requestKey);
198
+ }
199
+ };
200
+
201
+ const handleWebSocketOpen = payload => {
202
+ const consoleId = String(payload?.consoleId || '');
203
+ const channelId = String(payload?.channelId || '');
204
+ const path = String(payload?.path || '');
205
+ if (!UUID_PATTERN.test(consoleId) || !UUID_PATTERN.test(channelId) || !allowedWebSocketPath(path)) {
206
+ sendError(consoleId, channelId, 'console-websocket-route-not-allowed', 'ws-error');
207
+ return;
208
+ }
209
+ if (localSockets.size >= MAX_CHANNELS) {
210
+ sendError(consoleId, channelId, 'console-websocket-capacity-reached', 'ws-error');
211
+ return;
212
+ }
213
+ const key = channelKey(consoleId, channelId);
214
+ closeLocalSocket(key, 1000, 'console-channel-replaced');
215
+ const localUrl = new URL(path, httpBaseUrl);
216
+ localUrl.protocol = localUrl.protocol === 'https:' ? 'wss:' : 'ws:';
217
+ let socket;
218
+ try {
219
+ socket = new WebSocketImpl(localUrl, { perMessageDeflate: false });
220
+ } catch (error) {
221
+ sendError(consoleId, channelId, error instanceof Error ? error.message : error, 'ws-error');
222
+ return;
223
+ }
224
+ localSockets.set(key, socket);
225
+ socket.once('open', () => {
226
+ if (localSockets.get(key) !== socket) return;
227
+ sendRelay({ type: 'ws-opened', consoleId, channelId });
228
+ });
229
+ socket.on('message', (data, isBinary) => {
230
+ if (localSockets.get(key) !== socket) return;
231
+ if (isBinary) {
232
+ if (!relaySocket || relaySocket.readyState !== WebSocketImpl.OPEN
233
+ || Number(relaySocket.bufferedAmount || 0) > MAX_RELAY_BUFFERED_BYTES) {
234
+ droppedBinaryMessages += 1;
235
+ return;
236
+ }
237
+ const header = Buffer.from(`B${consoleId}${channelId}`, 'ascii');
238
+ if (header.byteLength !== BINARY_HEADER_BYTES) {
239
+ closeLocalSocket(key, 1008, 'invalid-relay-channel-id');
240
+ return;
241
+ }
242
+ relaySocket.send(Buffer.concat([header, Buffer.from(data)]));
243
+ return;
244
+ }
245
+ sendRelay({ type: 'ws-message', consoleId, channelId, data: String(data), binary: false });
246
+ });
247
+ socket.once('close', (code, reason) => {
248
+ if (localSockets.get(key) === socket) localSockets.delete(key);
249
+ sendRelay({ type: 'ws-closed', consoleId, channelId, code, reason: String(reason || '').slice(0, 120) });
250
+ });
251
+ socket.once('error', error => {
252
+ sendError(consoleId, channelId, error instanceof Error ? error.message : error, 'ws-error');
253
+ });
254
+ };
255
+
256
+ const handleMessage = raw => {
257
+ const payload = parseMessage(raw);
258
+ if (!payload?.type) return;
259
+ if (payload.type === 'relay-ready') {
260
+ state = 'connected';
261
+ lastConnectedAt = new Date().toISOString();
262
+ lastError = '';
263
+ return;
264
+ }
265
+ if (payload.type === 'console-detached') {
266
+ closeConsoleChannels(String(payload.consoleId || ''));
267
+ return;
268
+ }
269
+ if (payload.type === 'http-request') {
270
+ void handleHttpRequest(payload);
271
+ return;
272
+ }
273
+ if (payload.type === 'ws-open') {
274
+ handleWebSocketOpen(payload);
275
+ return;
276
+ }
277
+ const key = channelKey(String(payload.consoleId || ''), String(payload.channelId || ''));
278
+ const socket = localSockets.get(key);
279
+ if (!socket) return;
280
+ if (payload.type === 'ws-send' && socket.readyState === WebSocketImpl.OPEN) {
281
+ const data = String(payload.data || '');
282
+ if (Buffer.byteLength(data) <= MAX_HTTP_REQUEST_BYTES) socket.send(data);
283
+ } else if (payload.type === 'ws-close') {
284
+ closeLocalSocket(key, Number(payload.code || 1000), String(payload.reason || 'console-request').slice(0, 120));
285
+ }
286
+ };
287
+
288
+ const scheduleReconnect = connect => {
289
+ if (stopped || retryTimer || !(relayUrl instanceof URL)) return;
290
+ const delayMs = retryDelaysMs[Math.min(retryAttempt, retryDelaysMs.length - 1)];
291
+ retryAttempt += 1;
292
+ state = 'waiting-retry';
293
+ retryTimer = setTimeout(() => {
294
+ retryTimer = null;
295
+ void connect();
296
+ }, delayMs);
297
+ retryTimer.unref?.();
298
+ };
299
+
300
+ const connect = async () => {
301
+ if (stopped || !(relayUrl instanceof URL) || !deviceId) return;
302
+ const ownerGeneration = ++generation;
303
+ state = 'connecting';
304
+ let accessToken = '';
305
+ try {
306
+ accessToken = String(await getAccessToken() || '').trim();
307
+ } catch (error) {
308
+ lastError = error instanceof Error ? error.message : String(error);
309
+ }
310
+ if (stopped || ownerGeneration !== generation) return;
311
+ if (!accessToken) {
312
+ lastError = 'hub-session-required';
313
+ scheduleReconnect(connect);
314
+ return;
315
+ }
316
+ const url = new URL(relayUrl);
317
+ url.searchParams.set('deviceId', deviceId);
318
+ let socket;
319
+ try {
320
+ socket = new WebSocketImpl(url, {
321
+ headers: { Authorization: `Bearer ${accessToken}` },
322
+ perMessageDeflate: false
323
+ });
324
+ } catch (error) {
325
+ lastError = error instanceof Error ? error.message : String(error);
326
+ scheduleReconnect(connect);
327
+ return;
328
+ }
329
+ if (stopped || ownerGeneration !== generation) {
330
+ try { socket.terminate?.(); } catch { /* stale connect is already gone */ }
331
+ return;
332
+ }
333
+ relaySocket = socket;
334
+ socket.once('open', () => {
335
+ if (relaySocket !== socket || ownerGeneration !== generation || stopped) return;
336
+ retryAttempt = 0;
337
+ state = 'authenticating';
338
+ });
339
+ socket.on('message', raw => {
340
+ if (relaySocket === socket && ownerGeneration === generation && !stopped) handleMessage(raw);
341
+ });
342
+ socket.once('close', () => {
343
+ if (relaySocket === socket) relaySocket = null;
344
+ if (ownerGeneration !== generation || stopped) return;
345
+ state = 'disconnected';
346
+ closeAllLocalSockets('relay-disconnected');
347
+ scheduleReconnect(connect);
348
+ });
349
+ socket.once('error', error => {
350
+ lastError = error instanceof Error ? error.message : String(error);
351
+ try { socket.terminate?.(); } catch { /* close handler owns retry */ }
352
+ });
353
+ };
354
+
355
+ const start = () => {
356
+ if (!stopped || !(relayUrl instanceof URL) || !deviceId) return;
357
+ stopped = false;
358
+ void connect();
359
+ };
360
+
361
+ const refresh = () => {
362
+ if (stopped) {
363
+ start();
364
+ return;
365
+ }
366
+ generation += 1;
367
+ if (retryTimer) {
368
+ clearTimeout(retryTimer);
369
+ retryTimer = null;
370
+ }
371
+ const socket = relaySocket;
372
+ relaySocket = null;
373
+ try { socket?.terminate?.(); } catch { /* refresh owns the exact socket */ }
374
+ closeAllLocalSockets('relay-refresh');
375
+ void connect();
376
+ };
377
+
378
+ const close = () => {
379
+ if (stopped) return;
380
+ stopped = true;
381
+ generation += 1;
382
+ if (retryTimer) clearTimeout(retryTimer);
383
+ retryTimer = null;
384
+ closeAllLocalSockets('hub-shutdown');
385
+ const socket = relaySocket;
386
+ relaySocket = null;
387
+ try { socket?.close(1001, 'hub-shutdown'); } catch { /* exact socket is already closed */ }
388
+ try { socket?.terminate?.(); } catch { /* exact socket is already closed */ }
389
+ state = relayUrl ? 'closed' : 'disabled';
390
+ };
391
+
392
+ return {
393
+ start,
394
+ refresh,
395
+ close,
396
+ inspect: () => ({
397
+ enabled: relayUrl instanceof URL && Boolean(deviceId),
398
+ state,
399
+ connected: state === 'connected',
400
+ localWebSocketChannels: localSockets.size,
401
+ pendingHttpRequests: pendingHttp.size,
402
+ droppedBinaryMessages,
403
+ lastConnectedAt,
404
+ lastError
405
+ })
406
+ };
407
+ }
408
+
409
+ export const consoleRelayContract = Object.freeze({
410
+ maxChannels: MAX_CHANNELS,
411
+ maxHttpRequestBytes: MAX_HTTP_REQUEST_BYTES,
412
+ maxHttpResponseBytes: MAX_HTTP_RESPONSE_BYTES,
413
+ maxBufferedBytes: MAX_RELAY_BUFFERED_BYTES,
414
+ binaryHeaderBytes: BINARY_HEADER_BYTES
415
+ });