@livedesk/hub 0.1.42 → 0.1.43
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 +2 -2
- package/src/console-relay.js +415 -0
- package/src/server.js +52 -31
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@livedesk/hub",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.43",
|
|
4
4
|
"description": "LiveDesk local Hub API and browser frame bridge",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/server.js",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"scripts": {
|
|
12
12
|
"dev": "node src/server.js",
|
|
13
13
|
"start": "node src/server.js",
|
|
14
|
-
"check": "node --check src/server.js && node --check src/remote-hub.js",
|
|
14
|
+
"check": "node --check src/server.js && node --check src/remote-hub.js && node --check src/console-relay.js",
|
|
15
15
|
"prepublishOnly": "node ../../scripts/livedesk-release-git-gate.mjs"
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
@@ -0,0 +1,415 @@
|
|
|
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 => {
|
|
111
|
+
if (!relaySocket || relaySocket.readyState !== WebSocketImpl.OPEN) return false;
|
|
112
|
+
if (Number(relaySocket.bufferedAmount || 0) > MAX_RELAY_BUFFERED_BYTES) return false;
|
|
113
|
+
relaySocket.send(typeof payload === 'string' || Buffer.isBuffer(payload) ? payload : JSON.stringify(payload));
|
|
114
|
+
return true;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const sendError = (consoleId, channelId, error, type = 'relay-error') => {
|
|
118
|
+
sendRelay({ type, consoleId, channelId, error: String(error || 'console-relay-error').slice(0, 240) });
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const closeLocalSocket = (key, code = 1000, reason = 'console-channel-closed') => {
|
|
122
|
+
const socket = localSockets.get(key);
|
|
123
|
+
if (!socket) return;
|
|
124
|
+
localSockets.delete(key);
|
|
125
|
+
try { socket.close(code, reason); } catch { /* exact socket is already closed */ }
|
|
126
|
+
try { socket.terminate?.(); } catch { /* exact socket is already closed */ }
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const closeConsoleChannels = consoleId => {
|
|
130
|
+
for (const key of [...localSockets.keys()]) {
|
|
131
|
+
if (key.startsWith(`${consoleId}\n`)) closeLocalSocket(key, 1001, 'console-detached');
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const closeAllLocalSockets = reason => {
|
|
136
|
+
for (const key of [...localSockets.keys()]) closeLocalSocket(key, 1012, reason);
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const handleHttpRequest = async payload => {
|
|
140
|
+
const consoleId = String(payload?.consoleId || '');
|
|
141
|
+
const channelId = String(payload?.channelId || '');
|
|
142
|
+
const method = String(payload?.method || 'GET').toUpperCase();
|
|
143
|
+
const path = String(payload?.path || '');
|
|
144
|
+
if (!UUID_PATTERN.test(consoleId) || !UUID_PATTERN.test(channelId) || !allowedHttpRequest(method, path)) {
|
|
145
|
+
sendError(consoleId, channelId, 'console-route-not-allowed', 'http-response');
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (pendingHttp.size >= MAX_CHANNELS) {
|
|
149
|
+
sendError(consoleId, channelId, 'console-http-capacity-reached', 'http-response');
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
let body;
|
|
153
|
+
try {
|
|
154
|
+
body = payload.bodyBase64 ? Buffer.from(String(payload.bodyBase64), 'base64') : undefined;
|
|
155
|
+
} catch {
|
|
156
|
+
sendError(consoleId, channelId, 'invalid-console-http-body', 'http-response');
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (body && body.byteLength > MAX_HTTP_REQUEST_BYTES) {
|
|
160
|
+
sendError(consoleId, channelId, 'console-http-body-too-large', 'http-response');
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const requestKey = channelKey(consoleId, channelId);
|
|
165
|
+
pendingHttp.add(requestKey);
|
|
166
|
+
const controller = new AbortController();
|
|
167
|
+
const timeout = setTimeout(() => controller.abort(new Error('console-http-timeout')), HTTP_TIMEOUT_MS);
|
|
168
|
+
timeout.unref?.();
|
|
169
|
+
try {
|
|
170
|
+
const response = await fetchImpl(`${httpBaseUrl}${path}`, {
|
|
171
|
+
method,
|
|
172
|
+
headers: safeContentHeaders(payload.headers),
|
|
173
|
+
body: ['GET', 'HEAD'].includes(method) ? undefined : body,
|
|
174
|
+
signal: controller.signal
|
|
175
|
+
});
|
|
176
|
+
const responseBytes = Buffer.from(await response.arrayBuffer());
|
|
177
|
+
if (responseBytes.byteLength > MAX_HTTP_RESPONSE_BYTES) {
|
|
178
|
+
sendError(consoleId, channelId, 'console-http-response-too-large', 'http-response');
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
sendRelay({
|
|
182
|
+
type: 'http-response',
|
|
183
|
+
consoleId,
|
|
184
|
+
channelId,
|
|
185
|
+
status: response.status,
|
|
186
|
+
statusText: response.statusText,
|
|
187
|
+
headers: {
|
|
188
|
+
'content-type': String(response.headers.get('content-type') || '').slice(0, 256),
|
|
189
|
+
'content-range': String(response.headers.get('content-range') || '').slice(0, 256)
|
|
190
|
+
},
|
|
191
|
+
bodyBase64: responseBytes.toString('base64')
|
|
192
|
+
});
|
|
193
|
+
} catch (error) {
|
|
194
|
+
sendError(consoleId, channelId, error instanceof Error ? error.message : error, 'http-response');
|
|
195
|
+
} finally {
|
|
196
|
+
clearTimeout(timeout);
|
|
197
|
+
pendingHttp.delete(requestKey);
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const handleWebSocketOpen = payload => {
|
|
202
|
+
const consoleId = String(payload?.consoleId || '');
|
|
203
|
+
const channelId = String(payload?.channelId || '');
|
|
204
|
+
const path = String(payload?.path || '');
|
|
205
|
+
if (!UUID_PATTERN.test(consoleId) || !UUID_PATTERN.test(channelId) || !allowedWebSocketPath(path)) {
|
|
206
|
+
sendError(consoleId, channelId, 'console-websocket-route-not-allowed', 'ws-error');
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (localSockets.size >= MAX_CHANNELS) {
|
|
210
|
+
sendError(consoleId, channelId, 'console-websocket-capacity-reached', 'ws-error');
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
const key = channelKey(consoleId, channelId);
|
|
214
|
+
closeLocalSocket(key, 1000, 'console-channel-replaced');
|
|
215
|
+
const localUrl = new URL(path, httpBaseUrl);
|
|
216
|
+
localUrl.protocol = localUrl.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
217
|
+
let socket;
|
|
218
|
+
try {
|
|
219
|
+
socket = new WebSocketImpl(localUrl, { perMessageDeflate: false });
|
|
220
|
+
} catch (error) {
|
|
221
|
+
sendError(consoleId, channelId, error instanceof Error ? error.message : error, 'ws-error');
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
localSockets.set(key, socket);
|
|
225
|
+
socket.once('open', () => {
|
|
226
|
+
if (localSockets.get(key) !== socket) return;
|
|
227
|
+
sendRelay({ type: 'ws-opened', consoleId, channelId });
|
|
228
|
+
});
|
|
229
|
+
socket.on('message', (data, isBinary) => {
|
|
230
|
+
if (localSockets.get(key) !== socket) return;
|
|
231
|
+
if (isBinary) {
|
|
232
|
+
if (!relaySocket || relaySocket.readyState !== WebSocketImpl.OPEN
|
|
233
|
+
|| Number(relaySocket.bufferedAmount || 0) > MAX_RELAY_BUFFERED_BYTES) {
|
|
234
|
+
droppedBinaryMessages += 1;
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
const header = Buffer.from(`B${consoleId}${channelId}`, 'ascii');
|
|
238
|
+
if (header.byteLength !== BINARY_HEADER_BYTES) {
|
|
239
|
+
closeLocalSocket(key, 1008, 'invalid-relay-channel-id');
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
relaySocket.send(Buffer.concat([header, Buffer.from(data)]));
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
sendRelay({ type: 'ws-message', consoleId, channelId, data: String(data), binary: false });
|
|
246
|
+
});
|
|
247
|
+
socket.once('close', (code, reason) => {
|
|
248
|
+
if (localSockets.get(key) === socket) localSockets.delete(key);
|
|
249
|
+
sendRelay({ type: 'ws-closed', consoleId, channelId, code, reason: String(reason || '').slice(0, 120) });
|
|
250
|
+
});
|
|
251
|
+
socket.once('error', error => {
|
|
252
|
+
sendError(consoleId, channelId, error instanceof Error ? error.message : error, 'ws-error');
|
|
253
|
+
});
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
const handleMessage = raw => {
|
|
257
|
+
const payload = parseMessage(raw);
|
|
258
|
+
if (!payload?.type) return;
|
|
259
|
+
if (payload.type === 'relay-ready') {
|
|
260
|
+
state = 'connected';
|
|
261
|
+
lastConnectedAt = new Date().toISOString();
|
|
262
|
+
lastError = '';
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
if (payload.type === 'console-detached') {
|
|
266
|
+
closeConsoleChannels(String(payload.consoleId || ''));
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
if (payload.type === 'http-request') {
|
|
270
|
+
void handleHttpRequest(payload);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (payload.type === 'ws-open') {
|
|
274
|
+
handleWebSocketOpen(payload);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
const key = channelKey(String(payload.consoleId || ''), String(payload.channelId || ''));
|
|
278
|
+
const socket = localSockets.get(key);
|
|
279
|
+
if (!socket) return;
|
|
280
|
+
if (payload.type === 'ws-send' && socket.readyState === WebSocketImpl.OPEN) {
|
|
281
|
+
const data = String(payload.data || '');
|
|
282
|
+
if (Buffer.byteLength(data) <= MAX_HTTP_REQUEST_BYTES) socket.send(data);
|
|
283
|
+
} else if (payload.type === 'ws-close') {
|
|
284
|
+
closeLocalSocket(key, Number(payload.code || 1000), String(payload.reason || 'console-request').slice(0, 120));
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
const scheduleReconnect = connect => {
|
|
289
|
+
if (stopped || retryTimer || !(relayUrl instanceof URL)) return;
|
|
290
|
+
const delayMs = retryDelaysMs[Math.min(retryAttempt, retryDelaysMs.length - 1)];
|
|
291
|
+
retryAttempt += 1;
|
|
292
|
+
state = 'waiting-retry';
|
|
293
|
+
retryTimer = setTimeout(() => {
|
|
294
|
+
retryTimer = null;
|
|
295
|
+
void connect();
|
|
296
|
+
}, delayMs);
|
|
297
|
+
retryTimer.unref?.();
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
const connect = async () => {
|
|
301
|
+
if (stopped || !(relayUrl instanceof URL) || !deviceId) return;
|
|
302
|
+
const ownerGeneration = ++generation;
|
|
303
|
+
state = 'connecting';
|
|
304
|
+
let accessToken = '';
|
|
305
|
+
try {
|
|
306
|
+
accessToken = String(await getAccessToken() || '').trim();
|
|
307
|
+
} catch (error) {
|
|
308
|
+
lastError = error instanceof Error ? error.message : String(error);
|
|
309
|
+
}
|
|
310
|
+
if (stopped || ownerGeneration !== generation) return;
|
|
311
|
+
if (!accessToken) {
|
|
312
|
+
lastError = 'hub-session-required';
|
|
313
|
+
scheduleReconnect(connect);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
const url = new URL(relayUrl);
|
|
317
|
+
url.searchParams.set('deviceId', deviceId);
|
|
318
|
+
let socket;
|
|
319
|
+
try {
|
|
320
|
+
socket = new WebSocketImpl(url, {
|
|
321
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
322
|
+
perMessageDeflate: false
|
|
323
|
+
});
|
|
324
|
+
} catch (error) {
|
|
325
|
+
lastError = error instanceof Error ? error.message : String(error);
|
|
326
|
+
scheduleReconnect(connect);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
if (stopped || ownerGeneration !== generation) {
|
|
330
|
+
try { socket.terminate?.(); } catch { /* stale connect is already gone */ }
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
relaySocket = socket;
|
|
334
|
+
socket.once('open', () => {
|
|
335
|
+
if (relaySocket !== socket || ownerGeneration !== generation || stopped) return;
|
|
336
|
+
retryAttempt = 0;
|
|
337
|
+
state = 'authenticating';
|
|
338
|
+
});
|
|
339
|
+
socket.on('message', raw => {
|
|
340
|
+
if (relaySocket === socket && ownerGeneration === generation && !stopped) handleMessage(raw);
|
|
341
|
+
});
|
|
342
|
+
socket.once('close', () => {
|
|
343
|
+
if (relaySocket === socket) relaySocket = null;
|
|
344
|
+
if (ownerGeneration !== generation || stopped) return;
|
|
345
|
+
state = 'disconnected';
|
|
346
|
+
closeAllLocalSockets('relay-disconnected');
|
|
347
|
+
scheduleReconnect(connect);
|
|
348
|
+
});
|
|
349
|
+
socket.once('error', error => {
|
|
350
|
+
lastError = error instanceof Error ? error.message : String(error);
|
|
351
|
+
try { socket.terminate?.(); } catch { /* close handler owns retry */ }
|
|
352
|
+
});
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
const start = () => {
|
|
356
|
+
if (!stopped || !(relayUrl instanceof URL) || !deviceId) return;
|
|
357
|
+
stopped = false;
|
|
358
|
+
void connect();
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
const refresh = () => {
|
|
362
|
+
if (stopped) {
|
|
363
|
+
start();
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
generation += 1;
|
|
367
|
+
if (retryTimer) {
|
|
368
|
+
clearTimeout(retryTimer);
|
|
369
|
+
retryTimer = null;
|
|
370
|
+
}
|
|
371
|
+
const socket = relaySocket;
|
|
372
|
+
relaySocket = null;
|
|
373
|
+
try { socket?.terminate?.(); } catch { /* refresh owns the exact socket */ }
|
|
374
|
+
closeAllLocalSockets('relay-refresh');
|
|
375
|
+
void connect();
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
const close = () => {
|
|
379
|
+
if (stopped) return;
|
|
380
|
+
stopped = true;
|
|
381
|
+
generation += 1;
|
|
382
|
+
if (retryTimer) clearTimeout(retryTimer);
|
|
383
|
+
retryTimer = null;
|
|
384
|
+
closeAllLocalSockets('hub-shutdown');
|
|
385
|
+
const socket = relaySocket;
|
|
386
|
+
relaySocket = null;
|
|
387
|
+
try { socket?.close(1001, 'hub-shutdown'); } catch { /* exact socket is already closed */ }
|
|
388
|
+
try { socket?.terminate?.(); } catch { /* exact socket is already closed */ }
|
|
389
|
+
state = relayUrl ? 'closed' : 'disabled';
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
return {
|
|
393
|
+
start,
|
|
394
|
+
refresh,
|
|
395
|
+
close,
|
|
396
|
+
inspect: () => ({
|
|
397
|
+
enabled: relayUrl instanceof URL && Boolean(deviceId),
|
|
398
|
+
state,
|
|
399
|
+
connected: state === 'connected',
|
|
400
|
+
localWebSocketChannels: localSockets.size,
|
|
401
|
+
pendingHttpRequests: pendingHttp.size,
|
|
402
|
+
droppedBinaryMessages,
|
|
403
|
+
lastConnectedAt,
|
|
404
|
+
lastError
|
|
405
|
+
})
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
export const consoleRelayContract = Object.freeze({
|
|
410
|
+
maxChannels: MAX_CHANNELS,
|
|
411
|
+
maxHttpRequestBytes: MAX_HTTP_REQUEST_BYTES,
|
|
412
|
+
maxHttpResponseBytes: MAX_HTTP_RESPONSE_BYTES,
|
|
413
|
+
maxBufferedBytes: MAX_RELAY_BUFFERED_BYTES,
|
|
414
|
+
binaryHeaderBytes: BINARY_HEADER_BYTES
|
|
415
|
+
});
|
package/src/server.js
CHANGED
|
@@ -7,8 +7,9 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, wri
|
|
|
7
7
|
import { dirname, resolve } from 'node:path';
|
|
8
8
|
import { fileURLToPath } from 'node:url';
|
|
9
9
|
import os from 'node:os';
|
|
10
|
-
import { WebSocketServer } from 'ws';
|
|
11
|
-
import { createRemoteHub } from './remote-hub.js';
|
|
10
|
+
import { WebSocketServer } from 'ws';
|
|
11
|
+
import { createRemoteHub } from './remote-hub.js';
|
|
12
|
+
import { createHubConsoleRelay } from './console-relay.js';
|
|
12
13
|
import {
|
|
13
14
|
buildImmutableRemoteFramePacket,
|
|
14
15
|
createRemoteFramePacketMetrics,
|
|
@@ -202,15 +203,22 @@ let verifiedLicense = {
|
|
|
202
203
|
let frameClientSeq = 0;
|
|
203
204
|
let inputClientSeq = 0;
|
|
204
205
|
let audioClientSeq = 0;
|
|
205
|
-
let liveDeskUpdateManager = null;
|
|
206
|
-
let hubTransferJobs = null;
|
|
206
|
+
let liveDeskUpdateManager = null;
|
|
207
|
+
let hubTransferJobs = null;
|
|
208
|
+
let hubConsoleRelay = null;
|
|
207
209
|
const HUB_HOST_TARGET_LEASE_MS = Math.max(5000, readPositiveIntegerEnv('LIVEDESK_HOST_TARGET_LEASE_MS', 60_000));
|
|
208
210
|
const HUB_HOST_TARGET_RENEW_MS = Math.max(1000, Math.min(
|
|
209
211
|
readPositiveIntegerEnv('LIVEDESK_HOST_TARGET_RENEW_MS', 20_000),
|
|
210
212
|
Math.max(1000, HUB_HOST_TARGET_LEASE_MS - 1000)
|
|
211
213
|
));
|
|
212
|
-
const HUB_ACCESS_TOKEN_REFRESH_SKEW_MS = 90_000;
|
|
213
|
-
const hubWakeBaseUrl = String(process.env.LIVEDESK_WAKE_URL || 'https://livedesk-wake.lovecrdm.workers.dev').trim();
|
|
214
|
+
const HUB_ACCESS_TOKEN_REFRESH_SKEW_MS = 90_000;
|
|
215
|
+
const hubWakeBaseUrl = String(process.env.LIVEDESK_WAKE_URL || 'https://livedesk-wake.lovecrdm.workers.dev').trim();
|
|
216
|
+
const hubConsoleRelayBaseUrl = String(
|
|
217
|
+
process.env.LIVEDESK_CONSOLE_RELAY_URL
|
|
218
|
+
|| (process.env.LIVEDESK_TEST_MODE === '1' || process.env.LIVEDESK_AUTH_TEST_MODE === '1'
|
|
219
|
+
? 'off'
|
|
220
|
+
: 'https://livedesk-wake.lovecrdm.workers.dev')
|
|
221
|
+
).trim();
|
|
214
222
|
const HUB_WAKE_NOTIFY_RETRY_MS = 60_000;
|
|
215
223
|
let hubHostTargetRenewTimer = null;
|
|
216
224
|
let hubHostTargetRenewInFlight = false;
|
|
@@ -1333,9 +1341,16 @@ const hubSharedFolders = createHubSharedFolders({
|
|
|
1333
1341
|
dataDir: agentDataDir
|
|
1334
1342
|
});
|
|
1335
1343
|
|
|
1336
|
-
const app = express();
|
|
1337
|
-
const httpServer = createServer(app);
|
|
1338
|
-
|
|
1344
|
+
const app = express();
|
|
1345
|
+
const httpServer = createServer(app);
|
|
1346
|
+
hubConsoleRelay = createHubConsoleRelay({
|
|
1347
|
+
url: runtimeRole === 'hub' ? hubConsoleRelayBaseUrl : 'off',
|
|
1348
|
+
deviceId: runtimeDeviceId,
|
|
1349
|
+
httpBaseUrl: `http://127.0.0.1:${httpPort}`,
|
|
1350
|
+
getAccessToken: () => getRuntimeAccessToken(),
|
|
1351
|
+
logger: console
|
|
1352
|
+
});
|
|
1353
|
+
const httpConnections = new Set();
|
|
1339
1354
|
const frameWss = new WebSocketServer({ noServer: true, perMessageDeflate: false });
|
|
1340
1355
|
const atlasWss = new WebSocketServer({ noServer: true, perMessageDeflate: false });
|
|
1341
1356
|
const inputWss = new WebSocketServer({ noServer: true, perMessageDeflate: false });
|
|
@@ -4219,9 +4234,10 @@ app.get('/api/remote/status', (_req, res) => {
|
|
|
4219
4234
|
runtimeRole,
|
|
4220
4235
|
deviceId: runtimeDeviceId,
|
|
4221
4236
|
deviceName: runtimeDeviceName,
|
|
4222
|
-
roleSource: runtimeRoleSource,
|
|
4223
|
-
agentPackage: '@livedesk/client',
|
|
4224
|
-
|
|
4237
|
+
roleSource: runtimeRoleSource,
|
|
4238
|
+
agentPackage: '@livedesk/client',
|
|
4239
|
+
consoleRelay: hubConsoleRelay?.inspect() || { enabled: false, state: 'starting' },
|
|
4240
|
+
frameLanes: snapshotFrameLaneResourceHealth(),
|
|
4225
4241
|
update: getLiveDeskUpdateStatus()
|
|
4226
4242
|
});
|
|
4227
4243
|
});
|
|
@@ -4453,10 +4469,11 @@ app.post('/api/auth/session', async (req, res) => {
|
|
|
4453
4469
|
csrfToken: uiSession.csrfToken,
|
|
4454
4470
|
expiresAt: uiSession.expiresAt
|
|
4455
4471
|
}
|
|
4456
|
-
});
|
|
4457
|
-
if (runtimeRole === 'hub') {
|
|
4458
|
-
|
|
4459
|
-
|
|
4472
|
+
});
|
|
4473
|
+
if (runtimeRole === 'hub') {
|
|
4474
|
+
hubConsoleRelay?.refresh();
|
|
4475
|
+
scheduleAuthenticatedHubHostTargetPublication('session-received');
|
|
4476
|
+
}
|
|
4460
4477
|
} catch (error) {
|
|
4461
4478
|
const message = error instanceof Error ? error.message : String(error);
|
|
4462
4479
|
const status = authVerificationHttpStatus(message);
|
|
@@ -4476,10 +4493,11 @@ function getHubHostTargetLeaseStatus() {
|
|
|
4476
4493
|
...hubHostTargetLeaseState,
|
|
4477
4494
|
renewing: hubHostTargetRenewInFlight,
|
|
4478
4495
|
renewalIntervalMs: HUB_HOST_TARGET_RENEW_MS,
|
|
4479
|
-
leaseDurationMs: HUB_HOST_TARGET_LEASE_MS,
|
|
4480
|
-
authenticated: Boolean(runtimeAccessToken),
|
|
4481
|
-
timerActive: Boolean(hubHostTargetRenewTimer),
|
|
4482
|
-
wake: { ...hubWakeNotificationState }
|
|
4496
|
+
leaseDurationMs: HUB_HOST_TARGET_LEASE_MS,
|
|
4497
|
+
authenticated: Boolean(runtimeAccessToken),
|
|
4498
|
+
timerActive: Boolean(hubHostTargetRenewTimer),
|
|
4499
|
+
wake: { ...hubWakeNotificationState },
|
|
4500
|
+
consoleRelay: hubConsoleRelay?.inspect() || { enabled: false, state: 'starting' }
|
|
4483
4501
|
};
|
|
4484
4502
|
}
|
|
4485
4503
|
|
|
@@ -4783,10 +4801,11 @@ app.get('/api/hub/status', (_req, res) => {
|
|
|
4783
4801
|
...remoteHub.getStatus({ includeSecrets: false }),
|
|
4784
4802
|
role: 'hub',
|
|
4785
4803
|
deviceId: runtimeDeviceId,
|
|
4786
|
-
deviceName: runtimeDeviceName,
|
|
4787
|
-
roleSource: runtimeRoleSource,
|
|
4788
|
-
runtimeStarted: true,
|
|
4789
|
-
|
|
4804
|
+
deviceName: runtimeDeviceName,
|
|
4805
|
+
roleSource: runtimeRoleSource,
|
|
4806
|
+
runtimeStarted: true,
|
|
4807
|
+
consoleRelay: hubConsoleRelay?.inspect() || { enabled: false, state: 'starting' },
|
|
4808
|
+
hostTargetLease: getHubHostTargetLeaseStatus(),
|
|
4790
4809
|
update: getLiveDeskUpdateStatus()
|
|
4791
4810
|
});
|
|
4792
4811
|
});
|
|
@@ -5793,13 +5812,14 @@ hubSharedFolders.startAutoSync(
|
|
|
5793
5812
|
() => remoteHub.listDevices({ includeDataUrl: false }).filter(device => device.connected).map(device => device.deviceId),
|
|
5794
5813
|
() => process.env.LIVEDESK_REMOTE_DIRECTORY || 'Desktop/LiveDeskFiles'
|
|
5795
5814
|
);
|
|
5796
|
-
httpServer.listen(httpPort, httpHost, () => {
|
|
5815
|
+
httpServer.listen(httpPort, httpHost, () => {
|
|
5797
5816
|
const status = remoteHub.getStatus({ includeSecrets: true });
|
|
5798
5817
|
const managerVersion = String(process.env.LIVEDESK_MANAGER_VERSION || packageInfo.version || 'dev');
|
|
5799
5818
|
console.log(`[LiveDesk Hub] Version ${managerVersion}`);
|
|
5800
|
-
console.log(`[LiveDesk Hub] HTTP API http://${httpHost}:${httpPort}`);
|
|
5801
|
-
console.log(`[LiveDesk Hub] Client endpoint ${status.agentEndpoint} pair=${status.pairTokenPreview}`);
|
|
5802
|
-
|
|
5819
|
+
console.log(`[LiveDesk Hub] HTTP API http://${httpHost}:${httpPort}`);
|
|
5820
|
+
console.log(`[LiveDesk Hub] Client endpoint ${status.agentEndpoint} pair=${status.pairTokenPreview}`);
|
|
5821
|
+
hubConsoleRelay?.start();
|
|
5822
|
+
});
|
|
5803
5823
|
|
|
5804
5824
|
const roleWatchTimer = runtimeRole === 'hub'
|
|
5805
5825
|
? setInterval(() => { void watchAuthoritativeRuntimeRole(); }, 5000)
|
|
@@ -5880,9 +5900,10 @@ function shutdownHub(signal) {
|
|
|
5880
5900
|
const startedAt = Date.now();
|
|
5881
5901
|
console.log(`[LiveDesk Hub] Shutdown started signal=${signal} grace=${hubShutdownGraceMs}ms timeout=${hubShutdownTimeoutMs}ms.`);
|
|
5882
5902
|
if (roleWatchTimer) clearInterval(roleWatchTimer);
|
|
5883
|
-
clearInterval(browserWebSocketHeartbeatTimer);
|
|
5884
|
-
runSynchronousShutdownStep('update manager close', () => liveDeskUpdateManager?.close());
|
|
5885
|
-
|
|
5903
|
+
clearInterval(browserWebSocketHeartbeatTimer);
|
|
5904
|
+
runSynchronousShutdownStep('update manager close', () => liveDeskUpdateManager?.close());
|
|
5905
|
+
runSynchronousShutdownStep('mobile console relay close', () => hubConsoleRelay?.close());
|
|
5906
|
+
atlasClients.clear();
|
|
5886
5907
|
runSynchronousShutdownStep('shared folder close', () => hubSharedFolders.close());
|
|
5887
5908
|
|
|
5888
5909
|
const httpClosed = new Promise(resolveClose => {
|