@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,1018 @@
|
|
|
1
|
+
import { randomUUID, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { DESKTOP_BROWSER_HOST_MAX_CONTROL_FRAME_BYTES, DESKTOP_BROWSER_HOST_MAX_BINARY_FRAME_BYTES, DESKTOP_BROWSER_HOST_MAX_PENDING_COMMANDS, DESKTOP_BROWSER_HOST_MAX_QUEUED_EVENTS, DESKTOP_BROWSER_HOST_PATH, DESKTOP_BROWSER_HOST_PROTOCOL_VERSION, parseDesktopBrowserHostCdpEndpointResult, parseDesktopBrowserHostClientFrame, parseDesktopBrowserHostCompletedResult, parseDesktopBrowserHostCreateResult, parseDesktopBrowserHostPageCdpEndpointResult, parseDesktopBrowserHostSurfaceFrame, parseDesktopBrowserHostSurfaceOpenResult, parseDesktopBrowserHostSnapshot, } from "@rynx-ai/protocol/desktop-browser-host";
|
|
3
|
+
import { WebSocket, WebSocketServer } from "ws";
|
|
4
|
+
import { SessionBrowserHostError, SessionBrowserSurfaceUnsupportedError, } from "./session-browser-service.js";
|
|
5
|
+
const DEFAULT_COMMAND_TIMEOUT_MS = 120_000;
|
|
6
|
+
const DEFAULT_HEARTBEAT_INTERVAL_MS = 10_000;
|
|
7
|
+
const DEFAULT_HANDSHAKE_TIMEOUT_MS = 5_000;
|
|
8
|
+
const MAX_SOCKET_BUFFERED_BYTES = 1 * 1024 * 1024;
|
|
9
|
+
const CLOSE_BAD_PROTOCOL = 4400;
|
|
10
|
+
const CLOSE_UNSUPPORTED = 4406;
|
|
11
|
+
const CLOSE_BACKPRESSURE = 4408;
|
|
12
|
+
const CLOSE_REPLACED_OR_STALE = 4409;
|
|
13
|
+
/**
|
|
14
|
+
* The daemon-side physical Browser Host. It is safe to construct before the
|
|
15
|
+
* HTTP server exists; an authenticated desktop socket makes it live later.
|
|
16
|
+
*/
|
|
17
|
+
export class DesktopBrowserHostRegistry {
|
|
18
|
+
commandTimeoutMilliseconds;
|
|
19
|
+
now;
|
|
20
|
+
idFactory;
|
|
21
|
+
handles = new Map();
|
|
22
|
+
connectedListeners = new Set();
|
|
23
|
+
leases = new Map();
|
|
24
|
+
idSequence = 0;
|
|
25
|
+
constructor(options = {}) {
|
|
26
|
+
this.commandTimeoutMilliseconds = boundedDuration(options.commandTimeoutMilliseconds ?? DEFAULT_COMMAND_TIMEOUT_MS, "commandTimeoutMilliseconds");
|
|
27
|
+
this.now = options.now ?? Date.now;
|
|
28
|
+
this.idFactory = options.idFactory ?? randomUUID;
|
|
29
|
+
}
|
|
30
|
+
get connected() {
|
|
31
|
+
return this.currentLeases().length > 0;
|
|
32
|
+
}
|
|
33
|
+
get directConnected() {
|
|
34
|
+
return this.currentLease("direct") !== undefined;
|
|
35
|
+
}
|
|
36
|
+
/** Runs after a fresh lease frame has been queued to the Host. Listener
|
|
37
|
+
* failures are isolated from the authenticated Host handshake. */
|
|
38
|
+
onConnected(listener) {
|
|
39
|
+
this.connectedListeners.add(listener);
|
|
40
|
+
return () => this.connectedListeners.delete(listener);
|
|
41
|
+
}
|
|
42
|
+
async createBrowser(input) {
|
|
43
|
+
const lease = this.requireLease();
|
|
44
|
+
try {
|
|
45
|
+
const result = await this.request(lease, {
|
|
46
|
+
method: "browser.create",
|
|
47
|
+
sessionId: input.sessionId,
|
|
48
|
+
browserGeneration: input.browserGeneration,
|
|
49
|
+
...(input.url === undefined ? {} : { url: input.url }),
|
|
50
|
+
}, parseDesktopBrowserHostCreateResult);
|
|
51
|
+
if (!this.isCurrentLease(lease)) {
|
|
52
|
+
throw commandOutcomeError({ method: "browser.create", sessionId: input.sessionId, browserGeneration: input.browserGeneration }, "Desktop Browser Host create outcome is unknown after lease loss");
|
|
53
|
+
}
|
|
54
|
+
const key = browserKey(input.sessionId, input.browserGeneration);
|
|
55
|
+
const existing = this.handles.get(key);
|
|
56
|
+
if (existing) {
|
|
57
|
+
await this.request(lease, { method: "browser.close", sessionId: input.sessionId, browserGeneration: input.browserGeneration }, parseDesktopBrowserHostCompletedResult).catch(() => undefined);
|
|
58
|
+
throw new Error("Desktop Browser Host returned a duplicate Browser generation");
|
|
59
|
+
}
|
|
60
|
+
const handle = new DesktopBrowserHandle(this, lease, input, result.capabilities);
|
|
61
|
+
this.handles.set(key, handle);
|
|
62
|
+
return handle;
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
this.retireOffscreenIfIdle();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
async deleteSessionData(sessionId) {
|
|
69
|
+
const attemptedPresentations = new Set();
|
|
70
|
+
const failures = [];
|
|
71
|
+
let attempted = false;
|
|
72
|
+
while (true) {
|
|
73
|
+
const leases = this.currentLeases()
|
|
74
|
+
.filter((lease) => !attemptedPresentations.has(lease.presentation));
|
|
75
|
+
if (leases.length === 0)
|
|
76
|
+
break;
|
|
77
|
+
attempted = true;
|
|
78
|
+
for (const lease of leases) {
|
|
79
|
+
attemptedPresentations.add(lease.presentation);
|
|
80
|
+
}
|
|
81
|
+
const results = await Promise.allSettled(leases.map((lease) => this.request(lease, { method: "browser.delete-data", sessionId }, parseDesktopBrowserHostCompletedResult)));
|
|
82
|
+
failures.push(...results
|
|
83
|
+
.filter((result) => result.status === "rejected")
|
|
84
|
+
.map((result) => result.reason));
|
|
85
|
+
}
|
|
86
|
+
this.retireOffscreenIfIdle();
|
|
87
|
+
if (!attempted)
|
|
88
|
+
throw new DesktopBrowserHostAbsentError();
|
|
89
|
+
if (failures.length === 1)
|
|
90
|
+
throw failures[0];
|
|
91
|
+
if (failures.length > 0) {
|
|
92
|
+
throw new AggregateError(failures, "Desktop Browser Host profile cleanup failed");
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/** Attachment-only: installs one lease per presentation role. Existing
|
|
96
|
+
* generations stay pinned to their Host while new generations prefer the
|
|
97
|
+
* direct App-window Host. */
|
|
98
|
+
acceptConnection(socket, hello, daemonInstanceId, heartbeatIntervalMilliseconds) {
|
|
99
|
+
const presentation = hello.capabilities.presentation;
|
|
100
|
+
const direct = this.currentLease("direct");
|
|
101
|
+
if (presentation === "offscreen" &&
|
|
102
|
+
direct) {
|
|
103
|
+
throw new DesktopBrowserHostProtocolError("an offscreen Desktop Browser Host is unnecessary while the direct Host is active", CLOSE_REPLACED_OR_STALE);
|
|
104
|
+
}
|
|
105
|
+
const previous = this.installedLease(presentation);
|
|
106
|
+
if (previous) {
|
|
107
|
+
this.detach(previous, hostUnavailable("Desktop Browser Host lease was replaced"));
|
|
108
|
+
closeSocket(previous.socket, CLOSE_REPLACED_OR_STALE, "Desktop Browser Host lease replaced");
|
|
109
|
+
}
|
|
110
|
+
const lease = {
|
|
111
|
+
leaseId: this.nextId("lease"),
|
|
112
|
+
daemonInstanceId,
|
|
113
|
+
hostInstanceId: hello.hostInstanceId,
|
|
114
|
+
presentation: hello.capabilities.presentation,
|
|
115
|
+
semanticPageBinding: hello.capabilities.semanticPageBinding,
|
|
116
|
+
surface: hello.capabilities.surface,
|
|
117
|
+
socket,
|
|
118
|
+
pending: new Map(),
|
|
119
|
+
nextEventSequence: 1,
|
|
120
|
+
detached: false,
|
|
121
|
+
};
|
|
122
|
+
this.leases.set(presentation, lease);
|
|
123
|
+
if (presentation === "direct")
|
|
124
|
+
this.retireOffscreenIfIdle();
|
|
125
|
+
queueMicrotask(() => {
|
|
126
|
+
if (!this.isCurrentLease(lease))
|
|
127
|
+
return;
|
|
128
|
+
for (const listener of this.connectedListeners) {
|
|
129
|
+
void Promise.resolve().then(listener).catch(() => undefined);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
return {
|
|
133
|
+
type: "desktop.browser.host.lease",
|
|
134
|
+
protocolVersion: DESKTOP_BROWSER_HOST_PROTOCOL_VERSION,
|
|
135
|
+
leaseId: lease.leaseId,
|
|
136
|
+
daemonInstanceId,
|
|
137
|
+
commandTimeoutMilliseconds: this.commandTimeoutMilliseconds,
|
|
138
|
+
heartbeatIntervalMilliseconds,
|
|
139
|
+
capabilities: {
|
|
140
|
+
semanticPageBinding: hello.capabilities.semanticPageBinding,
|
|
141
|
+
surface: hello.capabilities.surface,
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
/** Attachment-only: validates lease scope before routing a client frame. */
|
|
146
|
+
receive(leaseId, frame) {
|
|
147
|
+
const lease = this.leaseById(leaseId);
|
|
148
|
+
if (!lease || frame.leaseId !== leaseId) {
|
|
149
|
+
throw new DesktopBrowserHostProtocolError("frame belongs to a stale lease", CLOSE_REPLACED_OR_STALE);
|
|
150
|
+
}
|
|
151
|
+
if (frame.type === "desktop.browser.host.reply") {
|
|
152
|
+
this.receiveReply(lease, frame);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
this.receiveEvent(lease, frame.eventSequence, frame.event);
|
|
156
|
+
}
|
|
157
|
+
/** Attachment-only: routes a fully identity-bound image envelope to one active Surface. */
|
|
158
|
+
receiveBinary(leaseId, frame) {
|
|
159
|
+
const lease = this.leaseById(leaseId);
|
|
160
|
+
if (!lease || lease.surface !== "binary-v1" || frame.leaseId !== leaseId) {
|
|
161
|
+
throw new DesktopBrowserHostProtocolError("binary frame belongs to a stale or unsupported lease", CLOSE_REPLACED_OR_STALE);
|
|
162
|
+
}
|
|
163
|
+
const handle = this.handles.get(browserKey(frame.sessionId, frame.browserGeneration));
|
|
164
|
+
if (!handle || handle.leaseId !== leaseId || !handle.available) {
|
|
165
|
+
throw new DesktopBrowserHostProtocolError("binary frame targets a stale Browser generation", CLOSE_REPLACED_OR_STALE);
|
|
166
|
+
}
|
|
167
|
+
handle.receiveSurfaceFrame(frame);
|
|
168
|
+
}
|
|
169
|
+
/** Attachment-only and identity-fenced: an old close cannot clear a new lease. */
|
|
170
|
+
disconnect(leaseId, reason = "Desktop Browser Host disconnected") {
|
|
171
|
+
const lease = this.installedLeaseById(leaseId);
|
|
172
|
+
if (!lease)
|
|
173
|
+
return;
|
|
174
|
+
this.detach(lease, hostUnavailable(reason));
|
|
175
|
+
}
|
|
176
|
+
async commandForHandle(handle, command, parseResult) {
|
|
177
|
+
const lease = this.requireCurrentHandle(handle);
|
|
178
|
+
return this.request(lease, command, parseResult);
|
|
179
|
+
}
|
|
180
|
+
forgetHandle(handle) {
|
|
181
|
+
const key = browserKey(handle.sessionId, handle.browserGeneration);
|
|
182
|
+
if (this.handles.get(key) === handle)
|
|
183
|
+
this.handles.delete(key);
|
|
184
|
+
this.retireOffscreenIfIdle();
|
|
185
|
+
}
|
|
186
|
+
createId(label) {
|
|
187
|
+
return this.nextId(label);
|
|
188
|
+
}
|
|
189
|
+
requireLease() {
|
|
190
|
+
const lease = this.currentLease("direct") ?? this.currentLease("offscreen");
|
|
191
|
+
if (!lease)
|
|
192
|
+
throw new DesktopBrowserHostAbsentError();
|
|
193
|
+
return lease;
|
|
194
|
+
}
|
|
195
|
+
requireCurrentHandle(handle) {
|
|
196
|
+
const lease = this.leaseById(handle.leaseId);
|
|
197
|
+
if (!lease ||
|
|
198
|
+
this.handles.get(browserKey(handle.sessionId, handle.browserGeneration)) !== handle ||
|
|
199
|
+
!handle.available)
|
|
200
|
+
throw hostUnavailable("Desktop Browser Host generation is unavailable");
|
|
201
|
+
return lease;
|
|
202
|
+
}
|
|
203
|
+
request(lease, command, parseResult) {
|
|
204
|
+
if (!this.isCurrentLease(lease)) {
|
|
205
|
+
return Promise.reject(command.method === "browser.create"
|
|
206
|
+
? new DesktopBrowserHostAbsentError()
|
|
207
|
+
: hostUnavailable("Desktop Browser Host lease ended"));
|
|
208
|
+
}
|
|
209
|
+
if (lease.pending.size >= DESKTOP_BROWSER_HOST_MAX_PENDING_COMMANDS) {
|
|
210
|
+
this.failLease(lease, CLOSE_BACKPRESSURE, "Desktop Browser Host command capacity exceeded");
|
|
211
|
+
return Promise.reject(new SessionBrowserHostError("capacity", "Desktop Browser Host command capacity exceeded"));
|
|
212
|
+
}
|
|
213
|
+
const commandId = this.nextId("command");
|
|
214
|
+
const deadlineUnixMilliseconds = this.now() + this.commandTimeoutMilliseconds;
|
|
215
|
+
const frame = {
|
|
216
|
+
type: "desktop.browser.host.command",
|
|
217
|
+
protocolVersion: DESKTOP_BROWSER_HOST_PROTOCOL_VERSION,
|
|
218
|
+
leaseId: lease.leaseId,
|
|
219
|
+
commandId,
|
|
220
|
+
deadlineUnixMilliseconds,
|
|
221
|
+
command,
|
|
222
|
+
};
|
|
223
|
+
const encoded = JSON.stringify(frame);
|
|
224
|
+
const encodedBytes = Buffer.byteLength(encoded, "utf8");
|
|
225
|
+
if (encodedBytes > DESKTOP_BROWSER_HOST_MAX_CONTROL_FRAME_BYTES ||
|
|
226
|
+
lease.socket.bufferedAmount + encodedBytes > MAX_SOCKET_BUFFERED_BYTES) {
|
|
227
|
+
this.failLease(lease, CLOSE_BACKPRESSURE, "Desktop Browser Host output capacity exceeded");
|
|
228
|
+
return Promise.reject(new SessionBrowserHostError("capacity", "Desktop Browser Host output capacity exceeded"));
|
|
229
|
+
}
|
|
230
|
+
return new Promise((resolve, reject) => {
|
|
231
|
+
const timer = setTimeout(() => {
|
|
232
|
+
const pending = lease.pending.get(commandId);
|
|
233
|
+
if (!pending)
|
|
234
|
+
return;
|
|
235
|
+
lease.pending.delete(commandId);
|
|
236
|
+
reject(commandOutcomeError(command, `Desktop Browser Host ${command.method} timed out`));
|
|
237
|
+
if (!isReadOnlyCommand(command)) {
|
|
238
|
+
this.failLease(lease, CLOSE_REPLACED_OR_STALE, `Desktop Browser Host ${command.method} outcome is unknown`);
|
|
239
|
+
}
|
|
240
|
+
}, this.commandTimeoutMilliseconds);
|
|
241
|
+
timer.unref?.();
|
|
242
|
+
lease.pending.set(commandId, {
|
|
243
|
+
command,
|
|
244
|
+
deadlineUnixMilliseconds,
|
|
245
|
+
parseResult,
|
|
246
|
+
resolve: (value) => resolve(value),
|
|
247
|
+
reject,
|
|
248
|
+
timer,
|
|
249
|
+
});
|
|
250
|
+
try {
|
|
251
|
+
lease.socket.send(encoded);
|
|
252
|
+
}
|
|
253
|
+
catch (error) {
|
|
254
|
+
clearTimeout(timer);
|
|
255
|
+
lease.pending.delete(commandId);
|
|
256
|
+
this.failLease(lease, CLOSE_BACKPRESSURE, "Desktop Browser Host command could not be sent");
|
|
257
|
+
reject(commandOutcomeError(command, "Desktop Browser Host command could not be sent", error));
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
nextId(label) {
|
|
262
|
+
if (this.idSequence >= Number.MAX_SAFE_INTEGER) {
|
|
263
|
+
throw new SessionBrowserHostError("capacity", "Desktop Browser Host identity space is exhausted");
|
|
264
|
+
}
|
|
265
|
+
this.idSequence += 1;
|
|
266
|
+
const base = opaqueServerId(this.idFactory(), `${label} id`);
|
|
267
|
+
const value = `${base}.${this.idSequence.toString(36)}`;
|
|
268
|
+
return opaqueServerId(value, `${label} id`);
|
|
269
|
+
}
|
|
270
|
+
receiveReply(lease, frame) {
|
|
271
|
+
const pending = lease.pending.get(frame.commandId);
|
|
272
|
+
if (!pending) {
|
|
273
|
+
throw new DesktopBrowserHostProtocolError("reply commandId is unknown or expired", CLOSE_REPLACED_OR_STALE);
|
|
274
|
+
}
|
|
275
|
+
lease.pending.delete(frame.commandId);
|
|
276
|
+
clearTimeout(pending.timer);
|
|
277
|
+
if (this.now() >= pending.deadlineUnixMilliseconds) {
|
|
278
|
+
pending.reject(commandOutcomeError(pending.command, `Desktop Browser Host ${pending.command.method} replied after its deadline`));
|
|
279
|
+
if (!isReadOnlyCommand(pending.command)) {
|
|
280
|
+
this.failLease(lease, CLOSE_REPLACED_OR_STALE, `Desktop Browser Host ${pending.command.method} outcome is unknown`);
|
|
281
|
+
}
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
if (!frame.ok) {
|
|
285
|
+
pending.reject(mapReplyError(frame, pending.command));
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
try {
|
|
289
|
+
pending.resolve(pending.parseResult(frame.result));
|
|
290
|
+
}
|
|
291
|
+
catch (error) {
|
|
292
|
+
pending.reject(error);
|
|
293
|
+
this.failLease(lease, CLOSE_BAD_PROTOCOL, "Desktop Browser Host result is malformed");
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
receiveEvent(lease, eventSequence, event) {
|
|
297
|
+
if (eventSequence !== lease.nextEventSequence) {
|
|
298
|
+
throw new DesktopBrowserHostProtocolError("event sequence is stale or discontinuous", CLOSE_REPLACED_OR_STALE);
|
|
299
|
+
}
|
|
300
|
+
lease.nextEventSequence += 1;
|
|
301
|
+
const handle = this.handles.get(browserKey(event.sessionId, event.browserGeneration));
|
|
302
|
+
if (!handle || handle.leaseId !== lease.leaseId || !handle.available) {
|
|
303
|
+
throw new DesktopBrowserHostProtocolError("event targets a stale Browser generation", CLOSE_REPLACED_OR_STALE);
|
|
304
|
+
}
|
|
305
|
+
if (event.event === "surface.closed") {
|
|
306
|
+
handle.receiveSurfaceClosed(event.hostPageId, event.surfaceId, event.reason);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
const hostEvent = toSessionBrowserHostEvent(event);
|
|
310
|
+
if (!handle.pushEvent(hostEvent)) {
|
|
311
|
+
this.failLease(lease, CLOSE_BACKPRESSURE, "Desktop Browser Host event capacity exceeded");
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
if (event.event === "unavailable") {
|
|
315
|
+
handle.markUnavailable();
|
|
316
|
+
this.forgetHandle(handle);
|
|
317
|
+
this.rejectCommandsFor(lease, event.sessionId, event.browserGeneration);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
rejectCommandsFor(lease, sessionId, generation) {
|
|
321
|
+
for (const [commandId, pending] of lease.pending) {
|
|
322
|
+
if ("browserGeneration" in pending.command &&
|
|
323
|
+
pending.command.sessionId === sessionId &&
|
|
324
|
+
pending.command.browserGeneration === generation) {
|
|
325
|
+
lease.pending.delete(commandId);
|
|
326
|
+
clearTimeout(pending.timer);
|
|
327
|
+
pending.reject(commandOutcomeError(pending.command, "Desktop Browser Host generation became unavailable"));
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
failLease(lease, code, reason) {
|
|
332
|
+
if (this.isInstalledLease(lease))
|
|
333
|
+
this.detach(lease, hostUnavailable(reason));
|
|
334
|
+
closeSocket(lease.socket, code, reason);
|
|
335
|
+
}
|
|
336
|
+
detach(lease, error) {
|
|
337
|
+
if (lease.detached)
|
|
338
|
+
return;
|
|
339
|
+
lease.detached = true;
|
|
340
|
+
if (this.leases.get(lease.presentation) === lease) {
|
|
341
|
+
this.leases.delete(lease.presentation);
|
|
342
|
+
}
|
|
343
|
+
for (const pending of lease.pending.values()) {
|
|
344
|
+
clearTimeout(pending.timer);
|
|
345
|
+
pending.reject(commandOutcomeError(pending.command, error.message, error));
|
|
346
|
+
}
|
|
347
|
+
lease.pending.clear();
|
|
348
|
+
for (const handle of [...this.handles.values()]) {
|
|
349
|
+
if (handle.leaseId !== lease.leaseId)
|
|
350
|
+
continue;
|
|
351
|
+
handle.disconnect(error.message);
|
|
352
|
+
this.forgetHandle(handle);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
currentLease(presentation) {
|
|
356
|
+
const lease = this.installedLease(presentation);
|
|
357
|
+
return lease && this.isCurrentLease(lease) ? lease : undefined;
|
|
358
|
+
}
|
|
359
|
+
currentLeases() {
|
|
360
|
+
return [...this.leases.values()].filter((lease) => this.isCurrentLease(lease));
|
|
361
|
+
}
|
|
362
|
+
leaseById(leaseId) {
|
|
363
|
+
return this.currentLeases().find((lease) => lease.leaseId === leaseId);
|
|
364
|
+
}
|
|
365
|
+
installedLease(presentation) {
|
|
366
|
+
const lease = this.leases.get(presentation);
|
|
367
|
+
return lease && this.isInstalledLease(lease) ? lease : undefined;
|
|
368
|
+
}
|
|
369
|
+
installedLeaseById(leaseId) {
|
|
370
|
+
return [...this.leases.values()].find((lease) => lease.leaseId === leaseId && this.isInstalledLease(lease));
|
|
371
|
+
}
|
|
372
|
+
isInstalledLease(lease) {
|
|
373
|
+
return !lease.detached && this.leases.get(lease.presentation) === lease;
|
|
374
|
+
}
|
|
375
|
+
isCurrentLease(lease) {
|
|
376
|
+
return this.isInstalledLease(lease) &&
|
|
377
|
+
lease.socket.readyState === WebSocket.OPEN;
|
|
378
|
+
}
|
|
379
|
+
retireOffscreenIfIdle() {
|
|
380
|
+
if (!this.directConnected)
|
|
381
|
+
return;
|
|
382
|
+
const offscreen = this.currentLease("offscreen");
|
|
383
|
+
if (!offscreen || offscreen.pending.size > 0)
|
|
384
|
+
return;
|
|
385
|
+
if ([...this.handles.values()].some((handle) => handle.available && handle.leaseId === offscreen.leaseId))
|
|
386
|
+
return;
|
|
387
|
+
this.detach(offscreen, hostUnavailable("Desktop Browser offscreen Host became idle"));
|
|
388
|
+
closeSocket(offscreen.socket, CLOSE_REPLACED_OR_STALE, "Desktop Browser offscreen Host became idle");
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
export class DesktopBrowserHostProtocolError extends Error {
|
|
392
|
+
closeCode;
|
|
393
|
+
constructor(message, closeCode = CLOSE_BAD_PROTOCOL) {
|
|
394
|
+
super(message);
|
|
395
|
+
this.closeCode = closeCode;
|
|
396
|
+
this.name = "DesktopBrowserHostProtocolError";
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
/** The only condition under which desktop-first composition may use Headless. */
|
|
400
|
+
export class DesktopBrowserHostAbsentError extends SessionBrowserHostError {
|
|
401
|
+
constructor() {
|
|
402
|
+
super("unavailable", "Desktop Browser Host is not connected");
|
|
403
|
+
this.name = "DesktopBrowserHostAbsentError";
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
export function attachDesktopBrowserHostServer(server, options) {
|
|
407
|
+
assertManagementToken(options.managementToken);
|
|
408
|
+
const daemonInstanceId = opaqueServerId(options.daemonInstanceId, "daemonInstanceId");
|
|
409
|
+
const heartbeatIntervalMilliseconds = boundedDuration(options.heartbeatIntervalMilliseconds ?? DEFAULT_HEARTBEAT_INTERVAL_MS, "heartbeatIntervalMilliseconds");
|
|
410
|
+
const handshakeTimeoutMilliseconds = boundedDuration(options.handshakeTimeoutMilliseconds ?? DEFAULT_HANDSHAKE_TIMEOUT_MS, "handshakeTimeoutMilliseconds");
|
|
411
|
+
const wss = new WebSocketServer({
|
|
412
|
+
noServer: true,
|
|
413
|
+
perMessageDeflate: false,
|
|
414
|
+
maxPayload: DESKTOP_BROWSER_HOST_MAX_BINARY_FRAME_BYTES,
|
|
415
|
+
});
|
|
416
|
+
const handleUpgrade = (request, socket, head) => {
|
|
417
|
+
let url;
|
|
418
|
+
try {
|
|
419
|
+
url = new URL(request.url ?? "", "http://127.0.0.1");
|
|
420
|
+
}
|
|
421
|
+
catch {
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
if (url.pathname !== DESKTOP_BROWSER_HOST_PATH)
|
|
425
|
+
return;
|
|
426
|
+
if (url.search.length > 0 || url.hash.length > 0) {
|
|
427
|
+
rejectUpgrade(socket, 400, "Desktop Browser Host path does not accept query parameters");
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
if (!isLoopbackAddress(request.socket.remoteAddress)) {
|
|
431
|
+
rejectUpgrade(socket, 403, "daemon management is loopback-only");
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
const token = singleHeader(request.headers["x-rynx-management-token"]);
|
|
435
|
+
if (!safeTokenEquals(token ?? "", options.managementToken)) {
|
|
436
|
+
rejectUpgrade(socket, 401, "invalid daemon management token");
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
try {
|
|
440
|
+
wss.handleUpgrade(request, socket, head, (ws) => {
|
|
441
|
+
handleDesktopConnection(ws, {
|
|
442
|
+
registry: options.registry,
|
|
443
|
+
daemonInstanceId,
|
|
444
|
+
heartbeatIntervalMilliseconds,
|
|
445
|
+
handshakeTimeoutMilliseconds,
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
catch {
|
|
450
|
+
rejectUpgrade(socket, 400, "invalid Desktop Browser Host WebSocket upgrade");
|
|
451
|
+
}
|
|
452
|
+
};
|
|
453
|
+
server.on("upgrade", handleUpgrade);
|
|
454
|
+
wss.once("close", () => server.removeListener("upgrade", handleUpgrade));
|
|
455
|
+
return wss;
|
|
456
|
+
}
|
|
457
|
+
export function createDesktopFirstSessionBrowserHost(desktop, fallback) {
|
|
458
|
+
return new DesktopFirstSessionBrowserHost(desktop, fallback);
|
|
459
|
+
}
|
|
460
|
+
class DesktopFirstSessionBrowserHost {
|
|
461
|
+
desktop;
|
|
462
|
+
fallback;
|
|
463
|
+
constructor(desktop, fallback) {
|
|
464
|
+
this.desktop = desktop;
|
|
465
|
+
this.fallback = fallback;
|
|
466
|
+
}
|
|
467
|
+
async createBrowser(input) {
|
|
468
|
+
try {
|
|
469
|
+
return await this.desktop.createBrowser(input);
|
|
470
|
+
}
|
|
471
|
+
catch (error) {
|
|
472
|
+
if (!(error instanceof DesktopBrowserHostAbsentError))
|
|
473
|
+
throw error;
|
|
474
|
+
return this.fallback.createBrowser(input);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
async deleteSessionData(sessionId) {
|
|
478
|
+
const attempts = [this.desktop.deleteSessionData, this.fallback.deleteSessionData]
|
|
479
|
+
.map((operation, index) => operation
|
|
480
|
+
? Promise.resolve().then(() => operation.call(index === 0 ? this.desktop : this.fallback, sessionId))
|
|
481
|
+
: Promise.resolve());
|
|
482
|
+
const results = await Promise.allSettled(attempts);
|
|
483
|
+
const failures = results
|
|
484
|
+
.filter((result) => result.status === "rejected")
|
|
485
|
+
.map((result) => result.reason);
|
|
486
|
+
if (failures.length > 0)
|
|
487
|
+
throw new AggregateError(failures, "Browser session data deletion failed");
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
class DesktopBrowserHandle {
|
|
491
|
+
registry;
|
|
492
|
+
lease;
|
|
493
|
+
browserGeneration;
|
|
494
|
+
executionBackend = "embedded-electron";
|
|
495
|
+
capabilities;
|
|
496
|
+
events;
|
|
497
|
+
leaseId;
|
|
498
|
+
sessionId;
|
|
499
|
+
available = true;
|
|
500
|
+
closed = false;
|
|
501
|
+
surfaces = new Map();
|
|
502
|
+
openingSurfaces = new Set();
|
|
503
|
+
eventQueue = new BoundedAsyncQueue(DESKTOP_BROWSER_HOST_MAX_QUEUED_EVENTS);
|
|
504
|
+
constructor(registry, lease, input, capabilities) {
|
|
505
|
+
this.registry = registry;
|
|
506
|
+
this.lease = lease;
|
|
507
|
+
this.leaseId = lease.leaseId;
|
|
508
|
+
this.sessionId = input.sessionId;
|
|
509
|
+
this.browserGeneration = input.browserGeneration;
|
|
510
|
+
this.capabilities = [...capabilities];
|
|
511
|
+
this.events = this.eventQueue;
|
|
512
|
+
}
|
|
513
|
+
getCdpEndpoint() {
|
|
514
|
+
return this.command({ method: "browser.cdp-endpoint", ...this.generationScope() }, parseDesktopBrowserHostCdpEndpointResult);
|
|
515
|
+
}
|
|
516
|
+
getPageCdpEndpoint(hostPageId) {
|
|
517
|
+
return this.command({ method: "page.cdp-endpoint", ...this.pageScope(hostPageId) }, parseDesktopBrowserHostPageCdpEndpointResult);
|
|
518
|
+
}
|
|
519
|
+
async openSurface(input) {
|
|
520
|
+
if (this.lease.surface !== "binary-v1") {
|
|
521
|
+
throw new SessionBrowserSurfaceUnsupportedError();
|
|
522
|
+
}
|
|
523
|
+
if (input.browserGeneration !== this.browserGeneration) {
|
|
524
|
+
throw new SessionBrowserHostError("unavailable", "Desktop Browser Host generation is stale");
|
|
525
|
+
}
|
|
526
|
+
input.signal?.throwIfAborted();
|
|
527
|
+
if (this.surfaces.has(input.hostPageId) || this.openingSurfaces.has(input.hostPageId)) {
|
|
528
|
+
throw new SessionBrowserHostError("capacity", "Desktop Browser Host Page already has an active Surface");
|
|
529
|
+
}
|
|
530
|
+
const surfaceId = this.registry.createId("surface");
|
|
531
|
+
this.openingSurfaces.add(input.hostPageId);
|
|
532
|
+
let result;
|
|
533
|
+
try {
|
|
534
|
+
result = await this.command({
|
|
535
|
+
method: "surface.open",
|
|
536
|
+
...this.pageScope(input.hostPageId),
|
|
537
|
+
surfaceId,
|
|
538
|
+
format: input.format,
|
|
539
|
+
quality: input.quality,
|
|
540
|
+
maximumFrameRate: input.maximumFrameRate,
|
|
541
|
+
}, (value) => {
|
|
542
|
+
const parsed = parseDesktopBrowserHostSurfaceOpenResult(value);
|
|
543
|
+
if (parsed.format !== input.format) {
|
|
544
|
+
throw new TypeError("surface.open changed the negotiated format");
|
|
545
|
+
}
|
|
546
|
+
return parsed;
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
finally {
|
|
550
|
+
this.openingSurfaces.delete(input.hostPageId);
|
|
551
|
+
}
|
|
552
|
+
const source = new DesktopBrowserSurfaceSource(this, input.hostPageId, surfaceId, result);
|
|
553
|
+
this.surfaces.set(input.hostPageId, source);
|
|
554
|
+
if (input.signal?.aborted) {
|
|
555
|
+
await source.close().catch(() => undefined);
|
|
556
|
+
input.signal.throwIfAborted();
|
|
557
|
+
}
|
|
558
|
+
return source;
|
|
559
|
+
}
|
|
560
|
+
snapshot() {
|
|
561
|
+
return this.command({ method: "browser.snapshot", ...this.generationScope() }, parseDesktopBrowserHostSnapshot);
|
|
562
|
+
}
|
|
563
|
+
async createPage(url) {
|
|
564
|
+
await this.completed({ method: "page.create", ...this.generationScope(), ...(url === undefined ? {} : { url }) });
|
|
565
|
+
}
|
|
566
|
+
async closePage(hostPageId) {
|
|
567
|
+
await this.surfaces.get(hostPageId)?.close().catch(() => undefined);
|
|
568
|
+
await this.completed({ method: "page.close", ...this.pageScope(hostPageId) });
|
|
569
|
+
}
|
|
570
|
+
async activatePage(hostPageId) {
|
|
571
|
+
await this.completed({ method: "page.activate", ...this.pageScope(hostPageId) });
|
|
572
|
+
}
|
|
573
|
+
async navigatePage(hostPageId, url) {
|
|
574
|
+
await this.completed({ method: "page.navigate", ...this.pageScope(hostPageId), url });
|
|
575
|
+
}
|
|
576
|
+
async goBack(hostPageId) {
|
|
577
|
+
await this.completed({ method: "page.back", ...this.pageScope(hostPageId) });
|
|
578
|
+
}
|
|
579
|
+
async goForward(hostPageId) {
|
|
580
|
+
await this.completed({ method: "page.forward", ...this.pageScope(hostPageId) });
|
|
581
|
+
}
|
|
582
|
+
async reload(hostPageId) {
|
|
583
|
+
await this.completed({ method: "page.reload", ...this.pageScope(hostPageId) });
|
|
584
|
+
}
|
|
585
|
+
async bindPage(hostPageId, pageId) {
|
|
586
|
+
if (!this.lease.semanticPageBinding)
|
|
587
|
+
return;
|
|
588
|
+
await this.completed({ method: "page.bind", ...this.pageScope(hostPageId), pageId });
|
|
589
|
+
}
|
|
590
|
+
async close() {
|
|
591
|
+
if (this.closed)
|
|
592
|
+
return;
|
|
593
|
+
this.closed = true;
|
|
594
|
+
try {
|
|
595
|
+
await Promise.allSettled([...this.surfaces.values()].map((surface) => surface.close()));
|
|
596
|
+
if (this.available) {
|
|
597
|
+
await this.completed({ method: "browser.close", ...this.generationScope() });
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
finally {
|
|
601
|
+
this.available = false;
|
|
602
|
+
this.registry.forgetHandle(this);
|
|
603
|
+
this.eventQueue.end();
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
pushEvent(event) {
|
|
607
|
+
return this.eventQueue.push(event);
|
|
608
|
+
}
|
|
609
|
+
markUnavailable() {
|
|
610
|
+
this.available = false;
|
|
611
|
+
for (const surface of [...this.surfaces.values()])
|
|
612
|
+
surface.hostDisconnected();
|
|
613
|
+
this.surfaces.clear();
|
|
614
|
+
this.eventQueue.end();
|
|
615
|
+
}
|
|
616
|
+
disconnect(reason) {
|
|
617
|
+
if (!this.available)
|
|
618
|
+
return;
|
|
619
|
+
this.eventQueue.push({ type: "unavailable", browserGeneration: this.browserGeneration, reason });
|
|
620
|
+
this.markUnavailable();
|
|
621
|
+
}
|
|
622
|
+
receiveSurfaceFrame(frame) {
|
|
623
|
+
const surface = this.surfaces.get(frame.hostPageId);
|
|
624
|
+
if (!surface || surface.surfaceId !== frame.surfaceId) {
|
|
625
|
+
throw new DesktopBrowserHostProtocolError("binary frame targets an inactive Surface", CLOSE_REPLACED_OR_STALE);
|
|
626
|
+
}
|
|
627
|
+
surface.receiveFrame(frame);
|
|
628
|
+
}
|
|
629
|
+
receiveSurfaceClosed(hostPageId, surfaceId, reason) {
|
|
630
|
+
const surface = this.surfaces.get(hostPageId);
|
|
631
|
+
if (!surface || surface.surfaceId !== surfaceId) {
|
|
632
|
+
throw new DesktopBrowserHostProtocolError("surface.closed targets an inactive Surface", CLOSE_REPLACED_OR_STALE);
|
|
633
|
+
}
|
|
634
|
+
surface.hostClosed(reason);
|
|
635
|
+
}
|
|
636
|
+
forgetSurface(surface) {
|
|
637
|
+
if (this.surfaces.get(surface.hostPageId) === surface)
|
|
638
|
+
this.surfaces.delete(surface.hostPageId);
|
|
639
|
+
}
|
|
640
|
+
generationScope() {
|
|
641
|
+
return { sessionId: this.sessionId, browserGeneration: this.browserGeneration };
|
|
642
|
+
}
|
|
643
|
+
pageScope(hostPageId) {
|
|
644
|
+
return { ...this.generationScope(), hostPageId };
|
|
645
|
+
}
|
|
646
|
+
completed(command) {
|
|
647
|
+
return this.command(command, parseDesktopBrowserHostCompletedResult);
|
|
648
|
+
}
|
|
649
|
+
surfaceCompleted(command) {
|
|
650
|
+
return this.completed(command);
|
|
651
|
+
}
|
|
652
|
+
command(command, parseResult) {
|
|
653
|
+
return this.registry.commandForHandle(this, command, parseResult);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
class DesktopBrowserSurfaceSource {
|
|
657
|
+
handle;
|
|
658
|
+
hostPageId;
|
|
659
|
+
surfaceId;
|
|
660
|
+
browserGeneration;
|
|
661
|
+
format;
|
|
662
|
+
frames;
|
|
663
|
+
frameQueue = new BoundedAsyncQueue(1);
|
|
664
|
+
viewport;
|
|
665
|
+
pendingFrameSequence;
|
|
666
|
+
lastFrameSequence = -1;
|
|
667
|
+
inputSequence = -1;
|
|
668
|
+
driver = "desktop";
|
|
669
|
+
closed = false;
|
|
670
|
+
closePromise;
|
|
671
|
+
constructor(handle, hostPageId, surfaceId, result) {
|
|
672
|
+
this.handle = handle;
|
|
673
|
+
this.hostPageId = hostPageId;
|
|
674
|
+
this.surfaceId = surfaceId;
|
|
675
|
+
this.browserGeneration = handle.browserGeneration;
|
|
676
|
+
this.format = result.format;
|
|
677
|
+
this.viewport = {
|
|
678
|
+
revision: result.viewportRevision,
|
|
679
|
+
width: result.width,
|
|
680
|
+
height: result.height,
|
|
681
|
+
deviceScaleFactor: result.deviceScaleFactor,
|
|
682
|
+
};
|
|
683
|
+
this.frames = this.frameQueue;
|
|
684
|
+
}
|
|
685
|
+
getViewport() {
|
|
686
|
+
return { ...this.viewport };
|
|
687
|
+
}
|
|
688
|
+
async setHumanDriver(driver) {
|
|
689
|
+
if (this.closed)
|
|
690
|
+
throw hostUnavailable("Desktop Browser Host Surface is closed");
|
|
691
|
+
if (this.driver === driver)
|
|
692
|
+
return;
|
|
693
|
+
await this.handle.surfaceCompleted({ method: "surface.driver", ...this.scope(), driver });
|
|
694
|
+
this.driver = driver;
|
|
695
|
+
}
|
|
696
|
+
async dispatchTrustedInput(input) {
|
|
697
|
+
if (this.closed)
|
|
698
|
+
throw hostUnavailable("Desktop Browser Host Surface is closed");
|
|
699
|
+
if (this.driver !== "stream") {
|
|
700
|
+
throw new SessionBrowserHostError("unavailable", "Desktop Browser Host Surface does not own the human driver");
|
|
701
|
+
}
|
|
702
|
+
if (input.browserGeneration !== this.browserGeneration || input.viewportRevision !== this.viewport.revision) {
|
|
703
|
+
throw new SessionBrowserHostError("unavailable", "Desktop Browser Host Surface scope is stale");
|
|
704
|
+
}
|
|
705
|
+
if (this.inputSequence >= Number.MAX_SAFE_INTEGER) {
|
|
706
|
+
throw new SessionBrowserHostError("capacity", "Desktop Browser Host Surface input identity is exhausted");
|
|
707
|
+
}
|
|
708
|
+
this.inputSequence += 1;
|
|
709
|
+
await this.handle.surfaceCompleted({
|
|
710
|
+
method: "surface.input",
|
|
711
|
+
...this.scope(),
|
|
712
|
+
viewportRevision: input.viewportRevision,
|
|
713
|
+
inputSequence: this.inputSequence,
|
|
714
|
+
event: input.event,
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
close() {
|
|
718
|
+
if (this.closePromise)
|
|
719
|
+
return this.closePromise;
|
|
720
|
+
if (this.closed)
|
|
721
|
+
return Promise.resolve();
|
|
722
|
+
this.closed = true;
|
|
723
|
+
this.pendingFrameSequence = undefined;
|
|
724
|
+
this.frameQueue.end();
|
|
725
|
+
this.closePromise = this.handle.available
|
|
726
|
+
? this.handle.surfaceCompleted({ method: "surface.close", ...this.scope() }).then(() => {
|
|
727
|
+
this.handle.forgetSurface(this);
|
|
728
|
+
})
|
|
729
|
+
: Promise.resolve().then(() => {
|
|
730
|
+
this.handle.forgetSurface(this);
|
|
731
|
+
});
|
|
732
|
+
return this.closePromise;
|
|
733
|
+
}
|
|
734
|
+
receiveFrame(frame) {
|
|
735
|
+
// The native Host may already have queued its last frame when the close
|
|
736
|
+
// command crossed the socket. Exact-identity closing frames are harmless;
|
|
737
|
+
// keeping this tombstone registered still rejects every stale Surface ID.
|
|
738
|
+
if (this.closed)
|
|
739
|
+
return;
|
|
740
|
+
if (frame.browserGeneration !== this.browserGeneration || frame.format !== this.format ||
|
|
741
|
+
frame.frameSequence <= this.lastFrameSequence || this.pendingFrameSequence !== undefined) {
|
|
742
|
+
throw new DesktopBrowserHostProtocolError("Surface frame is stale, out of order, or violates ACK backpressure", CLOSE_BACKPRESSURE);
|
|
743
|
+
}
|
|
744
|
+
this.lastFrameSequence = frame.frameSequence;
|
|
745
|
+
this.pendingFrameSequence = frame.frameSequence;
|
|
746
|
+
this.viewport = {
|
|
747
|
+
revision: frame.viewportRevision,
|
|
748
|
+
width: frame.width,
|
|
749
|
+
height: frame.height,
|
|
750
|
+
deviceScaleFactor: frame.deviceScaleFactor,
|
|
751
|
+
};
|
|
752
|
+
let acknowledged = false;
|
|
753
|
+
const accepted = this.frameQueue.push({
|
|
754
|
+
browserGeneration: frame.browserGeneration,
|
|
755
|
+
viewportRevision: frame.viewportRevision,
|
|
756
|
+
timestampMilliseconds: frame.timestampMilliseconds,
|
|
757
|
+
width: frame.width,
|
|
758
|
+
height: frame.height,
|
|
759
|
+
deviceScaleFactor: frame.deviceScaleFactor,
|
|
760
|
+
format: frame.format,
|
|
761
|
+
encodedBytes: frame.encodedBytes,
|
|
762
|
+
acknowledge: () => {
|
|
763
|
+
if (acknowledged)
|
|
764
|
+
return Promise.resolve();
|
|
765
|
+
if (this.pendingFrameSequence !== frame.frameSequence) {
|
|
766
|
+
return Promise.reject(new SessionBrowserHostError("unavailable", "Desktop Browser Host Surface frame is stale"));
|
|
767
|
+
}
|
|
768
|
+
acknowledged = true;
|
|
769
|
+
this.pendingFrameSequence = undefined;
|
|
770
|
+
return this.handle.surfaceCompleted({
|
|
771
|
+
method: "surface.frame.ack",
|
|
772
|
+
...this.scope(),
|
|
773
|
+
frameSequence: frame.frameSequence,
|
|
774
|
+
}).then(() => undefined);
|
|
775
|
+
},
|
|
776
|
+
});
|
|
777
|
+
if (!accepted) {
|
|
778
|
+
throw new DesktopBrowserHostProtocolError("Surface frame queue capacity exceeded", CLOSE_BACKPRESSURE);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
hostClosed(_reason) {
|
|
782
|
+
this.hostDisconnected();
|
|
783
|
+
this.handle.forgetSurface(this);
|
|
784
|
+
}
|
|
785
|
+
hostDisconnected() {
|
|
786
|
+
if (this.closed)
|
|
787
|
+
return;
|
|
788
|
+
this.closed = true;
|
|
789
|
+
this.pendingFrameSequence = undefined;
|
|
790
|
+
this.frameQueue.end();
|
|
791
|
+
}
|
|
792
|
+
scope() {
|
|
793
|
+
return {
|
|
794
|
+
sessionId: this.handle.sessionId,
|
|
795
|
+
browserGeneration: this.browserGeneration,
|
|
796
|
+
hostPageId: this.hostPageId,
|
|
797
|
+
surfaceId: this.surfaceId,
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
function handleDesktopConnection(socket, options) {
|
|
802
|
+
let leaseId;
|
|
803
|
+
let alive = true;
|
|
804
|
+
const handshakeTimer = setTimeout(() => {
|
|
805
|
+
closeSocket(socket, CLOSE_UNSUPPORTED, "Desktop Browser Host hello timed out");
|
|
806
|
+
}, options.handshakeTimeoutMilliseconds);
|
|
807
|
+
handshakeTimer.unref?.();
|
|
808
|
+
const heartbeat = setInterval(() => {
|
|
809
|
+
if (!alive) {
|
|
810
|
+
socket.terminate();
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
alive = false;
|
|
814
|
+
try {
|
|
815
|
+
socket.ping();
|
|
816
|
+
}
|
|
817
|
+
catch {
|
|
818
|
+
socket.terminate();
|
|
819
|
+
}
|
|
820
|
+
}, options.heartbeatIntervalMilliseconds);
|
|
821
|
+
heartbeat.unref?.();
|
|
822
|
+
socket.on("pong", () => { alive = true; });
|
|
823
|
+
socket.on("message", (data, binary) => {
|
|
824
|
+
try {
|
|
825
|
+
if (binary) {
|
|
826
|
+
if (!leaseId)
|
|
827
|
+
throw new DesktopBrowserHostProtocolError("first frame must be Desktop Browser Host hello");
|
|
828
|
+
options.registry.receiveBinary(leaseId, parseDesktopBrowserHostSurfaceFrame(rawBytes(data)));
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
const bytes = rawBytes(data);
|
|
832
|
+
if (bytes.byteLength > DESKTOP_BROWSER_HOST_MAX_CONTROL_FRAME_BYTES) {
|
|
833
|
+
throw new DesktopBrowserHostProtocolError("text control frame exceeds its byte limit", CLOSE_BACKPRESSURE);
|
|
834
|
+
}
|
|
835
|
+
const frame = parseDesktopBrowserHostClientFrame(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)));
|
|
836
|
+
if (!leaseId) {
|
|
837
|
+
if (frame.type !== "desktop.browser.host.hello") {
|
|
838
|
+
throw new DesktopBrowserHostProtocolError("first frame must be Desktop Browser Host hello");
|
|
839
|
+
}
|
|
840
|
+
clearTimeout(handshakeTimer);
|
|
841
|
+
const lease = options.registry.acceptConnection(socket, frame, options.daemonInstanceId, options.heartbeatIntervalMilliseconds);
|
|
842
|
+
leaseId = lease.leaseId;
|
|
843
|
+
socket.send(JSON.stringify(lease));
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
if (frame.type === "desktop.browser.host.hello") {
|
|
847
|
+
throw new DesktopBrowserHostProtocolError("hello may only be sent once");
|
|
848
|
+
}
|
|
849
|
+
options.registry.receive(leaseId, frame);
|
|
850
|
+
}
|
|
851
|
+
catch (error) {
|
|
852
|
+
const code = error instanceof DesktopBrowserHostProtocolError
|
|
853
|
+
? error.closeCode
|
|
854
|
+
: CLOSE_BAD_PROTOCOL;
|
|
855
|
+
closeSocket(socket, code, "invalid Desktop Browser Host frame");
|
|
856
|
+
}
|
|
857
|
+
});
|
|
858
|
+
socket.once("close", () => {
|
|
859
|
+
clearTimeout(handshakeTimer);
|
|
860
|
+
clearInterval(heartbeat);
|
|
861
|
+
if (leaseId)
|
|
862
|
+
options.registry.disconnect(leaseId);
|
|
863
|
+
});
|
|
864
|
+
socket.once("error", () => undefined);
|
|
865
|
+
}
|
|
866
|
+
function toSessionBrowserHostEvent(event) {
|
|
867
|
+
if (event.event === "changed") {
|
|
868
|
+
return { type: "changed", browserGeneration: event.browserGeneration };
|
|
869
|
+
}
|
|
870
|
+
if (event.event === "page.replaced") {
|
|
871
|
+
return {
|
|
872
|
+
type: "page.replaced",
|
|
873
|
+
browserGeneration: event.browserGeneration,
|
|
874
|
+
previousHostPageId: event.previousHostPageId,
|
|
875
|
+
page: event.page,
|
|
876
|
+
};
|
|
877
|
+
}
|
|
878
|
+
return {
|
|
879
|
+
type: "unavailable",
|
|
880
|
+
browserGeneration: event.browserGeneration,
|
|
881
|
+
reason: event.reason,
|
|
882
|
+
};
|
|
883
|
+
}
|
|
884
|
+
function mapReplyError(frame, command) {
|
|
885
|
+
if (command.method === "browser.create") {
|
|
886
|
+
if (frame.error.code === "capacity") {
|
|
887
|
+
return new SessionBrowserHostError("capacity", "Desktop Browser Host capacity was reached");
|
|
888
|
+
}
|
|
889
|
+
return commandOutcomeError(command, "Desktop Browser Host create outcome is unknown");
|
|
890
|
+
}
|
|
891
|
+
if (frame.error.code === "page_not_found") {
|
|
892
|
+
return new SessionBrowserHostError("page_not_found", "Desktop Browser Host Page was not found");
|
|
893
|
+
}
|
|
894
|
+
if (frame.error.code === "capacity") {
|
|
895
|
+
return new SessionBrowserHostError("capacity", "Desktop Browser Host capacity was reached");
|
|
896
|
+
}
|
|
897
|
+
if (frame.error.code === "unavailable" ||
|
|
898
|
+
frame.error.code === "stale_generation" ||
|
|
899
|
+
frame.error.code === "deadline_exceeded")
|
|
900
|
+
return hostUnavailable("Desktop Browser Host is unavailable");
|
|
901
|
+
if (frame.error.code === "internal") {
|
|
902
|
+
return commandOutcomeError(command, "Desktop Browser Host command outcome is unknown");
|
|
903
|
+
}
|
|
904
|
+
return new Error("Desktop Browser Host command failed");
|
|
905
|
+
}
|
|
906
|
+
function commandOutcomeError(command, message, cause) {
|
|
907
|
+
const readOnly = isReadOnlyCommand(command);
|
|
908
|
+
return new SessionBrowserHostError(readOnly ? "unavailable" : "outcome_unknown", message, cause === undefined ? undefined : { cause });
|
|
909
|
+
}
|
|
910
|
+
function isReadOnlyCommand(command) {
|
|
911
|
+
return command.method === "browser.snapshot" ||
|
|
912
|
+
command.method === "browser.cdp-endpoint" ||
|
|
913
|
+
command.method === "page.cdp-endpoint";
|
|
914
|
+
}
|
|
915
|
+
class BoundedAsyncQueue {
|
|
916
|
+
capacity;
|
|
917
|
+
values = [];
|
|
918
|
+
waiters = [];
|
|
919
|
+
ended = false;
|
|
920
|
+
constructor(capacity) {
|
|
921
|
+
this.capacity = capacity;
|
|
922
|
+
}
|
|
923
|
+
push(value) {
|
|
924
|
+
if (this.ended)
|
|
925
|
+
return false;
|
|
926
|
+
const waiter = this.waiters.shift();
|
|
927
|
+
if (waiter) {
|
|
928
|
+
waiter({ value, done: false });
|
|
929
|
+
return true;
|
|
930
|
+
}
|
|
931
|
+
if (this.values.length >= this.capacity)
|
|
932
|
+
return false;
|
|
933
|
+
this.values.push(value);
|
|
934
|
+
return true;
|
|
935
|
+
}
|
|
936
|
+
end() {
|
|
937
|
+
if (this.ended)
|
|
938
|
+
return;
|
|
939
|
+
this.ended = true;
|
|
940
|
+
for (const waiter of this.waiters.splice(0))
|
|
941
|
+
waiter({ value: undefined, done: true });
|
|
942
|
+
}
|
|
943
|
+
[Symbol.asyncIterator]() {
|
|
944
|
+
return {
|
|
945
|
+
next: () => {
|
|
946
|
+
const value = this.values.shift();
|
|
947
|
+
if (value !== undefined)
|
|
948
|
+
return Promise.resolve({ value, done: false });
|
|
949
|
+
if (this.ended)
|
|
950
|
+
return Promise.resolve({ value: undefined, done: true });
|
|
951
|
+
return new Promise((resolve) => this.waiters.push(resolve));
|
|
952
|
+
},
|
|
953
|
+
};
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
function rawBytes(data) {
|
|
957
|
+
let bytes;
|
|
958
|
+
if (data instanceof ArrayBuffer)
|
|
959
|
+
bytes = new Uint8Array(data);
|
|
960
|
+
else if (Array.isArray(data))
|
|
961
|
+
bytes = Buffer.concat(data);
|
|
962
|
+
else
|
|
963
|
+
bytes = data;
|
|
964
|
+
return bytes;
|
|
965
|
+
}
|
|
966
|
+
function browserKey(sessionId, generation) {
|
|
967
|
+
return `${sessionId}\u0000${generation}`;
|
|
968
|
+
}
|
|
969
|
+
function hostUnavailable(message, cause) {
|
|
970
|
+
return new SessionBrowserHostError("unavailable", message, cause === undefined ? undefined : { cause });
|
|
971
|
+
}
|
|
972
|
+
function closeSocket(socket, code, reason) {
|
|
973
|
+
if (socket.readyState !== WebSocket.OPEN && socket.readyState !== WebSocket.CONNECTING)
|
|
974
|
+
return;
|
|
975
|
+
try {
|
|
976
|
+
socket.close(code, reason.slice(0, 123));
|
|
977
|
+
}
|
|
978
|
+
catch {
|
|
979
|
+
socket.terminate();
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
function rejectUpgrade(socket, status, message) {
|
|
983
|
+
if (socket.destroyed)
|
|
984
|
+
return;
|
|
985
|
+
const body = `${message}\n`;
|
|
986
|
+
socket.end(`HTTP/1.1 ${status} ${status === 401 ? "Unauthorized" : status === 403 ? "Forbidden" : "Bad Request"}\r\n` +
|
|
987
|
+
"Connection: close\r\n" +
|
|
988
|
+
"Content-Type: text/plain; charset=utf-8\r\n" +
|
|
989
|
+
`Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`);
|
|
990
|
+
}
|
|
991
|
+
function singleHeader(value) {
|
|
992
|
+
return typeof value === "string" ? value : undefined;
|
|
993
|
+
}
|
|
994
|
+
function safeTokenEquals(actual, expected) {
|
|
995
|
+
const left = Buffer.from(actual, "utf8");
|
|
996
|
+
const right = Buffer.from(expected, "utf8");
|
|
997
|
+
return left.byteLength === right.byteLength && timingSafeEqual(left, right);
|
|
998
|
+
}
|
|
999
|
+
function isLoopbackAddress(value) {
|
|
1000
|
+
const normalized = value?.startsWith("::ffff:") ? value.slice("::ffff:".length) : value;
|
|
1001
|
+
return normalized === "127.0.0.1" || normalized === "::1";
|
|
1002
|
+
}
|
|
1003
|
+
function assertManagementToken(value) {
|
|
1004
|
+
const bytes = typeof value === "string" ? Buffer.byteLength(value, "utf8") : 0;
|
|
1005
|
+
if (bytes < 32 || bytes > 256)
|
|
1006
|
+
throw new Error("Desktop Browser Host management token is invalid");
|
|
1007
|
+
}
|
|
1008
|
+
function opaqueServerId(value, field) {
|
|
1009
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._~-]{0,511}$/.test(value))
|
|
1010
|
+
throw new Error(`${field} is invalid`);
|
|
1011
|
+
return value;
|
|
1012
|
+
}
|
|
1013
|
+
function boundedDuration(value, field) {
|
|
1014
|
+
if (!Number.isSafeInteger(value) || value < 10 || value > 10 * 60_000) {
|
|
1015
|
+
throw new Error(`${field} must be an integer between 10 and 600000 milliseconds`);
|
|
1016
|
+
}
|
|
1017
|
+
return value;
|
|
1018
|
+
}
|