@livedesk/hub 0.1.55 → 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 });
@@ -4342,27 +4345,35 @@ app.post('/api/settings/agent/summary', async (req, res) => {
4342
4345
  }
4343
4346
  });
4344
4347
 
4345
- app.get('/api/remote/status', (_req, res) => {
4346
- noStore(res);
4347
- if (runtimeRole !== 'hub') {
4348
+ app.get('/api/remote/status', async (_req, res) => {
4349
+ noStore(res);
4350
+ if (runtimeRole !== 'hub') {
4348
4351
  res.status(403).json({ ok: false, error: 'role-not-allowed' });
4349
4352
  return;
4350
4353
  }
4351
- const secretStatus = remoteHub.getStatus({ includeSecrets: true });
4352
- res.json({
4353
- ...remoteHub.getStatus({ includeSecrets: false }),
4354
- pairingPin: secretStatus.pairingPin,
4355
- product: 'LiveDesk',
4356
- runtimeRole,
4357
- deviceId: runtimeDeviceId,
4358
- deviceName: runtimeDeviceName,
4359
- roleSource: runtimeRoleSource,
4360
- agentPackage: '@livedesk/client',
4361
- consoleRelay: hubConsoleRelay?.inspect() || { enabled: false, state: 'starting' },
4362
- frameLanes: snapshotFrameLaneResourceHealth(),
4363
- update: getLiveDeskUpdateStatus()
4364
- });
4365
- });
4354
+ try {
4355
+ const [secretStatus, wallPreferences] = await Promise.all([
4356
+ Promise.resolve(remoteHub.getStatus({ includeSecrets: true })),
4357
+ liveDeskSettingsStore.getWallPreferencesRecord()
4358
+ ]);
4359
+ res.json({
4360
+ ...remoteHub.getStatus({ includeSecrets: false }),
4361
+ pairingPin: secretStatus.pairingPin,
4362
+ product: 'LiveDesk',
4363
+ runtimeRole,
4364
+ deviceId: runtimeDeviceId,
4365
+ deviceName: runtimeDeviceName,
4366
+ roleSource: runtimeRoleSource,
4367
+ agentPackage: '@livedesk/client',
4368
+ wallPreferences,
4369
+ consoleDirect: hubConsoleDirect?.inspect() || { enabled: false, state: 'starting' },
4370
+ frameLanes: snapshotFrameLaneResourceHealth(),
4371
+ update: getLiveDeskUpdateStatus()
4372
+ });
4373
+ } catch (error) {
4374
+ res.status(500).json({ ok: false, error: error instanceof Error ? error.message : String(error) });
4375
+ }
4376
+ });
4366
4377
 
4367
4378
  app.post('/api/remote/diagnostics/transport/start', async (req, res) => {
4368
4379
  noStore(res);
@@ -4593,7 +4604,7 @@ app.post('/api/auth/session', async (req, res) => {
4593
4604
  }
4594
4605
  });
4595
4606
  if (runtimeRole === 'hub') {
4596
- hubConsoleRelay?.refresh();
4607
+ hubConsoleDirect?.refresh();
4597
4608
  scheduleAuthenticatedHubHostTargetPublication('session-received');
4598
4609
  }
4599
4610
  } catch (error) {
@@ -4619,7 +4630,7 @@ function getHubHostTargetLeaseStatus() {
4619
4630
  authenticated: Boolean(runtimeAccessToken),
4620
4631
  timerActive: Boolean(hubHostTargetRenewTimer),
4621
4632
  wake: { ...hubWakeNotificationState },
4622
- consoleRelay: hubConsoleRelay?.inspect() || { enabled: false, state: 'starting' }
4633
+ consoleDirect: hubConsoleDirect?.inspect() || { enabled: false, state: 'starting' }
4623
4634
  };
4624
4635
  }
4625
4636
 
@@ -4905,10 +4916,11 @@ function scheduleAuthenticatedHubHostTargetPublication(reason = 'session-receive
4905
4916
 
4906
4917
  app.delete('/api/auth/session', async (req, res) => {
4907
4918
  noStore(res);
4908
- const hostTarget = runtimeRole === 'hub'
4909
- ? await clearHubHostTarget('logout')
4910
- : { ok: true, active: false };
4911
- clearRuntimeSession();
4919
+ const hostTarget = runtimeRole === 'hub'
4920
+ ? await clearHubHostTarget('logout')
4921
+ : { ok: true, active: false };
4922
+ clearRuntimeSession();
4923
+ hubConsoleDirect?.close();
4912
4924
  res.setHeader('Set-Cookie', clearHubUiSessionCookie({ secure: isSecureHubHttpRequest(req) }));
4913
4925
  res.json({ ok: true, authenticated: false, role: runtimeRole, hostTarget });
4914
4926
  });
@@ -4926,7 +4938,7 @@ app.get('/api/hub/status', (_req, res) => {
4926
4938
  deviceName: runtimeDeviceName,
4927
4939
  roleSource: runtimeRoleSource,
4928
4940
  runtimeStarted: true,
4929
- consoleRelay: hubConsoleRelay?.inspect() || { enabled: false, state: 'starting' },
4941
+ consoleDirect: hubConsoleDirect?.inspect() || { enabled: false, state: 'starting' },
4930
4942
  hostTargetLease: getHubHostTargetLeaseStatus(),
4931
4943
  update: getLiveDeskUpdateStatus()
4932
4944
  });
@@ -6010,7 +6022,7 @@ httpServer.listen(httpPort, httpHost, () => {
6010
6022
  console.log(`[VuvoDesk Hub] Version ${managerVersion}`);
6011
6023
  console.log(`[VuvoDesk Hub] HTTP API http://${httpHost}:${httpPort}`);
6012
6024
  console.log(`[VuvoDesk Hub] Client endpoint ${status.agentEndpoint} pair=${status.pairTokenPreview}`);
6013
- hubConsoleRelay?.start();
6025
+ hubConsoleDirect?.start();
6014
6026
  });
6015
6027
 
6016
6028
  const roleWatchTimer = runtimeRole === 'hub'
@@ -6094,7 +6106,7 @@ function shutdownHub(signal) {
6094
6106
  if (roleWatchTimer) clearInterval(roleWatchTimer);
6095
6107
  clearInterval(browserWebSocketHeartbeatTimer);
6096
6108
  runSynchronousShutdownStep('update manager close', () => liveDeskUpdateManager?.close());
6097
- runSynchronousShutdownStep('mobile console relay close', () => hubConsoleRelay?.close());
6109
+ runSynchronousShutdownStep('mobile console direct close', () => hubConsoleDirect?.close());
6098
6110
  atlasClients.clear();
6099
6111
  runSynchronousShutdownStep('shared folder close', () => hubSharedFolders.close());
6100
6112
 
@@ -46,9 +46,11 @@ export const DEFAULT_LIVEDESK_SETTINGS = Object.freeze({
46
46
  rememberLastMonitor: true,
47
47
  keepControlReadyBetweenPages: true
48
48
  },
49
- wall: {
50
- performanceMode: 'auto',
51
- autoStart: true,
49
+ wall: {
50
+ performanceMode: 'auto',
51
+ cadence: 'fast',
52
+ viewScale: 100,
53
+ autoStart: true,
52
54
  connectedOnly: false,
53
55
  keepEmptySlots: true,
54
56
  showDeviceStatus: true,
@@ -110,9 +112,10 @@ export const DEFAULT_LIVEDESK_SETTINGS = Object.freeze({
110
112
  }
111
113
  });
112
114
 
113
- const ENUMS = {
115
+ const ENUMS = {
114
116
  accessMode: new Set(['trusted-only', 'ask-every-time', 'view-only', 'block-remote-access']),
115
- performanceMode: new Set(['auto', 'responsive', 'quality', 'custom']),
117
+ performanceMode: new Set(['auto', 'responsive', 'quality', 'custom']),
118
+ wallCadence: new Set(['slow', 'fast']),
116
119
  permissionMode: new Set(['ask', 'safe-auto', 'full-access', 'custom']),
117
120
  wallFrameMode: new Set(['auto', 'mode2-lzo', 'mode3-h264-hw', 'mode4-h264-atlas']),
118
121
  controlFrameMode: new Set(['mode3-h264-hw', 'mode5-lzo-delta']),
@@ -169,9 +172,11 @@ const RULES = {
169
172
  control: {
170
173
  ...bools(['allowKeyboardMouse', 'allowSystemShortcuts', 'allowClipboardText', 'allowRemoteRestart', 'reconnectAfterRemoteRestart', 'allowSwitchingMonitors', 'showConnectionToolbar', 'showRemoteCursor', 'openControlOnDoubleClick', 'startRemoteAudioWithControl', 'fitRemoteScreen', 'rememberLastMonitor', 'keepControlReadyBetweenPages'])
171
174
  },
172
- wall: {
173
- performanceMode: { type: 'enum', values: ENUMS.performanceMode },
174
- ...bools(['autoStart', 'connectedOnly', 'keepEmptySlots', 'showDeviceStatus', 'showPerformanceDetails', 'pauseHiddenTiles', 'reduceWhenHidden', 'autoAdjustTileQuality', 'rememberDevicePositions'])
175
+ wall: {
176
+ performanceMode: { type: 'enum', values: ENUMS.performanceMode },
177
+ cadence: { type: 'enum', values: ENUMS.wallCadence },
178
+ viewScale: { type: 'number', min: 0, max: 100 },
179
+ ...bools(['autoStart', 'connectedOnly', 'keepEmptySlots', 'showDeviceStatus', 'showPerformanceDetails', 'pauseHiddenTiles', 'reduceWhenHidden', 'autoAdjustTileQuality', 'rememberDevicePositions'])
175
180
  },
176
181
  filesAudio: {
177
182
  ...bools(['allowFileTransfer', 'allowFolderSync', 'askBeforeReceivingFiles', 'openReceivedFolder', 'notifyTransferComplete', 'allowOverwrite', 'allowRemoteAudio', 'startAudioMuted', 'rememberVolume', 'automaticallyRecoverAudio', 'showAudioTroubleshooting', 'rollingBufferEnabled', 'includeRemoteCursor']),
@@ -40,12 +40,22 @@ export class LiveDeskSettingsStore {
40
40
  return structuredClone(this.record);
41
41
  }
42
42
 
43
- getCached() {
44
- if (!this.record) return DEFAULT_LIVEDESK_SETTINGS;
45
- return { ...this.record.settings, revision: this.record.revision };
46
- }
47
-
48
- async get() {
43
+ getCached() {
44
+ if (!this.record) return DEFAULT_LIVEDESK_SETTINGS;
45
+ return { ...this.record.settings, revision: this.record.revision };
46
+ }
47
+
48
+ async getWallPreferencesRecord() {
49
+ if (!this.record) await this.getRecord();
50
+ return {
51
+ revision: this.record.revision,
52
+ updatedAt: this.record.updatedAt,
53
+ cadence: this.record.settings.wall.cadence,
54
+ viewScale: this.record.settings.wall.viewScale
55
+ };
56
+ }
57
+
58
+ async get() {
49
59
  return publicSettings((await this.getRecord()).settings);
50
60
  }
51
61
 
@@ -1,424 +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];
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
- });