@rynx-ai/daemon 0.1.0 → 0.1.9
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-cli-args.d.ts +20 -0
- package/dist/browser-cli-args.js +100 -0
- package/dist/browser-runner-session-context.d.ts +24 -0
- package/dist/browser-runner-session-context.js +113 -0
- package/dist/cdp-keyboard-input.d.ts +6 -0
- package/dist/cdp-keyboard-input.js +209 -0
- package/dist/channel-store.d.ts +0 -6
- package/dist/channel-store.js +2 -18
- package/dist/chrome-for-testing-release-resolver.d.ts +34 -0
- package/dist/chrome-for-testing-release-resolver.js +214 -0
- package/dist/chrome-for-testing-store.d.ts +85 -0
- package/dist/chrome-for-testing-store.js +836 -0
- package/dist/cli.js +467 -30
- package/dist/control-client.d.ts +113 -0
- package/dist/control-client.js +792 -0
- package/dist/control-deps.js +37 -6
- package/dist/control-endpoint.d.ts +14 -0
- package/dist/control-endpoint.js +124 -0
- package/dist/control-link-store.d.ts +16 -0
- package/dist/control-link-store.js +53 -0
- package/dist/daemon-owner.d.ts +14 -0
- package/dist/daemon-owner.js +104 -0
- package/dist/daemon-server.d.ts +12 -2
- package/dist/daemon-server.js +559 -28
- package/dist/db.js +140 -10
- package/dist/desktop-browser-host-client.d.ts +34 -0
- package/dist/desktop-browser-host-client.js +413 -0
- package/dist/direct-runtime-authenticator.d.ts +13 -0
- package/dist/direct-runtime-authenticator.js +127 -0
- package/dist/direct-runtime-config.d.ts +20 -0
- package/dist/direct-runtime-config.js +101 -0
- package/dist/embedded-browser-profile-cleanup.d.ts +58 -0
- package/dist/embedded-browser-profile-cleanup.js +338 -0
- package/dist/headless-browser-host.d.ts +14 -0
- package/dist/headless-browser-host.js +2156 -0
- package/dist/index-daemon.js +28 -5
- package/dist/installation-id.d.ts +33 -0
- package/dist/installation-id.js +692 -0
- package/dist/migrations/cleanup-legacy-sessions.d.ts +0 -3
- package/dist/migrations/cleanup-legacy-sessions.js +10 -28
- package/dist/plugin-cli.d.ts +30 -0
- package/dist/plugin-cli.js +270 -0
- package/dist/plugin-host-rpc.d.ts +85 -0
- package/dist/plugin-host-rpc.js +878 -0
- package/dist/plugin-install-restart.d.ts +13 -0
- package/dist/plugin-install-restart.js +24 -0
- package/dist/plugin-installer.d.ts +36 -15
- package/dist/plugin-installer.js +420 -155
- package/dist/plugin-package.d.ts +38 -0
- package/dist/plugin-package.js +553 -0
- package/dist/plugin-runtime-control.d.ts +2 -0
- package/dist/plugin-runtime-control.js +37 -0
- package/dist/plugin-store.d.ts +37 -8
- package/dist/plugin-store.js +80 -15
- package/dist/plugin-supervisor.d.ts +160 -0
- package/dist/plugin-supervisor.js +1143 -0
- package/dist/pm2.d.ts +3 -1
- package/dist/pm2.js +55 -13
- package/dist/provider-cli-service.d.ts +46 -0
- package/dist/provider-cli-service.js +510 -0
- package/dist/registry.d.ts +6 -19
- package/dist/registry.js +22 -87
- package/dist/remote-runtime-access-store.d.ts +61 -0
- package/dist/remote-runtime-access-store.js +386 -0
- package/dist/remote-runtime-admin.d.ts +32 -0
- package/dist/remote-runtime-admin.js +107 -0
- package/dist/remote-runtime-connection-manager.d.ts +92 -0
- package/dist/remote-runtime-connection-manager.js +1409 -0
- package/dist/remote-runtime-credential-store.d.ts +45 -0
- package/dist/remote-runtime-credential-store.js +639 -0
- package/dist/remote-runtime-target-control.d.ts +47 -0
- package/dist/remote-runtime-target-control.js +433 -0
- package/dist/remote-runtime-target-store.d.ts +53 -0
- package/dist/remote-runtime-target-store.js +307 -0
- package/dist/runtime-share-addresses.d.ts +21 -0
- package/dist/runtime-share-addresses.js +50 -0
- package/dist/session-browser-capability-store.d.ts +13 -0
- package/dist/session-browser-capability-store.js +58 -0
- package/dist/session-emulator-binding-store.d.ts +10 -0
- package/dist/session-emulator-binding-store.js +61 -0
- package/dist/session-launch-operations.d.ts +41 -0
- package/dist/session-launch-operations.js +124 -0
- package/dist/session-meta-store.d.ts +3 -2
- package/dist/session-meta-store.js +57 -7
- package/dist/setup.js +9 -4
- package/dist/skills-catalog.js +8 -4
- package/dist/update.d.ts +11 -2
- package/dist/update.js +0 -27
- package/package.json +18 -10
|
@@ -0,0 +1,2156 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { execFile, spawn } from "node:child_process";
|
|
3
|
+
import { accessSync, chmodSync, closeSync, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync, } from "node:fs";
|
|
4
|
+
import { constants as fsConstants } from "node:fs";
|
|
5
|
+
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
6
|
+
import { rynxHome } from "@rynx-ai/core";
|
|
7
|
+
import { RUNTIME_BROWSER_MAX_PAGES, RUNTIME_BROWSER_MAX_TITLE_CHARS, RUNTIME_BROWSER_MAX_URL_CHARS, } from "@rynx-ai/protocol/runtime-browser";
|
|
8
|
+
import { RUNTIME_BROWSER_SURFACE_MAX_ENCODED_FRAME_BYTES, RUNTIME_BROWSER_SURFACE_MAX_FRAME_RATE, RUNTIME_BROWSER_SURFACE_MAX_VIEWPORT_HEIGHT, RUNTIME_BROWSER_SURFACE_MAX_VIEWPORT_PIXELS, RUNTIME_BROWSER_SURFACE_MAX_VIEWPORT_WIDTH, } from "@rynx-ai/protocol/runtime-browser-surface";
|
|
9
|
+
import { SessionBrowserHostError, } from "@rynx-ai/server";
|
|
10
|
+
import WebSocket from "ws";
|
|
11
|
+
import { createChromeForTestingArtifactStore, } from "./chrome-for-testing-store.js";
|
|
12
|
+
import { cdpKeyboardEventParams } from "./cdp-keyboard-input.js";
|
|
13
|
+
const PROFILE_OWNER_FILE = ".rynx-headless-browser-profile.json";
|
|
14
|
+
const PROCESS_LEASE_FILE = ".rynx-headless-browser-process.json";
|
|
15
|
+
const DEVTOOLS_ACTIVE_PORT_FILE = "DevToolsActivePort";
|
|
16
|
+
const MAX_PROFILE_METADATA_BYTES = 4 * 1_024;
|
|
17
|
+
const MAX_PROCESS_COMMAND_BYTES = 64 * 1_024;
|
|
18
|
+
const MAX_ACTIVE_PORT_BYTES = 1_024;
|
|
19
|
+
const MAX_CDP_MESSAGE_BYTES = 4 * 1_024 * 1_024;
|
|
20
|
+
const DEFAULT_STARTUP_TIMEOUT_MS = 15_000;
|
|
21
|
+
const DEFAULT_CDP_COMMAND_TIMEOUT_MS = 10_000;
|
|
22
|
+
const DEFAULT_TERMINATION_GRACE_MS = 2_500;
|
|
23
|
+
const DEFAULT_ACTIVE_PORT_POLL_MS = 25;
|
|
24
|
+
const STARTUP_PAGE_CLEANUP_ATTEMPTS = 40;
|
|
25
|
+
const STARTUP_PAGE_CLEANUP_POLL_MS = 25;
|
|
26
|
+
const PAGE_SNAPSHOT_TRANSITION_ATTEMPTS = 5;
|
|
27
|
+
const PAGE_SNAPSHOT_TRANSITION_POLL_MS = 25;
|
|
28
|
+
const BROWSER_CAPABILITIES = ["navigation", "pages", "runtime-local-cdp", "surface"];
|
|
29
|
+
const MIN_SURFACE_QUALITY = 1;
|
|
30
|
+
const MAX_SURFACE_QUALITY = 100;
|
|
31
|
+
const MIN_SURFACE_FALLBACK_INTERVAL_MS = 200;
|
|
32
|
+
const MIN_SURFACE_SCREENCAST_IDLE_MS = 250;
|
|
33
|
+
const DEVTOOLS_BROWSER_PATH = /^\/devtools\/browser\/[A-Za-z0-9._~-]+$/;
|
|
34
|
+
const CONTROL_CHARACTER_PATTERN = /[\u0000-\u001f\u007f]/;
|
|
35
|
+
const CONTROL_CHARACTER_GLOBAL_PATTERN = /[\u0000-\u001f\u007f]/g;
|
|
36
|
+
export function createHeadlessBrowserHost(options = {}) {
|
|
37
|
+
return new HeadlessBrowserHost(options);
|
|
38
|
+
}
|
|
39
|
+
export function headlessBrowserProfilePath(profileRoot, sessionId) {
|
|
40
|
+
const digest = createHash("sha256").update(sessionId, "utf8").digest("hex");
|
|
41
|
+
return join(resolve(profileRoot), `session-${digest}`);
|
|
42
|
+
}
|
|
43
|
+
class HeadlessBrowserHost {
|
|
44
|
+
artifactStore;
|
|
45
|
+
profileRoot;
|
|
46
|
+
startupTimeoutMs;
|
|
47
|
+
cdpCommandTimeoutMs;
|
|
48
|
+
terminationGraceMs;
|
|
49
|
+
activePortPollMs;
|
|
50
|
+
active = new Map();
|
|
51
|
+
creating = new Set();
|
|
52
|
+
recovery;
|
|
53
|
+
constructor(options) {
|
|
54
|
+
// Resolve the currently supported host only when Browser creation is
|
|
55
|
+
// requested, so an unsupported platform can still run the rest of Rynx.
|
|
56
|
+
this.artifactStore = options.artifactStore;
|
|
57
|
+
this.profileRoot = resolve(options.profileRoot ?? join(rynxHome(), "browser", "profiles", "headless-chromium"));
|
|
58
|
+
this.startupTimeoutMs = positiveDuration(options.startupTimeoutMs, DEFAULT_STARTUP_TIMEOUT_MS, "startupTimeoutMs");
|
|
59
|
+
this.cdpCommandTimeoutMs = positiveDuration(options.cdpCommandTimeoutMs, DEFAULT_CDP_COMMAND_TIMEOUT_MS, "cdpCommandTimeoutMs");
|
|
60
|
+
this.terminationGraceMs = positiveDuration(options.terminationGraceMs, DEFAULT_TERMINATION_GRACE_MS, "terminationGraceMs");
|
|
61
|
+
this.activePortPollMs = positiveDuration(options.activePortPollMs, DEFAULT_ACTIVE_PORT_POLL_MS, "activePortPollMs");
|
|
62
|
+
// A newly constructed Host represents the current daemon incarnation.
|
|
63
|
+
// Start fenced orphan recovery immediately instead of waiting for the next
|
|
64
|
+
// Browser mutation; retain the same promise so create/delete still observe
|
|
65
|
+
// and surface a recovery failure.
|
|
66
|
+
this.recovery = recoverOwnedProfileProcesses(this.profileRoot, this.terminationGraceMs);
|
|
67
|
+
void this.recovery.catch(() => undefined);
|
|
68
|
+
}
|
|
69
|
+
async createBrowser(input) {
|
|
70
|
+
await this.recoverOrphanedProcesses();
|
|
71
|
+
if (this.active.has(input.sessionId) || this.creating.has(input.sessionId)) {
|
|
72
|
+
throw new SessionBrowserHostError("capacity", "A Headless Browser process already owns this Session profile");
|
|
73
|
+
}
|
|
74
|
+
this.creating.add(input.sessionId);
|
|
75
|
+
try {
|
|
76
|
+
return await this.createBrowserGeneration(input);
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
this.creating.delete(input.sessionId);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
async createBrowserGeneration(input) {
|
|
83
|
+
const reservation = await this.reserveActiveBuildForLaunch();
|
|
84
|
+
const build = reservation.build;
|
|
85
|
+
let profileDir;
|
|
86
|
+
try {
|
|
87
|
+
validateActiveExecutable(build);
|
|
88
|
+
profileDir = ensureOwnedProfile(this.profileRoot, input.sessionId);
|
|
89
|
+
assertProfileProcessLeaseClear(profileDir);
|
|
90
|
+
removeStaleActivePort(profileDir);
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
reservation.release();
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
const ownerToken = randomBytes(32).toString("hex");
|
|
97
|
+
const args = chromeArguments(profileDir, ownerToken, input.url ?? "about:blank");
|
|
98
|
+
let child;
|
|
99
|
+
try {
|
|
100
|
+
child = spawn(build.executablePath, args, {
|
|
101
|
+
detached: true,
|
|
102
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
reservation.release();
|
|
107
|
+
throw unavailable("Managed Chrome for Testing could not be launched", error);
|
|
108
|
+
}
|
|
109
|
+
const observed = observeProcess(child);
|
|
110
|
+
const diagnostics = boundedStderr(child);
|
|
111
|
+
let connection;
|
|
112
|
+
let handle;
|
|
113
|
+
let lease;
|
|
114
|
+
try {
|
|
115
|
+
await observed.spawned;
|
|
116
|
+
lease = writeProcessLease(profileDir, build.executablePath, child, ownerToken);
|
|
117
|
+
// pruneBuilds shares this store lock. Release it only after the live
|
|
118
|
+
// process has a visible owned lease that pruning will retain.
|
|
119
|
+
reservation.release();
|
|
120
|
+
const endpoint = await waitForDevToolsActivePort({
|
|
121
|
+
profileDir,
|
|
122
|
+
exit: observed.exit,
|
|
123
|
+
timeoutMs: this.startupTimeoutMs,
|
|
124
|
+
pollMs: this.activePortPollMs,
|
|
125
|
+
diagnostics,
|
|
126
|
+
});
|
|
127
|
+
connection = await CdpConnection.connect(endpoint, this.cdpCommandTimeoutMs);
|
|
128
|
+
const version = await connection.command("Browser.getVersion");
|
|
129
|
+
const engineVersion = boundedEngineVersion(version.product);
|
|
130
|
+
handle = new HeadlessBrowserHandle({
|
|
131
|
+
input,
|
|
132
|
+
endpoint,
|
|
133
|
+
engineVersion,
|
|
134
|
+
child,
|
|
135
|
+
exit: observed.exit,
|
|
136
|
+
profileDir,
|
|
137
|
+
ownerToken,
|
|
138
|
+
connection,
|
|
139
|
+
terminationGraceMs: this.terminationGraceMs,
|
|
140
|
+
release: () => {
|
|
141
|
+
if (this.active.get(input.sessionId) === handle)
|
|
142
|
+
this.active.delete(input.sessionId);
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
await handle.initialize();
|
|
146
|
+
this.active.set(input.sessionId, handle);
|
|
147
|
+
return handle;
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
connection?.close();
|
|
151
|
+
await terminateProcessGroup(child, observed.exit, this.terminationGraceMs).catch(() => undefined);
|
|
152
|
+
if (lease)
|
|
153
|
+
removeMatchingProcessLease(profileDir, ownerToken);
|
|
154
|
+
throw error instanceof SessionBrowserHostError
|
|
155
|
+
? error
|
|
156
|
+
: unavailable(`Managed Chrome for Testing did not become ready${diagnostics() ? `: ${diagnostics()}` : ""}`, error);
|
|
157
|
+
}
|
|
158
|
+
finally {
|
|
159
|
+
reservation.release();
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
async deleteSessionData(sessionId) {
|
|
163
|
+
await this.recoverOrphanedProcesses();
|
|
164
|
+
if (this.creating.has(sessionId)) {
|
|
165
|
+
throw new SessionBrowserHostError("unavailable", "Headless Browser Session data cannot be deleted while its process is starting");
|
|
166
|
+
}
|
|
167
|
+
const live = this.active.get(sessionId);
|
|
168
|
+
if (live)
|
|
169
|
+
await live.close();
|
|
170
|
+
removeOwnedProfile(this.profileRoot, sessionId);
|
|
171
|
+
}
|
|
172
|
+
recoverOrphanedProcesses() {
|
|
173
|
+
return this.recovery ??= recoverOwnedProfileProcesses(this.profileRoot, this.terminationGraceMs);
|
|
174
|
+
}
|
|
175
|
+
async reserveActiveBuildForLaunch() {
|
|
176
|
+
let reservation;
|
|
177
|
+
try {
|
|
178
|
+
this.artifactStore ??= createChromeForTestingArtifactStore();
|
|
179
|
+
reservation = await this.artifactStore.reserveActiveForLaunch();
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
throw unavailable("The active Chrome for Testing build is invalid", error);
|
|
183
|
+
}
|
|
184
|
+
if (!reservation) {
|
|
185
|
+
throw new SessionBrowserHostError("unavailable", "No managed Chrome for Testing build is installed");
|
|
186
|
+
}
|
|
187
|
+
return reservation;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
class HeadlessBrowserHandle {
|
|
191
|
+
host;
|
|
192
|
+
executionBackend = "headless-chromium";
|
|
193
|
+
capabilities = BROWSER_CAPABILITIES;
|
|
194
|
+
events;
|
|
195
|
+
eventQueue = new BrowserHostEventQueue();
|
|
196
|
+
targets = new Map();
|
|
197
|
+
closingTargetIds = new Set();
|
|
198
|
+
pageSessions = new Map();
|
|
199
|
+
targetBySession = new Map();
|
|
200
|
+
surfaceSources = new Map();
|
|
201
|
+
loadingTargets = new Set();
|
|
202
|
+
activeTargetId = null;
|
|
203
|
+
unavailableReason;
|
|
204
|
+
closing = false;
|
|
205
|
+
closePromise;
|
|
206
|
+
operations = Promise.resolve();
|
|
207
|
+
constructor(host) {
|
|
208
|
+
this.host = host;
|
|
209
|
+
this.events = this.eventQueue;
|
|
210
|
+
this.host.connection.setEventHandler((event) => this.onCdpEvent(event));
|
|
211
|
+
this.host.connection.setDisconnectHandler((reason) => this.markUnavailable(reason));
|
|
212
|
+
void this.host.exit.then((result) => {
|
|
213
|
+
removeMatchingProcessLease(this.host.profileDir, this.host.ownerToken);
|
|
214
|
+
if (!this.closing) {
|
|
215
|
+
const reason = `Chrome for Testing exited (${result.code ?? result.signal ?? "unknown"})`;
|
|
216
|
+
this.markUnavailable(reason);
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
get browserGeneration() {
|
|
221
|
+
return this.host.input.browserGeneration;
|
|
222
|
+
}
|
|
223
|
+
async initialize() {
|
|
224
|
+
await this.host.connection.command("Target.setDiscoverTargets", { discover: true });
|
|
225
|
+
await this.replaceRestoredStartupPages(this.host.input.url ?? "about:blank");
|
|
226
|
+
await this.refreshTargets();
|
|
227
|
+
const requestedUrl = this.host.input.url;
|
|
228
|
+
if (requestedUrl) {
|
|
229
|
+
let requestedTarget;
|
|
230
|
+
for (const target of this.targets.values()) {
|
|
231
|
+
if (target.url === requestedUrl)
|
|
232
|
+
requestedTarget = target;
|
|
233
|
+
}
|
|
234
|
+
if (requestedTarget) {
|
|
235
|
+
await this.host.connection.command("Target.activateTarget", {
|
|
236
|
+
targetId: requestedTarget.targetId,
|
|
237
|
+
});
|
|
238
|
+
this.activeTargetId = requestedTarget.targetId;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
async getCdpEndpoint() {
|
|
243
|
+
this.ensureAvailable();
|
|
244
|
+
return { endpoint: this.host.endpoint, engineVersion: this.host.engineVersion };
|
|
245
|
+
}
|
|
246
|
+
openSurface(input) {
|
|
247
|
+
return this.exclusive(async () => {
|
|
248
|
+
this.ensureAvailable();
|
|
249
|
+
validateSurfaceOpenInput(input);
|
|
250
|
+
if (input.browserGeneration !== this.browserGeneration) {
|
|
251
|
+
throw new SessionBrowserHostError("unavailable", "Browser Surface generation is stale");
|
|
252
|
+
}
|
|
253
|
+
this.requireTarget(input.hostPageId);
|
|
254
|
+
if (input.signal?.aborted) {
|
|
255
|
+
throw new SessionBrowserHostError("unavailable", "Browser Surface open was cancelled");
|
|
256
|
+
}
|
|
257
|
+
if (this.surfaceSources.has(input.hostPageId)) {
|
|
258
|
+
throw new SessionBrowserHostError("capacity", "A Browser Surface already owns this physical Page source");
|
|
259
|
+
}
|
|
260
|
+
const pageSessionId = await this.pageSession(input.hostPageId);
|
|
261
|
+
let source;
|
|
262
|
+
source = new HeadlessBrowserSurfaceSource({
|
|
263
|
+
input,
|
|
264
|
+
pageSessionId,
|
|
265
|
+
connection: this.host.connection,
|
|
266
|
+
ensureCurrent: () => {
|
|
267
|
+
this.ensureAvailable();
|
|
268
|
+
this.requireTarget(input.hostPageId);
|
|
269
|
+
if (this.surfaceSources.get(input.hostPageId) !== source) {
|
|
270
|
+
throw new SessionBrowserHostError("unavailable", "Browser Surface is closed");
|
|
271
|
+
}
|
|
272
|
+
},
|
|
273
|
+
mapError: (error) => this.mapOperationError(error),
|
|
274
|
+
release: () => {
|
|
275
|
+
if (this.surfaceSources.get(input.hostPageId) === source) {
|
|
276
|
+
this.surfaceSources.delete(input.hostPageId);
|
|
277
|
+
}
|
|
278
|
+
},
|
|
279
|
+
});
|
|
280
|
+
this.surfaceSources.set(input.hostPageId, source);
|
|
281
|
+
try {
|
|
282
|
+
await source.initialize();
|
|
283
|
+
return source;
|
|
284
|
+
}
|
|
285
|
+
catch (error) {
|
|
286
|
+
await source.close().catch(() => undefined);
|
|
287
|
+
throw this.mapOperationError(error);
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
snapshot() {
|
|
292
|
+
return this.exclusive(async () => {
|
|
293
|
+
this.ensureAvailable();
|
|
294
|
+
await this.refreshTargets();
|
|
295
|
+
const pages = [];
|
|
296
|
+
for (const target of this.targets.values()) {
|
|
297
|
+
try {
|
|
298
|
+
pages.push(await this.snapshotPage(target));
|
|
299
|
+
}
|
|
300
|
+
catch (error) {
|
|
301
|
+
if (isMissingPageError(error)) {
|
|
302
|
+
this.dropTarget(target.targetId);
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
throw this.mapOperationError(error);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
if (this.activeTargetId !== null && !pages.some((page) => page.hostPageId === this.activeTargetId)) {
|
|
309
|
+
this.activeTargetId = pages.at(-1)?.hostPageId ?? null;
|
|
310
|
+
}
|
|
311
|
+
return { pages, activeHostPageId: this.activeTargetId };
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
createPage(url = "about:blank") {
|
|
315
|
+
return this.exclusive(async () => {
|
|
316
|
+
this.ensureAvailable();
|
|
317
|
+
try {
|
|
318
|
+
await this.refreshTargets();
|
|
319
|
+
if (this.targets.size >= RUNTIME_BROWSER_MAX_PAGES) {
|
|
320
|
+
throw new SessionBrowserHostError("capacity", "Headless Browser Page capacity was reached");
|
|
321
|
+
}
|
|
322
|
+
const result = await this.host.connection.command("Target.createTarget", { url });
|
|
323
|
+
const targetId = requiredCdpId(result.targetId, "Target.createTarget targetId");
|
|
324
|
+
this.activeTargetId = targetId;
|
|
325
|
+
await this.refreshTargets();
|
|
326
|
+
this.eventQueue.changed(this.browserGeneration);
|
|
327
|
+
}
|
|
328
|
+
catch (error) {
|
|
329
|
+
throw this.mapOperationError(error);
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
closePage(hostPageId) {
|
|
334
|
+
return this.exclusive(async () => {
|
|
335
|
+
this.ensureAvailable();
|
|
336
|
+
try {
|
|
337
|
+
this.requireTarget(hostPageId);
|
|
338
|
+
const result = await this.host.connection.command("Target.closeTarget", {
|
|
339
|
+
targetId: hostPageId,
|
|
340
|
+
});
|
|
341
|
+
if (result.success !== true)
|
|
342
|
+
throw missingPage();
|
|
343
|
+
this.closingTargetIds.add(hostPageId);
|
|
344
|
+
this.dropTarget(hostPageId);
|
|
345
|
+
if (this.activeTargetId === hostPageId) {
|
|
346
|
+
this.activeTargetId = [...this.targets.keys()].at(-1) ?? null;
|
|
347
|
+
}
|
|
348
|
+
this.eventQueue.changed(this.browserGeneration);
|
|
349
|
+
}
|
|
350
|
+
catch (error) {
|
|
351
|
+
throw this.mapOperationError(error);
|
|
352
|
+
}
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
activatePage(hostPageId) {
|
|
356
|
+
return this.exclusive(async () => {
|
|
357
|
+
this.ensureAvailable();
|
|
358
|
+
this.requireTarget(hostPageId);
|
|
359
|
+
try {
|
|
360
|
+
await this.host.connection.command("Target.activateTarget", { targetId: hostPageId });
|
|
361
|
+
}
|
|
362
|
+
catch (error) {
|
|
363
|
+
throw this.mapOperationError(error);
|
|
364
|
+
}
|
|
365
|
+
this.activeTargetId = hostPageId;
|
|
366
|
+
this.eventQueue.changed(this.browserGeneration);
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
navigatePage(hostPageId, url) {
|
|
370
|
+
return this.pageCommand(hostPageId, "Page.navigate", { url });
|
|
371
|
+
}
|
|
372
|
+
goBack(hostPageId) {
|
|
373
|
+
return this.navigateHistory(hostPageId, -1);
|
|
374
|
+
}
|
|
375
|
+
goForward(hostPageId) {
|
|
376
|
+
return this.navigateHistory(hostPageId, 1);
|
|
377
|
+
}
|
|
378
|
+
reload(hostPageId) {
|
|
379
|
+
return this.pageCommand(hostPageId, "Page.reload", {});
|
|
380
|
+
}
|
|
381
|
+
close() {
|
|
382
|
+
if (this.closePromise)
|
|
383
|
+
return this.closePromise;
|
|
384
|
+
this.closing = true;
|
|
385
|
+
this.closePromise = (async () => {
|
|
386
|
+
await Promise.allSettled([...this.surfaceSources.values()].map((source) => source.close()));
|
|
387
|
+
this.host.connection.close();
|
|
388
|
+
let terminated = false;
|
|
389
|
+
try {
|
|
390
|
+
await terminateProcessGroup(this.host.child, this.host.exit, this.host.terminationGraceMs);
|
|
391
|
+
removeMatchingProcessLease(this.host.profileDir, this.host.ownerToken);
|
|
392
|
+
terminated = true;
|
|
393
|
+
}
|
|
394
|
+
finally {
|
|
395
|
+
if (terminated)
|
|
396
|
+
this.host.release();
|
|
397
|
+
this.eventQueue.end();
|
|
398
|
+
}
|
|
399
|
+
})();
|
|
400
|
+
return this.closePromise;
|
|
401
|
+
}
|
|
402
|
+
navigateHistory(hostPageId, delta) {
|
|
403
|
+
return this.exclusive(async () => {
|
|
404
|
+
this.ensureAvailable();
|
|
405
|
+
try {
|
|
406
|
+
const sessionId = await this.pageSession(hostPageId);
|
|
407
|
+
const history = await this.host.connection.command("Page.getNavigationHistory", {}, sessionId);
|
|
408
|
+
const parsed = parseNavigationHistory(history);
|
|
409
|
+
const next = parsed.entries[parsed.currentIndex + delta];
|
|
410
|
+
if (!next)
|
|
411
|
+
return;
|
|
412
|
+
await this.host.connection.command("Page.navigateToHistoryEntry", { entryId: next.id }, sessionId);
|
|
413
|
+
this.eventQueue.changed(this.browserGeneration);
|
|
414
|
+
}
|
|
415
|
+
catch (error) {
|
|
416
|
+
throw this.mapOperationError(error);
|
|
417
|
+
}
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
pageCommand(hostPageId, method, params) {
|
|
421
|
+
return this.exclusive(async () => {
|
|
422
|
+
this.ensureAvailable();
|
|
423
|
+
try {
|
|
424
|
+
const sessionId = await this.pageSession(hostPageId);
|
|
425
|
+
await this.host.connection.command(method, params, sessionId);
|
|
426
|
+
this.eventQueue.changed(this.browserGeneration);
|
|
427
|
+
}
|
|
428
|
+
catch (error) {
|
|
429
|
+
throw this.mapOperationError(error);
|
|
430
|
+
}
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
async refreshTargets() {
|
|
434
|
+
try {
|
|
435
|
+
const targets = await this.readPageTargets();
|
|
436
|
+
const next = new Map();
|
|
437
|
+
for (const target of targets) {
|
|
438
|
+
if (next.size >= RUNTIME_BROWSER_MAX_PAGES) {
|
|
439
|
+
throw new SessionBrowserHostError("capacity", "Headless Browser has too many Pages");
|
|
440
|
+
}
|
|
441
|
+
next.set(target.targetId, target);
|
|
442
|
+
}
|
|
443
|
+
for (const targetId of this.targets.keys()) {
|
|
444
|
+
if (!next.has(targetId))
|
|
445
|
+
this.dropTarget(targetId);
|
|
446
|
+
}
|
|
447
|
+
this.targets.clear();
|
|
448
|
+
for (const [targetId, target] of next)
|
|
449
|
+
this.targets.set(targetId, target);
|
|
450
|
+
if (this.activeTargetId === null || !this.targets.has(this.activeTargetId)) {
|
|
451
|
+
this.activeTargetId = [...this.targets.keys()].at(-1) ?? null;
|
|
452
|
+
}
|
|
453
|
+
await Promise.all([...this.targets.keys()].map(async (targetId) => {
|
|
454
|
+
try {
|
|
455
|
+
await this.pageSession(targetId);
|
|
456
|
+
}
|
|
457
|
+
catch (error) {
|
|
458
|
+
// Target.getTargets is only a point-in-time snapshot. A Page may be
|
|
459
|
+
// destroyed before its CDP session is attached, especially directly
|
|
460
|
+
// after Target.closeTarget succeeds. That is normal Page convergence,
|
|
461
|
+
// not a Browser-level failure.
|
|
462
|
+
if (isMissingPageError(error)) {
|
|
463
|
+
this.dropTarget(targetId);
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
throw error;
|
|
467
|
+
}
|
|
468
|
+
}));
|
|
469
|
+
if (this.activeTargetId !== null && !this.targets.has(this.activeTargetId)) {
|
|
470
|
+
this.activeTargetId = [...this.targets.keys()].at(-1) ?? null;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
catch (error) {
|
|
474
|
+
throw this.mapOperationError(error);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
async replaceRestoredStartupPages(url) {
|
|
478
|
+
const initialPages = await this.readPageTargets();
|
|
479
|
+
if (initialPages.length <= 1)
|
|
480
|
+
return;
|
|
481
|
+
const created = await this.host.connection.command("Target.createTarget", { url });
|
|
482
|
+
const freshTargetId = requiredCdpId(created.targetId, "Target.createTarget targetId");
|
|
483
|
+
this.activeTargetId = freshTargetId;
|
|
484
|
+
for (let attempt = 0; attempt < STARTUP_PAGE_CLEANUP_ATTEMPTS; attempt += 1) {
|
|
485
|
+
const pages = await this.readPageTargets();
|
|
486
|
+
if (!pages.some((page) => page.targetId === freshTargetId)) {
|
|
487
|
+
throw new Error("Headless Browser fresh startup Page disappeared");
|
|
488
|
+
}
|
|
489
|
+
const restoredPages = pages.filter((page) => page.targetId !== freshTargetId);
|
|
490
|
+
if (restoredPages.length === 0)
|
|
491
|
+
return;
|
|
492
|
+
for (const page of restoredPages) {
|
|
493
|
+
try {
|
|
494
|
+
await this.host.connection.command("Target.closeTarget", { targetId: page.targetId });
|
|
495
|
+
}
|
|
496
|
+
catch (error) {
|
|
497
|
+
if (!isMissingPageError(error))
|
|
498
|
+
throw error;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
await delay(STARTUP_PAGE_CLEANUP_POLL_MS);
|
|
502
|
+
}
|
|
503
|
+
throw new Error("Headless Browser restored Pages did not close during startup");
|
|
504
|
+
}
|
|
505
|
+
async readPageTargets() {
|
|
506
|
+
const result = await this.host.connection.command("Target.getTargets");
|
|
507
|
+
if (!Array.isArray(result.targetInfos)) {
|
|
508
|
+
throw new Error("CDP Target.getTargets omitted targetInfos");
|
|
509
|
+
}
|
|
510
|
+
const pages = result.targetInfos
|
|
511
|
+
.map((value) => parseTargetInfo(value))
|
|
512
|
+
.filter((target) => target.type === "page");
|
|
513
|
+
const observedIds = new Set(pages.map((target) => target.targetId));
|
|
514
|
+
for (const targetId of this.closingTargetIds) {
|
|
515
|
+
if (!observedIds.has(targetId))
|
|
516
|
+
this.closingTargetIds.delete(targetId);
|
|
517
|
+
}
|
|
518
|
+
return pages.filter((target) => !this.closingTargetIds.has(target.targetId));
|
|
519
|
+
}
|
|
520
|
+
async snapshotPage(target) {
|
|
521
|
+
const sessionId = await this.pageSession(target.targetId);
|
|
522
|
+
for (let attempt = 0; attempt < PAGE_SNAPSHOT_TRANSITION_ATTEMPTS; attempt += 1) {
|
|
523
|
+
let history;
|
|
524
|
+
try {
|
|
525
|
+
history = parseNavigationHistory(await this.host.connection.command("Page.getNavigationHistory", {}, sessionId));
|
|
526
|
+
}
|
|
527
|
+
catch (error) {
|
|
528
|
+
if (!isTransientPageTransitionError(error))
|
|
529
|
+
throw error;
|
|
530
|
+
if (attempt < PAGE_SNAPSHOT_TRANSITION_ATTEMPTS - 1) {
|
|
531
|
+
await delay(PAGE_SNAPSHOT_TRANSITION_POLL_MS);
|
|
532
|
+
}
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
let ready = "loading";
|
|
536
|
+
try {
|
|
537
|
+
ready = parseReadyState(await this.host.connection.command("Runtime.evaluate", { expression: "document.readyState", returnByValue: true }, sessionId));
|
|
538
|
+
}
|
|
539
|
+
catch (error) {
|
|
540
|
+
if (!isTransientPageTransitionError(error))
|
|
541
|
+
throw error;
|
|
542
|
+
}
|
|
543
|
+
const current = history.entries[history.currentIndex];
|
|
544
|
+
const currentTarget = this.targets.get(target.targetId) ?? target;
|
|
545
|
+
return {
|
|
546
|
+
hostPageId: target.targetId,
|
|
547
|
+
url: displayUrl(current?.url ?? currentTarget.url),
|
|
548
|
+
title: displayTitle(currentTarget.title),
|
|
549
|
+
loading: this.loadingTargets.has(target.targetId) || ready !== "complete",
|
|
550
|
+
canGoBack: history.currentIndex > 0,
|
|
551
|
+
canGoForward: history.currentIndex >= 0 && history.currentIndex < history.entries.length - 1,
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
// Chrome briefly reports an attached Page as inactive while a cross-site
|
|
555
|
+
// navigation swaps renderers. Target.getTargets already proved the Page is
|
|
556
|
+
// still present, so expose a conservative loading snapshot instead of
|
|
557
|
+
// escalating a renderer transition into a Browser-level failure.
|
|
558
|
+
return {
|
|
559
|
+
hostPageId: target.targetId,
|
|
560
|
+
url: displayUrl((this.targets.get(target.targetId) ?? target).url),
|
|
561
|
+
title: displayTitle((this.targets.get(target.targetId) ?? target).title),
|
|
562
|
+
loading: true,
|
|
563
|
+
canGoBack: false,
|
|
564
|
+
canGoForward: false,
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
async pageSession(targetId) {
|
|
568
|
+
this.requireTarget(targetId);
|
|
569
|
+
const existing = this.pageSessions.get(targetId);
|
|
570
|
+
if (existing)
|
|
571
|
+
return existing;
|
|
572
|
+
const pending = (async () => {
|
|
573
|
+
const result = await this.host.connection.command("Target.attachToTarget", {
|
|
574
|
+
targetId,
|
|
575
|
+
flatten: true,
|
|
576
|
+
});
|
|
577
|
+
const sessionId = requiredCdpId(result.sessionId, "Target.attachToTarget sessionId");
|
|
578
|
+
this.targetBySession.set(sessionId, targetId);
|
|
579
|
+
await this.host.connection.command("Page.enable", {}, sessionId);
|
|
580
|
+
return sessionId;
|
|
581
|
+
})();
|
|
582
|
+
this.pageSessions.set(targetId, pending);
|
|
583
|
+
try {
|
|
584
|
+
return await pending;
|
|
585
|
+
}
|
|
586
|
+
catch (error) {
|
|
587
|
+
this.pageSessions.delete(targetId);
|
|
588
|
+
throw error;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
onCdpEvent(event) {
|
|
592
|
+
if (this.closing || this.unavailableReason)
|
|
593
|
+
return;
|
|
594
|
+
if (event.method === "Target.targetCreated" || event.method === "Target.targetInfoChanged") {
|
|
595
|
+
const target = parseTargetInfo(event.params.targetInfo);
|
|
596
|
+
if (target.type !== "page")
|
|
597
|
+
return;
|
|
598
|
+
this.targets.set(target.targetId, target);
|
|
599
|
+
if (event.method === "Target.targetCreated")
|
|
600
|
+
this.activeTargetId = target.targetId;
|
|
601
|
+
this.eventQueue.changed(this.browserGeneration);
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
if (event.method === "Target.targetDestroyed") {
|
|
605
|
+
const targetId = optionalCdpId(event.params.targetId);
|
|
606
|
+
if (!targetId)
|
|
607
|
+
return;
|
|
608
|
+
this.closingTargetIds.delete(targetId);
|
|
609
|
+
this.dropTarget(targetId);
|
|
610
|
+
if (this.activeTargetId === targetId) {
|
|
611
|
+
this.activeTargetId = [...this.targets.keys()].at(-1) ?? null;
|
|
612
|
+
}
|
|
613
|
+
this.eventQueue.changed(this.browserGeneration);
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
if (event.method === "Target.targetCrashed") {
|
|
617
|
+
const targetId = optionalCdpId(event.params.targetId);
|
|
618
|
+
if (targetId && this.targets.has(targetId)) {
|
|
619
|
+
this.markUnavailable("A Headless Browser Page renderer crashed");
|
|
620
|
+
}
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
if (event.method === "Target.detachedFromTarget") {
|
|
624
|
+
const sessionId = optionalCdpId(event.params.sessionId);
|
|
625
|
+
if (sessionId)
|
|
626
|
+
this.dropPageSession(sessionId);
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
const targetId = event.sessionId ? this.targetBySession.get(event.sessionId) : undefined;
|
|
630
|
+
if (!targetId)
|
|
631
|
+
return;
|
|
632
|
+
if (event.method === "Inspector.detached") {
|
|
633
|
+
this.dropPageSession(event.sessionId);
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
if (event.method === "Page.screencastFrame") {
|
|
637
|
+
this.surfaceSources.get(targetId)?.acceptScreencastFrame(event.params);
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
if (event.method === "Page.frameResized") {
|
|
641
|
+
this.surfaceSources.get(targetId)?.refreshViewport();
|
|
642
|
+
}
|
|
643
|
+
if (event.method === "Page.frameStartedLoading")
|
|
644
|
+
this.loadingTargets.add(targetId);
|
|
645
|
+
if (event.method === "Page.frameStoppedLoading" || event.method === "Page.loadEventFired") {
|
|
646
|
+
this.loadingTargets.delete(targetId);
|
|
647
|
+
}
|
|
648
|
+
if (event.method.startsWith("Page."))
|
|
649
|
+
this.eventQueue.changed(this.browserGeneration);
|
|
650
|
+
}
|
|
651
|
+
requireTarget(targetId) {
|
|
652
|
+
const target = this.targets.get(targetId);
|
|
653
|
+
if (!target)
|
|
654
|
+
throw missingPage();
|
|
655
|
+
return target;
|
|
656
|
+
}
|
|
657
|
+
dropTarget(targetId) {
|
|
658
|
+
const surface = this.surfaceSources.get(targetId);
|
|
659
|
+
if (surface) {
|
|
660
|
+
// Stop routing renderer events immediately; close remains best-effort
|
|
661
|
+
// because the physical target may already have disappeared.
|
|
662
|
+
this.surfaceSources.delete(targetId);
|
|
663
|
+
void surface.close();
|
|
664
|
+
}
|
|
665
|
+
this.targets.delete(targetId);
|
|
666
|
+
this.loadingTargets.delete(targetId);
|
|
667
|
+
const session = this.pageSessions.get(targetId);
|
|
668
|
+
this.pageSessions.delete(targetId);
|
|
669
|
+
if (session)
|
|
670
|
+
void session.then((id) => this.targetBySession.delete(id), () => undefined);
|
|
671
|
+
}
|
|
672
|
+
dropPageSession(sessionId) {
|
|
673
|
+
const targetId = this.targetBySession.get(sessionId);
|
|
674
|
+
if (!targetId)
|
|
675
|
+
return;
|
|
676
|
+
this.targetBySession.delete(sessionId);
|
|
677
|
+
this.pageSessions.delete(targetId);
|
|
678
|
+
const surface = this.surfaceSources.get(targetId);
|
|
679
|
+
if (surface) {
|
|
680
|
+
this.surfaceSources.delete(targetId);
|
|
681
|
+
surface.closeAfterSourceLoss();
|
|
682
|
+
}
|
|
683
|
+
this.eventQueue.changed(this.browserGeneration);
|
|
684
|
+
}
|
|
685
|
+
ensureAvailable() {
|
|
686
|
+
if (this.unavailableReason || this.closing || !this.host.connection.connected) {
|
|
687
|
+
throw new SessionBrowserHostError("unavailable", "Headless Browser is unavailable");
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
markUnavailable(reason) {
|
|
691
|
+
if (this.closing || this.unavailableReason)
|
|
692
|
+
return;
|
|
693
|
+
this.unavailableReason = reason;
|
|
694
|
+
for (const source of this.surfaceSources.values())
|
|
695
|
+
source.closeAfterSourceLoss();
|
|
696
|
+
this.host.connection.close();
|
|
697
|
+
this.eventQueue.unavailable(this.browserGeneration, reason);
|
|
698
|
+
}
|
|
699
|
+
mapOperationError(error) {
|
|
700
|
+
if (error instanceof SessionBrowserHostError)
|
|
701
|
+
return error;
|
|
702
|
+
if (isMissingTargetError(error))
|
|
703
|
+
return missingPage(error);
|
|
704
|
+
if (!this.host.connection.connected || error instanceof CdpDisconnectedError) {
|
|
705
|
+
this.markUnavailable("The native CDP observer disconnected");
|
|
706
|
+
return unavailable("Headless Browser is unavailable", error);
|
|
707
|
+
}
|
|
708
|
+
return unavailable("Headless Browser rejected the CDP operation", error);
|
|
709
|
+
}
|
|
710
|
+
exclusive(operation) {
|
|
711
|
+
const result = this.operations.then(operation, operation);
|
|
712
|
+
this.operations = result.then(() => undefined, () => undefined);
|
|
713
|
+
return result;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
class HeadlessBrowserSurfaceSource {
|
|
717
|
+
source;
|
|
718
|
+
frames;
|
|
719
|
+
frameQueue = new LatestSurfaceFrameQueue();
|
|
720
|
+
viewport = {
|
|
721
|
+
revision: 0,
|
|
722
|
+
width: 1,
|
|
723
|
+
height: 1,
|
|
724
|
+
deviceScaleFactor: 1,
|
|
725
|
+
};
|
|
726
|
+
started = false;
|
|
727
|
+
closed = false;
|
|
728
|
+
closePromise;
|
|
729
|
+
inputOperations = Promise.resolve();
|
|
730
|
+
pressedKeys = new Map();
|
|
731
|
+
pressedButtons = new Set();
|
|
732
|
+
lastPointer = { x: 0, y: 0 };
|
|
733
|
+
viewportRefresh;
|
|
734
|
+
lastAcceptedFrameAt = Number.NEGATIVE_INFINITY;
|
|
735
|
+
lastScreencastFrameAt = Number.NEGATIVE_INFINITY;
|
|
736
|
+
lastFrameSignature;
|
|
737
|
+
fallbackTimer;
|
|
738
|
+
fallbackCapture;
|
|
739
|
+
consecutiveDiscreteInputTimeouts = 0;
|
|
740
|
+
minimumFrameIntervalMs;
|
|
741
|
+
abortHandler;
|
|
742
|
+
constructor(source) {
|
|
743
|
+
this.source = source;
|
|
744
|
+
this.frames = {
|
|
745
|
+
[Symbol.asyncIterator]: () => {
|
|
746
|
+
const iterator = this.frameQueue[Symbol.asyncIterator]();
|
|
747
|
+
return {
|
|
748
|
+
next: () => iterator.next(),
|
|
749
|
+
return: async () => {
|
|
750
|
+
await this.close();
|
|
751
|
+
return { done: true, value: undefined };
|
|
752
|
+
},
|
|
753
|
+
};
|
|
754
|
+
},
|
|
755
|
+
};
|
|
756
|
+
this.minimumFrameIntervalMs = 1_000 / source.input.maximumFrameRate;
|
|
757
|
+
if (source.input.signal) {
|
|
758
|
+
this.abortHandler = () => void this.close();
|
|
759
|
+
source.input.signal.addEventListener("abort", this.abortHandler, { once: true });
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
get browserGeneration() {
|
|
763
|
+
return this.source.input.browserGeneration;
|
|
764
|
+
}
|
|
765
|
+
get hostPageId() {
|
|
766
|
+
return this.source.input.hostPageId;
|
|
767
|
+
}
|
|
768
|
+
get format() {
|
|
769
|
+
return this.source.input.format;
|
|
770
|
+
}
|
|
771
|
+
async initialize() {
|
|
772
|
+
this.ensureCurrent();
|
|
773
|
+
this.viewport = await readSurfaceViewport(this.source.connection, this.source.pageSessionId, 0);
|
|
774
|
+
this.ensureCurrent();
|
|
775
|
+
const capture = await this.source.connection.command("Page.captureScreenshot", {
|
|
776
|
+
format: this.format,
|
|
777
|
+
...(this.format === "jpeg" ? { quality: this.source.input.quality } : {}),
|
|
778
|
+
fromSurface: true,
|
|
779
|
+
captureBeyondViewport: false,
|
|
780
|
+
}, this.source.pageSessionId);
|
|
781
|
+
const capturedAt = Date.now();
|
|
782
|
+
this.pushFrameIfChanged(decodeSurfaceImage(capture.data, this.format), capturedAt, async () => undefined);
|
|
783
|
+
this.lastAcceptedFrameAt = capturedAt;
|
|
784
|
+
this.lastScreencastFrameAt = capturedAt;
|
|
785
|
+
this.ensureCurrent();
|
|
786
|
+
this.started = true;
|
|
787
|
+
await this.source.connection.command("Page.startScreencast", {
|
|
788
|
+
format: this.format,
|
|
789
|
+
quality: this.source.input.quality,
|
|
790
|
+
everyNthFrame: 1,
|
|
791
|
+
}, this.source.pageSessionId);
|
|
792
|
+
this.ensureCurrent();
|
|
793
|
+
this.scheduleFallbackCapture();
|
|
794
|
+
}
|
|
795
|
+
getViewport() {
|
|
796
|
+
return { ...this.viewport };
|
|
797
|
+
}
|
|
798
|
+
dispatchTrustedInput(input) {
|
|
799
|
+
const result = this.inputOperations.then(() => this.dispatchTrustedInputNow(input), () => this.dispatchTrustedInputNow(input));
|
|
800
|
+
this.inputOperations = result.then(() => undefined, () => undefined);
|
|
801
|
+
return result;
|
|
802
|
+
}
|
|
803
|
+
resetTrustedInput() {
|
|
804
|
+
const result = this.inputOperations.then(() => this.resetTrustedInputNow(), () => this.resetTrustedInputNow());
|
|
805
|
+
this.inputOperations = result.then(() => undefined, () => undefined);
|
|
806
|
+
return result;
|
|
807
|
+
}
|
|
808
|
+
acceptScreencastFrame(params) {
|
|
809
|
+
const screencastSessionId = surfaceFrameSessionId(params.sessionId);
|
|
810
|
+
const acknowledge = onceAsync(async () => {
|
|
811
|
+
await this.source.connection.command("Page.screencastFrameAck", { sessionId: screencastSessionId }, this.source.pageSessionId);
|
|
812
|
+
});
|
|
813
|
+
// Chrome may have queued a final frame before stopScreencast takes effect.
|
|
814
|
+
// It still owns a capture slot, so ACK it, but never let an expected close
|
|
815
|
+
// race invalidate the Browser's shared CDP connection.
|
|
816
|
+
if (this.closed) {
|
|
817
|
+
void acknowledge().catch(() => undefined);
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
this.ensureCurrent();
|
|
821
|
+
const receivedAt = Date.now();
|
|
822
|
+
this.lastScreencastFrameAt = receivedAt;
|
|
823
|
+
if (receivedAt - this.lastAcceptedFrameAt < this.minimumFrameIntervalMs) {
|
|
824
|
+
void acknowledge().catch(() => undefined);
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
try {
|
|
828
|
+
this.updateViewportFromScreencastMetadata(params.metadata);
|
|
829
|
+
const timestampMilliseconds = surfaceFrameTimestamp(params.metadata, receivedAt);
|
|
830
|
+
this.lastAcceptedFrameAt = receivedAt;
|
|
831
|
+
// CDP ACK means the bounded local capture slot accepted (or replaced)
|
|
832
|
+
// the frame. The downstream viewer ACK controls only this queue; it must
|
|
833
|
+
// never extend across network RTT back into Chrome capture.
|
|
834
|
+
this.pushFrameIfChanged(decodeSurfaceImage(params.data, this.format), timestampMilliseconds, async () => undefined);
|
|
835
|
+
}
|
|
836
|
+
finally {
|
|
837
|
+
void acknowledge().catch(() => undefined);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
refreshViewport() {
|
|
841
|
+
if (this.closed || this.viewportRefresh)
|
|
842
|
+
return;
|
|
843
|
+
this.viewportRefresh = (async () => {
|
|
844
|
+
try {
|
|
845
|
+
this.ensureCurrent();
|
|
846
|
+
const next = await readSurfaceViewport(this.source.connection, this.source.pageSessionId, this.viewport.revision);
|
|
847
|
+
if (!sameSurfaceViewport(this.viewport, next)) {
|
|
848
|
+
this.viewport = { ...next, revision: this.viewport.revision + 1 };
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
catch {
|
|
852
|
+
// The authoritative Page/Host lifecycle closes this source separately.
|
|
853
|
+
}
|
|
854
|
+
finally {
|
|
855
|
+
this.viewportRefresh = undefined;
|
|
856
|
+
}
|
|
857
|
+
})();
|
|
858
|
+
}
|
|
859
|
+
closeAfterSourceLoss() {
|
|
860
|
+
if (this.closed)
|
|
861
|
+
return;
|
|
862
|
+
this.closed = true;
|
|
863
|
+
this.cancelFallbackCapture();
|
|
864
|
+
this.removeAbortHandler();
|
|
865
|
+
void this.frameQueue.close();
|
|
866
|
+
this.source.release();
|
|
867
|
+
this.closePromise = Promise.resolve();
|
|
868
|
+
}
|
|
869
|
+
close() {
|
|
870
|
+
if (this.closePromise)
|
|
871
|
+
return this.closePromise;
|
|
872
|
+
this.closed = true;
|
|
873
|
+
this.cancelFallbackCapture();
|
|
874
|
+
this.removeAbortHandler();
|
|
875
|
+
this.closePromise = (async () => {
|
|
876
|
+
try {
|
|
877
|
+
await this.frameQueue.close();
|
|
878
|
+
await this.inputOperations;
|
|
879
|
+
await this.resetTrustedInputNow().catch(() => undefined);
|
|
880
|
+
if (this.started && this.source.connection.connected) {
|
|
881
|
+
await this.source.connection.command("Page.stopScreencast", {}, this.source.pageSessionId).catch(() => undefined);
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
finally {
|
|
885
|
+
this.source.release();
|
|
886
|
+
}
|
|
887
|
+
})();
|
|
888
|
+
return this.closePromise;
|
|
889
|
+
}
|
|
890
|
+
async dispatchTrustedInputNow(input) {
|
|
891
|
+
this.ensureCurrent();
|
|
892
|
+
if (input.browserGeneration !== this.browserGeneration) {
|
|
893
|
+
throw new SessionBrowserHostError("unavailable", "Browser Surface generation is stale");
|
|
894
|
+
}
|
|
895
|
+
if (input.viewportRevision !== this.viewport.revision) {
|
|
896
|
+
throw new SessionBrowserHostError("unavailable", "Browser Surface viewport is stale");
|
|
897
|
+
}
|
|
898
|
+
validateSurfaceInputCoordinates(input.event, this.viewport);
|
|
899
|
+
this.recordTrustedInputBeforeDispatch(input.event);
|
|
900
|
+
try {
|
|
901
|
+
const command = cdpTrustedInputCommand(input.event, pressedButtonMask(this.pressedButtons));
|
|
902
|
+
await this.source.connection.command(command.method, command.params, this.source.pageSessionId);
|
|
903
|
+
this.consecutiveDiscreteInputTimeouts = 0;
|
|
904
|
+
this.recordTrustedInputAfterDispatch(input.event);
|
|
905
|
+
}
|
|
906
|
+
catch (error) {
|
|
907
|
+
if (error instanceof CdpCommandTimeoutError) {
|
|
908
|
+
if (input.event.type === "pointer.move" || input.event.type === "wheel")
|
|
909
|
+
return;
|
|
910
|
+
this.consecutiveDiscreteInputTimeouts += 1;
|
|
911
|
+
if (this.consecutiveDiscreteInputTimeouts < 3)
|
|
912
|
+
return;
|
|
913
|
+
this.consecutiveDiscreteInputTimeouts = 0;
|
|
914
|
+
try {
|
|
915
|
+
await this.source.connection.command("Browser.getVersion");
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
catch (probeError) {
|
|
919
|
+
throw this.source.mapError(probeError);
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
throw this.source.mapError(error);
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
recordTrustedInputBeforeDispatch(event) {
|
|
926
|
+
if (event.type === "pointer.move" ||
|
|
927
|
+
event.type === "pointer.down" ||
|
|
928
|
+
event.type === "pointer.up" ||
|
|
929
|
+
event.type === "wheel") {
|
|
930
|
+
this.lastPointer = { x: event.x, y: event.y };
|
|
931
|
+
}
|
|
932
|
+
if (event.type === "pointer.down")
|
|
933
|
+
this.pressedButtons.add(event.button);
|
|
934
|
+
if (event.type === "key.down") {
|
|
935
|
+
this.pressedKeys.set(event.code, { key: event.key, code: event.code });
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
recordTrustedInputAfterDispatch(event) {
|
|
939
|
+
if (event.type === "pointer.up")
|
|
940
|
+
this.pressedButtons.delete(event.button);
|
|
941
|
+
if (event.type === "key.up")
|
|
942
|
+
this.pressedKeys.delete(event.code);
|
|
943
|
+
}
|
|
944
|
+
async resetTrustedInputNow() {
|
|
945
|
+
if (!this.source.connection.connected)
|
|
946
|
+
return;
|
|
947
|
+
let failure;
|
|
948
|
+
for (const [code, key] of [...this.pressedKeys]) {
|
|
949
|
+
try {
|
|
950
|
+
await this.source.connection.command("Input.dispatchKeyEvent", cdpKeyboardEventParams({
|
|
951
|
+
type: "key.up",
|
|
952
|
+
key: key.key,
|
|
953
|
+
code: key.code,
|
|
954
|
+
repeat: false,
|
|
955
|
+
modifiers: [],
|
|
956
|
+
}, process.platform), this.source.pageSessionId);
|
|
957
|
+
if (this.pressedKeys.get(code) === key)
|
|
958
|
+
this.pressedKeys.delete(code);
|
|
959
|
+
}
|
|
960
|
+
catch (error) {
|
|
961
|
+
failure ??= error;
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
for (const button of SURFACE_MOUSE_BUTTONS) {
|
|
965
|
+
if (!this.pressedButtons.has(button))
|
|
966
|
+
continue;
|
|
967
|
+
const buttonsAfterRelease = new Set(this.pressedButtons);
|
|
968
|
+
buttonsAfterRelease.delete(button);
|
|
969
|
+
try {
|
|
970
|
+
await this.source.connection.command("Input.dispatchMouseEvent", {
|
|
971
|
+
type: "mouseReleased",
|
|
972
|
+
x: Math.max(0, Math.min(this.viewport.width, this.lastPointer.x)),
|
|
973
|
+
y: Math.max(0, Math.min(this.viewport.height, this.lastPointer.y)),
|
|
974
|
+
button,
|
|
975
|
+
buttons: pressedButtonMask(buttonsAfterRelease),
|
|
976
|
+
clickCount: 1,
|
|
977
|
+
modifiers: 0,
|
|
978
|
+
}, this.source.pageSessionId);
|
|
979
|
+
this.pressedButtons.delete(button);
|
|
980
|
+
}
|
|
981
|
+
catch (error) {
|
|
982
|
+
failure ??= error;
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
if (failure)
|
|
986
|
+
throw this.source.mapError(failure);
|
|
987
|
+
}
|
|
988
|
+
createFrame(encodedBytes, timestampMilliseconds, acknowledge) {
|
|
989
|
+
const viewport = this.viewport;
|
|
990
|
+
return {
|
|
991
|
+
browserGeneration: this.browserGeneration,
|
|
992
|
+
viewportRevision: viewport.revision,
|
|
993
|
+
timestampMilliseconds,
|
|
994
|
+
width: viewport.width,
|
|
995
|
+
height: viewport.height,
|
|
996
|
+
deviceScaleFactor: viewport.deviceScaleFactor,
|
|
997
|
+
format: this.format,
|
|
998
|
+
encodedBytes,
|
|
999
|
+
acknowledge: onceAsync(acknowledge),
|
|
1000
|
+
};
|
|
1001
|
+
}
|
|
1002
|
+
pushFrameIfChanged(encodedBytes, timestampMilliseconds, acknowledge) {
|
|
1003
|
+
const signature = surfaceFrameSignature(encodedBytes, this.viewport.revision);
|
|
1004
|
+
if (signature === this.lastFrameSignature) {
|
|
1005
|
+
void acknowledge().catch(() => undefined);
|
|
1006
|
+
return false;
|
|
1007
|
+
}
|
|
1008
|
+
this.lastFrameSignature = signature;
|
|
1009
|
+
this.frameQueue.push(this.createFrame(encodedBytes, timestampMilliseconds, acknowledge));
|
|
1010
|
+
return true;
|
|
1011
|
+
}
|
|
1012
|
+
/**
|
|
1013
|
+
* Chrome's headless screencast can legitimately emit an initial frame and
|
|
1014
|
+
* then stay silent for small DOM-only paints. Polling bounded snapshots only
|
|
1015
|
+
* after that stream is idle keeps the viewer live without replacing CDP
|
|
1016
|
+
* screencast when it is producing frames.
|
|
1017
|
+
*/
|
|
1018
|
+
scheduleFallbackCapture() {
|
|
1019
|
+
if (this.closed || this.fallbackTimer || this.fallbackCapture)
|
|
1020
|
+
return;
|
|
1021
|
+
const intervalMs = Math.max(this.minimumFrameIntervalMs, MIN_SURFACE_FALLBACK_INTERVAL_MS);
|
|
1022
|
+
this.fallbackTimer = setTimeout(() => {
|
|
1023
|
+
this.fallbackTimer = undefined;
|
|
1024
|
+
if (this.closed)
|
|
1025
|
+
return;
|
|
1026
|
+
this.fallbackCapture = this.captureFallbackFrame()
|
|
1027
|
+
.catch(() => undefined)
|
|
1028
|
+
.finally(() => {
|
|
1029
|
+
this.fallbackCapture = undefined;
|
|
1030
|
+
this.scheduleFallbackCapture();
|
|
1031
|
+
});
|
|
1032
|
+
}, intervalMs);
|
|
1033
|
+
this.fallbackTimer.unref?.();
|
|
1034
|
+
}
|
|
1035
|
+
async captureFallbackFrame() {
|
|
1036
|
+
const screencastIdleMs = Math.max(this.minimumFrameIntervalMs * 2, MIN_SURFACE_SCREENCAST_IDLE_MS);
|
|
1037
|
+
if (Date.now() - this.lastScreencastFrameAt < screencastIdleMs)
|
|
1038
|
+
return;
|
|
1039
|
+
this.ensureCurrent();
|
|
1040
|
+
const capture = await this.source.connection.command("Page.captureScreenshot", {
|
|
1041
|
+
format: this.format,
|
|
1042
|
+
...(this.format === "jpeg" ? { quality: this.source.input.quality } : {}),
|
|
1043
|
+
fromSurface: true,
|
|
1044
|
+
captureBeyondViewport: false,
|
|
1045
|
+
}, this.source.pageSessionId);
|
|
1046
|
+
if (this.closed)
|
|
1047
|
+
return;
|
|
1048
|
+
this.ensureCurrent();
|
|
1049
|
+
const capturedAt = Date.now();
|
|
1050
|
+
this.lastAcceptedFrameAt = capturedAt;
|
|
1051
|
+
this.pushFrameIfChanged(decodeSurfaceImage(capture.data, this.format), capturedAt, async () => undefined);
|
|
1052
|
+
}
|
|
1053
|
+
cancelFallbackCapture() {
|
|
1054
|
+
if (!this.fallbackTimer)
|
|
1055
|
+
return;
|
|
1056
|
+
clearTimeout(this.fallbackTimer);
|
|
1057
|
+
this.fallbackTimer = undefined;
|
|
1058
|
+
}
|
|
1059
|
+
updateViewportFromScreencastMetadata(value) {
|
|
1060
|
+
if (!isRecord(value))
|
|
1061
|
+
return;
|
|
1062
|
+
const width = value.deviceWidth;
|
|
1063
|
+
const height = value.deviceHeight;
|
|
1064
|
+
if (!validSurfaceDimensions(width, height))
|
|
1065
|
+
return;
|
|
1066
|
+
const parsedHeight = height;
|
|
1067
|
+
if (width === this.viewport.width && parsedHeight === this.viewport.height)
|
|
1068
|
+
return;
|
|
1069
|
+
this.viewport = {
|
|
1070
|
+
...this.viewport,
|
|
1071
|
+
revision: this.viewport.revision + 1,
|
|
1072
|
+
width,
|
|
1073
|
+
height: parsedHeight,
|
|
1074
|
+
};
|
|
1075
|
+
}
|
|
1076
|
+
ensureCurrent() {
|
|
1077
|
+
if (this.closed) {
|
|
1078
|
+
throw new SessionBrowserHostError("unavailable", "Browser Surface is closed");
|
|
1079
|
+
}
|
|
1080
|
+
this.source.ensureCurrent();
|
|
1081
|
+
}
|
|
1082
|
+
removeAbortHandler() {
|
|
1083
|
+
if (this.abortHandler && this.source.input.signal) {
|
|
1084
|
+
this.source.input.signal.removeEventListener("abort", this.abortHandler);
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
function surfaceFrameSignature(encodedBytes, viewportRevision) {
|
|
1089
|
+
return createHash("sha256")
|
|
1090
|
+
.update(String(viewportRevision))
|
|
1091
|
+
.update("\0")
|
|
1092
|
+
.update(encodedBytes)
|
|
1093
|
+
.digest("base64url");
|
|
1094
|
+
}
|
|
1095
|
+
/** One current frame plus one replaceable latest frame, never an unbounded queue. */
|
|
1096
|
+
class LatestSurfaceFrameQueue {
|
|
1097
|
+
current;
|
|
1098
|
+
latest;
|
|
1099
|
+
currentDelivered = false;
|
|
1100
|
+
waiter;
|
|
1101
|
+
closed = false;
|
|
1102
|
+
push(frame) {
|
|
1103
|
+
const entry = this.wrap(frame);
|
|
1104
|
+
if (this.closed) {
|
|
1105
|
+
void this.acknowledge(entry);
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
1108
|
+
if (!this.current) {
|
|
1109
|
+
this.current = entry;
|
|
1110
|
+
this.deliverCurrent();
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
const replaced = this.latest;
|
|
1114
|
+
this.latest = entry;
|
|
1115
|
+
if (replaced)
|
|
1116
|
+
void this.acknowledge(replaced);
|
|
1117
|
+
}
|
|
1118
|
+
async close() {
|
|
1119
|
+
if (this.closed)
|
|
1120
|
+
return;
|
|
1121
|
+
this.closed = true;
|
|
1122
|
+
const frames = [this.current, this.latest].filter((entry) => entry !== undefined);
|
|
1123
|
+
this.current = undefined;
|
|
1124
|
+
this.latest = undefined;
|
|
1125
|
+
this.waiter?.({ done: true, value: undefined });
|
|
1126
|
+
this.waiter = undefined;
|
|
1127
|
+
await Promise.allSettled(frames.map((entry) => this.acknowledge(entry)));
|
|
1128
|
+
}
|
|
1129
|
+
[Symbol.asyncIterator]() {
|
|
1130
|
+
return {
|
|
1131
|
+
next: () => this.next(),
|
|
1132
|
+
return: async () => {
|
|
1133
|
+
await this.close();
|
|
1134
|
+
return { done: true, value: undefined };
|
|
1135
|
+
},
|
|
1136
|
+
};
|
|
1137
|
+
}
|
|
1138
|
+
next() {
|
|
1139
|
+
if (this.closed)
|
|
1140
|
+
return Promise.resolve({ done: true, value: undefined });
|
|
1141
|
+
if (this.current && !this.currentDelivered) {
|
|
1142
|
+
this.currentDelivered = true;
|
|
1143
|
+
return Promise.resolve({ done: false, value: this.current.frame });
|
|
1144
|
+
}
|
|
1145
|
+
if (this.waiter)
|
|
1146
|
+
return Promise.reject(new Error("Browser Surface frame read is already pending"));
|
|
1147
|
+
return new Promise((resolveFrame) => {
|
|
1148
|
+
this.waiter = resolveFrame;
|
|
1149
|
+
});
|
|
1150
|
+
}
|
|
1151
|
+
wrap(frame) {
|
|
1152
|
+
let entry;
|
|
1153
|
+
const upstreamAcknowledge = frame.acknowledge;
|
|
1154
|
+
const exposed = {
|
|
1155
|
+
...frame,
|
|
1156
|
+
acknowledge: () => this.acknowledge(entry),
|
|
1157
|
+
};
|
|
1158
|
+
entry = { frame: exposed, upstreamAcknowledge };
|
|
1159
|
+
return entry;
|
|
1160
|
+
}
|
|
1161
|
+
acknowledge(entry) {
|
|
1162
|
+
entry.acknowledgePromise ??= (async () => {
|
|
1163
|
+
try {
|
|
1164
|
+
await entry.upstreamAcknowledge();
|
|
1165
|
+
}
|
|
1166
|
+
finally {
|
|
1167
|
+
this.release(entry);
|
|
1168
|
+
}
|
|
1169
|
+
})();
|
|
1170
|
+
return entry.acknowledgePromise;
|
|
1171
|
+
}
|
|
1172
|
+
release(entry) {
|
|
1173
|
+
if (this.current === entry) {
|
|
1174
|
+
this.current = this.latest;
|
|
1175
|
+
this.latest = undefined;
|
|
1176
|
+
this.currentDelivered = false;
|
|
1177
|
+
this.deliverCurrent();
|
|
1178
|
+
return;
|
|
1179
|
+
}
|
|
1180
|
+
if (this.latest === entry)
|
|
1181
|
+
this.latest = undefined;
|
|
1182
|
+
}
|
|
1183
|
+
deliverCurrent() {
|
|
1184
|
+
if (!this.waiter || !this.current || this.currentDelivered)
|
|
1185
|
+
return;
|
|
1186
|
+
const resolveFrame = this.waiter;
|
|
1187
|
+
this.waiter = undefined;
|
|
1188
|
+
this.currentDelivered = true;
|
|
1189
|
+
resolveFrame({ done: false, value: this.current.frame });
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
const SURFACE_MOUSE_BUTTONS = [
|
|
1193
|
+
"left",
|
|
1194
|
+
"middle",
|
|
1195
|
+
"right",
|
|
1196
|
+
"back",
|
|
1197
|
+
"forward",
|
|
1198
|
+
];
|
|
1199
|
+
function cdpTrustedInputCommand(event, pressedButtons = 0) {
|
|
1200
|
+
const modifiers = cdpModifierMask("modifiers" in event ? event.modifiers : []);
|
|
1201
|
+
switch (event.type) {
|
|
1202
|
+
case "pointer.move":
|
|
1203
|
+
return {
|
|
1204
|
+
method: "Input.dispatchMouseEvent",
|
|
1205
|
+
params: { type: "mouseMoved", x: event.x, y: event.y, buttons: pressedButtons, modifiers },
|
|
1206
|
+
};
|
|
1207
|
+
case "pointer.down":
|
|
1208
|
+
case "pointer.up":
|
|
1209
|
+
pressedButtons = event.type === "pointer.down"
|
|
1210
|
+
? pressedButtons | surfaceMouseButtonMask(event.button)
|
|
1211
|
+
: pressedButtons & ~surfaceMouseButtonMask(event.button);
|
|
1212
|
+
return {
|
|
1213
|
+
method: "Input.dispatchMouseEvent",
|
|
1214
|
+
params: {
|
|
1215
|
+
type: event.type === "pointer.down" ? "mousePressed" : "mouseReleased",
|
|
1216
|
+
x: event.x,
|
|
1217
|
+
y: event.y,
|
|
1218
|
+
button: event.button,
|
|
1219
|
+
buttons: pressedButtons,
|
|
1220
|
+
clickCount: 1,
|
|
1221
|
+
modifiers,
|
|
1222
|
+
},
|
|
1223
|
+
};
|
|
1224
|
+
case "wheel":
|
|
1225
|
+
return {
|
|
1226
|
+
method: "Input.dispatchMouseEvent",
|
|
1227
|
+
params: {
|
|
1228
|
+
type: "mouseWheel",
|
|
1229
|
+
x: event.x,
|
|
1230
|
+
y: event.y,
|
|
1231
|
+
deltaX: event.deltaX,
|
|
1232
|
+
deltaY: event.deltaY,
|
|
1233
|
+
buttons: pressedButtons,
|
|
1234
|
+
modifiers,
|
|
1235
|
+
},
|
|
1236
|
+
};
|
|
1237
|
+
case "key.down":
|
|
1238
|
+
case "key.up":
|
|
1239
|
+
return {
|
|
1240
|
+
method: "Input.dispatchKeyEvent",
|
|
1241
|
+
params: cdpKeyboardEventParams(event, process.platform),
|
|
1242
|
+
};
|
|
1243
|
+
case "text.insert":
|
|
1244
|
+
return { method: "Input.insertText", params: { text: event.text } };
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
function pressedButtonMask(buttons) {
|
|
1248
|
+
let mask = 0;
|
|
1249
|
+
for (const button of buttons)
|
|
1250
|
+
mask |= surfaceMouseButtonMask(button);
|
|
1251
|
+
return mask;
|
|
1252
|
+
}
|
|
1253
|
+
function surfaceMouseButtonMask(button) {
|
|
1254
|
+
if (button === "left")
|
|
1255
|
+
return 1;
|
|
1256
|
+
if (button === "right")
|
|
1257
|
+
return 2;
|
|
1258
|
+
if (button === "middle")
|
|
1259
|
+
return 4;
|
|
1260
|
+
if (button === "back")
|
|
1261
|
+
return 8;
|
|
1262
|
+
return 16;
|
|
1263
|
+
}
|
|
1264
|
+
function cdpModifierMask(modifiers) {
|
|
1265
|
+
let mask = 0;
|
|
1266
|
+
for (const modifier of modifiers) {
|
|
1267
|
+
if (modifier === "alt")
|
|
1268
|
+
mask |= 1;
|
|
1269
|
+
if (modifier === "control")
|
|
1270
|
+
mask |= 2;
|
|
1271
|
+
if (modifier === "meta")
|
|
1272
|
+
mask |= 4;
|
|
1273
|
+
if (modifier === "shift")
|
|
1274
|
+
mask |= 8;
|
|
1275
|
+
}
|
|
1276
|
+
return mask;
|
|
1277
|
+
}
|
|
1278
|
+
function validateSurfaceOpenInput(input) {
|
|
1279
|
+
if (!Number.isSafeInteger(input.browserGeneration) ||
|
|
1280
|
+
input.browserGeneration < 1 ||
|
|
1281
|
+
(input.format !== "jpeg" && input.format !== "png") ||
|
|
1282
|
+
!Number.isInteger(input.quality) ||
|
|
1283
|
+
input.quality < MIN_SURFACE_QUALITY ||
|
|
1284
|
+
input.quality > MAX_SURFACE_QUALITY ||
|
|
1285
|
+
!Number.isInteger(input.maximumFrameRate) ||
|
|
1286
|
+
input.maximumFrameRate < 1 ||
|
|
1287
|
+
input.maximumFrameRate > RUNTIME_BROWSER_SURFACE_MAX_FRAME_RATE) {
|
|
1288
|
+
throw new SessionBrowserHostError("unavailable", "Browser Surface options are invalid");
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
async function readSurfaceViewport(connection, pageSessionId, revision) {
|
|
1292
|
+
const result = await connection.command("Runtime.evaluate", {
|
|
1293
|
+
expression: "({width: Math.round(window.innerWidth), height: Math.round(window.innerHeight), deviceScaleFactor: window.devicePixelRatio})",
|
|
1294
|
+
returnByValue: true,
|
|
1295
|
+
}, pageSessionId);
|
|
1296
|
+
if (!isRecord(result) || !isRecord(result.result) || !isRecord(result.result.value)) {
|
|
1297
|
+
throw new Error("CDP omitted the Browser Surface viewport");
|
|
1298
|
+
}
|
|
1299
|
+
const value = result.result.value;
|
|
1300
|
+
if (!validSurfaceDimensions(value.width, value.height)) {
|
|
1301
|
+
throw new Error("CDP returned an invalid Browser Surface viewport");
|
|
1302
|
+
}
|
|
1303
|
+
const height = value.height;
|
|
1304
|
+
if (typeof value.deviceScaleFactor !== "number" ||
|
|
1305
|
+
!Number.isFinite(value.deviceScaleFactor) ||
|
|
1306
|
+
value.deviceScaleFactor < 0.25 ||
|
|
1307
|
+
value.deviceScaleFactor > 8) {
|
|
1308
|
+
throw new Error("CDP returned an invalid Browser Surface device scale factor");
|
|
1309
|
+
}
|
|
1310
|
+
return {
|
|
1311
|
+
revision,
|
|
1312
|
+
width: value.width,
|
|
1313
|
+
height,
|
|
1314
|
+
deviceScaleFactor: value.deviceScaleFactor,
|
|
1315
|
+
};
|
|
1316
|
+
}
|
|
1317
|
+
function validSurfaceDimensions(width, height) {
|
|
1318
|
+
return Number.isInteger(width) && Number.isInteger(height) &&
|
|
1319
|
+
width >= 1 && width <= RUNTIME_BROWSER_SURFACE_MAX_VIEWPORT_WIDTH &&
|
|
1320
|
+
height >= 1 && height <= RUNTIME_BROWSER_SURFACE_MAX_VIEWPORT_HEIGHT &&
|
|
1321
|
+
width * height <= RUNTIME_BROWSER_SURFACE_MAX_VIEWPORT_PIXELS;
|
|
1322
|
+
}
|
|
1323
|
+
function sameSurfaceViewport(left, right) {
|
|
1324
|
+
return left.width === right.width && left.height === right.height &&
|
|
1325
|
+
left.deviceScaleFactor === right.deviceScaleFactor;
|
|
1326
|
+
}
|
|
1327
|
+
function decodeSurfaceImage(value, format) {
|
|
1328
|
+
if (typeof value !== "string" ||
|
|
1329
|
+
value.length === 0 ||
|
|
1330
|
+
value.length > RUNTIME_BROWSER_SURFACE_MAX_ENCODED_FRAME_BYTES * 2 ||
|
|
1331
|
+
!/^[A-Za-z0-9+/]+={0,2}$/.test(value)) {
|
|
1332
|
+
throw new Error("CDP returned an invalid Browser Surface image");
|
|
1333
|
+
}
|
|
1334
|
+
const bytes = Buffer.from(value, "base64");
|
|
1335
|
+
if (bytes.byteLength === 0 ||
|
|
1336
|
+
bytes.byteLength > RUNTIME_BROWSER_SURFACE_MAX_ENCODED_FRAME_BYTES ||
|
|
1337
|
+
Buffer.from(bytes).toString("base64").replace(/=+$/, "") !== value.replace(/=+$/, "")) {
|
|
1338
|
+
throw new Error("CDP returned an invalid Browser Surface image");
|
|
1339
|
+
}
|
|
1340
|
+
const validMagic = format === "jpeg"
|
|
1341
|
+
? bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff
|
|
1342
|
+
: bytes.length >= 8 && bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
|
|
1343
|
+
if (!validMagic)
|
|
1344
|
+
throw new Error(`CDP returned bytes that are not ${format}`);
|
|
1345
|
+
return new Uint8Array(bytes);
|
|
1346
|
+
}
|
|
1347
|
+
function surfaceFrameSessionId(value) {
|
|
1348
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
1349
|
+
throw new Error("CDP screencast frame omitted its session ID");
|
|
1350
|
+
}
|
|
1351
|
+
return value;
|
|
1352
|
+
}
|
|
1353
|
+
function surfaceFrameTimestamp(metadata, fallback) {
|
|
1354
|
+
if (!isRecord(metadata))
|
|
1355
|
+
return fallback;
|
|
1356
|
+
const timestamp = metadata.timestamp;
|
|
1357
|
+
if (typeof timestamp !== "number" || !Number.isFinite(timestamp) || timestamp < 0) {
|
|
1358
|
+
return fallback;
|
|
1359
|
+
}
|
|
1360
|
+
const milliseconds = Math.round(timestamp * 1_000);
|
|
1361
|
+
return Number.isSafeInteger(milliseconds) ? milliseconds : fallback;
|
|
1362
|
+
}
|
|
1363
|
+
function validateSurfaceInputCoordinates(event, viewport) {
|
|
1364
|
+
if (!("x" in event))
|
|
1365
|
+
return;
|
|
1366
|
+
if (!Number.isFinite(event.x) ||
|
|
1367
|
+
!Number.isFinite(event.y) ||
|
|
1368
|
+
event.x < 0 ||
|
|
1369
|
+
event.y < 0 ||
|
|
1370
|
+
event.x > viewport.width ||
|
|
1371
|
+
event.y > viewport.height) {
|
|
1372
|
+
throw new SessionBrowserHostError("unavailable", "Browser Surface input is outside its viewport");
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
function onceAsync(operation) {
|
|
1376
|
+
let result;
|
|
1377
|
+
return () => result ??= operation();
|
|
1378
|
+
}
|
|
1379
|
+
class CdpProtocolError extends Error {
|
|
1380
|
+
code;
|
|
1381
|
+
constructor(code, message) {
|
|
1382
|
+
super(message);
|
|
1383
|
+
this.code = code;
|
|
1384
|
+
this.name = "CdpProtocolError";
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
class CdpDisconnectedError extends Error {
|
|
1388
|
+
constructor(message) {
|
|
1389
|
+
super(message);
|
|
1390
|
+
this.name = "CdpDisconnectedError";
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
class CdpCommandTimeoutError extends Error {
|
|
1394
|
+
constructor(message) {
|
|
1395
|
+
super(message);
|
|
1396
|
+
this.name = "CdpCommandTimeoutError";
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
class CdpConnection {
|
|
1400
|
+
socket;
|
|
1401
|
+
commandTimeoutMs;
|
|
1402
|
+
nextId = 1;
|
|
1403
|
+
pending = new Map();
|
|
1404
|
+
eventHandler = () => undefined;
|
|
1405
|
+
disconnectHandler = () => undefined;
|
|
1406
|
+
didDisconnect = false;
|
|
1407
|
+
constructor(socket, commandTimeoutMs) {
|
|
1408
|
+
this.socket = socket;
|
|
1409
|
+
this.commandTimeoutMs = commandTimeoutMs;
|
|
1410
|
+
socket.on("message", (data, isBinary) => this.onMessage(data, isBinary));
|
|
1411
|
+
socket.on("close", () => this.disconnect("Native CDP WebSocket closed"));
|
|
1412
|
+
socket.on("error", (error) => this.disconnect(`Native CDP WebSocket error: ${error.message}`));
|
|
1413
|
+
}
|
|
1414
|
+
static async connect(endpoint, commandTimeoutMs) {
|
|
1415
|
+
validateCdpEndpoint(endpoint);
|
|
1416
|
+
const socket = new WebSocket(endpoint, {
|
|
1417
|
+
maxPayload: MAX_CDP_MESSAGE_BYTES,
|
|
1418
|
+
perMessageDeflate: false,
|
|
1419
|
+
});
|
|
1420
|
+
await new Promise((resolveOpen, rejectOpen) => {
|
|
1421
|
+
const timer = setTimeout(() => {
|
|
1422
|
+
cleanup();
|
|
1423
|
+
socket.terminate();
|
|
1424
|
+
rejectOpen(new Error("Native CDP WebSocket connection timed out"));
|
|
1425
|
+
}, commandTimeoutMs);
|
|
1426
|
+
const onOpen = () => {
|
|
1427
|
+
cleanup();
|
|
1428
|
+
resolveOpen();
|
|
1429
|
+
};
|
|
1430
|
+
const onError = (error) => {
|
|
1431
|
+
cleanup();
|
|
1432
|
+
rejectOpen(error);
|
|
1433
|
+
};
|
|
1434
|
+
const cleanup = () => {
|
|
1435
|
+
clearTimeout(timer);
|
|
1436
|
+
socket.off("open", onOpen);
|
|
1437
|
+
socket.off("error", onError);
|
|
1438
|
+
};
|
|
1439
|
+
socket.once("open", onOpen);
|
|
1440
|
+
socket.once("error", onError);
|
|
1441
|
+
});
|
|
1442
|
+
return new CdpConnection(socket, commandTimeoutMs);
|
|
1443
|
+
}
|
|
1444
|
+
get connected() {
|
|
1445
|
+
return !this.didDisconnect && this.socket.readyState === WebSocket.OPEN;
|
|
1446
|
+
}
|
|
1447
|
+
setEventHandler(handler) {
|
|
1448
|
+
this.eventHandler = handler;
|
|
1449
|
+
}
|
|
1450
|
+
setDisconnectHandler(handler) {
|
|
1451
|
+
this.disconnectHandler = handler;
|
|
1452
|
+
}
|
|
1453
|
+
command(method, params = {}, sessionId) {
|
|
1454
|
+
if (!this.connected)
|
|
1455
|
+
return Promise.reject(new CdpDisconnectedError("Native CDP is disconnected"));
|
|
1456
|
+
const id = this.nextId++;
|
|
1457
|
+
return new Promise((resolveCommand, rejectCommand) => {
|
|
1458
|
+
const timer = setTimeout(() => {
|
|
1459
|
+
this.pending.delete(id);
|
|
1460
|
+
rejectCommand(new CdpCommandTimeoutError(`Native CDP command ${method} timed out`));
|
|
1461
|
+
}, this.commandTimeoutMs);
|
|
1462
|
+
this.pending.set(id, { resolve: resolveCommand, reject: rejectCommand, timer });
|
|
1463
|
+
try {
|
|
1464
|
+
this.socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }));
|
|
1465
|
+
}
|
|
1466
|
+
catch (error) {
|
|
1467
|
+
clearTimeout(timer);
|
|
1468
|
+
this.pending.delete(id);
|
|
1469
|
+
rejectCommand(error);
|
|
1470
|
+
}
|
|
1471
|
+
});
|
|
1472
|
+
}
|
|
1473
|
+
close() {
|
|
1474
|
+
if (this.socket.readyState === WebSocket.OPEN || this.socket.readyState === WebSocket.CONNECTING) {
|
|
1475
|
+
this.socket.close();
|
|
1476
|
+
}
|
|
1477
|
+
this.disconnect("Native CDP connection closed");
|
|
1478
|
+
}
|
|
1479
|
+
onMessage(data, isBinary) {
|
|
1480
|
+
const bytes = rawDataBytes(data);
|
|
1481
|
+
if (isBinary || bytes.byteLength > MAX_CDP_MESSAGE_BYTES) {
|
|
1482
|
+
this.socket.terminate();
|
|
1483
|
+
this.disconnect("Native CDP sent an invalid message");
|
|
1484
|
+
return;
|
|
1485
|
+
}
|
|
1486
|
+
let value;
|
|
1487
|
+
try {
|
|
1488
|
+
value = JSON.parse(bytes.toString("utf8"));
|
|
1489
|
+
}
|
|
1490
|
+
catch {
|
|
1491
|
+
this.socket.terminate();
|
|
1492
|
+
this.disconnect("Native CDP sent invalid JSON");
|
|
1493
|
+
return;
|
|
1494
|
+
}
|
|
1495
|
+
if (!isRecord(value))
|
|
1496
|
+
return;
|
|
1497
|
+
if (Number.isSafeInteger(value.id)) {
|
|
1498
|
+
const pending = this.pending.get(value.id);
|
|
1499
|
+
if (!pending)
|
|
1500
|
+
return;
|
|
1501
|
+
this.pending.delete(value.id);
|
|
1502
|
+
clearTimeout(pending.timer);
|
|
1503
|
+
if (isRecord(value.error)) {
|
|
1504
|
+
pending.reject(new CdpProtocolError(typeof value.error.code === "number" ? value.error.code : -1, typeof value.error.message === "string" ? value.error.message : "Native CDP command failed"));
|
|
1505
|
+
}
|
|
1506
|
+
else {
|
|
1507
|
+
pending.resolve(value.result ?? {});
|
|
1508
|
+
}
|
|
1509
|
+
return;
|
|
1510
|
+
}
|
|
1511
|
+
if (typeof value.method !== "string")
|
|
1512
|
+
return;
|
|
1513
|
+
try {
|
|
1514
|
+
this.eventHandler({
|
|
1515
|
+
method: value.method,
|
|
1516
|
+
params: isRecord(value.params) ? value.params : {},
|
|
1517
|
+
...(typeof value.sessionId === "string" ? { sessionId: value.sessionId } : {}),
|
|
1518
|
+
});
|
|
1519
|
+
}
|
|
1520
|
+
catch {
|
|
1521
|
+
this.socket.terminate();
|
|
1522
|
+
this.disconnect("Native CDP sent an invalid event");
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
disconnect(reason) {
|
|
1526
|
+
if (this.didDisconnect)
|
|
1527
|
+
return;
|
|
1528
|
+
this.didDisconnect = true;
|
|
1529
|
+
const error = new CdpDisconnectedError(reason);
|
|
1530
|
+
for (const pending of this.pending.values()) {
|
|
1531
|
+
clearTimeout(pending.timer);
|
|
1532
|
+
pending.reject(error);
|
|
1533
|
+
}
|
|
1534
|
+
this.pending.clear();
|
|
1535
|
+
this.disconnectHandler(reason);
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
class BrowserHostEventQueue {
|
|
1539
|
+
changedEvent;
|
|
1540
|
+
terminalEvent;
|
|
1541
|
+
ended = false;
|
|
1542
|
+
waiter;
|
|
1543
|
+
[Symbol.asyncIterator]() {
|
|
1544
|
+
return this;
|
|
1545
|
+
}
|
|
1546
|
+
next() {
|
|
1547
|
+
if (this.changedEvent) {
|
|
1548
|
+
const value = this.changedEvent;
|
|
1549
|
+
this.changedEvent = undefined;
|
|
1550
|
+
return Promise.resolve({ value, done: false });
|
|
1551
|
+
}
|
|
1552
|
+
if (this.terminalEvent) {
|
|
1553
|
+
const value = this.terminalEvent;
|
|
1554
|
+
this.terminalEvent = undefined;
|
|
1555
|
+
this.ended = true;
|
|
1556
|
+
return Promise.resolve({ value, done: false });
|
|
1557
|
+
}
|
|
1558
|
+
if (this.ended)
|
|
1559
|
+
return Promise.resolve({ value: undefined, done: true });
|
|
1560
|
+
return new Promise((resolveNext) => {
|
|
1561
|
+
this.waiter = resolveNext;
|
|
1562
|
+
});
|
|
1563
|
+
}
|
|
1564
|
+
changed(browserGeneration) {
|
|
1565
|
+
if (this.ended || this.terminalEvent)
|
|
1566
|
+
return;
|
|
1567
|
+
const event = { type: "changed", browserGeneration };
|
|
1568
|
+
if (this.waiter) {
|
|
1569
|
+
const waiter = this.waiter;
|
|
1570
|
+
this.waiter = undefined;
|
|
1571
|
+
waiter({ value: event, done: false });
|
|
1572
|
+
}
|
|
1573
|
+
else {
|
|
1574
|
+
this.changedEvent = event;
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
unavailable(browserGeneration, reason) {
|
|
1578
|
+
if (this.ended || this.terminalEvent)
|
|
1579
|
+
return;
|
|
1580
|
+
this.changedEvent = undefined;
|
|
1581
|
+
const event = { type: "unavailable", browserGeneration, reason };
|
|
1582
|
+
if (this.waiter) {
|
|
1583
|
+
const waiter = this.waiter;
|
|
1584
|
+
this.waiter = undefined;
|
|
1585
|
+
this.ended = true;
|
|
1586
|
+
waiter({ value: event, done: false });
|
|
1587
|
+
}
|
|
1588
|
+
else {
|
|
1589
|
+
this.terminalEvent = event;
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
end() {
|
|
1593
|
+
if (this.ended)
|
|
1594
|
+
return;
|
|
1595
|
+
this.changedEvent = undefined;
|
|
1596
|
+
this.ended = true;
|
|
1597
|
+
if (this.waiter) {
|
|
1598
|
+
const waiter = this.waiter;
|
|
1599
|
+
this.waiter = undefined;
|
|
1600
|
+
waiter({ value: undefined, done: true });
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
function ensureOwnedProfile(profileRoot, sessionId) {
|
|
1605
|
+
ensureDirectDirectory(profileRoot, profileRoot);
|
|
1606
|
+
const profileDir = headlessBrowserProfilePath(profileRoot, sessionId);
|
|
1607
|
+
if (!existsSync(profileDir)) {
|
|
1608
|
+
mkdirSync(profileDir, { mode: 0o700 });
|
|
1609
|
+
chmodSync(profileDir, 0o700);
|
|
1610
|
+
writeFileSync(join(profileDir, PROFILE_OWNER_FILE), `${JSON.stringify(profileMarker(sessionId))}\n`, { flag: "wx", mode: 0o600 });
|
|
1611
|
+
}
|
|
1612
|
+
validateOwnedProfile(profileRoot, profileDir, sessionId);
|
|
1613
|
+
return profileDir;
|
|
1614
|
+
}
|
|
1615
|
+
function ensureDirectDirectory(path, containmentRoot) {
|
|
1616
|
+
mkdirSync(path, { recursive: true, mode: 0o700 });
|
|
1617
|
+
const stat = lstatSync(path);
|
|
1618
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
1619
|
+
throw new Error(`Headless Browser storage path is not a direct directory: ${path}`);
|
|
1620
|
+
}
|
|
1621
|
+
chmodSync(path, 0o700);
|
|
1622
|
+
if (!isWithin(realpathSync(containmentRoot), realpathSync(path))) {
|
|
1623
|
+
throw new Error(`Headless Browser storage path escapes its root: ${path}`);
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
function validateOwnedProfile(profileRoot, profileDir, sessionId) {
|
|
1627
|
+
const stat = lstatSync(profileDir);
|
|
1628
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
1629
|
+
throw new Error("Headless Browser profile is not an owned direct directory");
|
|
1630
|
+
}
|
|
1631
|
+
if (resolve(profileDir).startsWith(`${resolve(profileRoot)}${sep}`) === false ||
|
|
1632
|
+
!isWithin(realpathSync(profileRoot), realpathSync(profileDir))) {
|
|
1633
|
+
throw new Error("Headless Browser profile escapes its owned root");
|
|
1634
|
+
}
|
|
1635
|
+
const marker = readSmallRegularJson(join(profileDir, PROFILE_OWNER_FILE), MAX_PROFILE_METADATA_BYTES, "Headless Browser profile ownership marker");
|
|
1636
|
+
const expected = profileMarker(sessionId);
|
|
1637
|
+
if (!isRecord(marker) ||
|
|
1638
|
+
marker.schemaVersion !== expected.schemaVersion ||
|
|
1639
|
+
marker.kind !== expected.kind ||
|
|
1640
|
+
marker.executionBackend !== expected.executionBackend ||
|
|
1641
|
+
marker.sessionId !== expected.sessionId) {
|
|
1642
|
+
throw new Error("Headless Browser profile ownership marker does not match the Session");
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
function removeOwnedProfile(profileRoot, sessionId) {
|
|
1646
|
+
if (!existsSync(profileRoot))
|
|
1647
|
+
return;
|
|
1648
|
+
const rootStat = lstatSync(profileRoot);
|
|
1649
|
+
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) {
|
|
1650
|
+
throw new Error("Headless Browser profile root is not an owned direct directory");
|
|
1651
|
+
}
|
|
1652
|
+
const profileDir = headlessBrowserProfilePath(profileRoot, sessionId);
|
|
1653
|
+
if (!existsSync(profileDir))
|
|
1654
|
+
return;
|
|
1655
|
+
validateOwnedProfile(profileRoot, profileDir, sessionId);
|
|
1656
|
+
rmSync(profileDir, { recursive: true });
|
|
1657
|
+
}
|
|
1658
|
+
function profileMarker(sessionId) {
|
|
1659
|
+
return {
|
|
1660
|
+
schemaVersion: 1,
|
|
1661
|
+
kind: "rynx-headless-browser-profile",
|
|
1662
|
+
executionBackend: "headless-chromium",
|
|
1663
|
+
sessionId,
|
|
1664
|
+
};
|
|
1665
|
+
}
|
|
1666
|
+
async function recoverOwnedProfileProcesses(profileRoot, graceMs) {
|
|
1667
|
+
if (!existsSync(profileRoot))
|
|
1668
|
+
return;
|
|
1669
|
+
const rootStat = lstatSync(profileRoot);
|
|
1670
|
+
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) {
|
|
1671
|
+
throw new Error("Headless Browser profile root is not an owned direct directory");
|
|
1672
|
+
}
|
|
1673
|
+
for (const entry of readdirSync(profileRoot, { withFileTypes: true })) {
|
|
1674
|
+
if (!entry.isDirectory() || entry.isSymbolicLink() || !entry.name.startsWith("session-"))
|
|
1675
|
+
continue;
|
|
1676
|
+
const profileDir = join(profileRoot, entry.name);
|
|
1677
|
+
let marker;
|
|
1678
|
+
try {
|
|
1679
|
+
marker = readSmallRegularJson(join(profileDir, PROFILE_OWNER_FILE), MAX_PROFILE_METADATA_BYTES, "Headless Browser profile ownership marker");
|
|
1680
|
+
}
|
|
1681
|
+
catch {
|
|
1682
|
+
continue;
|
|
1683
|
+
}
|
|
1684
|
+
if (!isHeadlessBrowserProfileMarker(marker))
|
|
1685
|
+
continue;
|
|
1686
|
+
if (headlessBrowserProfilePath(profileRoot, marker.sessionId) !== profileDir)
|
|
1687
|
+
continue;
|
|
1688
|
+
await recoverProfileProcess(profileRoot, profileDir, marker.sessionId, graceMs);
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
async function recoverProfileProcess(profileRoot, profileDir, sessionId, graceMs) {
|
|
1692
|
+
const leasePath = join(profileDir, PROCESS_LEASE_FILE);
|
|
1693
|
+
if (!existsSync(leasePath)) {
|
|
1694
|
+
removeStaleActivePort(profileDir);
|
|
1695
|
+
return;
|
|
1696
|
+
}
|
|
1697
|
+
validateOwnedProfile(profileRoot, profileDir, sessionId);
|
|
1698
|
+
let lease;
|
|
1699
|
+
try {
|
|
1700
|
+
lease = parseProcessLease(readSmallRegularJson(leasePath, MAX_PROFILE_METADATA_BYTES, "Headless Browser process lease"));
|
|
1701
|
+
}
|
|
1702
|
+
catch {
|
|
1703
|
+
// A malformed or replaced lease cannot authorize a signal.
|
|
1704
|
+
return;
|
|
1705
|
+
}
|
|
1706
|
+
if (lease.profileDir !== resolve(profileDir))
|
|
1707
|
+
return;
|
|
1708
|
+
if (!processExists(lease.pid)) {
|
|
1709
|
+
removeMatchingProcessLease(profileDir, lease.ownerToken);
|
|
1710
|
+
removeStaleActivePort(profileDir);
|
|
1711
|
+
return;
|
|
1712
|
+
}
|
|
1713
|
+
if (!await processMatchesLease(lease))
|
|
1714
|
+
return;
|
|
1715
|
+
signalVerifiedProcessGroup(lease.processGroupId, "SIGTERM");
|
|
1716
|
+
if (!await waitForPidExit(lease.pid, graceMs)) {
|
|
1717
|
+
// Re-verify immediately before escalation so PID reuse cannot turn the
|
|
1718
|
+
// persisted lease into authority over a different process.
|
|
1719
|
+
if (!await processMatchesLease(lease))
|
|
1720
|
+
return;
|
|
1721
|
+
signalVerifiedProcessGroup(lease.processGroupId, "SIGKILL");
|
|
1722
|
+
if (!await waitForPidExit(lease.pid, Math.max(graceMs, 250)))
|
|
1723
|
+
return;
|
|
1724
|
+
}
|
|
1725
|
+
removeMatchingProcessLease(profileDir, lease.ownerToken);
|
|
1726
|
+
removeStaleActivePort(profileDir);
|
|
1727
|
+
}
|
|
1728
|
+
function isHeadlessBrowserProfileMarker(value) {
|
|
1729
|
+
return isRecord(value) &&
|
|
1730
|
+
value.schemaVersion === 1 &&
|
|
1731
|
+
value.kind === "rynx-headless-browser-profile" &&
|
|
1732
|
+
value.executionBackend === "headless-chromium" &&
|
|
1733
|
+
typeof value.sessionId === "string" &&
|
|
1734
|
+
value.sessionId.length > 0;
|
|
1735
|
+
}
|
|
1736
|
+
function writeProcessLease(profileDir, executablePath, child, ownerToken) {
|
|
1737
|
+
const pid = child.pid;
|
|
1738
|
+
if (!pid || pid < 2)
|
|
1739
|
+
throw new Error("Chrome for Testing did not publish a process ID");
|
|
1740
|
+
const lease = {
|
|
1741
|
+
schemaVersion: 1,
|
|
1742
|
+
kind: "rynx-headless-browser-process",
|
|
1743
|
+
pid,
|
|
1744
|
+
processGroupId: pid,
|
|
1745
|
+
ownerToken,
|
|
1746
|
+
executablePath: resolve(executablePath),
|
|
1747
|
+
profileDir: resolve(profileDir),
|
|
1748
|
+
startedAt: new Date().toISOString(),
|
|
1749
|
+
};
|
|
1750
|
+
writeFileSync(join(profileDir, PROCESS_LEASE_FILE), `${JSON.stringify(lease)}\n`, { flag: "wx", mode: 0o600 });
|
|
1751
|
+
return lease;
|
|
1752
|
+
}
|
|
1753
|
+
function parseProcessLease(value) {
|
|
1754
|
+
if (!isRecord(value) ||
|
|
1755
|
+
value.schemaVersion !== 1 ||
|
|
1756
|
+
value.kind !== "rynx-headless-browser-process" ||
|
|
1757
|
+
!Number.isSafeInteger(value.pid) ||
|
|
1758
|
+
value.pid < 2 ||
|
|
1759
|
+
value.processGroupId !== value.pid ||
|
|
1760
|
+
typeof value.ownerToken !== "string" ||
|
|
1761
|
+
!/^[a-f0-9]{64}$/.test(value.ownerToken) ||
|
|
1762
|
+
typeof value.executablePath !== "string" ||
|
|
1763
|
+
!isAbsolute(value.executablePath) ||
|
|
1764
|
+
typeof value.profileDir !== "string" ||
|
|
1765
|
+
!isAbsolute(value.profileDir) ||
|
|
1766
|
+
typeof value.startedAt !== "string" ||
|
|
1767
|
+
!Number.isFinite(Date.parse(value.startedAt))) {
|
|
1768
|
+
throw new Error("Headless Browser process lease is invalid");
|
|
1769
|
+
}
|
|
1770
|
+
return value;
|
|
1771
|
+
}
|
|
1772
|
+
function assertProfileProcessLeaseClear(profileDir) {
|
|
1773
|
+
if (existsSync(join(profileDir, PROCESS_LEASE_FILE))) {
|
|
1774
|
+
throw new SessionBrowserHostError("capacity", "A process lease still owns this Headless Browser Session profile");
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
function removeMatchingProcessLease(profileDir, ownerToken) {
|
|
1778
|
+
const leasePath = join(profileDir, PROCESS_LEASE_FILE);
|
|
1779
|
+
if (!existsSync(leasePath))
|
|
1780
|
+
return;
|
|
1781
|
+
try {
|
|
1782
|
+
const lease = parseProcessLease(readSmallRegularJson(leasePath, MAX_PROFILE_METADATA_BYTES, "Headless Browser process lease"));
|
|
1783
|
+
if (lease.ownerToken === ownerToken)
|
|
1784
|
+
rmSync(leasePath, { force: true });
|
|
1785
|
+
}
|
|
1786
|
+
catch {
|
|
1787
|
+
// Never remove a lease that cannot be proven to be ours.
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
async function processMatchesLease(lease) {
|
|
1791
|
+
let command;
|
|
1792
|
+
try {
|
|
1793
|
+
command = await readProcessCommand(lease.pid);
|
|
1794
|
+
}
|
|
1795
|
+
catch {
|
|
1796
|
+
return false;
|
|
1797
|
+
}
|
|
1798
|
+
return command.includes(lease.executablePath) &&
|
|
1799
|
+
command.includes(`--user-data-dir=${lease.profileDir}`) &&
|
|
1800
|
+
command.includes(`--rynx-browser-owner=${lease.ownerToken}`);
|
|
1801
|
+
}
|
|
1802
|
+
async function readProcessCommand(pid) {
|
|
1803
|
+
if (process.platform === "linux") {
|
|
1804
|
+
const value = readFileSync(`/proc/${pid}/cmdline`);
|
|
1805
|
+
if (value.byteLength === 0 || value.byteLength > MAX_PROCESS_COMMAND_BYTES) {
|
|
1806
|
+
throw new Error("Process command line is not bounded");
|
|
1807
|
+
}
|
|
1808
|
+
return value.toString("utf8").replaceAll("\0", "\n");
|
|
1809
|
+
}
|
|
1810
|
+
if (process.platform === "darwin") {
|
|
1811
|
+
return await new Promise((resolveCommand, rejectCommand) => {
|
|
1812
|
+
execFile("/bin/ps", ["-ww", "-p", String(pid), "-o", "command="], { encoding: "utf8", maxBuffer: MAX_PROCESS_COMMAND_BYTES, timeout: 1_000 }, (error, stdout) => {
|
|
1813
|
+
if (error)
|
|
1814
|
+
rejectCommand(error);
|
|
1815
|
+
else if (!stdout.trim())
|
|
1816
|
+
rejectCommand(new Error("Process command line is empty"));
|
|
1817
|
+
else
|
|
1818
|
+
resolveCommand(stdout);
|
|
1819
|
+
});
|
|
1820
|
+
});
|
|
1821
|
+
}
|
|
1822
|
+
throw new Error("Safe orphan process verification is unsupported on this platform");
|
|
1823
|
+
}
|
|
1824
|
+
function processExists(pid) {
|
|
1825
|
+
try {
|
|
1826
|
+
process.kill(pid, 0);
|
|
1827
|
+
return true;
|
|
1828
|
+
}
|
|
1829
|
+
catch (error) {
|
|
1830
|
+
return !isNoSuchProcess(error);
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
async function waitForPidExit(pid, timeoutMs) {
|
|
1834
|
+
const deadline = Date.now() + timeoutMs;
|
|
1835
|
+
while (Date.now() < deadline) {
|
|
1836
|
+
if (!processExists(pid))
|
|
1837
|
+
return true;
|
|
1838
|
+
await delay(Math.min(25, Math.max(1, deadline - Date.now())));
|
|
1839
|
+
}
|
|
1840
|
+
return !processExists(pid);
|
|
1841
|
+
}
|
|
1842
|
+
function signalVerifiedProcessGroup(processGroupId, signal) {
|
|
1843
|
+
try {
|
|
1844
|
+
process.kill(-processGroupId, signal);
|
|
1845
|
+
}
|
|
1846
|
+
catch (error) {
|
|
1847
|
+
if (!isNoSuchProcess(error))
|
|
1848
|
+
throw error;
|
|
1849
|
+
}
|
|
1850
|
+
}
|
|
1851
|
+
function removeStaleActivePort(profileDir) {
|
|
1852
|
+
rmSync(join(profileDir, DEVTOOLS_ACTIVE_PORT_FILE), { force: true });
|
|
1853
|
+
}
|
|
1854
|
+
function readSmallRegularJson(path, maxBytes, label) {
|
|
1855
|
+
const descriptor = openNoFollow(path);
|
|
1856
|
+
try {
|
|
1857
|
+
const stat = fstatSync(descriptor);
|
|
1858
|
+
if (!stat.isFile() || stat.size > maxBytes) {
|
|
1859
|
+
throw new Error(`${label} is not a bounded regular file`);
|
|
1860
|
+
}
|
|
1861
|
+
const contents = readFileSync(descriptor, "utf8");
|
|
1862
|
+
try {
|
|
1863
|
+
return JSON.parse(contents);
|
|
1864
|
+
}
|
|
1865
|
+
catch {
|
|
1866
|
+
throw new Error(`${label} is invalid JSON`);
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
1869
|
+
finally {
|
|
1870
|
+
closeSync(descriptor);
|
|
1871
|
+
}
|
|
1872
|
+
}
|
|
1873
|
+
function chromeArguments(profileDir, ownerToken, url) {
|
|
1874
|
+
return [
|
|
1875
|
+
"--headless=new",
|
|
1876
|
+
"--use-mock-keychain",
|
|
1877
|
+
"--password-store=basic",
|
|
1878
|
+
"--no-first-run",
|
|
1879
|
+
"--no-default-browser-check",
|
|
1880
|
+
"--keep-alive-for-test",
|
|
1881
|
+
`--rynx-browser-owner=${ownerToken}`,
|
|
1882
|
+
`--user-data-dir=${profileDir}`,
|
|
1883
|
+
"--remote-debugging-address=127.0.0.1",
|
|
1884
|
+
"--remote-debugging-port=0",
|
|
1885
|
+
"--disable-background-networking",
|
|
1886
|
+
"--disable-component-update",
|
|
1887
|
+
"--disable-sync",
|
|
1888
|
+
"--metrics-recording-only",
|
|
1889
|
+
"--no-report-upload",
|
|
1890
|
+
"--window-size=1280,800",
|
|
1891
|
+
url,
|
|
1892
|
+
];
|
|
1893
|
+
}
|
|
1894
|
+
function validateActiveExecutable(build) {
|
|
1895
|
+
const install = lstatSync(build.installPath);
|
|
1896
|
+
const executable = lstatSync(build.executablePath);
|
|
1897
|
+
if (!install.isDirectory() || install.isSymbolicLink()) {
|
|
1898
|
+
throw new SessionBrowserHostError("unavailable", "Active Chrome for Testing build is invalid");
|
|
1899
|
+
}
|
|
1900
|
+
if (!executable.isFile() || executable.isSymbolicLink()) {
|
|
1901
|
+
throw new SessionBrowserHostError("unavailable", "Active Chrome for Testing executable is invalid");
|
|
1902
|
+
}
|
|
1903
|
+
if (!isWithin(realpathSync(build.installPath), realpathSync(build.executablePath))) {
|
|
1904
|
+
throw new SessionBrowserHostError("unavailable", "Active Chrome for Testing executable escapes its build");
|
|
1905
|
+
}
|
|
1906
|
+
try {
|
|
1907
|
+
accessSync(build.executablePath, fsConstants.X_OK);
|
|
1908
|
+
}
|
|
1909
|
+
catch (error) {
|
|
1910
|
+
throw unavailable("Active Chrome for Testing executable is not executable", error);
|
|
1911
|
+
}
|
|
1912
|
+
}
|
|
1913
|
+
async function waitForDevToolsActivePort(options) {
|
|
1914
|
+
const path = join(options.profileDir, DEVTOOLS_ACTIVE_PORT_FILE);
|
|
1915
|
+
const deadline = Date.now() + options.timeoutMs;
|
|
1916
|
+
let lastError;
|
|
1917
|
+
while (Date.now() < deadline) {
|
|
1918
|
+
const exited = await Promise.race([
|
|
1919
|
+
options.exit.then((value) => value),
|
|
1920
|
+
delay(options.pollMs).then(() => undefined),
|
|
1921
|
+
]);
|
|
1922
|
+
if (exited) {
|
|
1923
|
+
throw new Error(`Chrome for Testing exited before native CDP was ready${options.diagnostics() ? `: ${options.diagnostics()}` : ""}`, { cause: exited });
|
|
1924
|
+
}
|
|
1925
|
+
try {
|
|
1926
|
+
const contents = readSmallRegularFile(path, MAX_ACTIVE_PORT_BYTES, "DevToolsActivePort");
|
|
1927
|
+
const lines = contents.trim().split(/\r?\n/);
|
|
1928
|
+
if (lines.length !== 2)
|
|
1929
|
+
throw new Error("DevToolsActivePort must contain exactly two lines");
|
|
1930
|
+
const port = Number(lines[0]);
|
|
1931
|
+
const webSocketPath = lines[1];
|
|
1932
|
+
if (!Number.isInteger(port) || port < 1 || port > 65_535) {
|
|
1933
|
+
throw new Error("DevToolsActivePort contains an invalid loopback port");
|
|
1934
|
+
}
|
|
1935
|
+
if (!webSocketPath || !DEVTOOLS_BROWSER_PATH.test(webSocketPath)) {
|
|
1936
|
+
throw new Error("DevToolsActivePort contains an invalid Browser WebSocket path");
|
|
1937
|
+
}
|
|
1938
|
+
const endpoint = `ws://127.0.0.1:${port}${webSocketPath}`;
|
|
1939
|
+
validateCdpEndpoint(endpoint);
|
|
1940
|
+
return endpoint;
|
|
1941
|
+
}
|
|
1942
|
+
catch (error) {
|
|
1943
|
+
lastError = error;
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
throw new Error("Timed out waiting for a valid loopback DevToolsActivePort", { cause: lastError });
|
|
1947
|
+
}
|
|
1948
|
+
function validateCdpEndpoint(endpoint) {
|
|
1949
|
+
const url = new URL(endpoint);
|
|
1950
|
+
if (url.protocol !== "ws:" ||
|
|
1951
|
+
url.hostname !== "127.0.0.1" ||
|
|
1952
|
+
url.username ||
|
|
1953
|
+
url.password ||
|
|
1954
|
+
!DEVTOOLS_BROWSER_PATH.test(url.pathname) ||
|
|
1955
|
+
url.search ||
|
|
1956
|
+
url.hash) {
|
|
1957
|
+
throw new Error("Native CDP endpoint is not an exact loopback Browser endpoint");
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
function observeProcess(child) {
|
|
1961
|
+
let didSpawn = false;
|
|
1962
|
+
const spawned = new Promise((resolveSpawn, rejectSpawn) => {
|
|
1963
|
+
child.once("spawn", () => {
|
|
1964
|
+
didSpawn = true;
|
|
1965
|
+
resolveSpawn();
|
|
1966
|
+
});
|
|
1967
|
+
// A ChildProcess "error" only proves that spawn failed before "spawn".
|
|
1968
|
+
// Later operational errors are not process exits and must not settle exit.
|
|
1969
|
+
child.on("error", (error) => {
|
|
1970
|
+
if (!didSpawn)
|
|
1971
|
+
rejectSpawn(error);
|
|
1972
|
+
});
|
|
1973
|
+
});
|
|
1974
|
+
const exit = new Promise((resolveExit) => {
|
|
1975
|
+
child.once("exit", (code, signal) => resolveExit({ code, signal }));
|
|
1976
|
+
});
|
|
1977
|
+
return { spawned, exit };
|
|
1978
|
+
}
|
|
1979
|
+
function boundedStderr(child) {
|
|
1980
|
+
let value = "";
|
|
1981
|
+
child.stderr?.setEncoding("utf8");
|
|
1982
|
+
child.stderr?.on("data", (chunk) => {
|
|
1983
|
+
value = `${value}${chunk}`.slice(-16_000);
|
|
1984
|
+
});
|
|
1985
|
+
return () => value.trim();
|
|
1986
|
+
}
|
|
1987
|
+
async function terminateProcessGroup(child, exit, graceMs) {
|
|
1988
|
+
if (!child.pid)
|
|
1989
|
+
return;
|
|
1990
|
+
if (child.exitCode !== null || child.signalCode !== null)
|
|
1991
|
+
return;
|
|
1992
|
+
signalProcessGroup(child, "SIGTERM");
|
|
1993
|
+
const exited = await Promise.race([
|
|
1994
|
+
exit.then(() => true),
|
|
1995
|
+
delay(graceMs).then(() => false),
|
|
1996
|
+
]);
|
|
1997
|
+
if (exited || child.exitCode !== null || child.signalCode !== null)
|
|
1998
|
+
return;
|
|
1999
|
+
signalProcessGroup(child, "SIGKILL");
|
|
2000
|
+
const killed = await Promise.race([
|
|
2001
|
+
exit.then(() => true),
|
|
2002
|
+
delay(Math.max(graceMs, 250)).then(() => false),
|
|
2003
|
+
]);
|
|
2004
|
+
if (!killed && child.exitCode === null && child.signalCode === null) {
|
|
2005
|
+
throw new Error("Chrome for Testing process group did not exit after SIGKILL");
|
|
2006
|
+
}
|
|
2007
|
+
}
|
|
2008
|
+
function signalProcessGroup(child, signal) {
|
|
2009
|
+
const pid = child.pid;
|
|
2010
|
+
if (!pid || pid < 2)
|
|
2011
|
+
return;
|
|
2012
|
+
try {
|
|
2013
|
+
process.kill(-pid, signal);
|
|
2014
|
+
}
|
|
2015
|
+
catch (error) {
|
|
2016
|
+
if (isNoSuchProcess(error))
|
|
2017
|
+
return;
|
|
2018
|
+
try {
|
|
2019
|
+
child.kill(signal);
|
|
2020
|
+
}
|
|
2021
|
+
catch (fallbackError) {
|
|
2022
|
+
if (!isNoSuchProcess(fallbackError))
|
|
2023
|
+
throw fallbackError;
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
2027
|
+
function parseTargetInfo(value) {
|
|
2028
|
+
if (!isRecord(value))
|
|
2029
|
+
throw new Error("CDP targetInfo is invalid");
|
|
2030
|
+
return {
|
|
2031
|
+
targetId: requiredCdpId(value.targetId, "targetInfo targetId"),
|
|
2032
|
+
type: typeof value.type === "string" ? value.type : "",
|
|
2033
|
+
title: typeof value.title === "string" ? value.title : "",
|
|
2034
|
+
url: typeof value.url === "string" ? value.url : "about:blank",
|
|
2035
|
+
};
|
|
2036
|
+
}
|
|
2037
|
+
function parseNavigationHistory(value) {
|
|
2038
|
+
if (!isRecord(value) || !Number.isSafeInteger(value.currentIndex) || !Array.isArray(value.entries)) {
|
|
2039
|
+
throw new Error("CDP navigation history is invalid");
|
|
2040
|
+
}
|
|
2041
|
+
const entries = value.entries.map((entry) => {
|
|
2042
|
+
if (!isRecord(entry) || !Number.isSafeInteger(entry.id) || typeof entry.url !== "string") {
|
|
2043
|
+
throw new Error("CDP navigation history entry is invalid");
|
|
2044
|
+
}
|
|
2045
|
+
return { id: entry.id, url: entry.url };
|
|
2046
|
+
});
|
|
2047
|
+
const currentIndex = value.currentIndex;
|
|
2048
|
+
if (currentIndex < -1 || currentIndex >= entries.length) {
|
|
2049
|
+
throw new Error("CDP navigation history index is invalid");
|
|
2050
|
+
}
|
|
2051
|
+
return { currentIndex, entries };
|
|
2052
|
+
}
|
|
2053
|
+
function parseReadyState(value) {
|
|
2054
|
+
if (!isRecord(value) || !isRecord(value.result) || typeof value.result.value !== "string") {
|
|
2055
|
+
return "loading";
|
|
2056
|
+
}
|
|
2057
|
+
return value.result.value;
|
|
2058
|
+
}
|
|
2059
|
+
function displayUrl(value) {
|
|
2060
|
+
if (value === "about:blank")
|
|
2061
|
+
return value;
|
|
2062
|
+
try {
|
|
2063
|
+
const url = new URL(value);
|
|
2064
|
+
if (url.protocol !== "http:" && url.protocol !== "https:")
|
|
2065
|
+
return "about:blank";
|
|
2066
|
+
return url.href.length <= RUNTIME_BROWSER_MAX_URL_CHARS ? url.href : "about:blank";
|
|
2067
|
+
}
|
|
2068
|
+
catch {
|
|
2069
|
+
return "about:blank";
|
|
2070
|
+
}
|
|
2071
|
+
}
|
|
2072
|
+
function displayTitle(value) {
|
|
2073
|
+
return value.replace(CONTROL_CHARACTER_GLOBAL_PATTERN, "").slice(0, RUNTIME_BROWSER_MAX_TITLE_CHARS);
|
|
2074
|
+
}
|
|
2075
|
+
function boundedEngineVersion(value) {
|
|
2076
|
+
if (typeof value !== "string" || value.length === 0 || value.length > 256 || CONTROL_CHARACTER_PATTERN.test(value)) {
|
|
2077
|
+
throw new Error("CDP Browser version is invalid");
|
|
2078
|
+
}
|
|
2079
|
+
return value;
|
|
2080
|
+
}
|
|
2081
|
+
function requiredCdpId(value, label) {
|
|
2082
|
+
if (typeof value !== "string" || value.length === 0 || value.length > 2_048 || CONTROL_CHARACTER_PATTERN.test(value)) {
|
|
2083
|
+
throw new Error(`CDP ${label} is invalid`);
|
|
2084
|
+
}
|
|
2085
|
+
return value;
|
|
2086
|
+
}
|
|
2087
|
+
function optionalCdpId(value) {
|
|
2088
|
+
try {
|
|
2089
|
+
return requiredCdpId(value, "identifier");
|
|
2090
|
+
}
|
|
2091
|
+
catch {
|
|
2092
|
+
return undefined;
|
|
2093
|
+
}
|
|
2094
|
+
}
|
|
2095
|
+
function isMissingTargetError(error) {
|
|
2096
|
+
return error instanceof CdpProtocolError && /target|session/i.test(error.message) && /no|closed|found/i.test(error.message);
|
|
2097
|
+
}
|
|
2098
|
+
function isTransientPageTransitionError(error) {
|
|
2099
|
+
return error instanceof CdpProtocolError && error.code === -32_000 && (/not attached to an active page/i.test(error.message) ||
|
|
2100
|
+
/execution context (?:was )?destroyed/i.test(error.message) ||
|
|
2101
|
+
/cannot find (?:an )?execution context/i.test(error.message) ||
|
|
2102
|
+
/cannot find context with specified id/i.test(error.message));
|
|
2103
|
+
}
|
|
2104
|
+
function isMissingPageError(error) {
|
|
2105
|
+
return (error instanceof SessionBrowserHostError && error.code === "page_not_found") || isMissingTargetError(error);
|
|
2106
|
+
}
|
|
2107
|
+
function missingPage(cause) {
|
|
2108
|
+
return new SessionBrowserHostError("page_not_found", "Headless Browser Page was not found", {
|
|
2109
|
+
cause,
|
|
2110
|
+
});
|
|
2111
|
+
}
|
|
2112
|
+
function unavailable(message, cause) {
|
|
2113
|
+
return new SessionBrowserHostError("unavailable", message, { cause });
|
|
2114
|
+
}
|
|
2115
|
+
function positiveDuration(value, fallback, label) {
|
|
2116
|
+
const parsed = value ?? fallback;
|
|
2117
|
+
if (!Number.isSafeInteger(parsed) || parsed <= 0)
|
|
2118
|
+
throw new Error(`${label} must be positive`);
|
|
2119
|
+
return parsed;
|
|
2120
|
+
}
|
|
2121
|
+
function delay(milliseconds) {
|
|
2122
|
+
return new Promise((resolveDelay) => setTimeout(resolveDelay, milliseconds));
|
|
2123
|
+
}
|
|
2124
|
+
function isNoSuchProcess(error) {
|
|
2125
|
+
return isRecord(error) && error.code === "ESRCH";
|
|
2126
|
+
}
|
|
2127
|
+
function rawDataBytes(data) {
|
|
2128
|
+
if (Array.isArray(data))
|
|
2129
|
+
return Buffer.concat(data);
|
|
2130
|
+
if (data instanceof ArrayBuffer)
|
|
2131
|
+
return Buffer.from(data);
|
|
2132
|
+
return Buffer.from(data.buffer, data.byteOffset, data.byteLength);
|
|
2133
|
+
}
|
|
2134
|
+
function openNoFollow(path) {
|
|
2135
|
+
return openSync(path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
|
|
2136
|
+
}
|
|
2137
|
+
function readSmallRegularFile(path, maxBytes, label) {
|
|
2138
|
+
const descriptor = openNoFollow(path);
|
|
2139
|
+
try {
|
|
2140
|
+
const stat = fstatSync(descriptor);
|
|
2141
|
+
if (!stat.isFile() || stat.size > maxBytes) {
|
|
2142
|
+
throw new Error(`${label} is not a bounded regular file`);
|
|
2143
|
+
}
|
|
2144
|
+
return readFileSync(descriptor, "utf8");
|
|
2145
|
+
}
|
|
2146
|
+
finally {
|
|
2147
|
+
closeSync(descriptor);
|
|
2148
|
+
}
|
|
2149
|
+
}
|
|
2150
|
+
function isWithin(root, candidate) {
|
|
2151
|
+
const rel = relative(root, candidate);
|
|
2152
|
+
return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
|
|
2153
|
+
}
|
|
2154
|
+
function isRecord(value) {
|
|
2155
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2156
|
+
}
|