@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
|
@@ -0,0 +1,1538 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import WebSocket from 'ws';
|
|
3
|
+
import nodeDataChannel from 'node-datachannel';
|
|
4
|
+
import {
|
|
5
|
+
DIRECT_CONSOLE_MAX_MESSAGE_BYTES,
|
|
6
|
+
DIRECT_CONSOLE_WIRE_CHUNK_BYTES,
|
|
7
|
+
createDirectConsoleWireAssembler,
|
|
8
|
+
createDirectConsoleWireBudget,
|
|
9
|
+
encodeDirectConsoleWireMessage
|
|
10
|
+
} from '../../runtime-core/src/console-direct-wire.js';
|
|
11
|
+
|
|
12
|
+
const DEFAULT_SIGNAL_URL = 'https://livedesk-wake.lovecrdm.workers.dev';
|
|
13
|
+
const DEFAULT_STUN_URLS = Object.freeze(['stun:stun.cloudflare.com:3478']);
|
|
14
|
+
const DEFAULT_RETRY_DELAYS_MS = Object.freeze([1_000, 2_000, 5_000, 10_000, 20_000]);
|
|
15
|
+
const SIGNAL_HTTP_429_RETRY_MS = 60_000;
|
|
16
|
+
const SIGNAL_MESSAGE_MAX_BYTES = 32 * 1024;
|
|
17
|
+
const SDP_MAX_BYTES = 24 * 1024;
|
|
18
|
+
const ICE_CANDIDATE_MAX_BYTES = 4 * 1024;
|
|
19
|
+
const CONTROL_LABEL = 'livedesk-control-v1';
|
|
20
|
+
const LOGICAL_WS_LABEL_PREFIX = 'livedesk-ws-v1:';
|
|
21
|
+
const LOGICAL_WS_PURPOSES = new Set(['frame', 'atlas', 'input', 'audio']);
|
|
22
|
+
const MAX_PEERS = 4;
|
|
23
|
+
const MAX_LOGICAL_CHANNELS_PER_PEER = 16;
|
|
24
|
+
const MAX_PENDING_HTTP_PER_PEER = 32;
|
|
25
|
+
const MAX_HTTP_REQUEST_BYTES = 1024 * 1024;
|
|
26
|
+
const MAX_HTTP_RESPONSE_BYTES = 4 * 1024 * 1024;
|
|
27
|
+
const MAX_SHARED_ASSEMBLY_BYTES = 12 * 1024 * 1024;
|
|
28
|
+
const MAX_FRAME_MESSAGE_BYTES = (8 * 1024 * 1024) + DIRECT_CONSOLE_WIRE_CHUNK_BYTES;
|
|
29
|
+
const MAX_PENDING_MEDIA_WIRE_MESSAGES = 8;
|
|
30
|
+
const MAX_DATA_CHANNEL_BUFFERED_BYTES = 12 * 1024 * 1024;
|
|
31
|
+
const MAX_LOCAL_WS_BUFFERED_BYTES = 4 * 1024 * 1024;
|
|
32
|
+
const HTTP_TIMEOUT_MS = 15_000;
|
|
33
|
+
const ICE_CONNECT_TIMEOUT_MS = 10_000;
|
|
34
|
+
const PEER_DISCONNECTED_TIMEOUT_MS = 5_000;
|
|
35
|
+
const ACCESS_TOKEN_TIMEOUT_MS = 8_000;
|
|
36
|
+
const SIGNAL_READY_TIMEOUT_MS = 10_000;
|
|
37
|
+
const CONTROL_ASSEMBLY_TIMEOUT_MS = 15_000;
|
|
38
|
+
const MEDIA_ASSEMBLY_TIMEOUT_MS = 500;
|
|
39
|
+
const LOGICAL_BIND_TIMEOUT_MS = 5_000;
|
|
40
|
+
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;
|
|
41
|
+
|
|
42
|
+
function normalizeSignalUrl(value) {
|
|
43
|
+
const source = String(value || DEFAULT_SIGNAL_URL).trim();
|
|
44
|
+
if (!source || /^(off|disabled|none)$/i.test(source)) return '';
|
|
45
|
+
try {
|
|
46
|
+
const url = new URL(source);
|
|
47
|
+
url.protocol = url.protocol === 'http:' ? 'ws:' : url.protocol === 'https:' ? 'wss:' : url.protocol;
|
|
48
|
+
if (url.protocol !== 'ws:' && url.protocol !== 'wss:') return null;
|
|
49
|
+
url.pathname = '/v2/rtc/hub';
|
|
50
|
+
url.search = '';
|
|
51
|
+
url.hash = '';
|
|
52
|
+
return url;
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function normalizeStunUrls(value) {
|
|
59
|
+
const values = Array.isArray(value)
|
|
60
|
+
? value
|
|
61
|
+
: String(value || '').split(/[\s,]+/);
|
|
62
|
+
const urls = [...new Set(values
|
|
63
|
+
.map(item => String(item || '').trim())
|
|
64
|
+
.filter(url => /^stun:[^\s@]+$/i.test(url)))];
|
|
65
|
+
const resolved = urls.length > 0 ? urls : [...DEFAULT_STUN_URLS];
|
|
66
|
+
return Object.freeze(resolved);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function byteLength(value) {
|
|
70
|
+
return Buffer.byteLength(String(value || ''), 'utf8');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function parseJson(value) {
|
|
74
|
+
try {
|
|
75
|
+
return JSON.parse(String(value || ''));
|
|
76
|
+
} catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function safeContentHeaders(headers) {
|
|
82
|
+
const result = {};
|
|
83
|
+
for (const name of ['accept', 'content-type', 'if-match', 'range', 'x-livedesk-csrf']) {
|
|
84
|
+
const value = headers && typeof headers === 'object'
|
|
85
|
+
? headers[name] || headers[name.toUpperCase()]
|
|
86
|
+
: '';
|
|
87
|
+
if (value) result[name] = String(value).slice(0, 512);
|
|
88
|
+
}
|
|
89
|
+
return result;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function normalizeHttpBaseUrl(value) {
|
|
93
|
+
try {
|
|
94
|
+
const url = new URL(String(value || '').trim());
|
|
95
|
+
if (!['http:', 'https:'].includes(url.protocol)
|
|
96
|
+
|| url.username
|
|
97
|
+
|| url.password
|
|
98
|
+
|| url.pathname !== '/'
|
|
99
|
+
|| url.search
|
|
100
|
+
|| url.hash) {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
return url;
|
|
104
|
+
} catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function canonicalHttpRequestUrl(value, httpBaseUrl) {
|
|
110
|
+
const source = String(value || '');
|
|
111
|
+
const queryIndex = source.indexOf('?');
|
|
112
|
+
const rawPathname = queryIndex >= 0 ? source.slice(0, queryIndex) : source;
|
|
113
|
+
if (!(httpBaseUrl instanceof URL)
|
|
114
|
+
|| !rawPathname.startsWith('/')
|
|
115
|
+
|| rawPathname.startsWith('//')
|
|
116
|
+
|| source.includes('#')
|
|
117
|
+
|| rawPathname.includes('\\')
|
|
118
|
+
|| /\/{2,}/.test(rawPathname)
|
|
119
|
+
|| /(?:^|\/)\.{1,2}(?:\/|$)/.test(rawPathname)
|
|
120
|
+
|| /%(?:25)*(?:2e|2f|5c)/i.test(rawPathname)) {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
const decodedPathname = decodeURIComponent(rawPathname);
|
|
125
|
+
if (/[\u0000-\u001f\u007f]/.test(decodedPathname)
|
|
126
|
+
|| decodedPathname.includes('\\')
|
|
127
|
+
|| /\/{2,}/.test(decodedPathname)
|
|
128
|
+
|| /(?:^|\/)\.{1,2}(?:\/|$)/.test(decodedPathname)) {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
const canonical = new URL(source, httpBaseUrl);
|
|
132
|
+
if (canonical.origin !== httpBaseUrl.origin
|
|
133
|
+
|| canonical.protocol !== httpBaseUrl.protocol
|
|
134
|
+
|| canonical.username
|
|
135
|
+
|| canonical.password
|
|
136
|
+
|| canonical.hash
|
|
137
|
+
|| /\/{2,}/.test(canonical.pathname)) {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
return canonical;
|
|
141
|
+
} catch {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function allowedHttpRequest(method, pathname) {
|
|
147
|
+
if (!['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) return false;
|
|
148
|
+
pathname = String(pathname || '');
|
|
149
|
+
if (pathname === '/api/health'
|
|
150
|
+
|| pathname === '/api/runtime/status'
|
|
151
|
+
|| pathname === '/api/runtime/restart'
|
|
152
|
+
|| pathname === '/api/auth/status'
|
|
153
|
+
|| pathname === '/api/remote/status'
|
|
154
|
+
|| pathname === '/api/hub/status'
|
|
155
|
+
|| pathname === '/api/update/status'
|
|
156
|
+
|| pathname === '/api/update/apply') {
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
if (/^\/api\/settings(?:\/|$)/.test(pathname)
|
|
160
|
+
|| /^\/api\/captures(?:\/|$)/.test(pathname)
|
|
161
|
+
|| /^\/api\/capture-sessions(?:\/|$)/.test(pathname)
|
|
162
|
+
|| /^\/api\/remote\/devices(?:\/|$)/.test(pathname)
|
|
163
|
+
|| /^\/api\/remote\/frames$/.test(pathname)
|
|
164
|
+
|| /^\/api\/remote\/filesystem(?:\/|$)/.test(pathname)
|
|
165
|
+
|| /^\/api\/remote\/files(?:\/|$)/.test(pathname)
|
|
166
|
+
|| /^\/api\/remote\/tasks(?:\/|$)/.test(pathname)
|
|
167
|
+
|| /^\/api\/remote\/license(?:\/sync)?$/.test(pathname)) {
|
|
168
|
+
return pathname !== '/api/remote/registry-credentials'
|
|
169
|
+
&& pathname !== '/api/remote/pairing-pin'
|
|
170
|
+
&& !pathname.startsWith('/api/remote/host-target')
|
|
171
|
+
&& !pathname.startsWith('/api/remote/synthetic');
|
|
172
|
+
}
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const WEBSOCKET_PATH_BY_PURPOSE = Object.freeze({
|
|
177
|
+
frame: '/api/remote/frames/ws',
|
|
178
|
+
atlas: '/api/remote/atlas/ws',
|
|
179
|
+
input: '/api/remote/input/ws',
|
|
180
|
+
audio: '/api/remote/audio/ws'
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
function allowedWebSocketPath(path, purpose) {
|
|
184
|
+
return String(path || '').split('?')[0] === WEBSOCKET_PATH_BY_PURPOSE[purpose];
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function parseLogicalWebSocketLabel(label) {
|
|
188
|
+
const source = String(label || '');
|
|
189
|
+
if (!source.startsWith(LOGICAL_WS_LABEL_PREFIX)) return null;
|
|
190
|
+
const remainder = source.slice(LOGICAL_WS_LABEL_PREFIX.length);
|
|
191
|
+
const separator = remainder.lastIndexOf(':');
|
|
192
|
+
if (separator <= 0) return null;
|
|
193
|
+
const channelId = remainder.slice(0, separator);
|
|
194
|
+
const purpose = remainder.slice(separator + 1);
|
|
195
|
+
if (!UUID_PATTERN.test(channelId) || !LOGICAL_WS_PURPOSES.has(purpose)) return null;
|
|
196
|
+
return Object.freeze({ channelId, purpose });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function candidateIsRelay(candidate) {
|
|
200
|
+
return /(?:^|\s)typ\s+relay(?:\s|$)/i.test(String(candidate || ''));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function peerSelectedRelay(peer) {
|
|
204
|
+
try {
|
|
205
|
+
const pair = peer?.getSelectedCandidatePair?.();
|
|
206
|
+
return String(pair?.local?.type || '').toLowerCase() === 'relay'
|
|
207
|
+
|| String(pair?.remote?.type || '').toLowerCase() === 'relay'
|
|
208
|
+
|| candidateIsRelay(pair?.local?.candidate)
|
|
209
|
+
|| candidateIsRelay(pair?.remote?.candidate);
|
|
210
|
+
} catch {
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function channelIsOpen(channel) {
|
|
216
|
+
try {
|
|
217
|
+
return channel?.isOpen?.() === true;
|
|
218
|
+
} catch {
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function socketIsOpen(socket, WebSocketImpl) {
|
|
224
|
+
return Boolean(socket) && socket.readyState === Number(WebSocketImpl.OPEN ?? 1);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function decodeBase64Bounded(value, maxBytes) {
|
|
228
|
+
const source = String(value || '');
|
|
229
|
+
if (!source) return Buffer.alloc(0);
|
|
230
|
+
if (source.length > Math.ceil(maxBytes / 3) * 4 + 8
|
|
231
|
+
|| !/^[A-Za-z0-9+/]*={0,2}$/.test(source)) {
|
|
232
|
+
throw new Error('invalid-console-http-body');
|
|
233
|
+
}
|
|
234
|
+
const bytes = Buffer.from(source, 'base64');
|
|
235
|
+
if (bytes.byteLength > maxBytes) throw new Error('console-http-body-too-large');
|
|
236
|
+
return bytes;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function readBoundedResponseBytes(response, maxBytes) {
|
|
240
|
+
const contentLength = Number(response?.headers?.get?.('content-length') || 0);
|
|
241
|
+
if (Number.isFinite(contentLength) && contentLength > maxBytes) {
|
|
242
|
+
throw new Error('console-http-response-too-large');
|
|
243
|
+
}
|
|
244
|
+
const reader = response?.body?.getReader?.();
|
|
245
|
+
if (!reader) {
|
|
246
|
+
const bytes = Buffer.from(await response.arrayBuffer());
|
|
247
|
+
if (bytes.byteLength > maxBytes) throw new Error('console-http-response-too-large');
|
|
248
|
+
return bytes;
|
|
249
|
+
}
|
|
250
|
+
const chunks = [];
|
|
251
|
+
let total = 0;
|
|
252
|
+
try {
|
|
253
|
+
while (true) {
|
|
254
|
+
const result = await reader.read();
|
|
255
|
+
if (result.done) break;
|
|
256
|
+
const chunk = Buffer.from(result.value || []);
|
|
257
|
+
total += chunk.byteLength;
|
|
258
|
+
if (total > maxBytes) {
|
|
259
|
+
await reader.cancel('console-http-response-too-large').catch(() => {});
|
|
260
|
+
throw new Error('console-http-response-too-large');
|
|
261
|
+
}
|
|
262
|
+
chunks.push(chunk);
|
|
263
|
+
}
|
|
264
|
+
} finally {
|
|
265
|
+
reader.releaseLock?.();
|
|
266
|
+
}
|
|
267
|
+
return Buffer.concat(chunks, total);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function createHubConsoleDirect(options = {}) {
|
|
271
|
+
const signalUrl = normalizeSignalUrl(options.url);
|
|
272
|
+
const deviceId = String(options.deviceId || '').trim();
|
|
273
|
+
const requestedHubInstanceId = String(options.hubInstanceId || '').trim();
|
|
274
|
+
const hubInstanceId = UUID_PATTERN.test(requestedHubInstanceId)
|
|
275
|
+
? requestedHubInstanceId
|
|
276
|
+
: crypto.randomUUID();
|
|
277
|
+
const httpBaseUrl = String(options.httpBaseUrl || '').replace(/\/+$/, '');
|
|
278
|
+
const canonicalHttpBaseUrl = normalizeHttpBaseUrl(httpBaseUrl);
|
|
279
|
+
const stunUrls = normalizeStunUrls(options.stunUrls);
|
|
280
|
+
const SignalingWebSocketImpl = options.SignalingWebSocketImpl || options.WebSocketImpl || WebSocket;
|
|
281
|
+
const LocalWebSocketImpl = options.LocalWebSocketImpl || options.WebSocketImpl || WebSocket;
|
|
282
|
+
const fetchImpl = options.fetchImpl || globalThis.fetch;
|
|
283
|
+
const getAccessToken = typeof options.getAccessToken === 'function'
|
|
284
|
+
? options.getAccessToken
|
|
285
|
+
: async () => String(options.accessToken || '').trim();
|
|
286
|
+
const createPeerConnection = typeof options.createPeerConnection === 'function'
|
|
287
|
+
? options.createPeerConnection
|
|
288
|
+
: (name, config) => new nodeDataChannel.PeerConnection(name, config);
|
|
289
|
+
const retryDelaysMs = Array.isArray(options.retryDelaysMs) && options.retryDelaysMs.length > 0
|
|
290
|
+
? options.retryDelaysMs.map(value => Math.max(0, Number(value) || 0))
|
|
291
|
+
: DEFAULT_RETRY_DELAYS_MS;
|
|
292
|
+
const iceConnectTimeoutMs = Math.max(1, Number(options.iceConnectTimeoutMs) || ICE_CONNECT_TIMEOUT_MS);
|
|
293
|
+
const peerDisconnectedTimeoutMs = Math.max(
|
|
294
|
+
1,
|
|
295
|
+
Number(options.peerDisconnectedTimeoutMs) || PEER_DISCONNECTED_TIMEOUT_MS
|
|
296
|
+
);
|
|
297
|
+
const accessTokenTimeoutMs = Math.max(
|
|
298
|
+
1,
|
|
299
|
+
Number(options.accessTokenTimeoutMs) || ACCESS_TOKEN_TIMEOUT_MS
|
|
300
|
+
);
|
|
301
|
+
const signalReadyTimeoutMs = Math.max(
|
|
302
|
+
1,
|
|
303
|
+
Number(options.signalReadyTimeoutMs) || SIGNAL_READY_TIMEOUT_MS
|
|
304
|
+
);
|
|
305
|
+
const mediaAssemblyTimeoutMs = Math.max(
|
|
306
|
+
1,
|
|
307
|
+
Number(options.mediaAssemblyTimeoutMs) || MEDIA_ASSEMBLY_TIMEOUT_MS
|
|
308
|
+
);
|
|
309
|
+
const logicalBindTimeoutMs = Math.max(
|
|
310
|
+
1,
|
|
311
|
+
Number(options.logicalBindTimeoutMs) || LOGICAL_BIND_TIMEOUT_MS
|
|
312
|
+
);
|
|
313
|
+
const peers = new Map();
|
|
314
|
+
let signalSocket = null;
|
|
315
|
+
let signalGeneration = 0;
|
|
316
|
+
let peerGeneration = 0;
|
|
317
|
+
let retryTimer = null;
|
|
318
|
+
let accessTokenAttempt = null;
|
|
319
|
+
let signalReadyDeadline = null;
|
|
320
|
+
let retryAttempt = 0;
|
|
321
|
+
let retryNotBeforeAt = 0;
|
|
322
|
+
let nextRetryAt = '';
|
|
323
|
+
let stopped = true;
|
|
324
|
+
let state = signalUrl instanceof URL ? 'idle' : signalUrl === '' ? 'disabled' : 'error';
|
|
325
|
+
let hubEpoch = '';
|
|
326
|
+
let lastConnectedAt = '';
|
|
327
|
+
let lastError = signalUrl === null ? 'invalid-console-direct-signal-url' : '';
|
|
328
|
+
let lastHttpStatus = 0;
|
|
329
|
+
let rejectedRelayCandidates = 0;
|
|
330
|
+
let dataChannelBackpressureCloses = 0;
|
|
331
|
+
let malformedWireCloses = 0;
|
|
332
|
+
let wireBudgetCleanupFailures = 0;
|
|
333
|
+
|
|
334
|
+
const isOwnerActive = owner => !owner.retired
|
|
335
|
+
&& owner.hubEpoch === hubEpoch
|
|
336
|
+
&& peers.get(owner.consoleId) === owner
|
|
337
|
+
&& owner.generation > 0;
|
|
338
|
+
|
|
339
|
+
const sendSignal = payload => {
|
|
340
|
+
if (!socketIsOpen(signalSocket, SignalingWebSocketImpl)) return false;
|
|
341
|
+
const serialized = JSON.stringify(payload);
|
|
342
|
+
if (byteLength(serialized) > SIGNAL_MESSAGE_MAX_BYTES) return false;
|
|
343
|
+
try {
|
|
344
|
+
signalSocket.send(serialized);
|
|
345
|
+
return true;
|
|
346
|
+
} catch {
|
|
347
|
+
return false;
|
|
348
|
+
}
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
const ownerEnvelope = owner => ({
|
|
352
|
+
consoleId: owner.consoleId,
|
|
353
|
+
connectionId: owner.connectionId,
|
|
354
|
+
hubEpoch: owner.hubEpoch
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
const disposeAssemblerTimer = channelState => {
|
|
358
|
+
if (channelState.assemblyTimer) clearTimeout(channelState.assemblyTimer);
|
|
359
|
+
channelState.assemblyTimer = null;
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
const disposePendingLogicalControl = (owner, channelId) => {
|
|
363
|
+
const pending = owner.pendingLogicalControl.get(channelId);
|
|
364
|
+
if (!pending) return null;
|
|
365
|
+
clearTimeout(pending.timer);
|
|
366
|
+
owner.pendingLogicalControl.delete(channelId);
|
|
367
|
+
return pending;
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
const lifecycleOwnerMatches = (owner, payload) => payload?.connectionId === owner.connectionId
|
|
371
|
+
&& payload?.hubEpoch === owner.hubEpoch;
|
|
372
|
+
|
|
373
|
+
function sendLifecycleControl(owner, payload, options = {}) {
|
|
374
|
+
return sendControl(owner, {
|
|
375
|
+
...payload,
|
|
376
|
+
connectionId: owner.connectionId,
|
|
377
|
+
hubEpoch: owner.hubEpoch
|
|
378
|
+
}, options);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const closeLocalSocket = (owner, channelState, code = 1000, reason = 'console-direct-channel-closed') => {
|
|
382
|
+
const socket = channelState.localSocket;
|
|
383
|
+
channelState.localSocket = null;
|
|
384
|
+
if (!socket) return;
|
|
385
|
+
try { socket.close(code, reason); } catch { /* exact socket is already closed */ }
|
|
386
|
+
try { socket.terminate?.(); } catch { /* exact socket is already closed */ }
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
const closeLogicalChannel = (owner, channelState, options = {}) => {
|
|
390
|
+
if (!channelState || channelState.closed) return;
|
|
391
|
+
if (options.notify === true && isOwnerActive(owner)) {
|
|
392
|
+
sendLifecycleControl(owner, {
|
|
393
|
+
type: 'ws-closed',
|
|
394
|
+
channelId: channelState.channelId,
|
|
395
|
+
code: Number(options.code || 1000),
|
|
396
|
+
reason: String(options.reason || 'console-direct-channel-closed').slice(0, 120)
|
|
397
|
+
}, { closeOnFailure: false });
|
|
398
|
+
}
|
|
399
|
+
channelState.closed = true;
|
|
400
|
+
owner.channels.delete(channelState.channelId);
|
|
401
|
+
disposePendingLogicalControl(owner, channelState.channelId);
|
|
402
|
+
disposeAssemblerTimer(channelState);
|
|
403
|
+
channelState.assembler.dispose();
|
|
404
|
+
closeLocalSocket(
|
|
405
|
+
owner,
|
|
406
|
+
channelState,
|
|
407
|
+
Number(options.code || 1000),
|
|
408
|
+
String(options.reason || 'console-direct-channel-closed').slice(0, 120)
|
|
409
|
+
);
|
|
410
|
+
try { channelState.channel.close(); } catch { /* exact channel is already closed */ }
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
const retirePeer = (owner, reason = 'console-direct-peer-closed', options = {}) => {
|
|
414
|
+
if (!owner || owner.retired) return;
|
|
415
|
+
owner.retired = true;
|
|
416
|
+
if (peers.get(owner.consoleId) === owner) peers.delete(owner.consoleId);
|
|
417
|
+
clearTimeout(owner.iceTimer);
|
|
418
|
+
owner.iceTimer = null;
|
|
419
|
+
clearTimeout(owner.disconnectTimer);
|
|
420
|
+
owner.disconnectTimer = null;
|
|
421
|
+
for (const pending of owner.pendingHttp.values()) {
|
|
422
|
+
clearTimeout(pending.timeout);
|
|
423
|
+
pending.cancelled = true;
|
|
424
|
+
try { pending.controller.abort(new Error(reason)); } catch { /* request is already terminal */ }
|
|
425
|
+
}
|
|
426
|
+
owner.pendingHttp.clear();
|
|
427
|
+
for (const channelId of [...owner.pendingLogicalControl.keys()]) {
|
|
428
|
+
disposePendingLogicalControl(owner, channelId);
|
|
429
|
+
}
|
|
430
|
+
for (const channelState of [...owner.channels.values()]) {
|
|
431
|
+
closeLogicalChannel(owner, channelState, { code: 1012, reason, notify: false });
|
|
432
|
+
}
|
|
433
|
+
if (owner.control) {
|
|
434
|
+
disposeAssemblerTimer(owner.control);
|
|
435
|
+
owner.control.assembler.dispose();
|
|
436
|
+
try { owner.control.channel.close(); } catch { /* exact channel is already closed */ }
|
|
437
|
+
owner.control = null;
|
|
438
|
+
}
|
|
439
|
+
const terminalBudget = owner.wireBudget.inspect();
|
|
440
|
+
if (terminalBudget.retainedBytes !== 0 || terminalBudget.reservationCount !== 0) {
|
|
441
|
+
wireBudgetCleanupFailures += 1;
|
|
442
|
+
lastError = 'console-direct-wire-budget-not-zero-after-peer-close';
|
|
443
|
+
}
|
|
444
|
+
try { owner.peer.close(); } catch { /* exact peer is already closed */ }
|
|
445
|
+
if (options.notify !== false) {
|
|
446
|
+
sendSignal({ type: 'rtc-close', ...ownerEnvelope(owner), reason: String(reason).slice(0, 120) });
|
|
447
|
+
}
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
const retireAllPeers = reason => {
|
|
451
|
+
for (const owner of [...peers.values()]) retirePeer(owner, reason, { notify: false });
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
const retireNegotiatingPeers = reason => {
|
|
455
|
+
for (const owner of [...peers.values()]) {
|
|
456
|
+
if (!owner.control || !channelIsOpen(owner.control.channel)) {
|
|
457
|
+
retirePeer(owner, reason, { notify: false });
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
const refreshPeerDisconnectDeadline = owner => {
|
|
463
|
+
if (!isOwnerActive(owner)) return;
|
|
464
|
+
const disconnected = owner.peerState === 'disconnected'
|
|
465
|
+
|| owner.iceState === 'disconnected';
|
|
466
|
+
if (!disconnected) {
|
|
467
|
+
clearTimeout(owner.disconnectTimer);
|
|
468
|
+
owner.disconnectTimer = null;
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
if (owner.disconnectTimer) return;
|
|
472
|
+
const timer = setTimeout(() => {
|
|
473
|
+
if (owner.disconnectTimer !== timer) return;
|
|
474
|
+
owner.disconnectTimer = null;
|
|
475
|
+
if (isOwnerActive(owner)
|
|
476
|
+
&& (owner.peerState === 'disconnected' || owner.iceState === 'disconnected')) {
|
|
477
|
+
retirePeer(owner, 'console-direct-peer-disconnected-timeout');
|
|
478
|
+
}
|
|
479
|
+
}, peerDisconnectedTimeoutMs);
|
|
480
|
+
owner.disconnectTimer = timer;
|
|
481
|
+
timer.unref?.();
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
const ownerBufferedAmount = owner => {
|
|
485
|
+
let total = 0;
|
|
486
|
+
const states = [owner.control, ...owner.channels.values()];
|
|
487
|
+
for (const state of states) {
|
|
488
|
+
if (!state || state.closed) continue;
|
|
489
|
+
try { total += Math.max(0, Number(state.channel.bufferedAmount?.() || 0)); } catch {}
|
|
490
|
+
}
|
|
491
|
+
return total;
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
const channelBufferedAmount = channelState => {
|
|
495
|
+
if (!channelState || channelState.closed) return 0;
|
|
496
|
+
try {
|
|
497
|
+
return Math.max(0, Number(channelState.channel.bufferedAmount?.() || 0));
|
|
498
|
+
} catch {
|
|
499
|
+
return 0;
|
|
500
|
+
}
|
|
501
|
+
};
|
|
502
|
+
|
|
503
|
+
const releaseLogicalBacklogForControl = (owner, requiredBytes) => {
|
|
504
|
+
const purposePriority = { frame: 0, atlas: 1, audio: 2, input: 3 };
|
|
505
|
+
const candidates = [...owner.channels.values()]
|
|
506
|
+
.filter(state => !state.closed && channelBufferedAmount(state) > 0)
|
|
507
|
+
.sort((left, right) => (
|
|
508
|
+
(purposePriority[left.purpose] ?? 4) - (purposePriority[right.purpose] ?? 4)
|
|
509
|
+
|| channelBufferedAmount(right) - channelBufferedAmount(left)
|
|
510
|
+
));
|
|
511
|
+
for (const state of candidates) {
|
|
512
|
+
if (ownerBufferedAmount(owner) + requiredBytes <= MAX_DATA_CHANNEL_BUFFERED_BYTES) break;
|
|
513
|
+
dataChannelBackpressureCloses += 1;
|
|
514
|
+
closeLogicalChannel(owner, state, {
|
|
515
|
+
code: 1013,
|
|
516
|
+
reason: 'console-direct-control-headroom',
|
|
517
|
+
notify: false
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
};
|
|
521
|
+
|
|
522
|
+
function sendWire(owner, channelState, kind, value, maxMessageBytes, options = {}) {
|
|
523
|
+
if (!isOwnerActive(owner) || channelState.closed || !channelIsOpen(channelState.channel)) return false;
|
|
524
|
+
let chunks;
|
|
525
|
+
try {
|
|
526
|
+
const messageId = channelState.nextSendMessageId >>> 0;
|
|
527
|
+
channelState.nextSendMessageId = (messageId + 1) >>> 0;
|
|
528
|
+
chunks = encodeDirectConsoleWireMessage(value, { messageId, kind, maxMessageBytes });
|
|
529
|
+
} catch {
|
|
530
|
+
if (options.closeOnFailure !== false) {
|
|
531
|
+
if (channelState === owner.control) retirePeer(owner, 'console-direct-wire-encode-failed');
|
|
532
|
+
else closeLogicalChannel(owner, channelState, {
|
|
533
|
+
code: 1009,
|
|
534
|
+
reason: 'console-direct-wire-encode-failed',
|
|
535
|
+
notify: false
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
return false;
|
|
539
|
+
}
|
|
540
|
+
const wireBytes = chunks.reduce((total, chunk) => total + chunk.byteLength, 0);
|
|
541
|
+
if (channelState === owner.control
|
|
542
|
+
&& ownerBufferedAmount(owner) + wireBytes > MAX_DATA_CHANNEL_BUFFERED_BYTES) {
|
|
543
|
+
releaseLogicalBacklogForControl(owner, wireBytes);
|
|
544
|
+
}
|
|
545
|
+
if (wireBytes > MAX_DATA_CHANNEL_BUFFERED_BYTES
|
|
546
|
+
|| ownerBufferedAmount(owner) + wireBytes > MAX_DATA_CHANNEL_BUFFERED_BYTES) {
|
|
547
|
+
dataChannelBackpressureCloses += 1;
|
|
548
|
+
if (options.closeOnFailure !== false) {
|
|
549
|
+
if (channelState === owner.control) retirePeer(owner, 'console-direct-control-backpressure');
|
|
550
|
+
else closeLogicalChannel(owner, channelState, {
|
|
551
|
+
code: 1013,
|
|
552
|
+
reason: 'console-direct-datachannel-backpressure',
|
|
553
|
+
notify: false
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
return false;
|
|
557
|
+
}
|
|
558
|
+
for (const chunk of chunks) {
|
|
559
|
+
if (!isOwnerActive(owner) || channelState.closed || !channelIsOpen(channelState.channel)) return false;
|
|
560
|
+
try {
|
|
561
|
+
if (channelState.channel.sendMessageBinary(chunk) !== true) throw new Error('send-rejected');
|
|
562
|
+
} catch {
|
|
563
|
+
if (options.closeOnFailure !== false) {
|
|
564
|
+
if (channelState === owner.control) retirePeer(owner, 'console-direct-control-send-failed');
|
|
565
|
+
else closeLogicalChannel(owner, channelState, {
|
|
566
|
+
code: 1011,
|
|
567
|
+
reason: 'console-direct-datachannel-send-failed',
|
|
568
|
+
notify: false
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
return false;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
return true;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const sendControl = (owner, payload, options = {}) => owner.control
|
|
578
|
+
? sendWire(owner, owner.control, 'control', JSON.stringify(payload), DIRECT_CONSOLE_MAX_MESSAGE_BYTES, options)
|
|
579
|
+
: false;
|
|
580
|
+
|
|
581
|
+
const sendHttpResponse = (owner, requestId, response = {}) => sendControl(owner, {
|
|
582
|
+
type: 'http-response',
|
|
583
|
+
requestId,
|
|
584
|
+
status: Math.max(0, Number(response.status || 0)),
|
|
585
|
+
statusText: String(response.statusText || '').slice(0, 120),
|
|
586
|
+
headers: response.headers && typeof response.headers === 'object' ? response.headers : {},
|
|
587
|
+
bodyBase64: String(response.bodyBase64 || ''),
|
|
588
|
+
...(response.error ? { error: String(response.error).slice(0, 240) } : {})
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
const handleHttpRequest = async (owner, payload) => {
|
|
592
|
+
const requestId = String(payload?.requestId || '');
|
|
593
|
+
const method = String(payload?.method || 'GET').toUpperCase();
|
|
594
|
+
const path = String(payload?.path || '');
|
|
595
|
+
const requestUrl = canonicalHttpRequestUrl(path, canonicalHttpBaseUrl);
|
|
596
|
+
if (!UUID_PATTERN.test(requestId)
|
|
597
|
+
|| !requestUrl
|
|
598
|
+
|| !allowedHttpRequest(method, requestUrl.pathname)) {
|
|
599
|
+
sendHttpResponse(owner, requestId, { error: 'console-route-not-allowed' });
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
if (owner.pendingHttp.has(requestId)) {
|
|
603
|
+
sendHttpResponse(owner, requestId, { error: 'console-http-request-duplicate' });
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
if (owner.pendingHttp.size >= MAX_PENDING_HTTP_PER_PEER) {
|
|
607
|
+
sendHttpResponse(owner, requestId, { error: 'console-http-capacity-reached' });
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
let body;
|
|
611
|
+
try {
|
|
612
|
+
body = decodeBase64Bounded(payload?.bodyBase64, MAX_HTTP_REQUEST_BYTES);
|
|
613
|
+
} catch (error) {
|
|
614
|
+
sendHttpResponse(owner, requestId, { error: error instanceof Error ? error.message : error });
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
const controller = new AbortController();
|
|
618
|
+
const pending = { controller, timeout: null, cancelled: false };
|
|
619
|
+
pending.timeout = setTimeout(() => {
|
|
620
|
+
pending.cancelled = true;
|
|
621
|
+
try { controller.abort(new Error('console-http-timeout')); } catch { /* request is already terminal */ }
|
|
622
|
+
}, HTTP_TIMEOUT_MS);
|
|
623
|
+
pending.timeout.unref?.();
|
|
624
|
+
owner.pendingHttp.set(requestId, pending);
|
|
625
|
+
try {
|
|
626
|
+
const response = await fetchImpl(requestUrl.href, {
|
|
627
|
+
method,
|
|
628
|
+
headers: safeContentHeaders(payload?.headers),
|
|
629
|
+
body: ['GET', 'HEAD'].includes(method) || body.byteLength === 0 ? undefined : body,
|
|
630
|
+
signal: controller.signal
|
|
631
|
+
});
|
|
632
|
+
const responseBytes = await readBoundedResponseBytes(response, MAX_HTTP_RESPONSE_BYTES);
|
|
633
|
+
if (!isOwnerActive(owner) || owner.pendingHttp.get(requestId) !== pending || pending.cancelled) return;
|
|
634
|
+
sendHttpResponse(owner, requestId, {
|
|
635
|
+
status: response.status,
|
|
636
|
+
statusText: response.statusText,
|
|
637
|
+
headers: {
|
|
638
|
+
'content-type': String(response.headers?.get?.('content-type') || '').slice(0, 256),
|
|
639
|
+
'content-range': String(response.headers?.get?.('content-range') || '').slice(0, 256)
|
|
640
|
+
},
|
|
641
|
+
bodyBase64: responseBytes.toString('base64')
|
|
642
|
+
});
|
|
643
|
+
} catch (error) {
|
|
644
|
+
if (isOwnerActive(owner)
|
|
645
|
+
&& owner.pendingHttp.get(requestId) === pending
|
|
646
|
+
&& !pending.cancelled) {
|
|
647
|
+
sendHttpResponse(owner, requestId, { error: error instanceof Error ? error.message : error });
|
|
648
|
+
}
|
|
649
|
+
} finally {
|
|
650
|
+
clearTimeout(pending.timeout);
|
|
651
|
+
if (owner.pendingHttp.get(requestId) === pending) owner.pendingHttp.delete(requestId);
|
|
652
|
+
}
|
|
653
|
+
};
|
|
654
|
+
|
|
655
|
+
const cancelHttpRequest = (owner, requestId) => {
|
|
656
|
+
const pending = owner.pendingHttp.get(String(requestId || ''));
|
|
657
|
+
if (!pending) return;
|
|
658
|
+
owner.pendingHttp.delete(String(requestId || ''));
|
|
659
|
+
clearTimeout(pending.timeout);
|
|
660
|
+
pending.cancelled = true;
|
|
661
|
+
try { pending.controller.abort(new Error('console-http-cancelled')); } catch { /* request is already terminal */ }
|
|
662
|
+
};
|
|
663
|
+
|
|
664
|
+
const queuePendingLogicalControl = (owner, payload) => {
|
|
665
|
+
const channelId = String(payload?.channelId || '');
|
|
666
|
+
if (!UUID_PATTERN.test(channelId)) throw new Error('console-direct-logical-channel-invalid');
|
|
667
|
+
const existing = owner.pendingLogicalControl.get(channelId);
|
|
668
|
+
if (payload.type === 'ws-open' && existing?.payload?.type === 'ws-open') {
|
|
669
|
+
throw new Error('console-direct-logical-open-duplicate');
|
|
670
|
+
}
|
|
671
|
+
if (!existing && owner.pendingLogicalControl.size >= MAX_LOGICAL_CHANNELS_PER_PEER) {
|
|
672
|
+
throw new Error('console-direct-logical-control-capacity');
|
|
673
|
+
}
|
|
674
|
+
if (existing) clearTimeout(existing.timer);
|
|
675
|
+
const pending = {
|
|
676
|
+
payload: { ...payload },
|
|
677
|
+
timer: setTimeout(() => {
|
|
678
|
+
if (!isOwnerActive(owner) || owner.pendingLogicalControl.get(channelId) !== pending) return;
|
|
679
|
+
owner.pendingLogicalControl.delete(channelId);
|
|
680
|
+
if (pending.payload.type === 'ws-open') {
|
|
681
|
+
sendLifecycleControl(owner, {
|
|
682
|
+
type: 'ws-error',
|
|
683
|
+
channelId,
|
|
684
|
+
error: 'console-direct-logical-channel-bind-timeout'
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
}, logicalBindTimeoutMs)
|
|
688
|
+
};
|
|
689
|
+
pending.timer.unref?.();
|
|
690
|
+
owner.pendingLogicalControl.set(channelId, pending);
|
|
691
|
+
};
|
|
692
|
+
|
|
693
|
+
const openLogicalWebSocket = (owner, channelState, payload) => {
|
|
694
|
+
const channelId = String(payload?.channelId || '');
|
|
695
|
+
const purpose = String(payload?.purpose || '');
|
|
696
|
+
const path = String(payload?.path || '');
|
|
697
|
+
if (channelState.openRequested
|
|
698
|
+
|| channelId !== channelState.channelId
|
|
699
|
+
|| purpose !== channelState.purpose
|
|
700
|
+
|| !allowedWebSocketPath(path, purpose)) {
|
|
701
|
+
sendLifecycleControl(owner, {
|
|
702
|
+
type: 'ws-error',
|
|
703
|
+
channelId: channelState.channelId,
|
|
704
|
+
error: 'console-websocket-route-not-allowed'
|
|
705
|
+
});
|
|
706
|
+
closeLogicalChannel(owner, channelState, {
|
|
707
|
+
code: 1008,
|
|
708
|
+
reason: 'console-websocket-route-not-allowed',
|
|
709
|
+
notify: false
|
|
710
|
+
});
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
channelState.openRequested = true;
|
|
714
|
+
const localUrl = new URL(path, httpBaseUrl);
|
|
715
|
+
const httpOrigin = new URL(httpBaseUrl).origin;
|
|
716
|
+
if (localUrl.origin !== httpOrigin) {
|
|
717
|
+
closeLogicalChannel(owner, channelState, { code: 1008, reason: 'console-websocket-origin-not-allowed' });
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
localUrl.protocol = localUrl.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
721
|
+
let socket;
|
|
722
|
+
try {
|
|
723
|
+
socket = new LocalWebSocketImpl(localUrl, { perMessageDeflate: false });
|
|
724
|
+
} catch (error) {
|
|
725
|
+
sendLifecycleControl(owner, {
|
|
726
|
+
type: 'ws-error',
|
|
727
|
+
channelId,
|
|
728
|
+
error: String(error instanceof Error ? error.message : error).slice(0, 240)
|
|
729
|
+
});
|
|
730
|
+
closeLogicalChannel(owner, channelState, { code: 1011, reason: 'console-local-websocket-open-failed' });
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
channelState.localSocket = socket;
|
|
734
|
+
socket.once('open', () => {
|
|
735
|
+
if (!isOwnerActive(owner) || owner.channels.get(channelId) !== channelState || channelState.localSocket !== socket) {
|
|
736
|
+
try { socket.terminate?.(); } catch {}
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
sendLifecycleControl(owner, { type: 'ws-opened', channelId });
|
|
740
|
+
});
|
|
741
|
+
socket.on('message', (data, isBinary) => {
|
|
742
|
+
if (!isOwnerActive(owner) || owner.channels.get(channelId) !== channelState || channelState.localSocket !== socket) return;
|
|
743
|
+
const kind = isBinary ? 'binary' : 'text';
|
|
744
|
+
const value = isBinary ? new Uint8Array(Buffer.from(data)) : String(data);
|
|
745
|
+
if (!sendWire(owner, channelState, kind, value, channelState.maxMessageBytes)) {
|
|
746
|
+
closeLogicalChannel(owner, channelState, {
|
|
747
|
+
code: 1013,
|
|
748
|
+
reason: 'console-direct-datachannel-backpressure',
|
|
749
|
+
notify: false
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
});
|
|
753
|
+
socket.once('error', error => {
|
|
754
|
+
if (!isOwnerActive(owner) || owner.channels.get(channelId) !== channelState) return;
|
|
755
|
+
sendLifecycleControl(owner, {
|
|
756
|
+
type: 'ws-error',
|
|
757
|
+
channelId,
|
|
758
|
+
error: String(error instanceof Error ? error.message : error).slice(0, 240)
|
|
759
|
+
});
|
|
760
|
+
});
|
|
761
|
+
socket.once('close', (code, reason) => {
|
|
762
|
+
if (channelState.localSocket === socket) channelState.localSocket = null;
|
|
763
|
+
if (!isOwnerActive(owner) || owner.channels.get(channelId) !== channelState) return;
|
|
764
|
+
sendLifecycleControl(owner, {
|
|
765
|
+
type: 'ws-closed',
|
|
766
|
+
channelId,
|
|
767
|
+
code: Number(code || 1000),
|
|
768
|
+
reason: String(reason || 'console-local-websocket-closed').slice(0, 120)
|
|
769
|
+
});
|
|
770
|
+
closeLogicalChannel(owner, channelState, { code, reason: 'console-local-websocket-closed', notify: false });
|
|
771
|
+
});
|
|
772
|
+
};
|
|
773
|
+
|
|
774
|
+
const dispatchLogicalControl = (owner, payload) => {
|
|
775
|
+
if (!lifecycleOwnerMatches(owner, payload)) {
|
|
776
|
+
throw new Error('console-direct-logical-owner-invalid');
|
|
777
|
+
}
|
|
778
|
+
const channelId = String(payload.channelId || '');
|
|
779
|
+
if (!UUID_PATTERN.test(channelId)) throw new Error('console-direct-logical-channel-invalid');
|
|
780
|
+
const channelState = owner.channels.get(channelId);
|
|
781
|
+
if (payload.type === 'ws-open') {
|
|
782
|
+
if (!LOGICAL_WS_PURPOSES.has(String(payload.purpose || ''))
|
|
783
|
+
|| typeof payload.path !== 'string') {
|
|
784
|
+
throw new Error('console-direct-logical-open-invalid');
|
|
785
|
+
}
|
|
786
|
+
if (!channelState || !channelIsOpen(channelState.channel)) {
|
|
787
|
+
queuePendingLogicalControl(owner, payload);
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
disposePendingLogicalControl(owner, channelId);
|
|
791
|
+
openLogicalWebSocket(owner, channelState, payload);
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
if (payload.type === 'ws-close') {
|
|
795
|
+
if (!channelState) {
|
|
796
|
+
queuePendingLogicalControl(owner, payload);
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
closeLogicalChannel(owner, channelState, {
|
|
800
|
+
code: Math.max(1000, Number(payload.code || 1000)),
|
|
801
|
+
reason: String(payload.reason || 'console-request').slice(0, 120),
|
|
802
|
+
notify: true
|
|
803
|
+
});
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
throw new Error('console-direct-logical-control-type-not-allowed');
|
|
807
|
+
};
|
|
808
|
+
|
|
809
|
+
const consumePendingLogicalControl = (owner, channelState) => {
|
|
810
|
+
const pending = disposePendingLogicalControl(owner, channelState.channelId);
|
|
811
|
+
if (pending) dispatchLogicalControl(owner, pending.payload);
|
|
812
|
+
};
|
|
813
|
+
|
|
814
|
+
const handleControlPayload = (owner, text) => {
|
|
815
|
+
const payload = parseJson(text);
|
|
816
|
+
if (!payload?.type) throw new Error('console-direct-control-invalid');
|
|
817
|
+
if (payload.type === 'http-request') {
|
|
818
|
+
void handleHttpRequest(owner, payload);
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
if (payload.type === 'http-cancel') {
|
|
822
|
+
cancelHttpRequest(owner, payload.requestId);
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
if (payload.type === 'ws-open' || payload.type === 'ws-close') {
|
|
826
|
+
dispatchLogicalControl(owner, payload);
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
throw new Error('console-direct-control-type-not-allowed');
|
|
830
|
+
};
|
|
831
|
+
|
|
832
|
+
const forwardLogicalPayload = (owner, channelState, message) => {
|
|
833
|
+
const socket = channelState.localSocket;
|
|
834
|
+
if (!channelState.openRequested || !socketIsOpen(socket, LocalWebSocketImpl)) {
|
|
835
|
+
throw new Error('console-direct-logical-websocket-not-open');
|
|
836
|
+
}
|
|
837
|
+
if (Number(socket.bufferedAmount || 0) + message.byteLength > MAX_LOCAL_WS_BUFFERED_BYTES) {
|
|
838
|
+
throw new Error('console-direct-local-websocket-backpressure');
|
|
839
|
+
}
|
|
840
|
+
if (message.kind === 'text') socket.send(message.data);
|
|
841
|
+
else if (message.kind === 'binary') socket.send(Buffer.from(message.data));
|
|
842
|
+
else throw new Error('console-direct-logical-wire-kind-invalid');
|
|
843
|
+
};
|
|
844
|
+
|
|
845
|
+
const scheduleAssemblyDeadline = (owner, channelState) => {
|
|
846
|
+
const pending = Number(channelState.assembler.inspect().pendingMessages || 0);
|
|
847
|
+
if (pending === 0) {
|
|
848
|
+
disposeAssemblerTimer(channelState);
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
if (channelState.assemblyTimer) return;
|
|
852
|
+
const delay = channelState.purpose === 'frame' || channelState.purpose === 'atlas'
|
|
853
|
+
? mediaAssemblyTimeoutMs
|
|
854
|
+
: CONTROL_ASSEMBLY_TIMEOUT_MS;
|
|
855
|
+
const timer = setTimeout(() => {
|
|
856
|
+
if (channelState.assemblyTimer !== timer) return;
|
|
857
|
+
channelState.assemblyTimer = null;
|
|
858
|
+
if (!isOwnerActive(owner) || channelState.closed) return;
|
|
859
|
+
if (Number(channelState.assembler.inspect().pendingMessages || 0) === 0) return;
|
|
860
|
+
malformedWireCloses += 1;
|
|
861
|
+
if (channelState === owner.control) retirePeer(owner, 'console-direct-wire-assembly-timeout');
|
|
862
|
+
else closeLogicalChannel(owner, channelState, {
|
|
863
|
+
code: 1009,
|
|
864
|
+
reason: 'console-direct-wire-assembly-timeout',
|
|
865
|
+
notify: false
|
|
866
|
+
});
|
|
867
|
+
}, delay);
|
|
868
|
+
channelState.assemblyTimer = timer;
|
|
869
|
+
timer.unref?.();
|
|
870
|
+
};
|
|
871
|
+
|
|
872
|
+
const handleDataChannelMessage = (owner, channelState, raw) => {
|
|
873
|
+
if (!isOwnerActive(owner) || channelState.closed) return;
|
|
874
|
+
try {
|
|
875
|
+
if (typeof raw === 'string') throw new Error('console-direct-unframed-message');
|
|
876
|
+
const message = channelState.assembler.push(raw);
|
|
877
|
+
scheduleAssemblyDeadline(owner, channelState);
|
|
878
|
+
if (!message) return;
|
|
879
|
+
if (channelState === owner.control) {
|
|
880
|
+
if (message.kind !== 'control') throw new Error('console-direct-control-wire-kind-invalid');
|
|
881
|
+
handleControlPayload(owner, message.data);
|
|
882
|
+
} else {
|
|
883
|
+
if (message.kind === 'control') throw new Error('console-direct-logical-control-forbidden');
|
|
884
|
+
forwardLogicalPayload(owner, channelState, message);
|
|
885
|
+
}
|
|
886
|
+
} catch {
|
|
887
|
+
malformedWireCloses += 1;
|
|
888
|
+
if (channelState === owner.control) retirePeer(owner, 'console-direct-wire-invalid');
|
|
889
|
+
else closeLogicalChannel(owner, channelState, {
|
|
890
|
+
code: 1008,
|
|
891
|
+
reason: 'console-direct-wire-invalid',
|
|
892
|
+
notify: false
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
};
|
|
896
|
+
|
|
897
|
+
const bindDataChannel = (owner, channel) => {
|
|
898
|
+
if (!isOwnerActive(owner)) {
|
|
899
|
+
try { channel.close(); } catch {}
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
const label = String(channel.getLabel?.() || '');
|
|
903
|
+
let channelState;
|
|
904
|
+
if (label === CONTROL_LABEL) {
|
|
905
|
+
if (owner.control) {
|
|
906
|
+
try { channel.close(); } catch {}
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
channelState = {
|
|
910
|
+
channel,
|
|
911
|
+
channelId: 'control',
|
|
912
|
+
purpose: 'control',
|
|
913
|
+
maxMessageBytes: DIRECT_CONSOLE_MAX_MESSAGE_BYTES,
|
|
914
|
+
assembler: createDirectConsoleWireAssembler({
|
|
915
|
+
budget: owner.wireBudget,
|
|
916
|
+
maxMessageBytes: DIRECT_CONSOLE_MAX_MESSAGE_BYTES
|
|
917
|
+
}),
|
|
918
|
+
assemblyTimer: null,
|
|
919
|
+
nextSendMessageId: 1,
|
|
920
|
+
closed: false
|
|
921
|
+
};
|
|
922
|
+
owner.control = channelState;
|
|
923
|
+
} else {
|
|
924
|
+
const identity = parseLogicalWebSocketLabel(label);
|
|
925
|
+
if (!identity
|
|
926
|
+
|| owner.channels.size >= MAX_LOGICAL_CHANNELS_PER_PEER
|
|
927
|
+
|| owner.channels.has(identity?.channelId)) {
|
|
928
|
+
try { channel.close(); } catch {}
|
|
929
|
+
return;
|
|
930
|
+
}
|
|
931
|
+
const maxMessageBytes = identity.purpose === 'frame' || identity.purpose === 'atlas'
|
|
932
|
+
? MAX_FRAME_MESSAGE_BYTES
|
|
933
|
+
: DIRECT_CONSOLE_MAX_MESSAGE_BYTES;
|
|
934
|
+
channelState = {
|
|
935
|
+
channel,
|
|
936
|
+
...identity,
|
|
937
|
+
maxMessageBytes,
|
|
938
|
+
assembler: createDirectConsoleWireAssembler({
|
|
939
|
+
budget: owner.wireBudget,
|
|
940
|
+
maxMessageBytes,
|
|
941
|
+
maxPendingMessages: identity.purpose === 'frame' || identity.purpose === 'atlas'
|
|
942
|
+
? MAX_PENDING_MEDIA_WIRE_MESSAGES
|
|
943
|
+
: 2
|
|
944
|
+
}),
|
|
945
|
+
assemblyTimer: null,
|
|
946
|
+
nextSendMessageId: 1,
|
|
947
|
+
openRequested: false,
|
|
948
|
+
localSocket: null,
|
|
949
|
+
closed: false
|
|
950
|
+
};
|
|
951
|
+
owner.channels.set(identity.channelId, channelState);
|
|
952
|
+
}
|
|
953
|
+
try { channel.setBufferedAmountLowThreshold?.(4 * 1024 * 1024); } catch {}
|
|
954
|
+
channel.onMessage(raw => handleDataChannelMessage(owner, channelState, raw));
|
|
955
|
+
channel.onClosed(() => {
|
|
956
|
+
if (!isOwnerActive(owner) || channelState.closed) return;
|
|
957
|
+
if (channelState === owner.control) retirePeer(owner, 'console-direct-control-closed');
|
|
958
|
+
else closeLogicalChannel(owner, channelState, {
|
|
959
|
+
code: 1001,
|
|
960
|
+
reason: 'console-direct-datachannel-closed',
|
|
961
|
+
notify: false
|
|
962
|
+
});
|
|
963
|
+
});
|
|
964
|
+
channel.onError(() => {
|
|
965
|
+
if (!isOwnerActive(owner) || channelState.closed) return;
|
|
966
|
+
if (channelState === owner.control) retirePeer(owner, 'console-direct-control-error');
|
|
967
|
+
else closeLogicalChannel(owner, channelState, {
|
|
968
|
+
code: 1011,
|
|
969
|
+
reason: 'console-direct-datachannel-error',
|
|
970
|
+
notify: false
|
|
971
|
+
});
|
|
972
|
+
});
|
|
973
|
+
channel.onOpen(() => {
|
|
974
|
+
if (!isOwnerActive(owner) || channelState.closed) return;
|
|
975
|
+
if (channelState === owner.control) {
|
|
976
|
+
clearTimeout(owner.iceTimer);
|
|
977
|
+
owner.iceTimer = null;
|
|
978
|
+
sendControl(owner, {
|
|
979
|
+
type: 'direct-ready',
|
|
980
|
+
connectionId: owner.connectionId,
|
|
981
|
+
hubEpoch: owner.hubEpoch
|
|
982
|
+
});
|
|
983
|
+
} else {
|
|
984
|
+
consumePendingLogicalControl(owner, channelState);
|
|
985
|
+
}
|
|
986
|
+
});
|
|
987
|
+
};
|
|
988
|
+
|
|
989
|
+
const createPeerOwner = payload => {
|
|
990
|
+
const consoleId = String(payload?.consoleId || '');
|
|
991
|
+
const connectionId = String(payload?.connectionId || '');
|
|
992
|
+
const ownerHubEpoch = String(payload?.hubEpoch || '');
|
|
993
|
+
const description = payload?.description;
|
|
994
|
+
if (!UUID_PATTERN.test(consoleId)
|
|
995
|
+
|| !UUID_PATTERN.test(connectionId)
|
|
996
|
+
|| ownerHubEpoch !== hubEpoch
|
|
997
|
+
|| description?.type !== 'offer'
|
|
998
|
+
|| typeof description.sdp !== 'string'
|
|
999
|
+
|| byteLength(description.sdp) > SDP_MAX_BYTES) {
|
|
1000
|
+
return null;
|
|
1001
|
+
}
|
|
1002
|
+
if (candidateIsRelay(description.sdp)) {
|
|
1003
|
+
rejectedRelayCandidates += 1;
|
|
1004
|
+
sendSignal({
|
|
1005
|
+
type: 'rtc-close',
|
|
1006
|
+
consoleId,
|
|
1007
|
+
connectionId,
|
|
1008
|
+
hubEpoch: ownerHubEpoch,
|
|
1009
|
+
reason: 'console-direct-relay-candidate-rejected'
|
|
1010
|
+
});
|
|
1011
|
+
return null;
|
|
1012
|
+
}
|
|
1013
|
+
const existing = peers.get(consoleId);
|
|
1014
|
+
if (existing
|
|
1015
|
+
&& existing.connectionId === connectionId
|
|
1016
|
+
&& existing.hubEpoch === ownerHubEpoch
|
|
1017
|
+
&& !existing.retired) {
|
|
1018
|
+
const current = existing.peer.localDescription?.();
|
|
1019
|
+
if (current?.type === 'answer' && typeof current.sdp === 'string') {
|
|
1020
|
+
sendSignal({
|
|
1021
|
+
type: 'rtc-answer',
|
|
1022
|
+
...ownerEnvelope(existing),
|
|
1023
|
+
description: { type: 'answer', sdp: current.sdp }
|
|
1024
|
+
});
|
|
1025
|
+
}
|
|
1026
|
+
return existing;
|
|
1027
|
+
}
|
|
1028
|
+
if (!existing && peers.size >= MAX_PEERS) {
|
|
1029
|
+
sendSignal({
|
|
1030
|
+
type: 'rtc-close',
|
|
1031
|
+
consoleId,
|
|
1032
|
+
connectionId,
|
|
1033
|
+
hubEpoch: ownerHubEpoch,
|
|
1034
|
+
reason: 'console-direct-peer-capacity-reached'
|
|
1035
|
+
});
|
|
1036
|
+
return null;
|
|
1037
|
+
}
|
|
1038
|
+
if (existing) retirePeer(existing, 'console-direct-peer-replaced');
|
|
1039
|
+
let peer;
|
|
1040
|
+
try {
|
|
1041
|
+
peer = createPeerConnection(`console-${consoleId}`, {
|
|
1042
|
+
iceServers: [...stunUrls],
|
|
1043
|
+
iceTransportPolicy: 'all',
|
|
1044
|
+
disableAutoNegotiation: false,
|
|
1045
|
+
disableFingerprintVerification: false,
|
|
1046
|
+
maxMessageSize: MAX_FRAME_MESSAGE_BYTES
|
|
1047
|
+
});
|
|
1048
|
+
} catch (error) {
|
|
1049
|
+
lastError = error instanceof Error ? error.message : String(error);
|
|
1050
|
+
sendSignal({
|
|
1051
|
+
type: 'rtc-close',
|
|
1052
|
+
consoleId,
|
|
1053
|
+
connectionId,
|
|
1054
|
+
hubEpoch: ownerHubEpoch,
|
|
1055
|
+
reason: 'console-direct-peer-create-failed'
|
|
1056
|
+
});
|
|
1057
|
+
return null;
|
|
1058
|
+
}
|
|
1059
|
+
const owner = {
|
|
1060
|
+
consoleId,
|
|
1061
|
+
connectionId,
|
|
1062
|
+
hubEpoch: ownerHubEpoch,
|
|
1063
|
+
generation: ++peerGeneration,
|
|
1064
|
+
peer,
|
|
1065
|
+
control: null,
|
|
1066
|
+
channels: new Map(),
|
|
1067
|
+
pendingLogicalControl: new Map(),
|
|
1068
|
+
pendingHttp: new Map(),
|
|
1069
|
+
wireBudget: createDirectConsoleWireBudget({ maxBytes: MAX_SHARED_ASSEMBLY_BYTES }),
|
|
1070
|
+
iceTimer: null,
|
|
1071
|
+
disconnectTimer: null,
|
|
1072
|
+
peerState: 'new',
|
|
1073
|
+
iceState: 'new',
|
|
1074
|
+
retired: false,
|
|
1075
|
+
createdAt: Date.now()
|
|
1076
|
+
};
|
|
1077
|
+
peers.set(consoleId, owner);
|
|
1078
|
+
peer.onLocalDescription((sdp, type) => {
|
|
1079
|
+
if (!isOwnerActive(owner) || String(type).toLowerCase() !== 'answer' || byteLength(sdp) > SDP_MAX_BYTES) return;
|
|
1080
|
+
if (candidateIsRelay(sdp)) {
|
|
1081
|
+
rejectedRelayCandidates += 1;
|
|
1082
|
+
retirePeer(owner, 'console-direct-relay-candidate-rejected');
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
1085
|
+
sendSignal({
|
|
1086
|
+
type: 'rtc-answer',
|
|
1087
|
+
...ownerEnvelope(owner),
|
|
1088
|
+
description: { type: 'answer', sdp: String(sdp) }
|
|
1089
|
+
});
|
|
1090
|
+
});
|
|
1091
|
+
peer.onLocalCandidate((candidate, sdpMid) => {
|
|
1092
|
+
if (!isOwnerActive(owner)) return;
|
|
1093
|
+
const candidateText = String(candidate || '');
|
|
1094
|
+
if (byteLength(candidateText) > ICE_CANDIDATE_MAX_BYTES) {
|
|
1095
|
+
retirePeer(owner, 'console-direct-candidate-invalid');
|
|
1096
|
+
return;
|
|
1097
|
+
}
|
|
1098
|
+
if (candidateIsRelay(candidateText)) {
|
|
1099
|
+
rejectedRelayCandidates += 1;
|
|
1100
|
+
retirePeer(owner, 'console-direct-relay-candidate-rejected');
|
|
1101
|
+
return;
|
|
1102
|
+
}
|
|
1103
|
+
sendSignal({
|
|
1104
|
+
type: 'rtc-ice',
|
|
1105
|
+
...ownerEnvelope(owner),
|
|
1106
|
+
candidate: candidateText,
|
|
1107
|
+
sdpMid: sdpMid === undefined || sdpMid === null ? null : String(sdpMid).slice(0, 256)
|
|
1108
|
+
});
|
|
1109
|
+
});
|
|
1110
|
+
peer.onDataChannel(channel => bindDataChannel(owner, channel));
|
|
1111
|
+
peer.onStateChange(nextState => {
|
|
1112
|
+
if (!isOwnerActive(owner)) return;
|
|
1113
|
+
const normalized = String(nextState || '').toLowerCase();
|
|
1114
|
+
owner.peerState = normalized;
|
|
1115
|
+
if (normalized === 'connected') {
|
|
1116
|
+
if (peerSelectedRelay(peer)) {
|
|
1117
|
+
rejectedRelayCandidates += 1;
|
|
1118
|
+
retirePeer(owner, 'console-direct-relay-pair-rejected');
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
1121
|
+
} else if (normalized === 'failed' || normalized === 'closed') {
|
|
1122
|
+
retirePeer(owner, `console-direct-peer-${normalized}`);
|
|
1123
|
+
return;
|
|
1124
|
+
}
|
|
1125
|
+
refreshPeerDisconnectDeadline(owner);
|
|
1126
|
+
});
|
|
1127
|
+
peer.onIceStateChange?.(nextState => {
|
|
1128
|
+
if (!isOwnerActive(owner)) return;
|
|
1129
|
+
const normalized = String(nextState || '').toLowerCase();
|
|
1130
|
+
owner.iceState = normalized;
|
|
1131
|
+
if (normalized === 'failed' || normalized === 'closed') {
|
|
1132
|
+
retirePeer(owner, `console-direct-ice-${normalized}`);
|
|
1133
|
+
return;
|
|
1134
|
+
}
|
|
1135
|
+
refreshPeerDisconnectDeadline(owner);
|
|
1136
|
+
});
|
|
1137
|
+
owner.iceTimer = setTimeout(() => {
|
|
1138
|
+
owner.iceTimer = null;
|
|
1139
|
+
if (isOwnerActive(owner)
|
|
1140
|
+
&& (!owner.control || !channelIsOpen(owner.control.channel))) {
|
|
1141
|
+
retirePeer(owner, 'console-direct-ice-timeout');
|
|
1142
|
+
}
|
|
1143
|
+
}, iceConnectTimeoutMs);
|
|
1144
|
+
owner.iceTimer.unref?.();
|
|
1145
|
+
try {
|
|
1146
|
+
peer.setRemoteDescription(description.sdp, 'offer');
|
|
1147
|
+
} catch (error) {
|
|
1148
|
+
lastError = error instanceof Error ? error.message : String(error);
|
|
1149
|
+
retirePeer(owner, 'console-direct-description-failed');
|
|
1150
|
+
return null;
|
|
1151
|
+
}
|
|
1152
|
+
return owner;
|
|
1153
|
+
};
|
|
1154
|
+
|
|
1155
|
+
const handleSignalMessage = (raw, ownerSignalGeneration, socket) => {
|
|
1156
|
+
if (typeof raw !== 'string' && !Buffer.isBuffer(raw)) return;
|
|
1157
|
+
const text = Buffer.isBuffer(raw) ? raw.toString('utf8') : raw;
|
|
1158
|
+
if (byteLength(text) > SIGNAL_MESSAGE_MAX_BYTES) return;
|
|
1159
|
+
const payload = parseJson(text);
|
|
1160
|
+
if (!payload?.type) return;
|
|
1161
|
+
if (payload.type === 'signal-ready' && payload.role === 'hub') {
|
|
1162
|
+
const nextEpoch = String(payload.hubEpoch || '');
|
|
1163
|
+
if (!UUID_PATTERN.test(nextEpoch)) {
|
|
1164
|
+
lastError = 'console-direct-hub-epoch-invalid';
|
|
1165
|
+
return;
|
|
1166
|
+
}
|
|
1167
|
+
clearSignalReadyDeadline(ownerSignalGeneration, socket);
|
|
1168
|
+
if (hubEpoch && hubEpoch !== nextEpoch) retireAllPeers('console-direct-hub-epoch-replaced');
|
|
1169
|
+
hubEpoch = nextEpoch;
|
|
1170
|
+
state = 'connected';
|
|
1171
|
+
lastConnectedAt = new Date().toISOString();
|
|
1172
|
+
lastError = '';
|
|
1173
|
+
lastHttpStatus = 0;
|
|
1174
|
+
retryAttempt = 0;
|
|
1175
|
+
retryNotBeforeAt = 0;
|
|
1176
|
+
nextRetryAt = '';
|
|
1177
|
+
return;
|
|
1178
|
+
}
|
|
1179
|
+
const consoleId = String(payload.consoleId || '');
|
|
1180
|
+
const connectionId = String(payload.connectionId || '');
|
|
1181
|
+
const ownerHubEpoch = String(payload.hubEpoch || '');
|
|
1182
|
+
if (!UUID_PATTERN.test(consoleId)
|
|
1183
|
+
|| !UUID_PATTERN.test(connectionId)
|
|
1184
|
+
|| ownerHubEpoch !== hubEpoch) return;
|
|
1185
|
+
if (payload.type === 'rtc-offer') {
|
|
1186
|
+
createPeerOwner(payload);
|
|
1187
|
+
return;
|
|
1188
|
+
}
|
|
1189
|
+
const owner = peers.get(consoleId);
|
|
1190
|
+
if (!owner
|
|
1191
|
+
|| owner.connectionId !== connectionId
|
|
1192
|
+
|| owner.hubEpoch !== ownerHubEpoch
|
|
1193
|
+
|| !isOwnerActive(owner)) return;
|
|
1194
|
+
if (payload.type === 'rtc-ice') {
|
|
1195
|
+
const candidate = String(payload.candidate || '');
|
|
1196
|
+
if (byteLength(candidate) > ICE_CANDIDATE_MAX_BYTES) {
|
|
1197
|
+
retirePeer(owner, 'console-direct-candidate-invalid');
|
|
1198
|
+
return;
|
|
1199
|
+
}
|
|
1200
|
+
if (candidateIsRelay(candidate)) {
|
|
1201
|
+
rejectedRelayCandidates += 1;
|
|
1202
|
+
retirePeer(owner, 'console-direct-relay-candidate-rejected');
|
|
1203
|
+
return;
|
|
1204
|
+
}
|
|
1205
|
+
try {
|
|
1206
|
+
if (candidate) owner.peer.addRemoteCandidate(candidate, String(payload.sdpMid || ''));
|
|
1207
|
+
} catch {
|
|
1208
|
+
retirePeer(owner, 'console-direct-candidate-failed');
|
|
1209
|
+
}
|
|
1210
|
+
return;
|
|
1211
|
+
}
|
|
1212
|
+
if (payload.type === 'rtc-close') {
|
|
1213
|
+
retirePeer(owner, String(payload.reason || 'console-direct-remote-closed'), { notify: false });
|
|
1214
|
+
}
|
|
1215
|
+
};
|
|
1216
|
+
|
|
1217
|
+
const cancelAccessTokenAttempt = () => {
|
|
1218
|
+
accessTokenAttempt?.cancel();
|
|
1219
|
+
};
|
|
1220
|
+
|
|
1221
|
+
const readAccessToken = ownerSignalGeneration => new Promise(resolve => {
|
|
1222
|
+
let settled = false;
|
|
1223
|
+
let attempt = null;
|
|
1224
|
+
const finish = result => {
|
|
1225
|
+
if (settled) return;
|
|
1226
|
+
settled = true;
|
|
1227
|
+
clearTimeout(attempt.timer);
|
|
1228
|
+
if (accessTokenAttempt === attempt) accessTokenAttempt = null;
|
|
1229
|
+
resolve(result);
|
|
1230
|
+
};
|
|
1231
|
+
const timer = setTimeout(() => {
|
|
1232
|
+
finish({ kind: 'timeout' });
|
|
1233
|
+
}, accessTokenTimeoutMs);
|
|
1234
|
+
timer.unref?.();
|
|
1235
|
+
attempt = {
|
|
1236
|
+
generation: ownerSignalGeneration,
|
|
1237
|
+
timer,
|
|
1238
|
+
cancel: () => finish({ kind: 'cancelled' })
|
|
1239
|
+
};
|
|
1240
|
+
accessTokenAttempt = attempt;
|
|
1241
|
+
void Promise.resolve()
|
|
1242
|
+
.then(() => getAccessToken())
|
|
1243
|
+
.then(
|
|
1244
|
+
value => finish({ kind: 'value', value }),
|
|
1245
|
+
error => finish({ kind: 'error', error })
|
|
1246
|
+
);
|
|
1247
|
+
});
|
|
1248
|
+
|
|
1249
|
+
const clearSignalReadyDeadline = (ownerSignalGeneration, socket) => {
|
|
1250
|
+
const deadline = signalReadyDeadline;
|
|
1251
|
+
if (!deadline
|
|
1252
|
+
|| (ownerSignalGeneration !== undefined && deadline.generation !== ownerSignalGeneration)
|
|
1253
|
+
|| (socket !== undefined && deadline.socket !== socket)) {
|
|
1254
|
+
return false;
|
|
1255
|
+
}
|
|
1256
|
+
signalReadyDeadline = null;
|
|
1257
|
+
clearTimeout(deadline.timer);
|
|
1258
|
+
return true;
|
|
1259
|
+
};
|
|
1260
|
+
|
|
1261
|
+
const armSignalReadyDeadline = (ownerSignalGeneration, socket, connect) => {
|
|
1262
|
+
clearSignalReadyDeadline();
|
|
1263
|
+
const deadline = {
|
|
1264
|
+
generation: ownerSignalGeneration,
|
|
1265
|
+
socket,
|
|
1266
|
+
timer: null
|
|
1267
|
+
};
|
|
1268
|
+
deadline.timer = setTimeout(() => {
|
|
1269
|
+
if (signalReadyDeadline !== deadline) return;
|
|
1270
|
+
signalReadyDeadline = null;
|
|
1271
|
+
if (stopped
|
|
1272
|
+
|| ownerSignalGeneration !== signalGeneration
|
|
1273
|
+
|| signalSocket !== socket) {
|
|
1274
|
+
return;
|
|
1275
|
+
}
|
|
1276
|
+
signalSocket = null;
|
|
1277
|
+
lastHttpStatus = 0;
|
|
1278
|
+
lastError = 'console-direct-signal-ready-timeout';
|
|
1279
|
+
retireNegotiatingPeers('console-direct-signal-ready-timeout');
|
|
1280
|
+
try { socket.terminate?.(); } catch {}
|
|
1281
|
+
scheduleReconnect(connect);
|
|
1282
|
+
}, signalReadyTimeoutMs);
|
|
1283
|
+
deadline.timer.unref?.();
|
|
1284
|
+
signalReadyDeadline = deadline;
|
|
1285
|
+
};
|
|
1286
|
+
|
|
1287
|
+
const scheduleReconnect = connect => {
|
|
1288
|
+
if (stopped || retryTimer || !(signalUrl instanceof URL)) return;
|
|
1289
|
+
const sequenceDelay = retryDelaysMs[Math.min(retryAttempt, retryDelaysMs.length - 1)];
|
|
1290
|
+
const delayMs = Math.max(sequenceDelay, retryNotBeforeAt - Date.now(), 0);
|
|
1291
|
+
retryAttempt += 1;
|
|
1292
|
+
state = 'waiting-retry';
|
|
1293
|
+
nextRetryAt = new Date(Date.now() + delayMs).toISOString();
|
|
1294
|
+
retryTimer = setTimeout(() => {
|
|
1295
|
+
retryTimer = null;
|
|
1296
|
+
nextRetryAt = '';
|
|
1297
|
+
void connect();
|
|
1298
|
+
}, delayMs);
|
|
1299
|
+
retryTimer.unref?.();
|
|
1300
|
+
};
|
|
1301
|
+
|
|
1302
|
+
const connect = async () => {
|
|
1303
|
+
if (stopped || !(signalUrl instanceof URL) || !deviceId) return;
|
|
1304
|
+
const ownerSignalGeneration = ++signalGeneration;
|
|
1305
|
+
let ownerHttpStatus = 0;
|
|
1306
|
+
state = 'connecting';
|
|
1307
|
+
nextRetryAt = '';
|
|
1308
|
+
let accessToken = '';
|
|
1309
|
+
const accessTokenResult = await readAccessToken(ownerSignalGeneration);
|
|
1310
|
+
if (stopped || ownerSignalGeneration !== signalGeneration) return;
|
|
1311
|
+
if (accessTokenResult.kind === 'cancelled') return;
|
|
1312
|
+
if (accessTokenResult.kind === 'timeout') {
|
|
1313
|
+
lastError = 'console-direct-access-token-timeout';
|
|
1314
|
+
scheduleReconnect(connect);
|
|
1315
|
+
return;
|
|
1316
|
+
}
|
|
1317
|
+
if (accessTokenResult.kind === 'error') {
|
|
1318
|
+
lastError = accessTokenResult.error instanceof Error
|
|
1319
|
+
? accessTokenResult.error.message
|
|
1320
|
+
: String(accessTokenResult.error);
|
|
1321
|
+
} else {
|
|
1322
|
+
accessToken = String(accessTokenResult.value || '').trim();
|
|
1323
|
+
}
|
|
1324
|
+
if (!accessToken) {
|
|
1325
|
+
lastError = 'hub-session-required';
|
|
1326
|
+
scheduleReconnect(connect);
|
|
1327
|
+
return;
|
|
1328
|
+
}
|
|
1329
|
+
const url = new URL(signalUrl);
|
|
1330
|
+
url.searchParams.set('deviceId', deviceId);
|
|
1331
|
+
url.searchParams.set('hubInstanceId', hubInstanceId);
|
|
1332
|
+
let socket;
|
|
1333
|
+
try {
|
|
1334
|
+
socket = new SignalingWebSocketImpl(url, {
|
|
1335
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
1336
|
+
perMessageDeflate: false
|
|
1337
|
+
});
|
|
1338
|
+
} catch (error) {
|
|
1339
|
+
lastError = error instanceof Error ? error.message : String(error);
|
|
1340
|
+
scheduleReconnect(connect);
|
|
1341
|
+
return;
|
|
1342
|
+
}
|
|
1343
|
+
if (stopped || ownerSignalGeneration !== signalGeneration) {
|
|
1344
|
+
try { socket.terminate?.(); } catch {}
|
|
1345
|
+
return;
|
|
1346
|
+
}
|
|
1347
|
+
signalSocket = socket;
|
|
1348
|
+
armSignalReadyDeadline(ownerSignalGeneration, socket, connect);
|
|
1349
|
+
socket.once('open', () => {
|
|
1350
|
+
if (signalSocket !== socket || ownerSignalGeneration !== signalGeneration || stopped) return;
|
|
1351
|
+
state = 'authenticating';
|
|
1352
|
+
});
|
|
1353
|
+
socket.once('unexpected-response', (_request, response) => {
|
|
1354
|
+
if (signalSocket !== socket || ownerSignalGeneration !== signalGeneration || stopped) {
|
|
1355
|
+
response.resume?.();
|
|
1356
|
+
return;
|
|
1357
|
+
}
|
|
1358
|
+
const status = Math.max(0, Number(response?.statusCode || 0));
|
|
1359
|
+
ownerHttpStatus = status;
|
|
1360
|
+
lastHttpStatus = status;
|
|
1361
|
+
lastError = status > 0
|
|
1362
|
+
? `Console direct signaling returned HTTP ${status}`
|
|
1363
|
+
: 'Console direct signaling returned an unexpected response.';
|
|
1364
|
+
if (status === 429) retryNotBeforeAt = Math.max(retryNotBeforeAt, Date.now() + SIGNAL_HTTP_429_RETRY_MS);
|
|
1365
|
+
if (signalSocket === socket) signalSocket = null;
|
|
1366
|
+
clearSignalReadyDeadline(ownerSignalGeneration, socket);
|
|
1367
|
+
retireNegotiatingPeers('console-direct-signaling-rejected');
|
|
1368
|
+
response.resume?.();
|
|
1369
|
+
try { socket.terminate?.(); } catch {}
|
|
1370
|
+
scheduleReconnect(connect);
|
|
1371
|
+
});
|
|
1372
|
+
socket.on('message', raw => {
|
|
1373
|
+
if (signalSocket === socket && ownerSignalGeneration === signalGeneration && !stopped) {
|
|
1374
|
+
handleSignalMessage(raw, ownerSignalGeneration, socket);
|
|
1375
|
+
}
|
|
1376
|
+
});
|
|
1377
|
+
socket.once('close', () => {
|
|
1378
|
+
const wasOwner = signalSocket === socket
|
|
1379
|
+
&& ownerSignalGeneration === signalGeneration
|
|
1380
|
+
&& !stopped;
|
|
1381
|
+
if (signalSocket === socket) signalSocket = null;
|
|
1382
|
+
clearSignalReadyDeadline(ownerSignalGeneration, socket);
|
|
1383
|
+
if (!wasOwner) return;
|
|
1384
|
+
retireNegotiatingPeers('console-direct-signaling-closed');
|
|
1385
|
+
if (!retryTimer) state = 'disconnected';
|
|
1386
|
+
scheduleReconnect(connect);
|
|
1387
|
+
});
|
|
1388
|
+
socket.once('error', error => {
|
|
1389
|
+
if (signalSocket !== socket || ownerSignalGeneration !== signalGeneration || stopped) return;
|
|
1390
|
+
if (ownerHttpStatus <= 0) {
|
|
1391
|
+
lastHttpStatus = 0;
|
|
1392
|
+
lastError = error instanceof Error ? error.message : String(error);
|
|
1393
|
+
}
|
|
1394
|
+
signalSocket = null;
|
|
1395
|
+
clearSignalReadyDeadline(ownerSignalGeneration, socket);
|
|
1396
|
+
retireNegotiatingPeers('console-direct-signaling-error');
|
|
1397
|
+
try { socket.terminate?.(); } catch {}
|
|
1398
|
+
scheduleReconnect(connect);
|
|
1399
|
+
});
|
|
1400
|
+
};
|
|
1401
|
+
|
|
1402
|
+
const start = () => {
|
|
1403
|
+
if (!stopped || !(signalUrl instanceof URL) || !deviceId) return;
|
|
1404
|
+
stopped = false;
|
|
1405
|
+
void connect();
|
|
1406
|
+
};
|
|
1407
|
+
|
|
1408
|
+
const refresh = () => {
|
|
1409
|
+
if (stopped) {
|
|
1410
|
+
start();
|
|
1411
|
+
return;
|
|
1412
|
+
}
|
|
1413
|
+
signalGeneration += 1;
|
|
1414
|
+
cancelAccessTokenAttempt();
|
|
1415
|
+
clearSignalReadyDeadline();
|
|
1416
|
+
if (retryTimer) clearTimeout(retryTimer);
|
|
1417
|
+
retryTimer = null;
|
|
1418
|
+
retryAttempt = 0;
|
|
1419
|
+
retryNotBeforeAt = 0;
|
|
1420
|
+
nextRetryAt = '';
|
|
1421
|
+
retireNegotiatingPeers('console-direct-refresh');
|
|
1422
|
+
const socket = signalSocket;
|
|
1423
|
+
signalSocket = null;
|
|
1424
|
+
try { socket?.terminate?.(); } catch {}
|
|
1425
|
+
void connect();
|
|
1426
|
+
};
|
|
1427
|
+
|
|
1428
|
+
const close = () => {
|
|
1429
|
+
if (stopped) return;
|
|
1430
|
+
stopped = true;
|
|
1431
|
+
signalGeneration += 1;
|
|
1432
|
+
cancelAccessTokenAttempt();
|
|
1433
|
+
clearSignalReadyDeadline();
|
|
1434
|
+
if (retryTimer) clearTimeout(retryTimer);
|
|
1435
|
+
retryTimer = null;
|
|
1436
|
+
nextRetryAt = '';
|
|
1437
|
+
retireAllPeers('hub-shutdown');
|
|
1438
|
+
hubEpoch = '';
|
|
1439
|
+
const socket = signalSocket;
|
|
1440
|
+
signalSocket = null;
|
|
1441
|
+
try { socket?.close(1001, 'hub-shutdown'); } catch {}
|
|
1442
|
+
try { socket?.terminate?.(); } catch {}
|
|
1443
|
+
state = signalUrl ? 'closed' : 'disabled';
|
|
1444
|
+
};
|
|
1445
|
+
|
|
1446
|
+
const inspect = () => {
|
|
1447
|
+
let logicalWebSocketChannels = 0;
|
|
1448
|
+
let localWebSocketChannels = 0;
|
|
1449
|
+
let pendingHttpRequests = 0;
|
|
1450
|
+
let pendingLogicalControlRequests = 0;
|
|
1451
|
+
let controlChannels = 0;
|
|
1452
|
+
let retainedAssemblyBytes = 0;
|
|
1453
|
+
let bufferedSendBytes = 0;
|
|
1454
|
+
let iceConnectTimers = 0;
|
|
1455
|
+
let peerDisconnectTimers = 0;
|
|
1456
|
+
let assemblyDeadlineTimers = 0;
|
|
1457
|
+
for (const owner of peers.values()) {
|
|
1458
|
+
logicalWebSocketChannels += owner.channels.size;
|
|
1459
|
+
pendingHttpRequests += owner.pendingHttp.size;
|
|
1460
|
+
pendingLogicalControlRequests += owner.pendingLogicalControl.size;
|
|
1461
|
+
if (owner.iceTimer) iceConnectTimers += 1;
|
|
1462
|
+
if (owner.disconnectTimer) peerDisconnectTimers += 1;
|
|
1463
|
+
if (owner.control && !owner.control.closed) controlChannels += 1;
|
|
1464
|
+
if (owner.control?.assemblyTimer) assemblyDeadlineTimers += 1;
|
|
1465
|
+
for (const channelState of owner.channels.values()) {
|
|
1466
|
+
if (channelState.localSocket) localWebSocketChannels += 1;
|
|
1467
|
+
if (channelState.assemblyTimer) assemblyDeadlineTimers += 1;
|
|
1468
|
+
}
|
|
1469
|
+
retainedAssemblyBytes += Number(owner.wireBudget.inspect().retainedBytes || 0);
|
|
1470
|
+
bufferedSendBytes += ownerBufferedAmount(owner);
|
|
1471
|
+
}
|
|
1472
|
+
const reconnectTimerActive = Boolean(retryTimer);
|
|
1473
|
+
const accessTokenDeadlineActive = Boolean(accessTokenAttempt);
|
|
1474
|
+
const signalReadyDeadlineActive = Boolean(signalReadyDeadline);
|
|
1475
|
+
return Object.freeze({
|
|
1476
|
+
enabled: signalUrl instanceof URL && Boolean(deviceId),
|
|
1477
|
+
state,
|
|
1478
|
+
connected: state === 'connected',
|
|
1479
|
+
hubEpoch,
|
|
1480
|
+
peerConnections: peers.size,
|
|
1481
|
+
controlChannels,
|
|
1482
|
+
logicalWebSocketChannels,
|
|
1483
|
+
localWebSocketChannels,
|
|
1484
|
+
pendingHttpRequests,
|
|
1485
|
+
pendingLogicalControlRequests,
|
|
1486
|
+
retainedAssemblyBytes,
|
|
1487
|
+
bufferedSendBytes,
|
|
1488
|
+
iceConnectTimers,
|
|
1489
|
+
peerDisconnectTimers,
|
|
1490
|
+
assemblyDeadlineTimers,
|
|
1491
|
+
reconnectTimerActive,
|
|
1492
|
+
accessTokenDeadlineActive,
|
|
1493
|
+
signalReadyDeadlineActive,
|
|
1494
|
+
resourceTimers: iceConnectTimers
|
|
1495
|
+
+ peerDisconnectTimers
|
|
1496
|
+
+ assemblyDeadlineTimers
|
|
1497
|
+
+ pendingHttpRequests
|
|
1498
|
+
+ pendingLogicalControlRequests
|
|
1499
|
+
+ Number(reconnectTimerActive)
|
|
1500
|
+
+ Number(accessTokenDeadlineActive)
|
|
1501
|
+
+ Number(signalReadyDeadlineActive),
|
|
1502
|
+
rejectedRelayCandidates,
|
|
1503
|
+
dataChannelBackpressureCloses,
|
|
1504
|
+
malformedWireCloses,
|
|
1505
|
+
wireBudgetCleanupFailures,
|
|
1506
|
+
lastConnectedAt,
|
|
1507
|
+
lastError,
|
|
1508
|
+
lastHttpStatus,
|
|
1509
|
+
retryAttempt,
|
|
1510
|
+
nextRetryAt
|
|
1511
|
+
});
|
|
1512
|
+
};
|
|
1513
|
+
|
|
1514
|
+
return Object.freeze({ start, refresh, close, inspect });
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
export const consoleDirectContract = Object.freeze({
|
|
1518
|
+
controlLabel: CONTROL_LABEL,
|
|
1519
|
+
logicalWebSocketLabelPrefix: LOGICAL_WS_LABEL_PREFIX,
|
|
1520
|
+
maxPeers: MAX_PEERS,
|
|
1521
|
+
maxLogicalChannelsPerPeer: MAX_LOGICAL_CHANNELS_PER_PEER,
|
|
1522
|
+
maxPendingHttpPerPeer: MAX_PENDING_HTTP_PER_PEER,
|
|
1523
|
+
maxHttpRequestBytes: MAX_HTTP_REQUEST_BYTES,
|
|
1524
|
+
maxHttpResponseBytes: MAX_HTTP_RESPONSE_BYTES,
|
|
1525
|
+
maxSharedAssemblyBytes: MAX_SHARED_ASSEMBLY_BYTES,
|
|
1526
|
+
maxFrameMessageBytes: MAX_FRAME_MESSAGE_BYTES,
|
|
1527
|
+
maxPendingMediaWireMessages: MAX_PENDING_MEDIA_WIRE_MESSAGES,
|
|
1528
|
+
maxBufferedBytes: MAX_DATA_CHANNEL_BUFFERED_BYTES,
|
|
1529
|
+
maxLocalWebSocketBufferedBytes: MAX_LOCAL_WS_BUFFERED_BYTES,
|
|
1530
|
+
maxDefaultRetryDelayMs: DEFAULT_RETRY_DELAYS_MS.at(-1),
|
|
1531
|
+
accessTokenTimeoutMs: ACCESS_TOKEN_TIMEOUT_MS,
|
|
1532
|
+
signalReadyTimeoutMs: SIGNAL_READY_TIMEOUT_MS,
|
|
1533
|
+
logicalBindTimeoutMs: LOGICAL_BIND_TIMEOUT_MS,
|
|
1534
|
+
iceConnectTimeoutMs: ICE_CONNECT_TIMEOUT_MS,
|
|
1535
|
+
peerDisconnectedTimeoutMs: PEER_DISCONNECTED_TIMEOUT_MS,
|
|
1536
|
+
reliableAssemblyTimeoutMs: CONTROL_ASSEMBLY_TIMEOUT_MS,
|
|
1537
|
+
partialMediaAssemblyTimeoutMs: MEDIA_ASSEMBLY_TIMEOUT_MS
|
|
1538
|
+
});
|