@livedesk/hub 0.1.56 → 0.1.57

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.
package/src/server.js CHANGED
@@ -14,7 +14,7 @@ import {
14
14
  RemoteClipboardContractError,
15
15
  normalizeRemoteClipboardRequest
16
16
  } from './remote-clipboard-contract.mjs';
17
- import { createHubConsoleRelay } from './console-relay.js';
17
+ import { createHubConsoleDirect } from './console-direct.js';
18
18
  import {
19
19
  buildImmutableRemoteFramePacket,
20
20
  createRemoteFramePacketMetrics,
@@ -211,20 +211,23 @@ let inputClientSeq = 0;
211
211
  let audioClientSeq = 0;
212
212
  let liveDeskUpdateManager = null;
213
213
  let hubTransferJobs = null;
214
- let hubConsoleRelay = null;
214
+ let hubConsoleDirect = null;
215
215
  const HUB_HOST_TARGET_LEASE_MS = Math.max(5000, readPositiveIntegerEnv('LIVEDESK_HOST_TARGET_LEASE_MS', 60_000));
216
216
  const HUB_HOST_TARGET_RENEW_MS = Math.max(1000, Math.min(
217
217
  readPositiveIntegerEnv('LIVEDESK_HOST_TARGET_RENEW_MS', 20_000),
218
218
  Math.max(1000, HUB_HOST_TARGET_LEASE_MS - 1000)
219
219
  ));
220
220
  const HUB_ACCESS_TOKEN_REFRESH_SKEW_MS = 90_000;
221
- const hubWakeBaseUrl = String(process.env.LIVEDESK_WAKE_URL || 'https://livedesk-wake.lovecrdm.workers.dev').trim();
222
- const hubConsoleRelayBaseUrl = String(
223
- process.env.LIVEDESK_CONSOLE_RELAY_URL
224
- || (process.env.LIVEDESK_TEST_MODE === '1' || process.env.LIVEDESK_AUTH_TEST_MODE === '1'
225
- ? 'off'
226
- : 'https://livedesk-wake.lovecrdm.workers.dev')
227
- ).trim();
221
+ const hubWakeBaseUrl = String(process.env.LIVEDESK_WAKE_URL || 'https://livedesk-wake.lovecrdm.workers.dev').trim();
222
+ const hubConsoleDirectSignalBaseUrl = String(
223
+ process.env.LIVEDESK_CONSOLE_SIGNAL_URL
224
+ || (process.env.LIVEDESK_TEST_MODE === '1' || process.env.LIVEDESK_AUTH_TEST_MODE === '1'
225
+ ? 'off'
226
+ : hubWakeBaseUrl)
227
+ ).trim();
228
+ const hubConsoleDirectStunUrls = String(
229
+ process.env.LIVEDESK_CONSOLE_STUN_URLS || 'stun:stun.cloudflare.com:3478'
230
+ ).trim();
228
231
  const HUB_WAKE_NOTIFY_RETRY_MS = 60_000;
229
232
  let hubHostTargetRenewTimer = null;
230
233
  let hubHostTargetRenewInFlight = false;
@@ -1394,13 +1397,13 @@ const hubSharedFolders = createHubSharedFolders({
1394
1397
 
1395
1398
  const app = express();
1396
1399
  const httpServer = createServer(app);
1397
- hubConsoleRelay = createHubConsoleRelay({
1398
- url: runtimeRole === 'hub' ? hubConsoleRelayBaseUrl : 'off',
1399
- deviceId: runtimeDeviceId,
1400
- httpBaseUrl: `http://127.0.0.1:${httpPort}`,
1401
- getAccessToken: () => getRuntimeAccessToken(),
1402
- logger: console
1403
- });
1400
+ hubConsoleDirect = createHubConsoleDirect({
1401
+ url: runtimeRole === 'hub' ? hubConsoleDirectSignalBaseUrl : 'off',
1402
+ deviceId: runtimeDeviceId,
1403
+ httpBaseUrl: `http://127.0.0.1:${httpPort}`,
1404
+ stunUrls: hubConsoleDirectStunUrls,
1405
+ getAccessToken: () => getRuntimeAccessToken()
1406
+ });
1404
1407
  const httpConnections = new Set();
1405
1408
  const frameWss = new WebSocketServer({ noServer: true, perMessageDeflate: false });
1406
1409
  const atlasWss = new WebSocketServer({ noServer: true, perMessageDeflate: false });
@@ -4363,7 +4366,7 @@ app.get('/api/remote/status', async (_req, res) => {
4363
4366
  roleSource: runtimeRoleSource,
4364
4367
  agentPackage: '@livedesk/client',
4365
4368
  wallPreferences,
4366
- consoleRelay: hubConsoleRelay?.inspect() || { enabled: false, state: 'starting' },
4369
+ consoleDirect: hubConsoleDirect?.inspect() || { enabled: false, state: 'starting' },
4367
4370
  frameLanes: snapshotFrameLaneResourceHealth(),
4368
4371
  update: getLiveDeskUpdateStatus()
4369
4372
  });
@@ -4601,7 +4604,7 @@ app.post('/api/auth/session', async (req, res) => {
4601
4604
  }
4602
4605
  });
4603
4606
  if (runtimeRole === 'hub') {
4604
- hubConsoleRelay?.refresh();
4607
+ hubConsoleDirect?.refresh();
4605
4608
  scheduleAuthenticatedHubHostTargetPublication('session-received');
4606
4609
  }
4607
4610
  } catch (error) {
@@ -4627,7 +4630,7 @@ function getHubHostTargetLeaseStatus() {
4627
4630
  authenticated: Boolean(runtimeAccessToken),
4628
4631
  timerActive: Boolean(hubHostTargetRenewTimer),
4629
4632
  wake: { ...hubWakeNotificationState },
4630
- consoleRelay: hubConsoleRelay?.inspect() || { enabled: false, state: 'starting' }
4633
+ consoleDirect: hubConsoleDirect?.inspect() || { enabled: false, state: 'starting' }
4631
4634
  };
4632
4635
  }
4633
4636
 
@@ -4913,10 +4916,11 @@ function scheduleAuthenticatedHubHostTargetPublication(reason = 'session-receive
4913
4916
 
4914
4917
  app.delete('/api/auth/session', async (req, res) => {
4915
4918
  noStore(res);
4916
- const hostTarget = runtimeRole === 'hub'
4917
- ? await clearHubHostTarget('logout')
4918
- : { ok: true, active: false };
4919
- clearRuntimeSession();
4919
+ const hostTarget = runtimeRole === 'hub'
4920
+ ? await clearHubHostTarget('logout')
4921
+ : { ok: true, active: false };
4922
+ clearRuntimeSession();
4923
+ hubConsoleDirect?.close();
4920
4924
  res.setHeader('Set-Cookie', clearHubUiSessionCookie({ secure: isSecureHubHttpRequest(req) }));
4921
4925
  res.json({ ok: true, authenticated: false, role: runtimeRole, hostTarget });
4922
4926
  });
@@ -4934,7 +4938,7 @@ app.get('/api/hub/status', (_req, res) => {
4934
4938
  deviceName: runtimeDeviceName,
4935
4939
  roleSource: runtimeRoleSource,
4936
4940
  runtimeStarted: true,
4937
- consoleRelay: hubConsoleRelay?.inspect() || { enabled: false, state: 'starting' },
4941
+ consoleDirect: hubConsoleDirect?.inspect() || { enabled: false, state: 'starting' },
4938
4942
  hostTargetLease: getHubHostTargetLeaseStatus(),
4939
4943
  update: getLiveDeskUpdateStatus()
4940
4944
  });
@@ -6018,7 +6022,7 @@ httpServer.listen(httpPort, httpHost, () => {
6018
6022
  console.log(`[VuvoDesk Hub] Version ${managerVersion}`);
6019
6023
  console.log(`[VuvoDesk Hub] HTTP API http://${httpHost}:${httpPort}`);
6020
6024
  console.log(`[VuvoDesk Hub] Client endpoint ${status.agentEndpoint} pair=${status.pairTokenPreview}`);
6021
- hubConsoleRelay?.start();
6025
+ hubConsoleDirect?.start();
6022
6026
  });
6023
6027
 
6024
6028
  const roleWatchTimer = runtimeRole === 'hub'
@@ -6102,7 +6106,7 @@ function shutdownHub(signal) {
6102
6106
  if (roleWatchTimer) clearInterval(roleWatchTimer);
6103
6107
  clearInterval(browserWebSocketHeartbeatTimer);
6104
6108
  runSynchronousShutdownStep('update manager close', () => liveDeskUpdateManager?.close());
6105
- runSynchronousShutdownStep('mobile console relay close', () => hubConsoleRelay?.close());
6109
+ runSynchronousShutdownStep('mobile console direct close', () => hubConsoleDirect?.close());
6106
6110
  atlasClients.clear();
6107
6111
  runSynchronousShutdownStep('shared folder close', () => hubSharedFolders.close());
6108
6112
 
@@ -1,465 +0,0 @@
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, 60_000];
5
- const HTTP_429_RETRY_DELAY_MS = 60_000;
6
- const MAX_CHANNELS = 32;
7
- const MAX_HTTP_REQUEST_BYTES = 1024 * 1024;
8
- const MAX_HTTP_RESPONSE_BYTES = 4 * 1024 * 1024;
9
- const MAX_RELAY_BUFFERED_BYTES = 4 * 1024 * 1024;
10
- const HTTP_TIMEOUT_MS = 15_000;
11
- const BINARY_HEADER_BYTES = 73;
12
- 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;
13
-
14
- function normalizeRelayUrl(value) {
15
- const source = String(value || DEFAULT_CONSOLE_RELAY_URL).trim();
16
- if (!source || /^(off|disabled|none)$/i.test(source)) return '';
17
- try {
18
- const url = new URL(source);
19
- url.protocol = url.protocol === 'http:' ? 'ws:' : url.protocol === 'https:' ? 'wss:' : url.protocol;
20
- url.pathname = '/v1/hub';
21
- url.search = '';
22
- url.hash = '';
23
- return url;
24
- } catch {
25
- return null;
26
- }
27
- }
28
-
29
- function allowedHttpRequest(method, path) {
30
- if (!['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) return false;
31
- const pathname = String(path || '').split('?')[0];
32
- if (pathname === '/api/health'
33
- || pathname === '/api/runtime/status'
34
- || pathname === '/api/runtime/restart'
35
- || pathname === '/api/auth/status'
36
- || pathname === '/api/remote/status'
37
- || pathname === '/api/hub/status'
38
- || pathname === '/api/update/status'
39
- || pathname === '/api/update/apply') {
40
- return true;
41
- }
42
- if (/^\/api\/settings(?:\/|$)/.test(pathname)
43
- || /^\/api\/captures(?:\/|$)/.test(pathname)
44
- || /^\/api\/capture-sessions(?:\/|$)/.test(pathname)
45
- || /^\/api\/remote\/devices(?:\/|$)/.test(pathname)
46
- || /^\/api\/remote\/frames$/.test(pathname)
47
- || /^\/api\/remote\/filesystem(?:\/|$)/.test(pathname)
48
- || /^\/api\/remote\/files(?:\/|$)/.test(pathname)
49
- || /^\/api\/remote\/tasks(?:\/|$)/.test(pathname)
50
- || /^\/api\/remote\/license(?:\/sync)?$/.test(pathname)) {
51
- return pathname !== '/api/remote/registry-credentials'
52
- && pathname !== '/api/remote/pairing-pin'
53
- && !pathname.startsWith('/api/remote/host-target')
54
- && !pathname.startsWith('/api/remote/synthetic');
55
- }
56
- return false;
57
- }
58
-
59
- function allowedWebSocketPath(path) {
60
- const pathname = String(path || '').split('?')[0];
61
- return pathname === '/api/remote/frames/ws'
62
- || pathname === '/api/remote/atlas/ws'
63
- || pathname === '/api/remote/input/ws'
64
- || pathname === '/api/remote/audio/ws';
65
- }
66
-
67
- function channelKey(consoleId, channelId) {
68
- return `${consoleId}\n${channelId}`;
69
- }
70
-
71
- function parseMessage(raw) {
72
- try {
73
- return JSON.parse(String(raw || ''));
74
- } catch {
75
- return null;
76
- }
77
- }
78
-
79
- function safeContentHeaders(headers) {
80
- const result = {};
81
- for (const name of ['accept', 'content-type', 'if-match', 'range']) {
82
- const value = headers && typeof headers === 'object' ? headers[name] || headers[name.toUpperCase()] : '';
83
- if (value) result[name] = String(value).slice(0, 512);
84
- }
85
- return result;
86
- }
87
-
88
- export function createHubConsoleRelay(options = {}) {
89
- const relayUrl = normalizeRelayUrl(options.url);
90
- const deviceId = String(options.deviceId || '').trim();
91
- const httpBaseUrl = String(options.httpBaseUrl || '').replace(/\/+$/, '');
92
- const WebSocketImpl = options.WebSocketImpl || WebSocket;
93
- const fetchImpl = options.fetchImpl || globalThis.fetch;
94
- const getAccessToken = typeof options.getAccessToken === 'function'
95
- ? options.getAccessToken
96
- : async () => String(options.accessToken || '').trim();
97
- const logger = options.logger || console;
98
- const retryDelaysMs = options.retryDelaysMs || DEFAULT_RETRY_DELAYS_MS;
99
- const localSockets = new Map();
100
- const pendingHttp = new Set();
101
- let relaySocket = null;
102
- let retryTimer = null;
103
- let retryAttempt = 0;
104
- let generation = 0;
105
- let stopped = true;
106
- let state = relayUrl ? 'idle' : 'disabled';
107
- let lastConnectedAt = '';
108
- let lastError = relayUrl instanceof URL ? '' : relayUrl === '' ? '' : 'invalid-relay-url';
109
- let lastHttpStatus = 0;
110
- let retryNotBeforeAt = 0;
111
- let nextRetryAt = '';
112
- let droppedBinaryMessages = 0;
113
-
114
- const sendRelay = (payload, bypassBackpressure = false) => {
115
- if (!relaySocket || relaySocket.readyState !== WebSocketImpl.OPEN) return false;
116
- if (!bypassBackpressure
117
- && Number(relaySocket.bufferedAmount || 0) > MAX_RELAY_BUFFERED_BYTES) return false;
118
- relaySocket.send(typeof payload === 'string' || Buffer.isBuffer(payload) ? payload : JSON.stringify(payload));
119
- return true;
120
- };
121
-
122
- const sendError = (consoleId, channelId, error, type = 'relay-error') => {
123
- sendRelay({ type, consoleId, channelId, error: String(error || 'console-relay-error').slice(0, 240) });
124
- };
125
-
126
- const closeLocalSocket = (key, code = 1000, reason = 'console-channel-closed') => {
127
- const socket = localSockets.get(key);
128
- if (!socket) return;
129
- localSockets.delete(key);
130
- try { socket.close(code, reason); } catch { /* exact socket is already closed */ }
131
- try { socket.terminate?.(); } catch { /* exact socket is already closed */ }
132
- };
133
-
134
- const closeConsoleChannels = consoleId => {
135
- for (const key of [...localSockets.keys()]) {
136
- if (key.startsWith(`${consoleId}\n`)) closeLocalSocket(key, 1001, 'console-detached');
137
- }
138
- };
139
-
140
- const closeAllLocalSockets = reason => {
141
- for (const key of [...localSockets.keys()]) closeLocalSocket(key, 1012, reason);
142
- };
143
-
144
- const handleHttpRequest = async payload => {
145
- const consoleId = String(payload?.consoleId || '');
146
- const channelId = String(payload?.channelId || '');
147
- const method = String(payload?.method || 'GET').toUpperCase();
148
- const path = String(payload?.path || '');
149
- if (!UUID_PATTERN.test(consoleId) || !UUID_PATTERN.test(channelId) || !allowedHttpRequest(method, path)) {
150
- sendError(consoleId, channelId, 'console-route-not-allowed', 'http-response');
151
- return;
152
- }
153
- if (pendingHttp.size >= MAX_CHANNELS) {
154
- sendError(consoleId, channelId, 'console-http-capacity-reached', 'http-response');
155
- return;
156
- }
157
- let body;
158
- try {
159
- body = payload.bodyBase64 ? Buffer.from(String(payload.bodyBase64), 'base64') : undefined;
160
- } catch {
161
- sendError(consoleId, channelId, 'invalid-console-http-body', 'http-response');
162
- return;
163
- }
164
- if (body && body.byteLength > MAX_HTTP_REQUEST_BYTES) {
165
- sendError(consoleId, channelId, 'console-http-body-too-large', 'http-response');
166
- return;
167
- }
168
-
169
- const requestKey = channelKey(consoleId, channelId);
170
- pendingHttp.add(requestKey);
171
- const controller = new AbortController();
172
- const timeout = setTimeout(() => controller.abort(new Error('console-http-timeout')), HTTP_TIMEOUT_MS);
173
- timeout.unref?.();
174
- try {
175
- const response = await fetchImpl(`${httpBaseUrl}${path}`, {
176
- method,
177
- headers: safeContentHeaders(payload.headers),
178
- body: ['GET', 'HEAD'].includes(method) ? undefined : body,
179
- signal: controller.signal
180
- });
181
- const responseBytes = Buffer.from(await response.arrayBuffer());
182
- if (responseBytes.byteLength > MAX_HTTP_RESPONSE_BYTES) {
183
- sendError(consoleId, channelId, 'console-http-response-too-large', 'http-response');
184
- return;
185
- }
186
- sendRelay({
187
- type: 'http-response',
188
- consoleId,
189
- channelId,
190
- status: response.status,
191
- statusText: response.statusText,
192
- headers: {
193
- 'content-type': String(response.headers.get('content-type') || '').slice(0, 256),
194
- 'content-range': String(response.headers.get('content-range') || '').slice(0, 256)
195
- },
196
- bodyBase64: responseBytes.toString('base64')
197
- });
198
- } catch (error) {
199
- sendError(consoleId, channelId, error instanceof Error ? error.message : error, 'http-response');
200
- } finally {
201
- clearTimeout(timeout);
202
- pendingHttp.delete(requestKey);
203
- }
204
- };
205
-
206
- const handleWebSocketOpen = payload => {
207
- const consoleId = String(payload?.consoleId || '');
208
- const channelId = String(payload?.channelId || '');
209
- const path = String(payload?.path || '');
210
- if (!UUID_PATTERN.test(consoleId) || !UUID_PATTERN.test(channelId) || !allowedWebSocketPath(path)) {
211
- sendError(consoleId, channelId, 'console-websocket-route-not-allowed', 'ws-error');
212
- return;
213
- }
214
- if (localSockets.size >= MAX_CHANNELS) {
215
- sendError(consoleId, channelId, 'console-websocket-capacity-reached', 'ws-error');
216
- return;
217
- }
218
- const key = channelKey(consoleId, channelId);
219
- closeLocalSocket(key, 1000, 'console-channel-replaced');
220
- const localUrl = new URL(path, httpBaseUrl);
221
- localUrl.protocol = localUrl.protocol === 'https:' ? 'wss:' : 'ws:';
222
- let socket;
223
- try {
224
- socket = new WebSocketImpl(localUrl, { perMessageDeflate: false });
225
- } catch (error) {
226
- sendError(consoleId, channelId, error instanceof Error ? error.message : error, 'ws-error');
227
- return;
228
- }
229
- localSockets.set(key, socket);
230
- socket.once('open', () => {
231
- if (localSockets.get(key) !== socket) return;
232
- sendRelay({ type: 'ws-opened', consoleId, channelId });
233
- });
234
- socket.on('message', (data, isBinary) => {
235
- if (localSockets.get(key) !== socket) return;
236
- if (isBinary) {
237
- if (!relaySocket || relaySocket.readyState !== WebSocketImpl.OPEN
238
- || Number(relaySocket.bufferedAmount || 0) > MAX_RELAY_BUFFERED_BYTES) {
239
- droppedBinaryMessages += 1;
240
- closeLocalSocket(key, 1013, 'console-relay-backpressure');
241
- return;
242
- }
243
- const header = Buffer.from(`B${consoleId}${channelId}`, 'ascii');
244
- if (header.byteLength !== BINARY_HEADER_BYTES) {
245
- closeLocalSocket(key, 1008, 'invalid-relay-channel-id');
246
- return;
247
- }
248
- relaySocket.send(Buffer.concat([header, Buffer.from(data)]));
249
- return;
250
- }
251
- sendRelay({ type: 'ws-message', consoleId, channelId, data: String(data), binary: false });
252
- });
253
- socket.once('close', (code, reason) => {
254
- if (localSockets.get(key) === socket) localSockets.delete(key);
255
- const closeReason = String(reason || '').slice(0, 120);
256
- // Do not silently punch a hole in an H.264 GOP. A tiny terminal control
257
- // message is allowed behind the already-bounded video backlog so the
258
- // browser replaces only this logical lane and starts again on a key.
259
- sendRelay(
260
- { type: 'ws-closed', consoleId, channelId, code, reason: closeReason },
261
- closeReason === 'console-relay-backpressure'
262
- );
263
- });
264
- socket.once('error', error => {
265
- sendError(consoleId, channelId, error instanceof Error ? error.message : error, 'ws-error');
266
- });
267
- };
268
-
269
- const handleMessage = raw => {
270
- const payload = parseMessage(raw);
271
- if (!payload?.type) return;
272
- if (payload.type === 'relay-ready') {
273
- state = 'connected';
274
- lastConnectedAt = new Date().toISOString();
275
- lastError = '';
276
- lastHttpStatus = 0;
277
- retryAttempt = 0;
278
- retryNotBeforeAt = 0;
279
- nextRetryAt = '';
280
- return;
281
- }
282
- if (payload.type === 'console-detached') {
283
- closeConsoleChannels(String(payload.consoleId || ''));
284
- return;
285
- }
286
- if (payload.type === 'http-request') {
287
- void handleHttpRequest(payload);
288
- return;
289
- }
290
- if (payload.type === 'ws-open') {
291
- handleWebSocketOpen(payload);
292
- return;
293
- }
294
- const key = channelKey(String(payload.consoleId || ''), String(payload.channelId || ''));
295
- const socket = localSockets.get(key);
296
- if (!socket) return;
297
- if (payload.type === 'ws-send' && socket.readyState === WebSocketImpl.OPEN) {
298
- const data = String(payload.data || '');
299
- if (Buffer.byteLength(data) <= MAX_HTTP_REQUEST_BYTES) socket.send(data);
300
- } else if (payload.type === 'ws-close') {
301
- closeLocalSocket(key, Number(payload.code || 1000), String(payload.reason || 'console-request').slice(0, 120));
302
- }
303
- };
304
-
305
- const scheduleReconnect = connect => {
306
- if (stopped || retryTimer || !(relayUrl instanceof URL)) return;
307
- const sequenceDelayMs = retryDelaysMs[Math.min(retryAttempt, retryDelaysMs.length - 1)];
308
- const delayMs = Math.max(sequenceDelayMs, retryNotBeforeAt - Date.now(), 0);
309
- retryAttempt += 1;
310
- state = 'waiting-retry';
311
- nextRetryAt = new Date(Date.now() + delayMs).toISOString();
312
- retryTimer = setTimeout(() => {
313
- retryTimer = null;
314
- nextRetryAt = '';
315
- void connect();
316
- }, delayMs);
317
- retryTimer.unref?.();
318
- };
319
-
320
- const connect = async () => {
321
- if (stopped || !(relayUrl instanceof URL) || !deviceId) return;
322
- const ownerGeneration = ++generation;
323
- let ownerHttpStatus = 0;
324
- state = 'connecting';
325
- nextRetryAt = '';
326
- let accessToken = '';
327
- try {
328
- accessToken = String(await getAccessToken() || '').trim();
329
- } catch (error) {
330
- lastError = error instanceof Error ? error.message : String(error);
331
- }
332
- if (stopped || ownerGeneration !== generation) return;
333
- if (!accessToken) {
334
- lastError = 'hub-session-required';
335
- scheduleReconnect(connect);
336
- return;
337
- }
338
- const url = new URL(relayUrl);
339
- url.searchParams.set('deviceId', deviceId);
340
- let socket;
341
- try {
342
- socket = new WebSocketImpl(url, {
343
- headers: { Authorization: `Bearer ${accessToken}` },
344
- perMessageDeflate: false
345
- });
346
- } catch (error) {
347
- lastError = error instanceof Error ? error.message : String(error);
348
- scheduleReconnect(connect);
349
- return;
350
- }
351
- if (stopped || ownerGeneration !== generation) {
352
- try { socket.terminate?.(); } catch { /* stale connect is already gone */ }
353
- return;
354
- }
355
- relaySocket = socket;
356
- socket.once('open', () => {
357
- if (relaySocket !== socket || ownerGeneration !== generation || stopped) return;
358
- state = 'authenticating';
359
- });
360
- socket.once('unexpected-response', (_request, response) => {
361
- if (ownerGeneration !== generation || stopped) {
362
- response.resume?.();
363
- return;
364
- }
365
- const status = Math.max(0, Number(response?.statusCode || 0));
366
- ownerHttpStatus = status;
367
- lastHttpStatus = status;
368
- lastError = status > 0
369
- ? `Console relay returned HTTP ${status}`
370
- : 'Console relay returned an unexpected response.';
371
- if (status === 429) {
372
- retryNotBeforeAt = Math.max(retryNotBeforeAt, Date.now() + HTTP_429_RETRY_DELAY_MS);
373
- }
374
- if (relaySocket === socket) relaySocket = null;
375
- response.resume?.();
376
- try { socket.terminate?.(); } catch { /* retry owns the rejected upgrade */ }
377
- scheduleReconnect(connect);
378
- });
379
- socket.on('message', raw => {
380
- if (relaySocket === socket && ownerGeneration === generation && !stopped) handleMessage(raw);
381
- });
382
- socket.once('close', () => {
383
- if (relaySocket === socket) relaySocket = null;
384
- if (ownerGeneration !== generation || stopped) return;
385
- if (!retryTimer) state = 'disconnected';
386
- closeAllLocalSockets('relay-disconnected');
387
- scheduleReconnect(connect);
388
- });
389
- socket.once('error', error => {
390
- if (ownerHttpStatus <= 0) {
391
- lastHttpStatus = 0;
392
- lastError = error instanceof Error ? error.message : String(error);
393
- }
394
- try { socket.terminate?.(); } catch { /* close handler owns retry */ }
395
- });
396
- };
397
-
398
- const start = () => {
399
- if (!stopped || !(relayUrl instanceof URL) || !deviceId) return;
400
- stopped = false;
401
- void connect();
402
- };
403
-
404
- const refresh = () => {
405
- if (stopped) {
406
- start();
407
- return;
408
- }
409
- generation += 1;
410
- if (retryTimer) {
411
- clearTimeout(retryTimer);
412
- retryTimer = null;
413
- }
414
- retryAttempt = 0;
415
- retryNotBeforeAt = 0;
416
- nextRetryAt = '';
417
- const socket = relaySocket;
418
- relaySocket = null;
419
- try { socket?.terminate?.(); } catch { /* refresh owns the exact socket */ }
420
- closeAllLocalSockets('relay-refresh');
421
- void connect();
422
- };
423
-
424
- const close = () => {
425
- if (stopped) return;
426
- stopped = true;
427
- generation += 1;
428
- if (retryTimer) clearTimeout(retryTimer);
429
- retryTimer = null;
430
- nextRetryAt = '';
431
- closeAllLocalSockets('hub-shutdown');
432
- const socket = relaySocket;
433
- relaySocket = null;
434
- try { socket?.close(1001, 'hub-shutdown'); } catch { /* exact socket is already closed */ }
435
- try { socket?.terminate?.(); } catch { /* exact socket is already closed */ }
436
- state = relayUrl ? 'closed' : 'disabled';
437
- };
438
-
439
- return {
440
- start,
441
- refresh,
442
- close,
443
- inspect: () => ({
444
- enabled: relayUrl instanceof URL && Boolean(deviceId),
445
- state,
446
- connected: state === 'connected',
447
- localWebSocketChannels: localSockets.size,
448
- pendingHttpRequests: pendingHttp.size,
449
- droppedBinaryMessages,
450
- lastConnectedAt,
451
- lastError,
452
- lastHttpStatus,
453
- retryAttempt,
454
- nextRetryAt
455
- })
456
- };
457
- }
458
-
459
- export const consoleRelayContract = Object.freeze({
460
- maxChannels: MAX_CHANNELS,
461
- maxHttpRequestBytes: MAX_HTTP_REQUEST_BYTES,
462
- maxHttpResponseBytes: MAX_HTTP_RESPONSE_BYTES,
463
- maxBufferedBytes: MAX_RELAY_BUFFERED_BYTES,
464
- binaryHeaderBytes: BINARY_HEADER_BYTES
465
- });