@livedesk/hub 0.1.46 → 0.1.48

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,424 +1,424 @@
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, bypassBackpressure = false) => {
111
- if (!relaySocket || relaySocket.readyState !== WebSocketImpl.OPEN) return false;
112
- if (!bypassBackpressure
113
- && Number(relaySocket.bufferedAmount || 0) > MAX_RELAY_BUFFERED_BYTES) return false;
114
- relaySocket.send(typeof payload === 'string' || Buffer.isBuffer(payload) ? payload : JSON.stringify(payload));
115
- return true;
116
- };
117
-
118
- const sendError = (consoleId, channelId, error, type = 'relay-error') => {
119
- sendRelay({ type, consoleId, channelId, error: String(error || 'console-relay-error').slice(0, 240) });
120
- };
121
-
122
- const closeLocalSocket = (key, code = 1000, reason = 'console-channel-closed') => {
123
- const socket = localSockets.get(key);
124
- if (!socket) return;
125
- localSockets.delete(key);
126
- try { socket.close(code, reason); } catch { /* exact socket is already closed */ }
127
- try { socket.terminate?.(); } catch { /* exact socket is already closed */ }
128
- };
129
-
130
- const closeConsoleChannels = consoleId => {
131
- for (const key of [...localSockets.keys()]) {
132
- if (key.startsWith(`${consoleId}\n`)) closeLocalSocket(key, 1001, 'console-detached');
133
- }
134
- };
135
-
136
- const closeAllLocalSockets = reason => {
137
- for (const key of [...localSockets.keys()]) closeLocalSocket(key, 1012, reason);
138
- };
139
-
140
- const handleHttpRequest = async payload => {
141
- const consoleId = String(payload?.consoleId || '');
142
- const channelId = String(payload?.channelId || '');
143
- const method = String(payload?.method || 'GET').toUpperCase();
144
- const path = String(payload?.path || '');
145
- if (!UUID_PATTERN.test(consoleId) || !UUID_PATTERN.test(channelId) || !allowedHttpRequest(method, path)) {
146
- sendError(consoleId, channelId, 'console-route-not-allowed', 'http-response');
147
- return;
148
- }
149
- if (pendingHttp.size >= MAX_CHANNELS) {
150
- sendError(consoleId, channelId, 'console-http-capacity-reached', 'http-response');
151
- return;
152
- }
153
- let body;
154
- try {
155
- body = payload.bodyBase64 ? Buffer.from(String(payload.bodyBase64), 'base64') : undefined;
156
- } catch {
157
- sendError(consoleId, channelId, 'invalid-console-http-body', 'http-response');
158
- return;
159
- }
160
- if (body && body.byteLength > MAX_HTTP_REQUEST_BYTES) {
161
- sendError(consoleId, channelId, 'console-http-body-too-large', 'http-response');
162
- return;
163
- }
164
-
165
- const requestKey = channelKey(consoleId, channelId);
166
- pendingHttp.add(requestKey);
167
- const controller = new AbortController();
168
- const timeout = setTimeout(() => controller.abort(new Error('console-http-timeout')), HTTP_TIMEOUT_MS);
169
- timeout.unref?.();
170
- try {
171
- const response = await fetchImpl(`${httpBaseUrl}${path}`, {
172
- method,
173
- headers: safeContentHeaders(payload.headers),
174
- body: ['GET', 'HEAD'].includes(method) ? undefined : body,
175
- signal: controller.signal
176
- });
177
- const responseBytes = Buffer.from(await response.arrayBuffer());
178
- if (responseBytes.byteLength > MAX_HTTP_RESPONSE_BYTES) {
179
- sendError(consoleId, channelId, 'console-http-response-too-large', 'http-response');
180
- return;
181
- }
182
- sendRelay({
183
- type: 'http-response',
184
- consoleId,
185
- channelId,
186
- status: response.status,
187
- statusText: response.statusText,
188
- headers: {
189
- 'content-type': String(response.headers.get('content-type') || '').slice(0, 256),
190
- 'content-range': String(response.headers.get('content-range') || '').slice(0, 256)
191
- },
192
- bodyBase64: responseBytes.toString('base64')
193
- });
194
- } catch (error) {
195
- sendError(consoleId, channelId, error instanceof Error ? error.message : error, 'http-response');
196
- } finally {
197
- clearTimeout(timeout);
198
- pendingHttp.delete(requestKey);
199
- }
200
- };
201
-
202
- const handleWebSocketOpen = payload => {
203
- const consoleId = String(payload?.consoleId || '');
204
- const channelId = String(payload?.channelId || '');
205
- const path = String(payload?.path || '');
206
- if (!UUID_PATTERN.test(consoleId) || !UUID_PATTERN.test(channelId) || !allowedWebSocketPath(path)) {
207
- sendError(consoleId, channelId, 'console-websocket-route-not-allowed', 'ws-error');
208
- return;
209
- }
210
- if (localSockets.size >= MAX_CHANNELS) {
211
- sendError(consoleId, channelId, 'console-websocket-capacity-reached', 'ws-error');
212
- return;
213
- }
214
- const key = channelKey(consoleId, channelId);
215
- closeLocalSocket(key, 1000, 'console-channel-replaced');
216
- const localUrl = new URL(path, httpBaseUrl);
217
- localUrl.protocol = localUrl.protocol === 'https:' ? 'wss:' : 'ws:';
218
- let socket;
219
- try {
220
- socket = new WebSocketImpl(localUrl, { perMessageDeflate: false });
221
- } catch (error) {
222
- sendError(consoleId, channelId, error instanceof Error ? error.message : error, 'ws-error');
223
- return;
224
- }
225
- localSockets.set(key, socket);
226
- socket.once('open', () => {
227
- if (localSockets.get(key) !== socket) return;
228
- sendRelay({ type: 'ws-opened', consoleId, channelId });
229
- });
230
- socket.on('message', (data, isBinary) => {
231
- if (localSockets.get(key) !== socket) return;
232
- if (isBinary) {
233
- if (!relaySocket || relaySocket.readyState !== WebSocketImpl.OPEN
234
- || Number(relaySocket.bufferedAmount || 0) > MAX_RELAY_BUFFERED_BYTES) {
235
- droppedBinaryMessages += 1;
236
- closeLocalSocket(key, 1013, 'console-relay-backpressure');
237
- return;
238
- }
239
- const header = Buffer.from(`B${consoleId}${channelId}`, 'ascii');
240
- if (header.byteLength !== BINARY_HEADER_BYTES) {
241
- closeLocalSocket(key, 1008, 'invalid-relay-channel-id');
242
- return;
243
- }
244
- relaySocket.send(Buffer.concat([header, Buffer.from(data)]));
245
- return;
246
- }
247
- sendRelay({ type: 'ws-message', consoleId, channelId, data: String(data), binary: false });
248
- });
249
- socket.once('close', (code, reason) => {
250
- if (localSockets.get(key) === socket) localSockets.delete(key);
251
- const closeReason = String(reason || '').slice(0, 120);
252
- // Do not silently punch a hole in an H.264 GOP. A tiny terminal control
253
- // message is allowed behind the already-bounded video backlog so the
254
- // browser replaces only this logical lane and starts again on a key.
255
- sendRelay(
256
- { type: 'ws-closed', consoleId, channelId, code, reason: closeReason },
257
- closeReason === 'console-relay-backpressure'
258
- );
259
- });
260
- socket.once('error', error => {
261
- sendError(consoleId, channelId, error instanceof Error ? error.message : error, 'ws-error');
262
- });
263
- };
264
-
265
- const handleMessage = raw => {
266
- const payload = parseMessage(raw);
267
- if (!payload?.type) return;
268
- if (payload.type === 'relay-ready') {
269
- state = 'connected';
270
- lastConnectedAt = new Date().toISOString();
271
- lastError = '';
272
- return;
273
- }
274
- if (payload.type === 'console-detached') {
275
- closeConsoleChannels(String(payload.consoleId || ''));
276
- return;
277
- }
278
- if (payload.type === 'http-request') {
279
- void handleHttpRequest(payload);
280
- return;
281
- }
282
- if (payload.type === 'ws-open') {
283
- handleWebSocketOpen(payload);
284
- return;
285
- }
286
- const key = channelKey(String(payload.consoleId || ''), String(payload.channelId || ''));
287
- const socket = localSockets.get(key);
288
- if (!socket) return;
289
- if (payload.type === 'ws-send' && socket.readyState === WebSocketImpl.OPEN) {
290
- const data = String(payload.data || '');
291
- if (Buffer.byteLength(data) <= MAX_HTTP_REQUEST_BYTES) socket.send(data);
292
- } else if (payload.type === 'ws-close') {
293
- closeLocalSocket(key, Number(payload.code || 1000), String(payload.reason || 'console-request').slice(0, 120));
294
- }
295
- };
296
-
297
- const scheduleReconnect = connect => {
298
- if (stopped || retryTimer || !(relayUrl instanceof URL)) return;
299
- const delayMs = retryDelaysMs[Math.min(retryAttempt, retryDelaysMs.length - 1)];
300
- retryAttempt += 1;
301
- state = 'waiting-retry';
302
- retryTimer = setTimeout(() => {
303
- retryTimer = null;
304
- void connect();
305
- }, delayMs);
306
- retryTimer.unref?.();
307
- };
308
-
309
- const connect = async () => {
310
- if (stopped || !(relayUrl instanceof URL) || !deviceId) return;
311
- const ownerGeneration = ++generation;
312
- state = 'connecting';
313
- let accessToken = '';
314
- try {
315
- accessToken = String(await getAccessToken() || '').trim();
316
- } catch (error) {
317
- lastError = error instanceof Error ? error.message : String(error);
318
- }
319
- if (stopped || ownerGeneration !== generation) return;
320
- if (!accessToken) {
321
- lastError = 'hub-session-required';
322
- scheduleReconnect(connect);
323
- return;
324
- }
325
- const url = new URL(relayUrl);
326
- url.searchParams.set('deviceId', deviceId);
327
- let socket;
328
- try {
329
- socket = new WebSocketImpl(url, {
330
- headers: { Authorization: `Bearer ${accessToken}` },
331
- perMessageDeflate: false
332
- });
333
- } catch (error) {
334
- lastError = error instanceof Error ? error.message : String(error);
335
- scheduleReconnect(connect);
336
- return;
337
- }
338
- if (stopped || ownerGeneration !== generation) {
339
- try { socket.terminate?.(); } catch { /* stale connect is already gone */ }
340
- return;
341
- }
342
- relaySocket = socket;
343
- socket.once('open', () => {
344
- if (relaySocket !== socket || ownerGeneration !== generation || stopped) return;
345
- retryAttempt = 0;
346
- state = 'authenticating';
347
- });
348
- socket.on('message', raw => {
349
- if (relaySocket === socket && ownerGeneration === generation && !stopped) handleMessage(raw);
350
- });
351
- socket.once('close', () => {
352
- if (relaySocket === socket) relaySocket = null;
353
- if (ownerGeneration !== generation || stopped) return;
354
- state = 'disconnected';
355
- closeAllLocalSockets('relay-disconnected');
356
- scheduleReconnect(connect);
357
- });
358
- socket.once('error', error => {
359
- lastError = error instanceof Error ? error.message : String(error);
360
- try { socket.terminate?.(); } catch { /* close handler owns retry */ }
361
- });
362
- };
363
-
364
- const start = () => {
365
- if (!stopped || !(relayUrl instanceof URL) || !deviceId) return;
366
- stopped = false;
367
- void connect();
368
- };
369
-
370
- const refresh = () => {
371
- if (stopped) {
372
- start();
373
- return;
374
- }
375
- generation += 1;
376
- if (retryTimer) {
377
- clearTimeout(retryTimer);
378
- retryTimer = null;
379
- }
380
- const socket = relaySocket;
381
- relaySocket = null;
382
- try { socket?.terminate?.(); } catch { /* refresh owns the exact socket */ }
383
- closeAllLocalSockets('relay-refresh');
384
- void connect();
385
- };
386
-
387
- const close = () => {
388
- if (stopped) return;
389
- stopped = true;
390
- generation += 1;
391
- if (retryTimer) clearTimeout(retryTimer);
392
- retryTimer = null;
393
- closeAllLocalSockets('hub-shutdown');
394
- const socket = relaySocket;
395
- relaySocket = null;
396
- try { socket?.close(1001, 'hub-shutdown'); } catch { /* exact socket is already closed */ }
397
- try { socket?.terminate?.(); } catch { /* exact socket is already closed */ }
398
- state = relayUrl ? 'closed' : 'disabled';
399
- };
400
-
401
- return {
402
- start,
403
- refresh,
404
- close,
405
- inspect: () => ({
406
- enabled: relayUrl instanceof URL && Boolean(deviceId),
407
- state,
408
- connected: state === 'connected',
409
- localWebSocketChannels: localSockets.size,
410
- pendingHttpRequests: pendingHttp.size,
411
- droppedBinaryMessages,
412
- lastConnectedAt,
413
- lastError
414
- })
415
- };
416
- }
417
-
418
- export const consoleRelayContract = Object.freeze({
419
- maxChannels: MAX_CHANNELS,
420
- maxHttpRequestBytes: MAX_HTTP_REQUEST_BYTES,
421
- maxHttpResponseBytes: MAX_HTTP_RESPONSE_BYTES,
422
- maxBufferedBytes: MAX_RELAY_BUFFERED_BYTES,
423
- binaryHeaderBytes: BINARY_HEADER_BYTES
424
- });
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, bypassBackpressure = false) => {
111
+ if (!relaySocket || relaySocket.readyState !== WebSocketImpl.OPEN) return false;
112
+ if (!bypassBackpressure
113
+ && Number(relaySocket.bufferedAmount || 0) > MAX_RELAY_BUFFERED_BYTES) return false;
114
+ relaySocket.send(typeof payload === 'string' || Buffer.isBuffer(payload) ? payload : JSON.stringify(payload));
115
+ return true;
116
+ };
117
+
118
+ const sendError = (consoleId, channelId, error, type = 'relay-error') => {
119
+ sendRelay({ type, consoleId, channelId, error: String(error || 'console-relay-error').slice(0, 240) });
120
+ };
121
+
122
+ const closeLocalSocket = (key, code = 1000, reason = 'console-channel-closed') => {
123
+ const socket = localSockets.get(key);
124
+ if (!socket) return;
125
+ localSockets.delete(key);
126
+ try { socket.close(code, reason); } catch { /* exact socket is already closed */ }
127
+ try { socket.terminate?.(); } catch { /* exact socket is already closed */ }
128
+ };
129
+
130
+ const closeConsoleChannels = consoleId => {
131
+ for (const key of [...localSockets.keys()]) {
132
+ if (key.startsWith(`${consoleId}\n`)) closeLocalSocket(key, 1001, 'console-detached');
133
+ }
134
+ };
135
+
136
+ const closeAllLocalSockets = reason => {
137
+ for (const key of [...localSockets.keys()]) closeLocalSocket(key, 1012, reason);
138
+ };
139
+
140
+ const handleHttpRequest = async payload => {
141
+ const consoleId = String(payload?.consoleId || '');
142
+ const channelId = String(payload?.channelId || '');
143
+ const method = String(payload?.method || 'GET').toUpperCase();
144
+ const path = String(payload?.path || '');
145
+ if (!UUID_PATTERN.test(consoleId) || !UUID_PATTERN.test(channelId) || !allowedHttpRequest(method, path)) {
146
+ sendError(consoleId, channelId, 'console-route-not-allowed', 'http-response');
147
+ return;
148
+ }
149
+ if (pendingHttp.size >= MAX_CHANNELS) {
150
+ sendError(consoleId, channelId, 'console-http-capacity-reached', 'http-response');
151
+ return;
152
+ }
153
+ let body;
154
+ try {
155
+ body = payload.bodyBase64 ? Buffer.from(String(payload.bodyBase64), 'base64') : undefined;
156
+ } catch {
157
+ sendError(consoleId, channelId, 'invalid-console-http-body', 'http-response');
158
+ return;
159
+ }
160
+ if (body && body.byteLength > MAX_HTTP_REQUEST_BYTES) {
161
+ sendError(consoleId, channelId, 'console-http-body-too-large', 'http-response');
162
+ return;
163
+ }
164
+
165
+ const requestKey = channelKey(consoleId, channelId);
166
+ pendingHttp.add(requestKey);
167
+ const controller = new AbortController();
168
+ const timeout = setTimeout(() => controller.abort(new Error('console-http-timeout')), HTTP_TIMEOUT_MS);
169
+ timeout.unref?.();
170
+ try {
171
+ const response = await fetchImpl(`${httpBaseUrl}${path}`, {
172
+ method,
173
+ headers: safeContentHeaders(payload.headers),
174
+ body: ['GET', 'HEAD'].includes(method) ? undefined : body,
175
+ signal: controller.signal
176
+ });
177
+ const responseBytes = Buffer.from(await response.arrayBuffer());
178
+ if (responseBytes.byteLength > MAX_HTTP_RESPONSE_BYTES) {
179
+ sendError(consoleId, channelId, 'console-http-response-too-large', 'http-response');
180
+ return;
181
+ }
182
+ sendRelay({
183
+ type: 'http-response',
184
+ consoleId,
185
+ channelId,
186
+ status: response.status,
187
+ statusText: response.statusText,
188
+ headers: {
189
+ 'content-type': String(response.headers.get('content-type') || '').slice(0, 256),
190
+ 'content-range': String(response.headers.get('content-range') || '').slice(0, 256)
191
+ },
192
+ bodyBase64: responseBytes.toString('base64')
193
+ });
194
+ } catch (error) {
195
+ sendError(consoleId, channelId, error instanceof Error ? error.message : error, 'http-response');
196
+ } finally {
197
+ clearTimeout(timeout);
198
+ pendingHttp.delete(requestKey);
199
+ }
200
+ };
201
+
202
+ const handleWebSocketOpen = payload => {
203
+ const consoleId = String(payload?.consoleId || '');
204
+ const channelId = String(payload?.channelId || '');
205
+ const path = String(payload?.path || '');
206
+ if (!UUID_PATTERN.test(consoleId) || !UUID_PATTERN.test(channelId) || !allowedWebSocketPath(path)) {
207
+ sendError(consoleId, channelId, 'console-websocket-route-not-allowed', 'ws-error');
208
+ return;
209
+ }
210
+ if (localSockets.size >= MAX_CHANNELS) {
211
+ sendError(consoleId, channelId, 'console-websocket-capacity-reached', 'ws-error');
212
+ return;
213
+ }
214
+ const key = channelKey(consoleId, channelId);
215
+ closeLocalSocket(key, 1000, 'console-channel-replaced');
216
+ const localUrl = new URL(path, httpBaseUrl);
217
+ localUrl.protocol = localUrl.protocol === 'https:' ? 'wss:' : 'ws:';
218
+ let socket;
219
+ try {
220
+ socket = new WebSocketImpl(localUrl, { perMessageDeflate: false });
221
+ } catch (error) {
222
+ sendError(consoleId, channelId, error instanceof Error ? error.message : error, 'ws-error');
223
+ return;
224
+ }
225
+ localSockets.set(key, socket);
226
+ socket.once('open', () => {
227
+ if (localSockets.get(key) !== socket) return;
228
+ sendRelay({ type: 'ws-opened', consoleId, channelId });
229
+ });
230
+ socket.on('message', (data, isBinary) => {
231
+ if (localSockets.get(key) !== socket) return;
232
+ if (isBinary) {
233
+ if (!relaySocket || relaySocket.readyState !== WebSocketImpl.OPEN
234
+ || Number(relaySocket.bufferedAmount || 0) > MAX_RELAY_BUFFERED_BYTES) {
235
+ droppedBinaryMessages += 1;
236
+ closeLocalSocket(key, 1013, 'console-relay-backpressure');
237
+ return;
238
+ }
239
+ const header = Buffer.from(`B${consoleId}${channelId}`, 'ascii');
240
+ if (header.byteLength !== BINARY_HEADER_BYTES) {
241
+ closeLocalSocket(key, 1008, 'invalid-relay-channel-id');
242
+ return;
243
+ }
244
+ relaySocket.send(Buffer.concat([header, Buffer.from(data)]));
245
+ return;
246
+ }
247
+ sendRelay({ type: 'ws-message', consoleId, channelId, data: String(data), binary: false });
248
+ });
249
+ socket.once('close', (code, reason) => {
250
+ if (localSockets.get(key) === socket) localSockets.delete(key);
251
+ const closeReason = String(reason || '').slice(0, 120);
252
+ // Do not silently punch a hole in an H.264 GOP. A tiny terminal control
253
+ // message is allowed behind the already-bounded video backlog so the
254
+ // browser replaces only this logical lane and starts again on a key.
255
+ sendRelay(
256
+ { type: 'ws-closed', consoleId, channelId, code, reason: closeReason },
257
+ closeReason === 'console-relay-backpressure'
258
+ );
259
+ });
260
+ socket.once('error', error => {
261
+ sendError(consoleId, channelId, error instanceof Error ? error.message : error, 'ws-error');
262
+ });
263
+ };
264
+
265
+ const handleMessage = raw => {
266
+ const payload = parseMessage(raw);
267
+ if (!payload?.type) return;
268
+ if (payload.type === 'relay-ready') {
269
+ state = 'connected';
270
+ lastConnectedAt = new Date().toISOString();
271
+ lastError = '';
272
+ return;
273
+ }
274
+ if (payload.type === 'console-detached') {
275
+ closeConsoleChannels(String(payload.consoleId || ''));
276
+ return;
277
+ }
278
+ if (payload.type === 'http-request') {
279
+ void handleHttpRequest(payload);
280
+ return;
281
+ }
282
+ if (payload.type === 'ws-open') {
283
+ handleWebSocketOpen(payload);
284
+ return;
285
+ }
286
+ const key = channelKey(String(payload.consoleId || ''), String(payload.channelId || ''));
287
+ const socket = localSockets.get(key);
288
+ if (!socket) return;
289
+ if (payload.type === 'ws-send' && socket.readyState === WebSocketImpl.OPEN) {
290
+ const data = String(payload.data || '');
291
+ if (Buffer.byteLength(data) <= MAX_HTTP_REQUEST_BYTES) socket.send(data);
292
+ } else if (payload.type === 'ws-close') {
293
+ closeLocalSocket(key, Number(payload.code || 1000), String(payload.reason || 'console-request').slice(0, 120));
294
+ }
295
+ };
296
+
297
+ const scheduleReconnect = connect => {
298
+ if (stopped || retryTimer || !(relayUrl instanceof URL)) return;
299
+ const delayMs = retryDelaysMs[Math.min(retryAttempt, retryDelaysMs.length - 1)];
300
+ retryAttempt += 1;
301
+ state = 'waiting-retry';
302
+ retryTimer = setTimeout(() => {
303
+ retryTimer = null;
304
+ void connect();
305
+ }, delayMs);
306
+ retryTimer.unref?.();
307
+ };
308
+
309
+ const connect = async () => {
310
+ if (stopped || !(relayUrl instanceof URL) || !deviceId) return;
311
+ const ownerGeneration = ++generation;
312
+ state = 'connecting';
313
+ let accessToken = '';
314
+ try {
315
+ accessToken = String(await getAccessToken() || '').trim();
316
+ } catch (error) {
317
+ lastError = error instanceof Error ? error.message : String(error);
318
+ }
319
+ if (stopped || ownerGeneration !== generation) return;
320
+ if (!accessToken) {
321
+ lastError = 'hub-session-required';
322
+ scheduleReconnect(connect);
323
+ return;
324
+ }
325
+ const url = new URL(relayUrl);
326
+ url.searchParams.set('deviceId', deviceId);
327
+ let socket;
328
+ try {
329
+ socket = new WebSocketImpl(url, {
330
+ headers: { Authorization: `Bearer ${accessToken}` },
331
+ perMessageDeflate: false
332
+ });
333
+ } catch (error) {
334
+ lastError = error instanceof Error ? error.message : String(error);
335
+ scheduleReconnect(connect);
336
+ return;
337
+ }
338
+ if (stopped || ownerGeneration !== generation) {
339
+ try { socket.terminate?.(); } catch { /* stale connect is already gone */ }
340
+ return;
341
+ }
342
+ relaySocket = socket;
343
+ socket.once('open', () => {
344
+ if (relaySocket !== socket || ownerGeneration !== generation || stopped) return;
345
+ retryAttempt = 0;
346
+ state = 'authenticating';
347
+ });
348
+ socket.on('message', raw => {
349
+ if (relaySocket === socket && ownerGeneration === generation && !stopped) handleMessage(raw);
350
+ });
351
+ socket.once('close', () => {
352
+ if (relaySocket === socket) relaySocket = null;
353
+ if (ownerGeneration !== generation || stopped) return;
354
+ state = 'disconnected';
355
+ closeAllLocalSockets('relay-disconnected');
356
+ scheduleReconnect(connect);
357
+ });
358
+ socket.once('error', error => {
359
+ lastError = error instanceof Error ? error.message : String(error);
360
+ try { socket.terminate?.(); } catch { /* close handler owns retry */ }
361
+ });
362
+ };
363
+
364
+ const start = () => {
365
+ if (!stopped || !(relayUrl instanceof URL) || !deviceId) return;
366
+ stopped = false;
367
+ void connect();
368
+ };
369
+
370
+ const refresh = () => {
371
+ if (stopped) {
372
+ start();
373
+ return;
374
+ }
375
+ generation += 1;
376
+ if (retryTimer) {
377
+ clearTimeout(retryTimer);
378
+ retryTimer = null;
379
+ }
380
+ const socket = relaySocket;
381
+ relaySocket = null;
382
+ try { socket?.terminate?.(); } catch { /* refresh owns the exact socket */ }
383
+ closeAllLocalSockets('relay-refresh');
384
+ void connect();
385
+ };
386
+
387
+ const close = () => {
388
+ if (stopped) return;
389
+ stopped = true;
390
+ generation += 1;
391
+ if (retryTimer) clearTimeout(retryTimer);
392
+ retryTimer = null;
393
+ closeAllLocalSockets('hub-shutdown');
394
+ const socket = relaySocket;
395
+ relaySocket = null;
396
+ try { socket?.close(1001, 'hub-shutdown'); } catch { /* exact socket is already closed */ }
397
+ try { socket?.terminate?.(); } catch { /* exact socket is already closed */ }
398
+ state = relayUrl ? 'closed' : 'disabled';
399
+ };
400
+
401
+ return {
402
+ start,
403
+ refresh,
404
+ close,
405
+ inspect: () => ({
406
+ enabled: relayUrl instanceof URL && Boolean(deviceId),
407
+ state,
408
+ connected: state === 'connected',
409
+ localWebSocketChannels: localSockets.size,
410
+ pendingHttpRequests: pendingHttp.size,
411
+ droppedBinaryMessages,
412
+ lastConnectedAt,
413
+ lastError
414
+ })
415
+ };
416
+ }
417
+
418
+ export const consoleRelayContract = Object.freeze({
419
+ maxChannels: MAX_CHANNELS,
420
+ maxHttpRequestBytes: MAX_HTTP_REQUEST_BYTES,
421
+ maxHttpResponseBytes: MAX_HTTP_RESPONSE_BYTES,
422
+ maxBufferedBytes: MAX_RELAY_BUFFERED_BYTES,
423
+ binaryHeaderBytes: BINARY_HEADER_BYTES
424
+ });