@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.
- package/dist/browser-surface-control-queue.d.ts +42 -0
- package/dist/browser-surface-control-queue.js +187 -0
- package/dist/browser-surface-ws.d.ts +16 -0
- package/dist/browser-surface-ws.js +356 -0
- package/dist/control-api.d.ts +76 -4
- package/dist/control-api.js +3014 -133
- package/dist/control-web/assets/highlighted-body-OFNGDK62-DHu2g-rk.js +1 -0
- package/dist/control-web/assets/index-B6Pb5j9M.js +666 -0
- package/dist/control-web/assets/index-BU-_Qud_.css +32 -0
- package/dist/control-web/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
- package/dist/control-web/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
- package/dist/control-web/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
- package/dist/control-web/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
- package/dist/control-web/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
- package/dist/control-web/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- package/dist/control-web/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
- package/dist/control-web/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
- package/dist/control-web/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
- package/dist/control-web/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- package/dist/control-web/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- package/dist/control-web/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
- package/dist/control-web/assets/mermaid-GHXKKRXX-CSYzOmBR.js +131 -0
- package/dist/control-web/assets/rynx-wordmark-LL1QRYHD.png +0 -0
- package/dist/control-web/favicon.png +0 -0
- package/dist/control-web/favicon.svg +38 -0
- package/dist/control-web/index.html +16 -0
- package/dist/control-web-dist.d.ts +1 -1
- package/dist/control-web-dist.js +19 -14
- package/dist/desktop-browser-host.d.ts +170 -0
- package/dist/desktop-browser-host.js +1018 -0
- package/dist/direct-runtime-browser-inspect-server.d.ts +72 -0
- package/dist/direct-runtime-browser-inspect-server.js +749 -0
- package/dist/direct-runtime-browser-surface-server.d.ts +74 -0
- package/dist/direct-runtime-browser-surface-server.js +821 -0
- package/dist/direct-runtime-server.d.ts +143 -0
- package/dist/direct-runtime-server.js +1959 -0
- package/dist/emulator-surface-ws.d.ts +8 -0
- package/dist/emulator-surface-ws.js +198 -0
- package/dist/machine-session-service.d.ts +265 -0
- package/dist/machine-session-service.js +822 -0
- package/dist/provider-cli-host.d.ts +10 -0
- package/dist/provider-cli-host.js +1 -0
- package/dist/remote-runtime-dispatcher.d.ts +94 -0
- package/dist/remote-runtime-dispatcher.js +403 -0
- package/dist/remote-runtime-session-projection.d.ts +19 -0
- package/dist/remote-runtime-session-projection.js +566 -0
- package/dist/remote-runtime.d.ts +22 -0
- package/dist/remote-runtime.js +32 -0
- package/dist/runtime-web-auth.d.ts +26 -0
- package/dist/runtime-web-auth.js +132 -0
- package/dist/server.d.ts +364 -12
- package/dist/server.js +341 -100
- package/dist/session-browser-service.d.ts +248 -0
- package/dist/session-browser-service.js +772 -0
- package/dist/session-browser-surface-coordinator.d.ts +24 -0
- package/dist/session-browser-surface-coordinator.js +669 -0
- package/dist/session-emulator-service.d.ts +167 -0
- package/dist/session-emulator-service.js +464 -0
- package/dist/session-runtime-index.d.ts +23 -0
- package/dist/session-runtime-index.js +96 -0
- package/dist/session-terminal-host.d.ts +51 -0
- package/dist/session-terminal-host.js +270 -0
- package/dist/terminal-ws.d.ts +12 -20
- package/dist/terminal-ws.js +421 -97
- package/package.json +14 -7
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type RuntimeBrowserSurfaceClientFrame, type RuntimeBrowserSurfaceOpenFrame } from "@rynx-ai/protocol/runtime-browser-surface";
|
|
2
|
+
type SurfaceControlFrame = Exclude<RuntimeBrowserSurfaceClientFrame, RuntimeBrowserSurfaceOpenFrame>;
|
|
3
|
+
export interface BrowserSurfaceControlQueueOptions {
|
|
4
|
+
dispatch(frame: SurfaceControlFrame): void | Promise<void>;
|
|
5
|
+
onError(error: unknown): void;
|
|
6
|
+
maxPendingFrames?: number;
|
|
7
|
+
maxPendingBytes?: number;
|
|
8
|
+
}
|
|
9
|
+
export type BrowserSurfaceControlEnqueueResult = "queued" | "coalesced" | "dropped";
|
|
10
|
+
/**
|
|
11
|
+
* Bounded semantic queue shared by the local browser bridge and Direct Surface.
|
|
12
|
+
*
|
|
13
|
+
* Wire records are parsed before entering this queue. Discrete input remains
|
|
14
|
+
* ordered and reliable while the connection is live. Pointer movement is
|
|
15
|
+
* latest-wins, wheel samples are merged only when adjacent, and an explicit
|
|
16
|
+
* driver intent fences input that has not started executing yet.
|
|
17
|
+
*/
|
|
18
|
+
export declare class BrowserSurfaceControlQueue {
|
|
19
|
+
private readonly options;
|
|
20
|
+
private readonly maxPendingFrames;
|
|
21
|
+
private readonly maxPendingBytes;
|
|
22
|
+
private readonly items;
|
|
23
|
+
private queuedBytes;
|
|
24
|
+
private inFlight;
|
|
25
|
+
private running;
|
|
26
|
+
private aborted;
|
|
27
|
+
private inputAdmission;
|
|
28
|
+
constructor(options: BrowserSurfaceControlQueueOptions);
|
|
29
|
+
enqueue(frame: SurfaceControlFrame): BrowserSurfaceControlEnqueueResult;
|
|
30
|
+
abort(): void;
|
|
31
|
+
private insert;
|
|
32
|
+
private replaceTail;
|
|
33
|
+
private hasCapacity;
|
|
34
|
+
private removePending;
|
|
35
|
+
private start;
|
|
36
|
+
private pump;
|
|
37
|
+
}
|
|
38
|
+
export declare class BrowserSurfaceControlQueueCapacityError extends Error {
|
|
39
|
+
readonly code: "capacity";
|
|
40
|
+
constructor();
|
|
41
|
+
}
|
|
42
|
+
export {};
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { RUNTIME_BROWSER_SURFACE_MAX_CONTROL_FRAME_BYTES, RUNTIME_BROWSER_SURFACE_MAX_RELIABLE_INPUT_QUEUE, RUNTIME_BROWSER_SURFACE_MAX_WHEEL_DELTA, } from "@rynx-ai/protocol/runtime-browser-surface";
|
|
2
|
+
const DEFAULT_MAX_PENDING_BYTES = RUNTIME_BROWSER_SURFACE_MAX_CONTROL_FRAME_BYTES * 16;
|
|
3
|
+
/**
|
|
4
|
+
* Bounded semantic queue shared by the local browser bridge and Direct Surface.
|
|
5
|
+
*
|
|
6
|
+
* Wire records are parsed before entering this queue. Discrete input remains
|
|
7
|
+
* ordered and reliable while the connection is live. Pointer movement is
|
|
8
|
+
* latest-wins, wheel samples are merged only when adjacent, and an explicit
|
|
9
|
+
* driver intent fences input that has not started executing yet.
|
|
10
|
+
*/
|
|
11
|
+
export class BrowserSurfaceControlQueue {
|
|
12
|
+
options;
|
|
13
|
+
maxPendingFrames;
|
|
14
|
+
maxPendingBytes;
|
|
15
|
+
items = [];
|
|
16
|
+
queuedBytes = 0;
|
|
17
|
+
inFlight;
|
|
18
|
+
running = false;
|
|
19
|
+
aborted = false;
|
|
20
|
+
inputAdmission = true;
|
|
21
|
+
constructor(options) {
|
|
22
|
+
this.options = options;
|
|
23
|
+
this.maxPendingFrames = positiveLimit(options.maxPendingFrames, RUNTIME_BROWSER_SURFACE_MAX_RELIABLE_INPUT_QUEUE, "Browser Surface pending control frame limit");
|
|
24
|
+
this.maxPendingBytes = positiveLimit(options.maxPendingBytes, DEFAULT_MAX_PENDING_BYTES, "Browser Surface pending control byte limit");
|
|
25
|
+
}
|
|
26
|
+
enqueue(frame) {
|
|
27
|
+
if (this.aborted)
|
|
28
|
+
return "dropped";
|
|
29
|
+
if (frame.type === "browser.surface.driver.release") {
|
|
30
|
+
this.inputAdmission = false;
|
|
31
|
+
this.removePending((pending) => (pending.frame.type === "browser.surface.input" ||
|
|
32
|
+
pending.frame.type === "browser.surface.driver.take" ||
|
|
33
|
+
pending.frame.type === "browser.surface.driver.release"));
|
|
34
|
+
return this.insert(frame, "before-input");
|
|
35
|
+
}
|
|
36
|
+
if (frame.type === "browser.surface.driver.take") {
|
|
37
|
+
this.inputAdmission = true;
|
|
38
|
+
// A release is an input-state reset barrier, not a replaceable intent.
|
|
39
|
+
// Preserve it ahead of this take; only duplicate pending takes coalesce.
|
|
40
|
+
this.removePending((pending) => pending.frame.type === "browser.surface.driver.take");
|
|
41
|
+
return this.insert(frame, "tail");
|
|
42
|
+
}
|
|
43
|
+
if (frame.type === "browser.surface.frame.ack") {
|
|
44
|
+
return this.insert(frame, "before-input");
|
|
45
|
+
}
|
|
46
|
+
if (!this.inputAdmission)
|
|
47
|
+
return "dropped";
|
|
48
|
+
const tail = this.items.at(-1);
|
|
49
|
+
if (tail && isPointerMove(tail.frame) && isPointerMove(frame)) {
|
|
50
|
+
return this.replaceTail(frame);
|
|
51
|
+
}
|
|
52
|
+
if (tail && isWheel(tail.frame) && isWheel(frame)) {
|
|
53
|
+
return this.replaceTail(mergeWheel(tail.frame, frame));
|
|
54
|
+
}
|
|
55
|
+
return this.insert(frame, "tail", isLossyInput(frame));
|
|
56
|
+
}
|
|
57
|
+
abort() {
|
|
58
|
+
if (this.aborted)
|
|
59
|
+
return;
|
|
60
|
+
this.aborted = true;
|
|
61
|
+
this.inputAdmission = false;
|
|
62
|
+
this.items.length = 0;
|
|
63
|
+
this.queuedBytes = 0;
|
|
64
|
+
}
|
|
65
|
+
insert(frame, position, lossy = false) {
|
|
66
|
+
const pending = { frame, bytes: controlBytes(frame) };
|
|
67
|
+
if (!this.hasCapacity(pending.bytes)) {
|
|
68
|
+
if (lossy)
|
|
69
|
+
return "dropped";
|
|
70
|
+
const error = new BrowserSurfaceControlQueueCapacityError();
|
|
71
|
+
this.abort();
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
if (position === "before-input") {
|
|
75
|
+
const firstInput = this.items.findIndex((item) => (item.frame.type === "browser.surface.input"));
|
|
76
|
+
if (firstInput === -1)
|
|
77
|
+
this.items.push(pending);
|
|
78
|
+
else
|
|
79
|
+
this.items.splice(firstInput, 0, pending);
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
this.items.push(pending);
|
|
83
|
+
}
|
|
84
|
+
this.queuedBytes += pending.bytes;
|
|
85
|
+
this.start();
|
|
86
|
+
return "queued";
|
|
87
|
+
}
|
|
88
|
+
replaceTail(frame) {
|
|
89
|
+
const tail = this.items.at(-1);
|
|
90
|
+
if (!tail)
|
|
91
|
+
return this.insert(frame, "tail", true);
|
|
92
|
+
const bytes = controlBytes(frame);
|
|
93
|
+
const nextBytes = this.queuedBytes - tail.bytes + bytes;
|
|
94
|
+
if (nextBytes + (this.inFlight?.bytes ?? 0) > this.maxPendingBytes) {
|
|
95
|
+
return "dropped";
|
|
96
|
+
}
|
|
97
|
+
tail.frame = frame;
|
|
98
|
+
tail.bytes = bytes;
|
|
99
|
+
this.queuedBytes = nextBytes;
|
|
100
|
+
return "coalesced";
|
|
101
|
+
}
|
|
102
|
+
hasCapacity(bytes) {
|
|
103
|
+
const count = this.items.length + (this.inFlight ? 1 : 0);
|
|
104
|
+
const totalBytes = this.queuedBytes + (this.inFlight?.bytes ?? 0);
|
|
105
|
+
return count < this.maxPendingFrames && totalBytes + bytes <= this.maxPendingBytes;
|
|
106
|
+
}
|
|
107
|
+
removePending(predicate) {
|
|
108
|
+
for (let index = this.items.length - 1; index >= 0; index -= 1) {
|
|
109
|
+
const pending = this.items[index];
|
|
110
|
+
if (!predicate(pending))
|
|
111
|
+
continue;
|
|
112
|
+
this.items.splice(index, 1);
|
|
113
|
+
this.queuedBytes -= pending.bytes;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
start() {
|
|
117
|
+
if (this.running || this.aborted)
|
|
118
|
+
return;
|
|
119
|
+
this.running = true;
|
|
120
|
+
void this.pump();
|
|
121
|
+
}
|
|
122
|
+
async pump() {
|
|
123
|
+
try {
|
|
124
|
+
while (!this.aborted) {
|
|
125
|
+
const pending = this.items.shift();
|
|
126
|
+
if (!pending)
|
|
127
|
+
return;
|
|
128
|
+
this.queuedBytes -= pending.bytes;
|
|
129
|
+
this.inFlight = pending;
|
|
130
|
+
await this.options.dispatch(pending.frame);
|
|
131
|
+
this.inFlight = undefined;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
this.inFlight = undefined;
|
|
136
|
+
if (this.aborted)
|
|
137
|
+
return;
|
|
138
|
+
this.abort();
|
|
139
|
+
this.options.onError(error);
|
|
140
|
+
}
|
|
141
|
+
finally {
|
|
142
|
+
this.inFlight = undefined;
|
|
143
|
+
this.running = false;
|
|
144
|
+
if (!this.aborted && this.items.length > 0)
|
|
145
|
+
this.start();
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
export class BrowserSurfaceControlQueueCapacityError extends Error {
|
|
150
|
+
code = "capacity";
|
|
151
|
+
constructor() {
|
|
152
|
+
super("Browser Surface input queue is full");
|
|
153
|
+
this.name = "BackpressureError";
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
function controlBytes(frame) {
|
|
157
|
+
return Buffer.byteLength(JSON.stringify(frame), "utf8");
|
|
158
|
+
}
|
|
159
|
+
function isLossyInput(frame) {
|
|
160
|
+
return isPointerMove(frame) || isWheel(frame);
|
|
161
|
+
}
|
|
162
|
+
function isPointerMove(frame) {
|
|
163
|
+
return frame.type === "browser.surface.input" && frame.event.type === "pointer.move";
|
|
164
|
+
}
|
|
165
|
+
function isWheel(frame) {
|
|
166
|
+
return frame.type === "browser.surface.input" && frame.event.type === "wheel";
|
|
167
|
+
}
|
|
168
|
+
function mergeWheel(previous, next) {
|
|
169
|
+
return {
|
|
170
|
+
...next,
|
|
171
|
+
event: {
|
|
172
|
+
...next.event,
|
|
173
|
+
deltaX: clampWheel(previous.event.deltaX + next.event.deltaX),
|
|
174
|
+
deltaY: clampWheel(previous.event.deltaY + next.event.deltaY),
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
function clampWheel(value) {
|
|
179
|
+
return Math.max(-RUNTIME_BROWSER_SURFACE_MAX_WHEEL_DELTA, Math.min(RUNTIME_BROWSER_SURFACE_MAX_WHEEL_DELTA, value));
|
|
180
|
+
}
|
|
181
|
+
function positiveLimit(value, fallback, field) {
|
|
182
|
+
const resolved = value ?? fallback;
|
|
183
|
+
if (!Number.isSafeInteger(resolved) || resolved < 1) {
|
|
184
|
+
throw new Error(`${field} must be a positive safe integer`);
|
|
185
|
+
}
|
|
186
|
+
return resolved;
|
|
187
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Server } from "node:http";
|
|
2
|
+
import { WebSocketServer } from "ws";
|
|
3
|
+
import { type RuntimeWebAccessPolicy } from "./runtime-web-auth.js";
|
|
4
|
+
import type { RuntimeConnectionResolverHost } from "./server.js";
|
|
5
|
+
export interface BrowserSurfaceWsDeps {
|
|
6
|
+
runtimeConnectionResolver?: RuntimeConnectionResolverHost;
|
|
7
|
+
runtimeWebAccess?: RuntimeWebAccessPolicy;
|
|
8
|
+
keepAliveMs?: number;
|
|
9
|
+
peerLivenessTimeoutMs?: number;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Attach the browser-facing, same-origin Surface bridge. The browser receives
|
|
13
|
+
* only bounded presentation frames; Runtime credentials and native CDP remain
|
|
14
|
+
* behind RuntimeConnectionResolverHost.
|
|
15
|
+
*/
|
|
16
|
+
export declare function attachBrowserSurfaceWs(server: Server, deps: BrowserSurfaceWsDeps): WebSocketServer;
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { encodeRuntimeBrowserSurfaceImageFrame, parseRuntimeBrowserSurfaceClientFrame, RUNTIME_BROWSER_SURFACE_HEARTBEAT_INTERVAL_MS, RUNTIME_BROWSER_SURFACE_MAX_CONTROL_FRAME_BYTES, RUNTIME_BROWSER_SURFACE_MAX_ENCODED_FRAME_BYTES, RUNTIME_BROWSER_SURFACE_PEER_LIVENESS_TIMEOUT_MS, } from "@rynx-ai/protocol/runtime-browser-surface";
|
|
3
|
+
import { WebSocket, WebSocketServer } from "ws";
|
|
4
|
+
import { authorizeRuntimeWebRequest, RUNTIME_WEB_CAPABILITY_QUERY, } from "./runtime-web-auth.js";
|
|
5
|
+
import { BrowserSurfaceControlQueue } from "./browser-surface-control-queue.js";
|
|
6
|
+
const SURFACE_PATH = /^\/api\/runtimes\/([^/]+)\/sessions\/([^/]+)\/browser\/pages\/([^/]+)\/surface$/;
|
|
7
|
+
const SELECTOR_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._~-]{0,255}$/;
|
|
8
|
+
const MAX_ROUTE_ID_BYTES = 512;
|
|
9
|
+
const MAX_BROWSER_BUFFERED_BYTES = 8 * 1024 * 1024;
|
|
10
|
+
const OPEN_TIMEOUT_MS = 10_000;
|
|
11
|
+
const CLOSE_BAD_REQUEST = 4400;
|
|
12
|
+
const CLOSE_UNAUTHORIZED = 4403;
|
|
13
|
+
const CLOSE_RUNTIME_UNAVAILABLE = 4406;
|
|
14
|
+
const CLOSE_BACKPRESSURE = 4408;
|
|
15
|
+
const CLOSE_INTERNAL = 4500;
|
|
16
|
+
/**
|
|
17
|
+
* Attach the browser-facing, same-origin Surface bridge. The browser receives
|
|
18
|
+
* only bounded presentation frames; Runtime credentials and native CDP remain
|
|
19
|
+
* behind RuntimeConnectionResolverHost.
|
|
20
|
+
*/
|
|
21
|
+
export function attachBrowserSurfaceWs(server, deps) {
|
|
22
|
+
const wss = new WebSocketServer({
|
|
23
|
+
noServer: true,
|
|
24
|
+
perMessageDeflate: false,
|
|
25
|
+
maxPayload: RUNTIME_BROWSER_SURFACE_MAX_CONTROL_FRAME_BYTES,
|
|
26
|
+
});
|
|
27
|
+
server.on("upgrade", (request, socket, head) => {
|
|
28
|
+
const parsed = parseSurfaceUpgrade(request);
|
|
29
|
+
if (!parsed)
|
|
30
|
+
return;
|
|
31
|
+
if ("failure" in parsed) {
|
|
32
|
+
rejectUpgrade(socket, 400, parsed.failure);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const authorization = authorizeRuntimeWebRequest(request, {
|
|
36
|
+
mutation: true,
|
|
37
|
+
capability: parsed.url.searchParams.get(RUNTIME_WEB_CAPABILITY_QUERY) ?? "",
|
|
38
|
+
access: deps.runtimeWebAccess,
|
|
39
|
+
});
|
|
40
|
+
if (authorization) {
|
|
41
|
+
rejectUpgrade(socket, authorization.status, authorization.error);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
try {
|
|
45
|
+
wss.handleUpgrade(request, socket, head, (ws) => {
|
|
46
|
+
void handleConnection(ws, parsed.url, parsed.route, deps);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
rejectUpgrade(socket, 400, "invalid Browser Surface WebSocket upgrade");
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
return wss;
|
|
54
|
+
}
|
|
55
|
+
async function handleConnection(ws, url, route, deps) {
|
|
56
|
+
let open;
|
|
57
|
+
try {
|
|
58
|
+
open = parseOpenOptions(url, route);
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
closeBrowser(ws, CLOSE_BAD_REQUEST, "invalid Browser Surface options", errorMessage(error));
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (!deps.runtimeConnectionResolver) {
|
|
65
|
+
closeBrowser(ws, CLOSE_RUNTIME_UNAVAILABLE, "Browser Surface is unavailable");
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const controller = new AbortController();
|
|
69
|
+
let lease;
|
|
70
|
+
let surface;
|
|
71
|
+
let controlQueue;
|
|
72
|
+
let keepalive;
|
|
73
|
+
let lastPeerActivityAt = Date.now();
|
|
74
|
+
let cleaned = false;
|
|
75
|
+
const cleanup = async () => {
|
|
76
|
+
if (cleaned)
|
|
77
|
+
return;
|
|
78
|
+
cleaned = true;
|
|
79
|
+
if (keepalive)
|
|
80
|
+
clearInterval(keepalive);
|
|
81
|
+
controlQueue?.abort();
|
|
82
|
+
controller.abort(new Error("Browser Surface client disconnected"));
|
|
83
|
+
try {
|
|
84
|
+
await surface?.close();
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
lease?.release();
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
const keepAliveMs = deps.keepAliveMs ?? RUNTIME_BROWSER_SURFACE_HEARTBEAT_INTERVAL_MS;
|
|
91
|
+
const peerLivenessTimeoutMs = deps.peerLivenessTimeoutMs ??
|
|
92
|
+
RUNTIME_BROWSER_SURFACE_PEER_LIVENESS_TIMEOUT_MS;
|
|
93
|
+
keepalive = setInterval(() => {
|
|
94
|
+
if (ws.readyState !== WebSocket.OPEN)
|
|
95
|
+
return;
|
|
96
|
+
if (Date.now() - lastPeerActivityAt >= peerLivenessTimeoutMs) {
|
|
97
|
+
ws.terminate();
|
|
98
|
+
void cleanup();
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
ws.ping();
|
|
102
|
+
}, keepAliveMs);
|
|
103
|
+
keepalive.unref?.();
|
|
104
|
+
ws.on("pong", () => {
|
|
105
|
+
lastPeerActivityAt = Date.now();
|
|
106
|
+
});
|
|
107
|
+
ws.on("message", (data, isBinary) => {
|
|
108
|
+
lastPeerActivityAt = Date.now();
|
|
109
|
+
if (!surface) {
|
|
110
|
+
closeBrowser(ws, CLOSE_BAD_REQUEST, "Browser Surface is not ready");
|
|
111
|
+
void cleanup();
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
let frame;
|
|
115
|
+
try {
|
|
116
|
+
frame = parseControlFrame(data, isBinary, open);
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
closeBrowser(ws, CLOSE_BAD_REQUEST, "invalid Browser Surface control", errorMessage(error));
|
|
120
|
+
void cleanup();
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (!controlQueue) {
|
|
124
|
+
closeBrowser(ws, CLOSE_BAD_REQUEST, "Browser Surface is not ready");
|
|
125
|
+
void cleanup();
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
try {
|
|
129
|
+
controlQueue.enqueue(frame);
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
closeForError(ws, error);
|
|
133
|
+
void cleanup();
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
ws.on("close", () => void cleanup());
|
|
137
|
+
ws.on("error", () => void cleanup());
|
|
138
|
+
try {
|
|
139
|
+
lease = await deps.runtimeConnectionResolver.acquire(route.selector, {
|
|
140
|
+
signal: controller.signal,
|
|
141
|
+
});
|
|
142
|
+
if (cleaned)
|
|
143
|
+
return;
|
|
144
|
+
if (lease.target.selector !== route.selector) {
|
|
145
|
+
throw new Error("Runtime resolver returned a mismatched target");
|
|
146
|
+
}
|
|
147
|
+
surface = await lease.openBrowserSurface(open, {
|
|
148
|
+
signal: controller.signal,
|
|
149
|
+
timeoutMs: OPEN_TIMEOUT_MS,
|
|
150
|
+
});
|
|
151
|
+
if (cleaned)
|
|
152
|
+
return;
|
|
153
|
+
controlQueue = new BrowserSurfaceControlQueue({
|
|
154
|
+
dispatch: (frame) => surface.send(frame),
|
|
155
|
+
onError: (error) => {
|
|
156
|
+
if (cleaned)
|
|
157
|
+
return;
|
|
158
|
+
closeForError(ws, error);
|
|
159
|
+
void cleanup();
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
await sendText(ws, surface.ready);
|
|
163
|
+
for await (const event of surface) {
|
|
164
|
+
if (cleaned)
|
|
165
|
+
return;
|
|
166
|
+
if ("encodedBytes" in event)
|
|
167
|
+
await sendImage(ws, event);
|
|
168
|
+
else
|
|
169
|
+
await sendText(ws, event);
|
|
170
|
+
}
|
|
171
|
+
if (!cleaned)
|
|
172
|
+
closeBrowser(ws, 1000, "Browser Surface ended");
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
if (!cleaned)
|
|
176
|
+
closeForError(ws, error);
|
|
177
|
+
}
|
|
178
|
+
finally {
|
|
179
|
+
if (keepalive)
|
|
180
|
+
clearInterval(keepalive);
|
|
181
|
+
await cleanup().catch(() => undefined);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
function parseSurfaceUpgrade(request) {
|
|
185
|
+
let url;
|
|
186
|
+
try {
|
|
187
|
+
url = new URL(request.url ?? "", "http://localhost");
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|
|
192
|
+
const match = SURFACE_PATH.exec(url.pathname);
|
|
193
|
+
if (!match)
|
|
194
|
+
return undefined;
|
|
195
|
+
try {
|
|
196
|
+
const selector = decodeURIComponent(match[1]);
|
|
197
|
+
const sessionId = decodeURIComponent(match[2]);
|
|
198
|
+
const pageId = decodeURIComponent(match[3]);
|
|
199
|
+
if (!SELECTOR_PATTERN.test(selector) ||
|
|
200
|
+
!validRouteId(sessionId) ||
|
|
201
|
+
!validRouteId(pageId)) {
|
|
202
|
+
return { failure: "invalid Browser Surface route" };
|
|
203
|
+
}
|
|
204
|
+
return { url, route: { selector, sessionId, pageId } };
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
return { failure: "invalid Browser Surface route encoding" };
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
function parseOpenOptions(url, route) {
|
|
211
|
+
return parseRuntimeBrowserSurfaceClientFrame({
|
|
212
|
+
type: "browser.surface.open",
|
|
213
|
+
subscriptionId: randomUUID(),
|
|
214
|
+
sessionId: route.sessionId,
|
|
215
|
+
pageId: route.pageId,
|
|
216
|
+
browserGeneration: requiredInteger(url, "generation"),
|
|
217
|
+
requestedRole: url.searchParams.get("role") ?? "control",
|
|
218
|
+
format: url.searchParams.get("format") ?? "jpeg",
|
|
219
|
+
quality: optionalInteger(url, "quality", 75),
|
|
220
|
+
maximumFrameRate: optionalInteger(url, "maximumFrameRate", 15),
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
function parseControlFrame(data, isBinary, open) {
|
|
224
|
+
if (isBinary)
|
|
225
|
+
throw new Error("Browser Surface client frames must be text");
|
|
226
|
+
const bytes = rawDataBytes(data);
|
|
227
|
+
if (bytes.byteLength === 0 || bytes.byteLength > RUNTIME_BROWSER_SURFACE_MAX_CONTROL_FRAME_BYTES) {
|
|
228
|
+
throw new Error("Browser Surface control frame size is invalid");
|
|
229
|
+
}
|
|
230
|
+
const parsed = parseRuntimeBrowserSurfaceClientFrame(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)));
|
|
231
|
+
if (parsed.type === "browser.surface.open") {
|
|
232
|
+
throw new Error("Browser Surface is already open");
|
|
233
|
+
}
|
|
234
|
+
if (parsed.subscriptionId !== open.subscriptionId) {
|
|
235
|
+
throw new Error("Browser Surface control targets another subscription");
|
|
236
|
+
}
|
|
237
|
+
if ("browserGeneration" in parsed &&
|
|
238
|
+
parsed.browserGeneration !== open.browserGeneration) {
|
|
239
|
+
throw new Error("Browser Surface control targets another Browser generation");
|
|
240
|
+
}
|
|
241
|
+
return parsed;
|
|
242
|
+
}
|
|
243
|
+
async function sendText(ws, value) {
|
|
244
|
+
await send(ws, Buffer.from(JSON.stringify(value), "utf8"), false);
|
|
245
|
+
}
|
|
246
|
+
async function sendImage(ws, image) {
|
|
247
|
+
const encoded = encodeRuntimeBrowserSurfaceImageFrame(image);
|
|
248
|
+
if (encoded.byteLength > RUNTIME_BROWSER_SURFACE_MAX_ENCODED_FRAME_BYTES + 1024) {
|
|
249
|
+
throw backpressureError("Browser Surface image exceeded its wire bound");
|
|
250
|
+
}
|
|
251
|
+
await send(ws, encoded, true);
|
|
252
|
+
}
|
|
253
|
+
async function send(ws, data, binary) {
|
|
254
|
+
if (ws.readyState !== WebSocket.OPEN)
|
|
255
|
+
throw transportError("Browser Surface is closed");
|
|
256
|
+
if (ws.bufferedAmount + data.byteLength > MAX_BROWSER_BUFFERED_BYTES) {
|
|
257
|
+
throw backpressureError("Browser Surface output exceeded its bounded buffer");
|
|
258
|
+
}
|
|
259
|
+
await new Promise((resolve, reject) => {
|
|
260
|
+
ws.send(data, { binary, compress: false }, (error) => {
|
|
261
|
+
if (error)
|
|
262
|
+
reject(transportError(error.message));
|
|
263
|
+
else
|
|
264
|
+
resolve();
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
function closeForError(ws, error) {
|
|
269
|
+
const code = errorCode(error);
|
|
270
|
+
if (code === "unauthorized" || code === "authentication_failed") {
|
|
271
|
+
closeBrowser(ws, CLOSE_UNAUTHORIZED, "Browser Surface authorization failed");
|
|
272
|
+
}
|
|
273
|
+
else if (code === "capacity" || errorName(error) === "BackpressureError") {
|
|
274
|
+
closeBrowser(ws, CLOSE_BACKPRESSURE, "Browser Surface backpressure");
|
|
275
|
+
}
|
|
276
|
+
else if (code === "not_found" ||
|
|
277
|
+
code === "incompatible" ||
|
|
278
|
+
code === "unsupported" ||
|
|
279
|
+
code === "method_not_found") {
|
|
280
|
+
closeBrowser(ws, CLOSE_RUNTIME_UNAVAILABLE, "Browser Surface Runtime unavailable");
|
|
281
|
+
}
|
|
282
|
+
else if (code === "unreachable" ||
|
|
283
|
+
code === "closed" ||
|
|
284
|
+
code === "deadline_exceeded" ||
|
|
285
|
+
errorName(error) === "AbortError" ||
|
|
286
|
+
errorName(error) === "TransportError") {
|
|
287
|
+
closeBrowser(ws, 1012, "Browser Surface transport unavailable");
|
|
288
|
+
}
|
|
289
|
+
else {
|
|
290
|
+
closeBrowser(ws, CLOSE_INTERNAL, "Browser Surface bridge error", errorMessage(error));
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
function closeBrowser(ws, code, reason, message) {
|
|
294
|
+
if (message && ws.readyState === WebSocket.OPEN) {
|
|
295
|
+
ws.send(JSON.stringify({ type: "error", message }), { binary: false, compress: false });
|
|
296
|
+
}
|
|
297
|
+
if (ws.readyState === WebSocket.OPEN)
|
|
298
|
+
ws.close(code, boundedCloseReason(reason));
|
|
299
|
+
}
|
|
300
|
+
function rejectUpgrade(socket, status, message) {
|
|
301
|
+
if (socket.destroyed)
|
|
302
|
+
return;
|
|
303
|
+
const body = `${message}\n`;
|
|
304
|
+
socket.end(`HTTP/1.1 ${status} ${status === 401 ? "Unauthorized" : "Bad Request"}\r\n` +
|
|
305
|
+
"Connection: close\r\n" +
|
|
306
|
+
"Content-Type: text/plain; charset=utf-8\r\n" +
|
|
307
|
+
`Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`);
|
|
308
|
+
}
|
|
309
|
+
function rawDataBytes(data) {
|
|
310
|
+
if (data instanceof ArrayBuffer)
|
|
311
|
+
return new Uint8Array(data);
|
|
312
|
+
if (Array.isArray(data))
|
|
313
|
+
return Uint8Array.from(Buffer.concat(data));
|
|
314
|
+
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
315
|
+
}
|
|
316
|
+
function requiredInteger(url, field) {
|
|
317
|
+
const value = url.searchParams.get(field);
|
|
318
|
+
if (value === null || !/^\d+$/.test(value))
|
|
319
|
+
throw new Error(`${field} is required`);
|
|
320
|
+
return Number(value);
|
|
321
|
+
}
|
|
322
|
+
function optionalInteger(url, field, fallback) {
|
|
323
|
+
const value = url.searchParams.get(field);
|
|
324
|
+
if (value === null)
|
|
325
|
+
return fallback;
|
|
326
|
+
if (!/^\d+$/.test(value))
|
|
327
|
+
throw new Error(`${field} must be an integer`);
|
|
328
|
+
return Number(value);
|
|
329
|
+
}
|
|
330
|
+
function validRouteId(value) {
|
|
331
|
+
return value.length > 0 &&
|
|
332
|
+
Buffer.byteLength(value, "utf8") <= MAX_ROUTE_ID_BYTES &&
|
|
333
|
+
!/[\u0000-\u001f\u007f]/.test(value);
|
|
334
|
+
}
|
|
335
|
+
function boundedCloseReason(reason) {
|
|
336
|
+
return Buffer.byteLength(reason, "utf8") <= 123 ? reason : "Browser Surface closed";
|
|
337
|
+
}
|
|
338
|
+
function errorCode(error) {
|
|
339
|
+
return error && typeof error === "object" && "code" in error ? error.code : undefined;
|
|
340
|
+
}
|
|
341
|
+
function errorName(error) {
|
|
342
|
+
return error && typeof error === "object" && "name" in error ? error.name : undefined;
|
|
343
|
+
}
|
|
344
|
+
function errorMessage(error) {
|
|
345
|
+
return error instanceof Error ? error.message : "Browser Surface bridge failed";
|
|
346
|
+
}
|
|
347
|
+
function transportError(message) {
|
|
348
|
+
const error = new Error(message);
|
|
349
|
+
error.name = "TransportError";
|
|
350
|
+
return error;
|
|
351
|
+
}
|
|
352
|
+
function backpressureError(message) {
|
|
353
|
+
const error = new Error(message);
|
|
354
|
+
error.name = "BackpressureError";
|
|
355
|
+
return error;
|
|
356
|
+
}
|