@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/package.json +4 -3
- package/src/console-direct.js +1538 -0
- package/src/console-direct.test.mjs +984 -0
- package/src/server.js +55 -43
- package/src/settings/settings-schema.js +13 -8
- package/src/settings/settings-store.js +16 -6
- package/src/console-relay.js +0 -424
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 {
|
|
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
|
|
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
|
|
223
|
-
process.env.
|
|
224
|
-
|| (process.env.LIVEDESK_TEST_MODE === '1' || process.env.LIVEDESK_AUTH_TEST_MODE === '1'
|
|
225
|
-
? 'off'
|
|
226
|
-
:
|
|
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
|
-
|
|
1398
|
-
url: runtimeRole === 'hub' ?
|
|
1399
|
-
deviceId: runtimeDeviceId,
|
|
1400
|
-
httpBaseUrl: `http://127.0.0.1:${httpPort}`,
|
|
1401
|
-
|
|
1402
|
-
|
|
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
|
-
|
|
4352
|
-
|
|
4353
|
-
|
|
4354
|
-
|
|
4355
|
-
|
|
4356
|
-
|
|
4357
|
-
|
|
4358
|
-
|
|
4359
|
-
|
|
4360
|
-
|
|
4361
|
-
|
|
4362
|
-
|
|
4363
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
package/src/console-relay.js
DELETED
|
@@ -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
|
-
});
|