@livedesk/hub 0.1.56 → 0.1.58
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 +1677 -0
- package/src/console-direct.test.mjs +1096 -0
- package/src/server.js +30 -26
- package/src/console-relay.js +0 -465
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 });
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
6109
|
+
runSynchronousShutdownStep('mobile console direct close', () => hubConsoleDirect?.close());
|
|
6106
6110
|
atlasClients.clear();
|
|
6107
6111
|
runSynchronousShutdownStep('shared folder close', () => hubSharedFolders.close());
|
|
6108
6112
|
|
package/src/console-relay.js
DELETED
|
@@ -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
|
-
});
|