@rynx-ai/server 0.1.0 → 0.1.10-beta.2

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.
Files changed (65) hide show
  1. package/dist/browser-surface-control-queue.d.ts +42 -0
  2. package/dist/browser-surface-control-queue.js +187 -0
  3. package/dist/browser-surface-ws.d.ts +16 -0
  4. package/dist/browser-surface-ws.js +356 -0
  5. package/dist/control-api.d.ts +76 -4
  6. package/dist/control-api.js +3014 -133
  7. package/dist/control-web/assets/highlighted-body-OFNGDK62-DHu2g-rk.js +1 -0
  8. package/dist/control-web/assets/index-B6Pb5j9M.js +666 -0
  9. package/dist/control-web/assets/index-BU-_Qud_.css +32 -0
  10. package/dist/control-web/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  11. package/dist/control-web/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  12. package/dist/control-web/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  13. package/dist/control-web/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  14. package/dist/control-web/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  15. package/dist/control-web/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  16. package/dist/control-web/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  17. package/dist/control-web/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  18. package/dist/control-web/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  19. package/dist/control-web/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  20. package/dist/control-web/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  21. package/dist/control-web/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  22. package/dist/control-web/assets/mermaid-GHXKKRXX-CSYzOmBR.js +131 -0
  23. package/dist/control-web/assets/rynx-wordmark-LL1QRYHD.png +0 -0
  24. package/dist/control-web/favicon.png +0 -0
  25. package/dist/control-web/favicon.svg +38 -0
  26. package/dist/control-web/index.html +16 -0
  27. package/dist/control-web-dist.d.ts +1 -1
  28. package/dist/control-web-dist.js +19 -14
  29. package/dist/desktop-browser-host.d.ts +170 -0
  30. package/dist/desktop-browser-host.js +1018 -0
  31. package/dist/direct-runtime-browser-inspect-server.d.ts +72 -0
  32. package/dist/direct-runtime-browser-inspect-server.js +749 -0
  33. package/dist/direct-runtime-browser-surface-server.d.ts +74 -0
  34. package/dist/direct-runtime-browser-surface-server.js +821 -0
  35. package/dist/direct-runtime-server.d.ts +143 -0
  36. package/dist/direct-runtime-server.js +1959 -0
  37. package/dist/emulator-surface-ws.d.ts +8 -0
  38. package/dist/emulator-surface-ws.js +198 -0
  39. package/dist/machine-session-service.d.ts +265 -0
  40. package/dist/machine-session-service.js +822 -0
  41. package/dist/provider-cli-host.d.ts +10 -0
  42. package/dist/provider-cli-host.js +1 -0
  43. package/dist/remote-runtime-dispatcher.d.ts +94 -0
  44. package/dist/remote-runtime-dispatcher.js +403 -0
  45. package/dist/remote-runtime-session-projection.d.ts +19 -0
  46. package/dist/remote-runtime-session-projection.js +566 -0
  47. package/dist/remote-runtime.d.ts +22 -0
  48. package/dist/remote-runtime.js +32 -0
  49. package/dist/runtime-web-auth.d.ts +26 -0
  50. package/dist/runtime-web-auth.js +132 -0
  51. package/dist/server.d.ts +364 -12
  52. package/dist/server.js +341 -100
  53. package/dist/session-browser-service.d.ts +248 -0
  54. package/dist/session-browser-service.js +772 -0
  55. package/dist/session-browser-surface-coordinator.d.ts +24 -0
  56. package/dist/session-browser-surface-coordinator.js +669 -0
  57. package/dist/session-emulator-service.d.ts +167 -0
  58. package/dist/session-emulator-service.js +464 -0
  59. package/dist/session-runtime-index.d.ts +23 -0
  60. package/dist/session-runtime-index.js +96 -0
  61. package/dist/session-terminal-host.d.ts +51 -0
  62. package/dist/session-terminal-host.js +270 -0
  63. package/dist/terminal-ws.d.ts +12 -20
  64. package/dist/terminal-ws.js +421 -97
  65. package/package.json +14 -7
@@ -0,0 +1,749 @@
1
+ import { decodeDirectRuntimeFrame, DIRECT_RUNTIME_BROWSER_INSPECT_BINDING_PROTOCOL, DIRECT_RUNTIME_BROWSER_INSPECT_PATH, DIRECT_RUNTIME_CLOSE_CODE, DIRECT_RUNTIME_MAX_HANDSHAKE_FRAME_BYTES, encodeDirectRuntimeFrame, parseServerAccepted, parseServerHello, } from "@rynx-ai/protocol/direct-runtime";
2
+ import { encodeRuntimeBrowserInspectServerFrame, parseRuntimeBrowserInspectOpenFrame, RUNTIME_BROWSER_INSPECT_HEARTBEAT_INTERVAL_MS, RUNTIME_BROWSER_INSPECT_MAX_CONTROL_FRAME_BYTES, RUNTIME_BROWSER_INSPECT_MAX_MESSAGE_BYTES, RUNTIME_BROWSER_INSPECT_PEER_LIVENESS_TIMEOUT_MS, } from "@rynx-ai/protocol/runtime-browser-inspect";
3
+ import { createServerE2EEChannel, DIRECT_RUNTIME_E2EE_MAX_ENVELOPE_BYTES, } from "@rynx-ai/remote-runtime-e2ee";
4
+ import { WebSocket, WebSocketServer } from "ws";
5
+ const CLOSE_BAD_PROTOCOL = DIRECT_RUNTIME_CLOSE_CODE.badProtocol;
6
+ const CLOSE_UNAUTHORIZED = DIRECT_RUNTIME_CLOSE_CODE.unauthorized;
7
+ const CLOSE_AUTH_TIMEOUT = DIRECT_RUNTIME_CLOSE_CODE.authenticationTimeout;
8
+ const CLOSE_CAPACITY = DIRECT_RUNTIME_CLOSE_CODE.capacity;
9
+ const CLOSE_INTERNAL = DIRECT_RUNTIME_CLOSE_CODE.internal;
10
+ const DEFAULT_MAX_CONNECTIONS = 64;
11
+ const DEFAULT_MAX_UNAUTHENTICATED_CONNECTIONS_PER_SOURCE = 4;
12
+ const DEFAULT_MAX_AUTHENTICATION_ATTEMPTS_PER_SOURCE = 20;
13
+ const DEFAULT_AUTHENTICATION_ATTEMPT_WINDOW_MS = 60_000;
14
+ const DEFAULT_MAX_TRACKED_AUTHENTICATION_SOURCES = 1_024;
15
+ const DEFAULT_MAX_CONNECTIONS_PER_GRANT = 8;
16
+ const DEFAULT_MAX_QUEUED_BYTES = 32 * 1024 * 1024;
17
+ const DEFAULT_MAX_BUFFERED_BYTES = 32 * 1024 * 1024;
18
+ const DEFAULT_SHUTDOWN_DEADLINE_MS = 5_000;
19
+ const CONNECTION_CLOSE_DEADLINE_MS = 1_000;
20
+ export class DirectRuntimeBrowserInspectHostError extends Error {
21
+ reason;
22
+ constructor(reason, message, options) {
23
+ super(message, options);
24
+ this.reason = reason;
25
+ this.name = "DirectRuntimeBrowserInspectHostError";
26
+ }
27
+ }
28
+ /** Attach only the V5 dedicated Browser Inspect route to a Direct listener. */
29
+ export function attachDirectRuntimeBrowserInspectServer(server, options) {
30
+ const limits = resolveLimits(options.limits);
31
+ const wss = new WebSocketServer({
32
+ noServer: true,
33
+ clientTracking: true,
34
+ perMessageDeflate: false,
35
+ maxPayload: Math.max(DIRECT_RUNTIME_E2EE_MAX_ENVELOPE_BYTES, RUNTIME_BROWSER_INSPECT_MAX_MESSAGE_BYTES),
36
+ });
37
+ const connections = new Set();
38
+ const pendingUpgradesBySource = new Map();
39
+ const unauthenticatedConnectionsBySource = new Map();
40
+ const authenticationAttempts = new SourceAuthenticationAttemptLimiter({
41
+ maxAttempts: limits.maxAuthenticationAttemptsPerSource,
42
+ windowMs: limits.authenticationAttemptWindowMs,
43
+ maxSources: limits.maxTrackedAuthenticationSources,
44
+ });
45
+ let pendingUpgrades = 0;
46
+ let stopping = false;
47
+ let heartbeatTimer;
48
+ let shutdownPromise;
49
+ const handleUpgrade = (request, socket, head) => {
50
+ if (request.url !== DIRECT_RUNTIME_BROWSER_INSPECT_PATH)
51
+ return;
52
+ const sourceAddress = socketSourceAddress(request);
53
+ if (stopping) {
54
+ rejectUpgrade(socket, 503, "Browser Inspect is stopping");
55
+ return;
56
+ }
57
+ if (connections.size + pendingUpgrades >= limits.maxConnections) {
58
+ rejectUpgrade(socket, 503, "Browser Inspect connection capacity reached");
59
+ return;
60
+ }
61
+ if ((unauthenticatedConnectionsBySource.get(sourceAddress) ?? 0) +
62
+ (pendingUpgradesBySource.get(sourceAddress) ?? 0) >=
63
+ limits.maxUnauthenticatedConnectionsPerSource) {
64
+ rejectUpgrade(socket, 503, "Browser Inspect authentication capacity reached");
65
+ return;
66
+ }
67
+ pendingUpgrades += 1;
68
+ incrementCount(pendingUpgradesBySource, sourceAddress);
69
+ let pending = true;
70
+ const releasePending = () => {
71
+ if (!pending)
72
+ return;
73
+ pending = false;
74
+ pendingUpgrades -= 1;
75
+ decrementCount(pendingUpgradesBySource, sourceAddress);
76
+ };
77
+ try {
78
+ wss.handleUpgrade(request, socket, head, (ws) => {
79
+ releasePending();
80
+ connections.add(acceptConnection(ws, sourceAddress));
81
+ });
82
+ }
83
+ catch (error) {
84
+ releasePending();
85
+ options.onError?.(error, "Browser Inspect websocket upgrade");
86
+ rejectUpgrade(socket, 400, "invalid Browser Inspect WebSocket upgrade");
87
+ }
88
+ };
89
+ server.on("upgrade", handleUpgrade);
90
+ function acceptConnection(ws, sourceAddress) {
91
+ const abortController = new AbortController();
92
+ let connection;
93
+ const authenticationTimer = setTimeout(() => {
94
+ abortController.abort(new Error("Browser Inspect authentication timed out"));
95
+ connection.close(CLOSE_AUTH_TIMEOUT, "Browser Inspect authentication timed out");
96
+ }, limits.authenticationTimeoutMs);
97
+ authenticationTimer.unref?.();
98
+ connection = {
99
+ ws,
100
+ sourceAddress,
101
+ countedAsUnauthenticated: true,
102
+ state: "awaiting_e2ee_hello",
103
+ attachmentClosed: false,
104
+ abortController,
105
+ authenticationTimer,
106
+ receiveTail: Promise.resolve(),
107
+ queuedBytes: 0,
108
+ authenticationFrameQueued: false,
109
+ lastInboundActivityAt: Date.now(),
110
+ close(code, reason) {
111
+ closeConnection(connection, code, reason);
112
+ },
113
+ };
114
+ incrementCount(unauthenticatedConnectionsBySource, sourceAddress);
115
+ ws.on("message", (data, isBinary) => {
116
+ connection.lastInboundActivityAt = Date.now();
117
+ if (isClosed(connection))
118
+ return;
119
+ // Stop reading more transport data while this ordered E2EE record is
120
+ // being opened and forwarded. Frames already decoded from the current
121
+ // TCP chunk may still arrive below and remain bounded by queuedBytes.
122
+ ws.pause();
123
+ if (connection.state !== "active" &&
124
+ connection.authenticationFrameQueued) {
125
+ connection.close(CLOSE_BAD_PROTOCOL, "Browser Inspect traffic must wait for the opening barrier");
126
+ return;
127
+ }
128
+ if (connection.state !== "active") {
129
+ connection.authenticationFrameQueued = true;
130
+ }
131
+ const wireBytes = rawDataBytes(data);
132
+ connection.queuedBytes += wireBytes.byteLength;
133
+ if (connection.queuedBytes > limits.maxQueuedBytes) {
134
+ connection.queuedBytes -= wireBytes.byteLength;
135
+ connection.close(CLOSE_CAPACITY, "Browser Inspect input queue is full");
136
+ return;
137
+ }
138
+ connection.receiveTail = connection.receiveTail
139
+ .then(() => handleFrame(connection, wireBytes, isBinary))
140
+ .catch((error) => {
141
+ if (isClosed(connection))
142
+ return;
143
+ options.onError?.(error, "Browser Inspect frame");
144
+ connection.close(CLOSE_INTERNAL, "Browser Inspect frame handling failed");
145
+ })
146
+ .finally(() => {
147
+ connection.queuedBytes -= wireBytes.byteLength;
148
+ if (connection.queuedBytes === 0 &&
149
+ !isClosed(connection) &&
150
+ connection.ws.readyState === WebSocket.OPEN) {
151
+ connection.ws.resume();
152
+ }
153
+ });
154
+ });
155
+ ws.on("ping", () => {
156
+ connection.lastInboundActivityAt = Date.now();
157
+ });
158
+ ws.on("pong", () => {
159
+ connection.lastInboundActivityAt = Date.now();
160
+ });
161
+ ws.on("close", () => void cleanupConnection(connection, "client_closed"));
162
+ ws.on("error", (error) => {
163
+ options.onError?.(error, "Browser Inspect socket");
164
+ void cleanupConnection(connection, "transport_error");
165
+ });
166
+ return connection;
167
+ }
168
+ async function handleFrame(connection, wireBytes, isBinary) {
169
+ if (isClosed(connection))
170
+ return;
171
+ if (connection.state === "awaiting_e2ee_hello") {
172
+ if (isBinary) {
173
+ connection.close(CLOSE_BAD_PROTOCOL, "Browser Inspect E2EE hello must be text");
174
+ return;
175
+ }
176
+ await establishE2EE(connection, wireBytes);
177
+ return;
178
+ }
179
+ if (connection.state === "awaiting_authentication") {
180
+ if (isBinary) {
181
+ connection.close(CLOSE_BAD_PROTOCOL, "Browser Inspect authentication must be text");
182
+ return;
183
+ }
184
+ await authenticateConnection(connection, wireBytes);
185
+ return;
186
+ }
187
+ if (connection.state === "authenticating" ||
188
+ connection.state === "opening") {
189
+ connection.close(CLOSE_BAD_PROTOCOL, "Browser Inspect opening is already in progress");
190
+ return;
191
+ }
192
+ if (connection.state === "awaiting_open") {
193
+ if (isBinary) {
194
+ connection.close(CLOSE_BAD_PROTOCOL, "Browser Inspect open frame must be text");
195
+ return;
196
+ }
197
+ await handleOpen(connection, {
198
+ binary: false,
199
+ data: decodeText(wireBytes),
200
+ });
201
+ return;
202
+ }
203
+ if (connection.state !== "active" ||
204
+ !connection.peer ||
205
+ !connection.attachment) {
206
+ connection.close(CLOSE_BAD_PROTOCOL, "Browser Inspect connection is not active");
207
+ return;
208
+ }
209
+ // The authenticated open barrier checks the persisted grant immediately
210
+ // before attaching. Active revocation is fenced synchronously by the
211
+ // shared connection hub, so raw CDP traffic must not hit the grant store
212
+ // (and SQLite) for every message.
213
+ try {
214
+ await connection.attachment.send(isBinary
215
+ ? { binary: true, data: new Uint8Array(wireBytes) }
216
+ : { binary: false, data: decodeText(wireBytes) });
217
+ }
218
+ catch (error) {
219
+ options.onError?.(error, "Browser Inspect local page input");
220
+ connection.close(CLOSE_INTERNAL, "Browser Inspect local page send failed");
221
+ }
222
+ }
223
+ async function establishE2EE(connection, bytes) {
224
+ if (bytes.byteLength > DIRECT_RUNTIME_MAX_HANDSHAKE_FRAME_BYTES) {
225
+ connection.close(CLOSE_BAD_PROTOCOL, "Browser Inspect E2EE hello is too large");
226
+ return;
227
+ }
228
+ try {
229
+ const frame = decodeDirectRuntimeFrame(bytes);
230
+ if (frame.type !== "e2ee.client-hello") {
231
+ throw new Error("first Browser Inspect frame must be e2ee.client-hello");
232
+ }
233
+ const input = {
234
+ sharedSecret: options.e2ee.deriveSharedSecret(frame.clientEphemeralPublicKey),
235
+ serverEncryptionPublicKey: options.e2ee.serverPublicKey,
236
+ clientHello: frame,
237
+ };
238
+ connection.authenticationChannel = createServerE2EEChannel(input);
239
+ await sendWire(connection, encodeDirectRuntimeFrame({ type: "e2ee.ready" }), false);
240
+ await initializeServerHello(connection);
241
+ }
242
+ catch {
243
+ connection.close(CLOSE_BAD_PROTOCOL, "invalid Browser Inspect E2EE client hello");
244
+ }
245
+ }
246
+ async function initializeServerHello(connection) {
247
+ try {
248
+ const channel = connection.authenticationChannel;
249
+ if (!channel) {
250
+ throw new Error("Browser Inspect authentication channel is unavailable");
251
+ }
252
+ const hello = parseServerHello(await options.authenticator.createServerHello({
253
+ channelBinding: channel.channelBinding,
254
+ role: "browser-inspect",
255
+ signal: connection.abortController.signal,
256
+ }));
257
+ if (hello.role !== "browser-inspect" ||
258
+ hello.bindingProtocolVersion !==
259
+ DIRECT_RUNTIME_BROWSER_INSPECT_BINDING_PROTOCOL.currentVersion ||
260
+ hello.minimumCompatibleBindingVersion !==
261
+ DIRECT_RUNTIME_BROWSER_INSPECT_BINDING_PROTOCOL.minimumCompatibleVersion) {
262
+ throw new Error("Browser Inspect authenticator returned an incompatible hello");
263
+ }
264
+ const encoded = encodeDirectRuntimeFrame(hello);
265
+ connection.serverHello = hello;
266
+ connection.serverHelloBytes = Uint8Array.from(Buffer.from(encoded, "utf8"));
267
+ connection.authenticationFrameQueued = false;
268
+ connection.state = "awaiting_authentication";
269
+ await sendAuthenticationText(connection, encoded);
270
+ }
271
+ catch (error) {
272
+ if (isClosed(connection))
273
+ return;
274
+ options.onError?.(error, "Browser Inspect server hello");
275
+ connection.close(CLOSE_INTERNAL, "Browser Inspect authentication setup failed");
276
+ }
277
+ }
278
+ async function authenticateConnection(connection, wireBytes) {
279
+ if (!authenticationAttempts.tryConsume(connection.sourceAddress)) {
280
+ connection.close(CLOSE_CAPACITY, "Browser Inspect authentication limit reached");
281
+ return;
282
+ }
283
+ const channel = connection.authenticationChannel;
284
+ const serverHello = connection.serverHello;
285
+ const serverHelloBytes = connection.serverHelloBytes;
286
+ if (!channel || !serverHello || !serverHelloBytes) {
287
+ connection.close(CLOSE_INTERNAL, "Browser Inspect server hello is unavailable");
288
+ return;
289
+ }
290
+ let plaintext;
291
+ let authenticate;
292
+ try {
293
+ if (wireBytes.byteLength > DIRECT_RUNTIME_E2EE_MAX_ENVELOPE_BYTES) {
294
+ throw new Error("Browser Inspect authentication envelope is too large");
295
+ }
296
+ plaintext = channel.open(decodeText(wireBytes));
297
+ const bytes = Uint8Array.from(Buffer.from(plaintext, "utf8"));
298
+ if (bytes.byteLength > DIRECT_RUNTIME_MAX_HANDSHAKE_FRAME_BYTES) {
299
+ throw new Error("Browser Inspect authentication frame is too large");
300
+ }
301
+ const frame = decodeDirectRuntimeFrame(bytes);
302
+ if (frame.type !== "client.authenticate" ||
303
+ frame.role !== "browser-inspect" ||
304
+ frame.mode !== "connect") {
305
+ throw new Error("Browser Inspect client role or mode is invalid");
306
+ }
307
+ authenticate = frame;
308
+ }
309
+ catch {
310
+ connection.close(CLOSE_BAD_PROTOCOL, "invalid Browser Inspect authentication frame");
311
+ return;
312
+ }
313
+ connection.state = "authenticating";
314
+ try {
315
+ const result = await options.authenticator.authenticate({
316
+ channelBinding: channel.channelBinding,
317
+ role: "browser-inspect",
318
+ serverHello,
319
+ serverHelloBytes: new Uint8Array(serverHelloBytes),
320
+ clientAuthenticate: authenticate,
321
+ clientAuthenticateBytes: Uint8Array.from(Buffer.from(plaintext, "utf8")),
322
+ signal: connection.abortController.signal,
323
+ });
324
+ if (connection.state !== "authenticating")
325
+ return;
326
+ const accepted = parseServerAccepted(result.accepted);
327
+ assertAuthenticationResult(serverHello, authenticate, result.peer, accepted);
328
+ const registration = options.connectionHub.registerWithinLimit(result.peer.grantId, connection, limits.maxConnectionsPerGrant);
329
+ if (registration === "revoked") {
330
+ connection.close(CLOSE_UNAUTHORIZED, "Remote Runtime grant is not active");
331
+ return;
332
+ }
333
+ if (registration === "capacity") {
334
+ connection.close(CLOSE_CAPACITY, "Remote Runtime grant connection capacity reached");
335
+ return;
336
+ }
337
+ await sendAuthenticationText(connection, encodeDirectRuntimeFrame(accepted));
338
+ connection.peer = result.peer;
339
+ connection.accepted = accepted;
340
+ connection.authenticationChannel = undefined;
341
+ connection.state = "awaiting_open";
342
+ connection.authenticationFrameQueued = false;
343
+ releaseUnauthenticated(connection);
344
+ clearTimeout(connection.authenticationTimer);
345
+ }
346
+ catch (error) {
347
+ if (isClosed(connection))
348
+ return;
349
+ options.onError?.(error, "Browser Inspect client authentication");
350
+ connection.close(CLOSE_UNAUTHORIZED, "Browser Inspect authentication failed");
351
+ }
352
+ }
353
+ async function handleOpen(connection, message) {
354
+ if (message.binary) {
355
+ connection.close(CLOSE_BAD_PROTOCOL, "Browser Inspect open frame must be text");
356
+ return;
357
+ }
358
+ if (Buffer.byteLength(message.data, "utf8") >
359
+ RUNTIME_BROWSER_INSPECT_MAX_CONTROL_FRAME_BYTES) {
360
+ connection.close(CLOSE_BAD_PROTOCOL, "Browser Inspect open frame is too large");
361
+ return;
362
+ }
363
+ let target;
364
+ try {
365
+ const open = parseRuntimeBrowserInspectOpenFrame(JSON.parse(message.data));
366
+ target = {
367
+ sessionId: open.sessionId,
368
+ browserGeneration: open.browserGeneration,
369
+ pageId: open.pageId,
370
+ };
371
+ }
372
+ catch {
373
+ connection.close(CLOSE_BAD_PROTOCOL, "invalid Browser Inspect open frame");
374
+ return;
375
+ }
376
+ connection.state = "opening";
377
+ try {
378
+ const peer = connection.peer;
379
+ if (!peer || !(await options.isPeerActive(peer))) {
380
+ throw new DirectRuntimeBrowserInspectHostError("unauthorized", "Remote Runtime grant is not active");
381
+ }
382
+ const attachment = await options.host.open(target, {
383
+ peer,
384
+ signal: connection.abortController.signal,
385
+ });
386
+ if (connection.state !== "opening") {
387
+ await attachment.close("transport_error");
388
+ return;
389
+ }
390
+ connection.attachment = attachment;
391
+ await sendInspect(connection, {
392
+ binary: false,
393
+ data: encodeRuntimeBrowserInspectServerFrame({
394
+ type: "browser.inspect.ready",
395
+ }),
396
+ });
397
+ connection.state = "active";
398
+ connection.authenticationFrameQueued = false;
399
+ void pumpAttachment(connection, attachment);
400
+ }
401
+ catch (error) {
402
+ if (isClosed(connection))
403
+ return;
404
+ const reason = error instanceof DirectRuntimeBrowserInspectHostError
405
+ ? error.reason
406
+ : "internal_error";
407
+ const messageText = error instanceof Error
408
+ ? error.message
409
+ : "Browser Inspect target open failed";
410
+ await sendInspect(connection, {
411
+ binary: false,
412
+ data: encodeRuntimeBrowserInspectServerFrame({
413
+ type: "browser.inspect.closed",
414
+ reason,
415
+ message: boundedMessage(messageText),
416
+ }),
417
+ }).catch(() => undefined);
418
+ connection.close(reason === "unauthorized"
419
+ ? CLOSE_UNAUTHORIZED
420
+ : reason === "capacity"
421
+ ? CLOSE_CAPACITY
422
+ : reason === "internal_error"
423
+ ? CLOSE_INTERNAL
424
+ : 1000, messageText);
425
+ }
426
+ }
427
+ async function pumpAttachment(connection, attachment) {
428
+ try {
429
+ for await (const message of attachment.messages) {
430
+ if (isClosed(connection) ||
431
+ connection.attachment !== attachment) {
432
+ return;
433
+ }
434
+ await sendInspect(connection, message);
435
+ }
436
+ if (!isClosed(connection)) {
437
+ connection.close(1000, "Browser Inspect page stream closed");
438
+ }
439
+ }
440
+ catch (error) {
441
+ if (isClosed(connection))
442
+ return;
443
+ options.onError?.(error, "Browser Inspect local page output");
444
+ connection.close(CLOSE_INTERNAL, "Browser Inspect page stream failed");
445
+ }
446
+ }
447
+ async function sendAuthenticationText(connection, plaintext) {
448
+ const channel = connection.authenticationChannel;
449
+ if (!channel) {
450
+ throw new Error("Browser Inspect authentication channel is unavailable");
451
+ }
452
+ await sendWire(connection, channel.seal(plaintext), false);
453
+ }
454
+ async function sendInspect(connection, message) {
455
+ await sendWire(connection, message.data, message.binary);
456
+ }
457
+ async function sendWire(connection, frame, binary) {
458
+ if (connection.ws.readyState !== WebSocket.OPEN) {
459
+ throw new Error("Browser Inspect WebSocket is not open");
460
+ }
461
+ const byteLength = typeof frame === "string"
462
+ ? Buffer.byteLength(frame, "utf8")
463
+ : frame.byteLength;
464
+ if (byteLength > limits.maxBufferedBytes ||
465
+ connection.ws.bufferedAmount + byteLength > limits.maxBufferedBytes) {
466
+ connection.close(CLOSE_CAPACITY, "Browser Inspect output buffer is full");
467
+ throw new Error("Browser Inspect output buffer is full");
468
+ }
469
+ await new Promise((resolve, reject) => {
470
+ connection.ws.send(frame, { binary, compress: false }, (error) => {
471
+ if (error)
472
+ reject(error);
473
+ else
474
+ resolve();
475
+ });
476
+ });
477
+ }
478
+ function closeConnection(connection, code, reason) {
479
+ if (isClosed(connection))
480
+ return;
481
+ const closeReason = code === CLOSE_UNAUTHORIZED
482
+ ? "grant_revoked"
483
+ : "transport_error";
484
+ connection.state = "closing";
485
+ connection.abortController.abort(new Error(reason));
486
+ if (connection.ws.readyState === WebSocket.OPEN) {
487
+ connection.ws.close(code, boundedCloseReason(reason));
488
+ connection.closeTimer = setTimeout(() => {
489
+ if (connection.ws.readyState !== WebSocket.CLOSED) {
490
+ connection.ws.terminate();
491
+ }
492
+ }, CONNECTION_CLOSE_DEADLINE_MS);
493
+ connection.closeTimer.unref?.();
494
+ }
495
+ else if (connection.ws.readyState !== WebSocket.CLOSED) {
496
+ connection.ws.terminate();
497
+ }
498
+ void closeAttachment(connection, closeReason);
499
+ }
500
+ async function cleanupConnection(connection, reason) {
501
+ if (connection.state === "closed")
502
+ return;
503
+ connection.state = "closed";
504
+ clearTimeout(connection.authenticationTimer);
505
+ if (connection.closeTimer)
506
+ clearTimeout(connection.closeTimer);
507
+ connection.abortController.abort(new Error("Browser Inspect connection closed"));
508
+ releaseUnauthenticated(connection);
509
+ options.connectionHub.unregister(connection);
510
+ connections.delete(connection);
511
+ await closeAttachment(connection, reason);
512
+ }
513
+ async function closeAttachment(connection, reason) {
514
+ if (connection.attachmentClosed)
515
+ return;
516
+ connection.attachmentClosed = true;
517
+ try {
518
+ await connection.attachment?.close(reason);
519
+ }
520
+ catch (error) {
521
+ options.onError?.(error, "Browser Inspect attachment cleanup");
522
+ }
523
+ }
524
+ function releaseUnauthenticated(connection) {
525
+ if (!connection.countedAsUnauthenticated)
526
+ return;
527
+ connection.countedAsUnauthenticated = false;
528
+ decrementCount(unauthenticatedConnectionsBySource, connection.sourceAddress);
529
+ }
530
+ heartbeatTimer = setInterval(() => {
531
+ const now = Date.now();
532
+ for (const connection of connections) {
533
+ if (connection.ws.readyState !== WebSocket.OPEN)
534
+ continue;
535
+ if (now - connection.lastInboundActivityAt >
536
+ limits.peerLivenessTimeoutMs) {
537
+ connection.ws.terminate();
538
+ continue;
539
+ }
540
+ try {
541
+ connection.ws.ping();
542
+ }
543
+ catch {
544
+ connection.ws.terminate();
545
+ }
546
+ }
547
+ }, limits.heartbeatIntervalMs);
548
+ heartbeatTimer.unref?.();
549
+ return {
550
+ path: DIRECT_RUNTIME_BROWSER_INSPECT_PATH,
551
+ get activeConnectionCount() {
552
+ return connections.size;
553
+ },
554
+ shutdown() {
555
+ if (shutdownPromise)
556
+ return shutdownPromise;
557
+ shutdownPromise = (async () => {
558
+ stopping = true;
559
+ server.removeListener("upgrade", handleUpgrade);
560
+ if (heartbeatTimer)
561
+ clearInterval(heartbeatTimer);
562
+ heartbeatTimer = undefined;
563
+ for (const connection of [...connections]) {
564
+ connection.state = "closing";
565
+ connection.abortController.abort(new Error("Browser Inspect server is shutting down"));
566
+ void closeAttachment(connection, "server_shutdown");
567
+ if (connection.ws.readyState === WebSocket.OPEN) {
568
+ connection.ws.close(1001, "Browser Inspect server is shutting down");
569
+ }
570
+ else if (connection.ws.readyState !== WebSocket.CLOSED) {
571
+ connection.ws.terminate();
572
+ }
573
+ }
574
+ const deadline = Date.now() + limits.shutdownDeadlineMs;
575
+ while (connections.size > 0 && Date.now() < deadline) {
576
+ await new Promise((resolve) => setTimeout(resolve, 10));
577
+ }
578
+ for (const connection of [...connections]) {
579
+ connection.ws.terminate();
580
+ }
581
+ await closeWebSocketServer(wss);
582
+ })();
583
+ return shutdownPromise;
584
+ },
585
+ };
586
+ }
587
+ function assertAuthenticationResult(hello, authenticate, peer, accepted) {
588
+ if (hello.role !== "browser-inspect" ||
589
+ authenticate.role !== "browser-inspect" ||
590
+ accepted.role !== "browser-inspect" ||
591
+ authenticate.mode !== "connect" ||
592
+ accepted.daemonId !== hello.daemonId ||
593
+ accepted.daemonInstanceId !== hello.daemonInstanceId ||
594
+ authenticate.daemonId !== hello.daemonId ||
595
+ authenticate.daemonInstanceId !== hello.daemonInstanceId ||
596
+ authenticate.bindingProtocolVersion !==
597
+ DIRECT_RUNTIME_BROWSER_INSPECT_BINDING_PROTOCOL.currentVersion ||
598
+ accepted.bindingProtocolVersion !== authenticate.bindingProtocolVersion ||
599
+ accepted.coreProtocolVersion !== authenticate.coreProtocolVersion ||
600
+ accepted.clientId !== authenticate.clientId ||
601
+ accepted.clientId !== peer.clientId ||
602
+ accepted.grantId !== authenticate.grantId ||
603
+ accepted.grantId !== peer.grantId ||
604
+ accepted.authorities.length !== peer.subject.effectiveAuthorities.length ||
605
+ accepted.authorities.some((authority, index) => authority !== peer.subject.effectiveAuthorities[index])) {
606
+ throw new Error("Browser Inspect authenticator returned an inconsistent identity");
607
+ }
608
+ }
609
+ function isClosed(connection) {
610
+ return connection.state === "closing" || connection.state === "closed";
611
+ }
612
+ function rawDataBytes(data) {
613
+ if (data instanceof ArrayBuffer)
614
+ return new Uint8Array(data);
615
+ if (Array.isArray(data))
616
+ return Uint8Array.from(Buffer.concat(data));
617
+ return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
618
+ }
619
+ function decodeText(bytes) {
620
+ try {
621
+ return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
622
+ }
623
+ catch (error) {
624
+ throw new Error("Browser Inspect frame must be valid UTF-8", {
625
+ cause: error,
626
+ });
627
+ }
628
+ }
629
+ function socketSourceAddress(request) {
630
+ const address = request.socket.remoteAddress;
631
+ if (!address)
632
+ return "unknown";
633
+ return address.startsWith("::ffff:")
634
+ ? address.slice("::ffff:".length)
635
+ : address;
636
+ }
637
+ function rejectUpgrade(socket, status, message) {
638
+ if (socket.destroyed)
639
+ return;
640
+ const body = `${message}\n`;
641
+ socket.end(`HTTP/1.1 ${status} ${statusText(status)}\r\n` +
642
+ "Connection: close\r\n" +
643
+ "Cache-Control: no-store\r\n" +
644
+ "Content-Type: text/plain; charset=utf-8\r\n" +
645
+ `Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`);
646
+ }
647
+ function statusText(status) {
648
+ if (status === 400)
649
+ return "Bad Request";
650
+ if (status === 404)
651
+ return "Not Found";
652
+ if (status === 503)
653
+ return "Service Unavailable";
654
+ return "Error";
655
+ }
656
+ function boundedMessage(message) {
657
+ const bytes = Buffer.from(message.replaceAll("\0", ""), "utf8");
658
+ if (bytes.byteLength <= 1_024)
659
+ return message.replaceAll("\0", "");
660
+ return bytes.subarray(0, 1_024).toString("utf8").replace(/\uFFFD+$/u, "");
661
+ }
662
+ function boundedCloseReason(reason) {
663
+ const bytes = Buffer.from(reason, "utf8");
664
+ if (bytes.byteLength <= 123)
665
+ return reason;
666
+ return bytes.subarray(0, 123).toString("utf8").replace(/\uFFFD+$/u, "");
667
+ }
668
+ function incrementCount(counts, key) {
669
+ counts.set(key, (counts.get(key) ?? 0) + 1);
670
+ }
671
+ function decrementCount(counts, key) {
672
+ const current = counts.get(key);
673
+ if (current === undefined || current <= 1)
674
+ counts.delete(key);
675
+ else
676
+ counts.set(key, current - 1);
677
+ }
678
+ function resolveLimits(limits) {
679
+ const resolved = {
680
+ authenticationTimeoutMs: positiveInteger(limits?.authenticationTimeoutMs, 10_000),
681
+ maxConnections: positiveInteger(limits?.maxConnections, DEFAULT_MAX_CONNECTIONS),
682
+ maxUnauthenticatedConnectionsPerSource: positiveInteger(limits?.maxUnauthenticatedConnectionsPerSource, DEFAULT_MAX_UNAUTHENTICATED_CONNECTIONS_PER_SOURCE),
683
+ maxAuthenticationAttemptsPerSource: positiveInteger(limits?.maxAuthenticationAttemptsPerSource, DEFAULT_MAX_AUTHENTICATION_ATTEMPTS_PER_SOURCE),
684
+ authenticationAttemptWindowMs: positiveInteger(limits?.authenticationAttemptWindowMs, DEFAULT_AUTHENTICATION_ATTEMPT_WINDOW_MS),
685
+ maxTrackedAuthenticationSources: positiveInteger(limits?.maxTrackedAuthenticationSources, DEFAULT_MAX_TRACKED_AUTHENTICATION_SOURCES),
686
+ maxConnectionsPerGrant: positiveInteger(limits?.maxConnectionsPerGrant, DEFAULT_MAX_CONNECTIONS_PER_GRANT),
687
+ maxQueuedBytes: positiveInteger(limits?.maxQueuedBytes, DEFAULT_MAX_QUEUED_BYTES),
688
+ maxBufferedBytes: positiveInteger(limits?.maxBufferedBytes, DEFAULT_MAX_BUFFERED_BYTES),
689
+ heartbeatIntervalMs: positiveInteger(limits?.heartbeatIntervalMs, RUNTIME_BROWSER_INSPECT_HEARTBEAT_INTERVAL_MS),
690
+ peerLivenessTimeoutMs: positiveInteger(limits?.peerLivenessTimeoutMs, RUNTIME_BROWSER_INSPECT_PEER_LIVENESS_TIMEOUT_MS),
691
+ shutdownDeadlineMs: positiveInteger(limits?.shutdownDeadlineMs, DEFAULT_SHUTDOWN_DEADLINE_MS),
692
+ };
693
+ if (resolved.maxConnectionsPerGrant > resolved.maxConnections) {
694
+ throw new Error("Browser Inspect per-grant limit must not exceed maxConnections");
695
+ }
696
+ if (resolved.peerLivenessTimeoutMs <= resolved.heartbeatIntervalMs) {
697
+ throw new Error("Browser Inspect liveness timeout must exceed its heartbeat interval");
698
+ }
699
+ if (resolved.maxQueuedBytes <
700
+ RUNTIME_BROWSER_INSPECT_MAX_MESSAGE_BYTES ||
701
+ resolved.maxBufferedBytes <
702
+ RUNTIME_BROWSER_INSPECT_MAX_MESSAGE_BYTES) {
703
+ throw new Error("Browser Inspect byte limits must admit one maximum CDP frame");
704
+ }
705
+ return resolved;
706
+ }
707
+ function positiveInteger(value, fallback) {
708
+ const resolved = value ?? fallback;
709
+ if (!Number.isSafeInteger(resolved) || resolved <= 0) {
710
+ throw new Error("Browser Inspect limits must be positive safe integers");
711
+ }
712
+ return resolved;
713
+ }
714
+ class SourceAuthenticationAttemptLimiter {
715
+ options;
716
+ entries = new Map();
717
+ constructor(options) {
718
+ this.options = options;
719
+ }
720
+ tryConsume(sourceAddress, now = Date.now()) {
721
+ const existing = this.entries.get(sourceAddress);
722
+ if (existing && now - existing.startedAt < this.options.windowMs) {
723
+ existing.attempts += 1;
724
+ this.entries.delete(sourceAddress);
725
+ this.entries.set(sourceAddress, existing);
726
+ return existing.attempts <= this.options.maxAttempts;
727
+ }
728
+ if (existing)
729
+ this.entries.delete(sourceAddress);
730
+ while (this.entries.size >= this.options.maxSources) {
731
+ const oldest = this.entries.keys().next().value;
732
+ if (oldest === undefined)
733
+ break;
734
+ this.entries.delete(oldest);
735
+ }
736
+ this.entries.set(sourceAddress, { startedAt: now, attempts: 1 });
737
+ return true;
738
+ }
739
+ }
740
+ function closeWebSocketServer(server) {
741
+ return new Promise((resolve) => {
742
+ try {
743
+ server.close(() => resolve());
744
+ }
745
+ catch {
746
+ resolve();
747
+ }
748
+ });
749
+ }