@orkestrel/browser 0.0.1
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/LICENSE +21 -0
- package/README.md +68 -0
- package/dist/src/core/index.cjs +1209 -0
- package/dist/src/core/index.cjs.map +1 -0
- package/dist/src/core/index.d.cts +742 -0
- package/dist/src/core/index.d.ts +742 -0
- package/dist/src/core/index.js +1174 -0
- package/dist/src/core/index.js.map +1 -0
- package/dist/src/server/index.cjs +1172 -0
- package/dist/src/server/index.cjs.map +1 -0
- package/dist/src/server/index.d.cts +567 -0
- package/dist/src/server/index.d.ts +567 -0
- package/dist/src/server/index.js +1120 -0
- package/dist/src/server/index.js.map +1 -0
- package/package.json +99 -0
|
@@ -0,0 +1,1172 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
let _src_core = require("../core/index.cjs");
|
|
3
|
+
let node_fs = require("node:fs");
|
|
4
|
+
let node_path = require("node:path");
|
|
5
|
+
let node_child_process = require("node:child_process");
|
|
6
|
+
let _orkestrel_contract = require("@orkestrel/contract");
|
|
7
|
+
let node_crypto = require("node:crypto");
|
|
8
|
+
let node_http = require("node:http");
|
|
9
|
+
let _orkestrel_emitter = require("@orkestrel/emitter");
|
|
10
|
+
let _orkestrel_websocket = require("@orkestrel/websocket");
|
|
11
|
+
let node_fs_promises = require("node:fs/promises");
|
|
12
|
+
//#region src/server/constants.ts
|
|
13
|
+
/** Default CDP port probed for an existing browser and used for launches. */
|
|
14
|
+
var BROWSER_DEFAULT_CDP_PORT = 9222;
|
|
15
|
+
/** Default host probed for an existing browser and used for launches (avoids `localhost` resolving to `::1` when Chromium binds `127.0.0.1`). */
|
|
16
|
+
var BROWSER_DEFAULT_HOST = "127.0.0.1";
|
|
17
|
+
/** Protocol prefix for CDP discovery requests. */
|
|
18
|
+
var BROWSER_CDP_PROTOCOL = "http";
|
|
19
|
+
/** Path appended to the CDP host to fetch version metadata (endpoint discovery). */
|
|
20
|
+
var BROWSER_CDP_VERSION_PATH = "/json/version";
|
|
21
|
+
/** Path appended to the CDP host to list open targets (pages, workers, etc). */
|
|
22
|
+
var BROWSER_CDP_LIST_PATH = "/json/list";
|
|
23
|
+
/** Sentinel result returned by discovery when no browser is reachable. */
|
|
24
|
+
var BROWSER_NOT_FOUND_RESULT = Object.freeze({
|
|
25
|
+
found: false,
|
|
26
|
+
endpoint: void 0,
|
|
27
|
+
browser: void 0,
|
|
28
|
+
connection: void 0
|
|
29
|
+
});
|
|
30
|
+
/** Flags always passed to a launched browser process, alongside the caller's own. */
|
|
31
|
+
var BROWSER_LAUNCH_ARGS = Object.freeze(["--no-first-run", "--no-default-browser-check"]);
|
|
32
|
+
/** Flag enabling headless mode on a launched browser process. */
|
|
33
|
+
var BROWSER_HEADLESS_ARG = "--headless=new";
|
|
34
|
+
/** Grace period after SIGTERM before a launched process is escalated to SIGKILL during teardown. */
|
|
35
|
+
var BROWSER_KILL_GRACE_MS = 3e3;
|
|
36
|
+
/** Bound for the `discover: false` port-occupancy probe before launching — short, since it only needs to detect an already-listening CDP endpoint, not perform full discovery. */
|
|
37
|
+
var BROWSER_PORT_PROBE_TIMEOUT_MS = 200;
|
|
38
|
+
/** Brief defer applied once when a transport loss is observed on an owned process, giving a near-simultaneous process-exit event (which libuv may reap slightly later than the socket close) first say over the diagnosis. */
|
|
39
|
+
var BROWSER_TRANSPORT_LOSS_DEFER_MS = 50;
|
|
40
|
+
/** Environment variables checked (in order) for an explicit browser executable path override. */
|
|
41
|
+
var BROWSER_ENV_PATH_KEYS = Object.freeze(["PLAYWRIGHT_EXECUTABLE_PATH", "CHROME_PATH"]);
|
|
42
|
+
/** Well-known Chrome/Chromium/Edge executable paths with no platform-specific root, keyed by `process.platform`. */
|
|
43
|
+
var BROWSER_EXECUTABLE_PATHS = Object.freeze({
|
|
44
|
+
linux: Object.freeze([
|
|
45
|
+
"/usr/bin/google-chrome",
|
|
46
|
+
"/usr/bin/google-chrome-stable",
|
|
47
|
+
"/usr/bin/microsoft-edge",
|
|
48
|
+
"/usr/bin/microsoft-edge-stable",
|
|
49
|
+
"/usr/bin/chromium",
|
|
50
|
+
"/usr/bin/chromium-browser",
|
|
51
|
+
"/snap/bin/chromium",
|
|
52
|
+
"/opt/google/chrome/chrome",
|
|
53
|
+
"/opt/microsoft/msedge/msedge"
|
|
54
|
+
]),
|
|
55
|
+
darwin: Object.freeze([
|
|
56
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
57
|
+
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
58
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium"
|
|
59
|
+
]),
|
|
60
|
+
win32: Object.freeze([])
|
|
61
|
+
});
|
|
62
|
+
/** Windows install-root-relative suffixes for Chrome/Edge/Chromium, joined against each candidate root (`PROGRAMFILES`, `PROGRAMFILES(X86)`, `LOCALAPPDATA`). */
|
|
63
|
+
var BROWSER_WINDOWS_SUFFIXES = Object.freeze([
|
|
64
|
+
"Google\\Chrome\\Application\\chrome.exe",
|
|
65
|
+
"Microsoft\\Edge\\Application\\msedge.exe",
|
|
66
|
+
"Chromium\\Application\\chrome.exe"
|
|
67
|
+
]);
|
|
68
|
+
/** Fallback Windows install roots used when the corresponding environment variable is absent. */
|
|
69
|
+
var BROWSER_WINDOWS_ROOT_FALLBACKS = Object.freeze({
|
|
70
|
+
PROGRAMFILES: "C:\\Program Files",
|
|
71
|
+
"PROGRAMFILES(X86)": "C:\\Program Files (x86)"
|
|
72
|
+
});
|
|
73
|
+
/** Command names probed on PATH when no well-known executable path exists. */
|
|
74
|
+
var BROWSER_EXECUTABLE_NAMES = Object.freeze([
|
|
75
|
+
"google-chrome",
|
|
76
|
+
"google-chrome-stable",
|
|
77
|
+
"msedge",
|
|
78
|
+
"microsoft-edge",
|
|
79
|
+
"chromium",
|
|
80
|
+
"chromium-browser",
|
|
81
|
+
"chrome"
|
|
82
|
+
]);
|
|
83
|
+
/** Environment variable naming an additional Playwright browser store base directory. */
|
|
84
|
+
var BROWSER_STORE_ENV_KEY = "PLAYWRIGHT_BROWSERS_PATH";
|
|
85
|
+
/** Well-known Playwright browser store base directories checked in addition to `PLAYWRIGHT_BROWSERS_PATH`. */
|
|
86
|
+
var BROWSER_STORE_DEFAULT_DIRS = Object.freeze(["/opt/pw-browsers"]);
|
|
87
|
+
/** Per-OS default Playwright browser cache directory, relative to the home directory (win32 uses `LOCALAPPDATA` directly). */
|
|
88
|
+
var BROWSER_STORE_CACHE_DIRS = Object.freeze({
|
|
89
|
+
linux: ".cache/ms-playwright",
|
|
90
|
+
darwin: "Library/Caches/ms-playwright"
|
|
91
|
+
});
|
|
92
|
+
/** Name of the top-level Chromium symlink/binary Playwright maintains inside a browser store base. */
|
|
93
|
+
var BROWSER_STORE_LINK_NAME = "chromium";
|
|
94
|
+
/**
|
|
95
|
+
* Case-insensitive substrings identifying an executable path/name's browser
|
|
96
|
+
* engine, checked by `parseBrowserEngine` in the order `edge` → `chromium` → `chrome`.
|
|
97
|
+
*/
|
|
98
|
+
var BROWSER_ENGINE_HINTS = Object.freeze({
|
|
99
|
+
edge: Object.freeze([
|
|
100
|
+
"msedge",
|
|
101
|
+
"microsoft-edge",
|
|
102
|
+
"edge"
|
|
103
|
+
]),
|
|
104
|
+
chromium: Object.freeze([
|
|
105
|
+
"chromium",
|
|
106
|
+
"pw-browsers",
|
|
107
|
+
"chrome-linux",
|
|
108
|
+
"chrome-win",
|
|
109
|
+
"chrome-mac",
|
|
110
|
+
"chrome_headless"
|
|
111
|
+
]),
|
|
112
|
+
chrome: Object.freeze([
|
|
113
|
+
"google-chrome",
|
|
114
|
+
"google/chrome",
|
|
115
|
+
"google\\chrome",
|
|
116
|
+
"chrome"
|
|
117
|
+
])
|
|
118
|
+
});
|
|
119
|
+
/** Glob pattern (relative to a store base) matching a versioned Chromium binary, keyed by `process.platform`. */
|
|
120
|
+
var BROWSER_STORE_GLOBS = Object.freeze({
|
|
121
|
+
linux: "chromium-*/chrome-linux*/chrome",
|
|
122
|
+
darwin: "chromium-*/chrome-mac*/Chromium.app/Contents/MacOS/Chromium",
|
|
123
|
+
win32: "chromium-*/chrome-win*/chrome.exe"
|
|
124
|
+
});
|
|
125
|
+
//#endregion
|
|
126
|
+
//#region src/server/errors.ts
|
|
127
|
+
/**
|
|
128
|
+
* A CDP connection, discovery, or launch attempt failed.
|
|
129
|
+
*/
|
|
130
|
+
var BrowserConnectionError = class extends _src_core.BrowserError {
|
|
131
|
+
constructor(message, context) {
|
|
132
|
+
super(message, "BROWSER_CONNECTION_ERROR", context);
|
|
133
|
+
this.name = "BrowserConnectionError";
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
/**
|
|
137
|
+
* An operation requiring an active connection was attempted while disconnected.
|
|
138
|
+
*/
|
|
139
|
+
var BrowserNotConnectedError = class extends _src_core.BrowserError {
|
|
140
|
+
constructor(context) {
|
|
141
|
+
super("Browser is not connected", "BROWSER_NOT_CONNECTED_ERROR", context);
|
|
142
|
+
this.name = "BrowserNotConnectedError";
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
/**
|
|
146
|
+
* An operation was attempted after the Browser was destroyed.
|
|
147
|
+
*/
|
|
148
|
+
var BrowserDestroyedError = class extends _src_core.BrowserError {
|
|
149
|
+
constructor(context) {
|
|
150
|
+
super("Browser has been destroyed", "BROWSER_DESTROYED_ERROR", context);
|
|
151
|
+
this.name = "BrowserDestroyedError";
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
/**
|
|
155
|
+
* Narrow an unknown value to BrowserConnectionError.
|
|
156
|
+
*
|
|
157
|
+
* @param value - Value to check
|
|
158
|
+
* @returns True when value is a BrowserConnectionError instance
|
|
159
|
+
*/
|
|
160
|
+
function isBrowserConnectionError(value) {
|
|
161
|
+
return value instanceof BrowserConnectionError;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Narrow an unknown value to BrowserNotConnectedError.
|
|
165
|
+
*
|
|
166
|
+
* @param value - Value to check
|
|
167
|
+
* @returns True when value is a BrowserNotConnectedError instance
|
|
168
|
+
*/
|
|
169
|
+
function isBrowserNotConnectedError(value) {
|
|
170
|
+
return value instanceof BrowserNotConnectedError;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Narrow an unknown value to BrowserDestroyedError.
|
|
174
|
+
*
|
|
175
|
+
* @param value - Value to check
|
|
176
|
+
* @returns True when value is a BrowserDestroyedError instance
|
|
177
|
+
*/
|
|
178
|
+
function isBrowserDestroyedError(value) {
|
|
179
|
+
return value instanceof BrowserDestroyedError;
|
|
180
|
+
}
|
|
181
|
+
//#endregion
|
|
182
|
+
//#region src/server/helpers.ts
|
|
183
|
+
/**
|
|
184
|
+
* Enumerate every Chrome/Chromium/Edge executable discoverable on this
|
|
185
|
+
* machine, deduplicated by normalized absolute path.
|
|
186
|
+
*
|
|
187
|
+
* @remarks
|
|
188
|
+
* Resolution precedence (candidates appended in this order):
|
|
189
|
+
* 1. `PLAYWRIGHT_EXECUTABLE_PATH`, then `CHROME_PATH` — explicit environment
|
|
190
|
+
* overrides that exist on disk
|
|
191
|
+
* 2. Well-known platform install locations (Chrome, Edge, Chromium); Windows
|
|
192
|
+
* roots are derived from `PROGRAMFILES` / `PROGRAMFILES(X86)` / `LOCALAPPDATA`
|
|
193
|
+
* 3. PATH probe for known command names (`which` on POSIX, `where` on Windows)
|
|
194
|
+
* 4. Playwright-managed browser stores (`PLAYWRIGHT_BROWSERS_PATH`, `/opt/pw-browsers`,
|
|
195
|
+
* and the per-OS Playwright cache directory) — the top-level `chromium`
|
|
196
|
+
* link/binary, then the highest-revision `chromium-*` install found
|
|
197
|
+
*
|
|
198
|
+
* Each candidate is classified into a {@link BrowserEngine} via
|
|
199
|
+
* `parseBrowserEngine` (unclassifiable candidates default to `'chromium'`),
|
|
200
|
+
* and `options.engine` (when given) narrows the result to that engine.
|
|
201
|
+
*
|
|
202
|
+
* @param options - Overrides for the candidate sources; see {@link SystemBrowserOptions}
|
|
203
|
+
* @returns Every discovered browser, in resolution-precedence order
|
|
204
|
+
*/
|
|
205
|
+
function findSystemBrowsers(options) {
|
|
206
|
+
const platform = process.platform;
|
|
207
|
+
const env = options?.env ?? process.env;
|
|
208
|
+
const candidates = [];
|
|
209
|
+
candidates.push(...findAllEnvOverrides(env));
|
|
210
|
+
const paths = options?.paths ?? defaultInstallPaths(platform, env);
|
|
211
|
+
candidates.push(...findAllInstallPaths(paths));
|
|
212
|
+
const names = options?.names ?? BROWSER_EXECUTABLE_NAMES;
|
|
213
|
+
candidates.push(...probeAllPathNames(names, platform));
|
|
214
|
+
const stores = options?.stores ?? defaultStoreBases(env, platform);
|
|
215
|
+
for (const store of stores) candidates.push(...findAllInStore(store, platform));
|
|
216
|
+
const seen = /* @__PURE__ */ new Set();
|
|
217
|
+
const browsers = [];
|
|
218
|
+
for (const executable of candidates) {
|
|
219
|
+
const key = normalizeExecutablePath(executable, platform);
|
|
220
|
+
if (seen.has(key)) continue;
|
|
221
|
+
seen.add(key);
|
|
222
|
+
const engine = parseBrowserEngine(executable) ?? "chromium";
|
|
223
|
+
if (options?.engine !== void 0 && engine !== options.engine) continue;
|
|
224
|
+
browsers.push({
|
|
225
|
+
executable,
|
|
226
|
+
engine
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
return browsers;
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Locate a Chrome/Chromium/Edge executable on this machine — the first entry
|
|
233
|
+
* of {@link findSystemBrowsers}.
|
|
234
|
+
*
|
|
235
|
+
* @param options - Overrides for the candidate sources; see {@link SystemBrowserOptions}
|
|
236
|
+
* @returns The first discovered browser, or undefined
|
|
237
|
+
*/
|
|
238
|
+
function findSystemBrowser(options) {
|
|
239
|
+
return findSystemBrowsers(options)[0];
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Classify an executable path/name into a {@link BrowserEngine} by
|
|
243
|
+
* case-insensitive hint, checked in the order edge → chromium → chrome.
|
|
244
|
+
*
|
|
245
|
+
* @param executable - Executable path or command name to classify
|
|
246
|
+
* @returns The classified engine, or undefined when no hint matches
|
|
247
|
+
*/
|
|
248
|
+
function parseBrowserEngine(executable) {
|
|
249
|
+
const lower = executable.toLowerCase();
|
|
250
|
+
for (const engine of [
|
|
251
|
+
"edge",
|
|
252
|
+
"chromium",
|
|
253
|
+
"chrome"
|
|
254
|
+
]) if (BROWSER_ENGINE_HINTS[engine].some((hint) => lower.includes(hint))) return engine;
|
|
255
|
+
}
|
|
256
|
+
/** Normalize an executable path for cross-source deduplication (case-insensitive on Windows). */
|
|
257
|
+
function normalizeExecutablePath(path, platform) {
|
|
258
|
+
const normalized = platform === "win32" ? node_path.win32.normalize(path) : node_path.posix.normalize(path);
|
|
259
|
+
return platform === "win32" ? normalized.toLowerCase() : normalized;
|
|
260
|
+
}
|
|
261
|
+
/** Classify a `/json/version` `Browser` string into a {@link BrowserEngine} (`Edg/` → edge, `Chrome/` → chrome, else chromium). */
|
|
262
|
+
function browserToEngine(browser) {
|
|
263
|
+
if (browser.includes("Edg/")) return "edge";
|
|
264
|
+
if (browser.includes("Chrome/")) return "chrome";
|
|
265
|
+
return "chromium";
|
|
266
|
+
}
|
|
267
|
+
/** Check the env-override keys (`PLAYWRIGHT_EXECUTABLE_PATH`, `CHROME_PATH`) in order for an existing file. */
|
|
268
|
+
function findEnvOverride(env) {
|
|
269
|
+
return findAllEnvOverrides(env)[0];
|
|
270
|
+
}
|
|
271
|
+
/** Check the env-override keys (`PLAYWRIGHT_EXECUTABLE_PATH`, `CHROME_PATH`) in order, returning every one that exists. */
|
|
272
|
+
function findAllEnvOverrides(env) {
|
|
273
|
+
const found = [];
|
|
274
|
+
for (const key of BROWSER_ENV_PATH_KEYS) {
|
|
275
|
+
const value = env[key];
|
|
276
|
+
if (value !== void 0 && value.length > 0 && (0, node_fs.existsSync)(value)) found.push(value);
|
|
277
|
+
}
|
|
278
|
+
return found;
|
|
279
|
+
}
|
|
280
|
+
/** Build the default well-known install-path candidates for a platform, deriving Windows roots from env vars. */
|
|
281
|
+
function defaultInstallPaths(platform, env) {
|
|
282
|
+
if (platform !== "win32") return BROWSER_EXECUTABLE_PATHS[platform] ?? [];
|
|
283
|
+
const roots = windowsRoots(env);
|
|
284
|
+
const paths = [];
|
|
285
|
+
for (const root of roots) for (const suffix of BROWSER_WINDOWS_SUFFIXES) paths.push(node_path.win32.join(root, suffix));
|
|
286
|
+
return paths;
|
|
287
|
+
}
|
|
288
|
+
/** Derive Windows install roots from env vars, falling back to well-known literals when absent. */
|
|
289
|
+
function windowsRoots(env) {
|
|
290
|
+
return [
|
|
291
|
+
env["PROGRAMFILES"] ?? BROWSER_WINDOWS_ROOT_FALLBACKS["PROGRAMFILES"],
|
|
292
|
+
env["PROGRAMFILES(X86)"] ?? BROWSER_WINDOWS_ROOT_FALLBACKS["PROGRAMFILES(X86)"],
|
|
293
|
+
env["LOCALAPPDATA"]
|
|
294
|
+
].filter((root) => root !== void 0);
|
|
295
|
+
}
|
|
296
|
+
/** Return the first candidate path that exists on disk. */
|
|
297
|
+
function findInstallPath(paths) {
|
|
298
|
+
return findAllInstallPaths(paths)[0];
|
|
299
|
+
}
|
|
300
|
+
/** Return every candidate path that exists on disk, in the given order. */
|
|
301
|
+
function findAllInstallPaths(paths) {
|
|
302
|
+
return paths.filter((path) => (0, node_fs.existsSync)(path));
|
|
303
|
+
}
|
|
304
|
+
/** Probe PATH (`which`/`where`) for the first resolvable command name. */
|
|
305
|
+
function probePathNames(names, platform) {
|
|
306
|
+
return probeAllPathNames(names, platform)[0];
|
|
307
|
+
}
|
|
308
|
+
/** Probe PATH (`which`/`where`) for every resolvable command name, in the given order. */
|
|
309
|
+
function probeAllPathNames(names, platform) {
|
|
310
|
+
const finder = platform === "win32" ? "where" : "which";
|
|
311
|
+
const found = [];
|
|
312
|
+
for (const name of names) {
|
|
313
|
+
const result = (0, node_child_process.spawnSync)(finder, [name], { stdio: "pipe" });
|
|
314
|
+
if (result.status === 0) {
|
|
315
|
+
const output = result.stdout.toString("utf-8").trim().split("\n")[0];
|
|
316
|
+
if (output !== void 0 && output.length > 0) found.push(output);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return found;
|
|
320
|
+
}
|
|
321
|
+
/** Build the default Playwright browser store base directories to search for a managed Chromium. */
|
|
322
|
+
function defaultStoreBases(env, platform) {
|
|
323
|
+
const bases = [];
|
|
324
|
+
const envBase = env[BROWSER_STORE_ENV_KEY];
|
|
325
|
+
if (envBase !== void 0 && envBase.length > 0) bases.push(envBase);
|
|
326
|
+
bases.push(...BROWSER_STORE_DEFAULT_DIRS);
|
|
327
|
+
if (platform === "win32") {
|
|
328
|
+
const localAppData = env["LOCALAPPDATA"];
|
|
329
|
+
if (localAppData !== void 0) bases.push(node_path.win32.join(localAppData, "ms-playwright"));
|
|
330
|
+
} else {
|
|
331
|
+
const cacheDir = BROWSER_STORE_CACHE_DIRS[platform];
|
|
332
|
+
const home = env["HOME"];
|
|
333
|
+
if (cacheDir !== void 0 && home !== void 0) bases.push(node_path.posix.join(home, cacheDir));
|
|
334
|
+
}
|
|
335
|
+
return bases;
|
|
336
|
+
}
|
|
337
|
+
/** Search one store base for the top-level `chromium` link, else the highest-revision `chromium-*` install. */
|
|
338
|
+
function findInStore(base, platform) {
|
|
339
|
+
return findAllInStore(base, platform)[0];
|
|
340
|
+
}
|
|
341
|
+
/** Search one store base for the top-level `chromium` link and every `chromium-*` install, highest revision first. */
|
|
342
|
+
function findAllInStore(base, platform) {
|
|
343
|
+
const joiner = platform === "win32" ? node_path.win32.join : node_path.posix.join;
|
|
344
|
+
const found = [];
|
|
345
|
+
const linkPath = joiner(base, BROWSER_STORE_LINK_NAME);
|
|
346
|
+
if ((0, node_fs.existsSync)(linkPath)) found.push(linkPath);
|
|
347
|
+
const glob = BROWSER_STORE_GLOBS[platform];
|
|
348
|
+
if (glob === void 0) return found;
|
|
349
|
+
const matches = (0, node_fs.globSync)(`${base.replaceAll("\\", "/")}/${glob}`).filter((match) => (0, node_fs.existsSync)(match));
|
|
350
|
+
matches.sort((a, b) => a < b ? 1 : a > b ? -1 : 0);
|
|
351
|
+
found.push(...matches);
|
|
352
|
+
return found;
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Launch a browser process with raw-CDP debugging flags.
|
|
356
|
+
*
|
|
357
|
+
* @param executable - Absolute path to the browser executable
|
|
358
|
+
* @param port - Port the browser exposes its CDP endpoint on
|
|
359
|
+
* @param headless - Whether to launch in headless mode
|
|
360
|
+
* @param profile - Optional user-data-dir for a persistent profile
|
|
361
|
+
* @param extra - Additional command-line flags
|
|
362
|
+
* @returns The spawned ChildProcess
|
|
363
|
+
*/
|
|
364
|
+
function launchBrowserProcess(executable, port, headless, profile, extra) {
|
|
365
|
+
const args = [];
|
|
366
|
+
if (extra !== void 0) args.push(...extra);
|
|
367
|
+
args.push(`--remote-debugging-port=${port}`, ...BROWSER_LAUNCH_ARGS);
|
|
368
|
+
if (headless) args.push(BROWSER_HEADLESS_ARG);
|
|
369
|
+
if (profile !== void 0) args.push(`--user-data-dir=${profile}`);
|
|
370
|
+
return (0, node_child_process.spawn)(executable, args, { stdio: "ignore" });
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Poll a browser's CDP version endpoint until it responds or the timeout elapses.
|
|
374
|
+
*
|
|
375
|
+
* @param port - Port the browser exposes its CDP endpoint on
|
|
376
|
+
* @param timeout - Maximum time to wait in milliseconds
|
|
377
|
+
* @param host - Host the browser exposes its CDP endpoint on (default `127.0.0.1`)
|
|
378
|
+
* @returns The browser's WebSocket debugger URL
|
|
379
|
+
*
|
|
380
|
+
* @throws When the endpoint does not become ready before the timeout
|
|
381
|
+
*/
|
|
382
|
+
async function waitForCdpReady(port, timeout, host = BROWSER_DEFAULT_HOST) {
|
|
383
|
+
const url = `${BROWSER_CDP_PROTOCOL}://${host}:${port}${BROWSER_CDP_VERSION_PATH}`;
|
|
384
|
+
const deadline = Date.now() + timeout;
|
|
385
|
+
while (Date.now() < deadline) {
|
|
386
|
+
const remaining = Math.max(0, deadline - Date.now());
|
|
387
|
+
const controller = new AbortController();
|
|
388
|
+
const timer = setTimeout(() => controller.abort(), remaining);
|
|
389
|
+
try {
|
|
390
|
+
const response = await fetch(url, { signal: controller.signal });
|
|
391
|
+
if (response.ok) {
|
|
392
|
+
const info = await response.json();
|
|
393
|
+
if ((0, _orkestrel_contract.isRecord)(info) && (0, _orkestrel_contract.isString)(info["webSocketDebuggerUrl"])) return info["webSocketDebuggerUrl"];
|
|
394
|
+
}
|
|
395
|
+
} catch {} finally {
|
|
396
|
+
clearTimeout(timer);
|
|
397
|
+
}
|
|
398
|
+
await new Promise((resolve) => setTimeout(resolve, _src_core.BROWSER_WAIT_POLL_INTERVAL_MS));
|
|
399
|
+
}
|
|
400
|
+
throw new Error(`CDP endpoint on port ${port} did not become ready within ${timeout}ms`);
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Fetch the current CDP target list from a browser's `/json/list` endpoint.
|
|
404
|
+
*
|
|
405
|
+
* @param port - Port the browser exposes its CDP endpoint on
|
|
406
|
+
* @param timeout - Request timeout in milliseconds
|
|
407
|
+
* @param host - Host the browser exposes its CDP endpoint on (default `127.0.0.1`)
|
|
408
|
+
* @returns Normalized CDP targets
|
|
409
|
+
*/
|
|
410
|
+
async function fetchCdpTargets(port, timeout, host = BROWSER_DEFAULT_HOST) {
|
|
411
|
+
const url = `${BROWSER_CDP_PROTOCOL}://${host}:${port}${BROWSER_CDP_LIST_PATH}`;
|
|
412
|
+
const controller = new AbortController();
|
|
413
|
+
const timer = setTimeout(() => controller.abort(), timeout);
|
|
414
|
+
try {
|
|
415
|
+
const response = await fetch(url, { signal: controller.signal });
|
|
416
|
+
if (!response.ok) return [];
|
|
417
|
+
const list = await response.json();
|
|
418
|
+
if (!Array.isArray(list)) return [];
|
|
419
|
+
const targets = [];
|
|
420
|
+
for (const entry of list) {
|
|
421
|
+
if (!(0, _orkestrel_contract.isRecord)(entry)) continue;
|
|
422
|
+
if (!(0, _orkestrel_contract.isString)(entry["id"]) || !(0, _orkestrel_contract.isString)(entry["type"])) continue;
|
|
423
|
+
targets.push({
|
|
424
|
+
id: entry["id"],
|
|
425
|
+
type: entry["type"],
|
|
426
|
+
title: (0, _orkestrel_contract.isString)(entry["title"]) ? entry["title"] : "",
|
|
427
|
+
url: (0, _orkestrel_contract.isString)(entry["url"]) ? entry["url"] : ""
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
return targets;
|
|
431
|
+
} catch {
|
|
432
|
+
return [];
|
|
433
|
+
} finally {
|
|
434
|
+
clearTimeout(timer);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
//#endregion
|
|
438
|
+
//#region src/server/transports/WebSocketCDPTransport.ts
|
|
439
|
+
/**
|
|
440
|
+
* `@orkestrel/websocket`-backed {@link CDPTransportInterface} — the dumb text
|
|
441
|
+
* pipe a {@link import('@src/core').CDPClientInterface} sends and receives
|
|
442
|
+
* JSON-RPC frames over.
|
|
443
|
+
*
|
|
444
|
+
* @remarks
|
|
445
|
+
* `start()` hand-rolls the RFC 6455 client handshake: it opens a `node:http`
|
|
446
|
+
* `GET` carrying `Connection: Upgrade` / `Upgrade: websocket` / a random
|
|
447
|
+
* `Sec-WebSocket-Key` / `Sec-WebSocket-Version`, awaits the client `'upgrade'`
|
|
448
|
+
* event, and validates `Sec-WebSocket-Accept === computeWebSocketAccept(key)`
|
|
449
|
+
* — a mismatch (or a connection/request error, or a timeout) rejects `start()`
|
|
450
|
+
* with a coded {@link BrowserConnectionError} and cleans up the socket. On
|
|
451
|
+
* success it wraps the raw upgraded socket in `createNodeWebSocket({ socket,
|
|
452
|
+
* head })` (CLIENT mode — no `key` — so outgoing frames are masked per RFC
|
|
453
|
+
* 6455 §5.3) and bridges its `message`/`close`/`error` events onto this
|
|
454
|
+
* transport's emitter. `start()` opens a fresh socket each call (safe to call
|
|
455
|
+
* again after `close()` — a new request/socket/NodeWebSocket triple is
|
|
456
|
+
* created every time, which is what `CDPClient.reconnect()` depends on). CDP
|
|
457
|
+
* frames are text-only, so `message` always carries a `string`.
|
|
458
|
+
*/
|
|
459
|
+
var WebSocketCDPTransport = class {
|
|
460
|
+
#emitter;
|
|
461
|
+
#url;
|
|
462
|
+
#timeout;
|
|
463
|
+
#socket;
|
|
464
|
+
#connecting;
|
|
465
|
+
constructor(options) {
|
|
466
|
+
this.#emitter = new _orkestrel_emitter.Emitter();
|
|
467
|
+
this.#url = options.url;
|
|
468
|
+
this.#timeout = options.timeout ?? _src_core.BROWSER_DEFAULT_TIMEOUT_MS;
|
|
469
|
+
}
|
|
470
|
+
get emitter() {
|
|
471
|
+
return this.#emitter;
|
|
472
|
+
}
|
|
473
|
+
async start() {
|
|
474
|
+
const url = new URL(this.#url);
|
|
475
|
+
const key = (0, node_crypto.randomBytes)(16).toString("base64");
|
|
476
|
+
const ws = await new Promise((resolve, reject) => {
|
|
477
|
+
const settle = (fn) => {
|
|
478
|
+
this.#connecting = void 0;
|
|
479
|
+
fn();
|
|
480
|
+
};
|
|
481
|
+
const timer = setTimeout(() => {
|
|
482
|
+
request.destroy();
|
|
483
|
+
settle(() => reject(new BrowserConnectionError(`WebSocket CDP connection to ${this.#url} timed out after ${this.#timeout}ms`, {
|
|
484
|
+
url: this.#url,
|
|
485
|
+
timeout: this.#timeout
|
|
486
|
+
})));
|
|
487
|
+
}, this.#timeout);
|
|
488
|
+
const request = (0, node_http.request)({
|
|
489
|
+
hostname: url.hostname,
|
|
490
|
+
port: url.port.length > 0 ? Number(url.port) : 80,
|
|
491
|
+
path: `${url.pathname}${url.search}`,
|
|
492
|
+
headers: {
|
|
493
|
+
Connection: "Upgrade",
|
|
494
|
+
Upgrade: "websocket",
|
|
495
|
+
"Sec-WebSocket-Key": key,
|
|
496
|
+
"Sec-WebSocket-Version": _orkestrel_websocket.WEBSOCKET_VERSION
|
|
497
|
+
}
|
|
498
|
+
});
|
|
499
|
+
request.on("upgrade", (response, socket, head) => {
|
|
500
|
+
clearTimeout(timer);
|
|
501
|
+
const accept = response.headers["sec-websocket-accept"];
|
|
502
|
+
if (!(0, _orkestrel_contract.isString)(accept) || accept !== (0, _orkestrel_websocket.computeWebSocketAccept)(key)) {
|
|
503
|
+
socket.destroy();
|
|
504
|
+
settle(() => reject(new BrowserConnectionError(`WebSocket CDP connection to ${this.#url} failed: Sec-WebSocket-Accept mismatch`, { url: this.#url })));
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
settle(() => resolve((0, _orkestrel_websocket.createNodeWebSocket)({
|
|
508
|
+
socket,
|
|
509
|
+
head
|
|
510
|
+
})));
|
|
511
|
+
});
|
|
512
|
+
request.on("response", (response) => {
|
|
513
|
+
clearTimeout(timer);
|
|
514
|
+
response.resume();
|
|
515
|
+
settle(() => reject(new BrowserConnectionError(`WebSocket CDP connection to ${this.#url} failed: upgrade declined with status ${response.statusCode ?? 0}`, { url: this.#url })));
|
|
516
|
+
});
|
|
517
|
+
request.on("error", (error) => {
|
|
518
|
+
clearTimeout(timer);
|
|
519
|
+
settle(() => reject(new BrowserConnectionError(`WebSocket CDP connection to ${this.#url} failed: ${error.message}`, { url: this.#url })));
|
|
520
|
+
});
|
|
521
|
+
this.#connecting = {
|
|
522
|
+
abort: () => request.destroy(),
|
|
523
|
+
timer,
|
|
524
|
+
reject: (error) => settle(() => reject(error))
|
|
525
|
+
};
|
|
526
|
+
request.end();
|
|
527
|
+
});
|
|
528
|
+
this.#socket = ws;
|
|
529
|
+
this.#bind(ws);
|
|
530
|
+
}
|
|
531
|
+
async send(data) {
|
|
532
|
+
const socket = this.#socket;
|
|
533
|
+
if (socket === void 0 || socket.readyState !== _orkestrel_websocket.WEBSOCKET_READY_OPEN) throw new Error("WebSocket CDP transport is not open");
|
|
534
|
+
socket.send(data);
|
|
535
|
+
}
|
|
536
|
+
async close() {
|
|
537
|
+
const connecting = this.#connecting;
|
|
538
|
+
if (connecting !== void 0) {
|
|
539
|
+
clearTimeout(connecting.timer);
|
|
540
|
+
this.#connecting = void 0;
|
|
541
|
+
connecting.abort();
|
|
542
|
+
connecting.reject(new BrowserConnectionError(`WebSocket CDP connection to ${this.#url} was closed before it finished connecting`, { url: this.#url }));
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
const socket = this.#socket;
|
|
546
|
+
if (socket === void 0) return;
|
|
547
|
+
this.#socket = void 0;
|
|
548
|
+
await new Promise((resolve) => {
|
|
549
|
+
socket.emitter.on("close", () => resolve());
|
|
550
|
+
socket.close();
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
#bind(ws) {
|
|
554
|
+
ws.emitter.on("message", (data) => {
|
|
555
|
+
this.#emitter.emit("message", data);
|
|
556
|
+
});
|
|
557
|
+
ws.emitter.on("close", () => {
|
|
558
|
+
this.#emitter.emit("close");
|
|
559
|
+
});
|
|
560
|
+
ws.emitter.on("error", (error) => {
|
|
561
|
+
this.#emitter.emit("error", error);
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
};
|
|
565
|
+
//#endregion
|
|
566
|
+
//#region src/server/factories.ts
|
|
567
|
+
/**
|
|
568
|
+
* Create a raw-CDP Browser façade.
|
|
569
|
+
*
|
|
570
|
+
* @param options - Connection, launch, and viewport configuration
|
|
571
|
+
* @returns A {@link BrowserInterface}
|
|
572
|
+
*
|
|
573
|
+
* @example
|
|
574
|
+
* ```ts
|
|
575
|
+
* import { createBrowser } from '@src/server'
|
|
576
|
+
*
|
|
577
|
+
* const browser = createBrowser()
|
|
578
|
+
* await browser.connect()
|
|
579
|
+
* ```
|
|
580
|
+
*/
|
|
581
|
+
function createBrowser(options) {
|
|
582
|
+
return new Browser(options);
|
|
583
|
+
}
|
|
584
|
+
/**
|
|
585
|
+
* Create a Node `WebSocket`-backed CDP transport.
|
|
586
|
+
*
|
|
587
|
+
* @param options - The CDP WebSocket debugger URL (and optional timeout)
|
|
588
|
+
* @returns A {@link CDPTransportInterface}
|
|
589
|
+
*/
|
|
590
|
+
function createCDPTransport(options) {
|
|
591
|
+
return new WebSocketCDPTransport(options);
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* Create a filesystem-backed screenshot writer.
|
|
595
|
+
*
|
|
596
|
+
* @returns A {@link ScreenshotWriterInterface} that persists bytes via `node:fs/promises`
|
|
597
|
+
*/
|
|
598
|
+
function createScreenshotWriter() {
|
|
599
|
+
return { async write(path, data) {
|
|
600
|
+
await (0, node_fs_promises.mkdir)((0, node_path.dirname)(path), { recursive: true });
|
|
601
|
+
await (0, node_fs_promises.writeFile)(path, data);
|
|
602
|
+
} };
|
|
603
|
+
}
|
|
604
|
+
//#endregion
|
|
605
|
+
//#region src/server/Browser.ts
|
|
606
|
+
var Browser = class {
|
|
607
|
+
#emitter;
|
|
608
|
+
#options;
|
|
609
|
+
#engine;
|
|
610
|
+
#status = "idle";
|
|
611
|
+
#connection;
|
|
612
|
+
#client;
|
|
613
|
+
#process;
|
|
614
|
+
#cdpPort;
|
|
615
|
+
#cdpHost;
|
|
616
|
+
#contexts = [];
|
|
617
|
+
#destroyed = false;
|
|
618
|
+
#transportUnbind;
|
|
619
|
+
#processUnbind;
|
|
620
|
+
#lastPid;
|
|
621
|
+
constructor(options) {
|
|
622
|
+
this.#emitter = new _orkestrel_emitter.Emitter({ on: options?.on });
|
|
623
|
+
this.#options = options ?? {};
|
|
624
|
+
this.#engine = this.#options.engine ?? (this.#options.executable !== void 0 ? parseBrowserEngine(this.#options.executable) ?? "chromium" : "chromium");
|
|
625
|
+
this.#cdpPort = this.#options.cdp?.port ?? 9222;
|
|
626
|
+
this.#cdpHost = this.#options.cdp?.host ?? "127.0.0.1";
|
|
627
|
+
queueMicrotask(() => this.#emitter.emit("idle"));
|
|
628
|
+
}
|
|
629
|
+
get emitter() {
|
|
630
|
+
return this.#emitter;
|
|
631
|
+
}
|
|
632
|
+
get engine() {
|
|
633
|
+
return this.#engine;
|
|
634
|
+
}
|
|
635
|
+
get status() {
|
|
636
|
+
return this.#status;
|
|
637
|
+
}
|
|
638
|
+
get connection() {
|
|
639
|
+
return this.#connection;
|
|
640
|
+
}
|
|
641
|
+
get connected() {
|
|
642
|
+
return this.#status === "connected";
|
|
643
|
+
}
|
|
644
|
+
get pid() {
|
|
645
|
+
return this.#process?.pid ?? this.#lastPid;
|
|
646
|
+
}
|
|
647
|
+
async discover() {
|
|
648
|
+
const result = await this.#discoverCdp();
|
|
649
|
+
this.#emitter.emit("discover", result);
|
|
650
|
+
return result;
|
|
651
|
+
}
|
|
652
|
+
async connect() {
|
|
653
|
+
if (this.#destroyed) throw new BrowserDestroyedError();
|
|
654
|
+
if (this.#status === "connected") return;
|
|
655
|
+
this.#assertNotAborted();
|
|
656
|
+
this.#status = "connecting";
|
|
657
|
+
try {
|
|
658
|
+
const cdpEndpoint = this.#options.cdp?.endpoint;
|
|
659
|
+
if (cdpEndpoint !== void 0) {
|
|
660
|
+
await this.#connectCdp(cdpEndpoint);
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
if (this.#options.cdp?.discover ?? true) {
|
|
664
|
+
this.#assertNotAborted();
|
|
665
|
+
const discovery = await this.#raceAbort(this.#discoverCdp());
|
|
666
|
+
if (discovery.found && discovery.endpoint !== void 0) {
|
|
667
|
+
this.#engine = browserToEngine(discovery.browser ?? "");
|
|
668
|
+
await this.#connectCdp(discovery.endpoint);
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
} else {
|
|
672
|
+
this.#assertNotAborted();
|
|
673
|
+
await this.#raceAbort(this.#assertPortFree());
|
|
674
|
+
}
|
|
675
|
+
this.#assertNotAborted();
|
|
676
|
+
await this.#launch();
|
|
677
|
+
} catch (thrown) {
|
|
678
|
+
this.#status = "error";
|
|
679
|
+
this.#emitter.emit("error", thrown);
|
|
680
|
+
if (thrown instanceof BrowserConnectionError) throw thrown;
|
|
681
|
+
throw new BrowserConnectionError(thrown instanceof Error ? thrown.message : String(thrown), { executable: this.#options.executable });
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
async disconnect() {
|
|
685
|
+
if (this.#destroyed || this.#status !== "connected") return;
|
|
686
|
+
if (this.#connection === "launch") throw new BrowserConnectionError("Cannot disconnect() an ephemeral launch — no persistent profile to reattach to; ephemeral launches must use destroy() to release it", { connection: this.#connection });
|
|
687
|
+
this.#unbindTransport();
|
|
688
|
+
this.#unbindProcess();
|
|
689
|
+
const client = this.#client;
|
|
690
|
+
if (client !== void 0) try {
|
|
691
|
+
await client.close();
|
|
692
|
+
} catch {}
|
|
693
|
+
if (this.#process?.pid !== void 0) this.#lastPid = this.#process.pid;
|
|
694
|
+
this.#process = void 0;
|
|
695
|
+
this.#reset();
|
|
696
|
+
this.#emitter.emit("disconnect");
|
|
697
|
+
this.#emitter.emit("idle");
|
|
698
|
+
}
|
|
699
|
+
context(index) {
|
|
700
|
+
const i = index ?? 0;
|
|
701
|
+
if (i < 0 || i >= this.#contexts.length) return void 0;
|
|
702
|
+
return this.#contexts[i];
|
|
703
|
+
}
|
|
704
|
+
contexts() {
|
|
705
|
+
return [...this.#contexts];
|
|
706
|
+
}
|
|
707
|
+
async create(options) {
|
|
708
|
+
if (this.#destroyed) throw new BrowserDestroyedError();
|
|
709
|
+
if (this.#status !== "connected" || this.#client === void 0) throw new BrowserNotConnectedError();
|
|
710
|
+
let ctx = this.#contexts[0];
|
|
711
|
+
if (ctx === void 0) {
|
|
712
|
+
ctx = new _src_core.BrowserContext(this.#client, void 0, this.#options.viewport, createScreenshotWriter());
|
|
713
|
+
this.#contexts.push(ctx);
|
|
714
|
+
}
|
|
715
|
+
const page = await ctx.create(options);
|
|
716
|
+
this.#emitter.emit("page", page);
|
|
717
|
+
return page;
|
|
718
|
+
}
|
|
719
|
+
async destroy() {
|
|
720
|
+
if (this.#destroyed) return;
|
|
721
|
+
this.#destroyed = true;
|
|
722
|
+
try {
|
|
723
|
+
this.#unbindTransport();
|
|
724
|
+
this.#unbindProcess();
|
|
725
|
+
if (this.#process !== void 0) for (const ctx of this.#contexts) try {
|
|
726
|
+
await ctx.close();
|
|
727
|
+
} catch {}
|
|
728
|
+
this.#contexts = [];
|
|
729
|
+
await this.#terminate(this.#process);
|
|
730
|
+
this.#process = void 0;
|
|
731
|
+
if (this.#client !== void 0) {
|
|
732
|
+
try {
|
|
733
|
+
await this.#client.close();
|
|
734
|
+
} catch {}
|
|
735
|
+
this.#client = void 0;
|
|
736
|
+
}
|
|
737
|
+
} finally {
|
|
738
|
+
this.#status = "disconnected";
|
|
739
|
+
this.#connection = void 0;
|
|
740
|
+
this.#lastPid = void 0;
|
|
741
|
+
this.#emitter.emit("destroy");
|
|
742
|
+
this.#emitter.destroy();
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
/**
|
|
746
|
+
* Gracefully shut down the remote browser, whether this instance owns its
|
|
747
|
+
* process or merely attached to it via CDP.
|
|
748
|
+
*
|
|
749
|
+
* @remarks
|
|
750
|
+
* Sends CDP `Browser.close` best-effort; on an owned browser also awaits
|
|
751
|
+
* the process's exit (bounded by the kill-escalation grace period,
|
|
752
|
+
* escalating to a kill only if it does not exit in time). Ends in the
|
|
753
|
+
* same local-cleanup state as `destroy()`.
|
|
754
|
+
*/
|
|
755
|
+
async close() {
|
|
756
|
+
if (this.#destroyed) return;
|
|
757
|
+
this.#destroyed = true;
|
|
758
|
+
try {
|
|
759
|
+
this.#unbindTransport();
|
|
760
|
+
this.#unbindProcess();
|
|
761
|
+
const client = this.#client;
|
|
762
|
+
if (client !== void 0) try {
|
|
763
|
+
await client.send("Browser.close");
|
|
764
|
+
} catch {}
|
|
765
|
+
const process = this.#process;
|
|
766
|
+
if (process !== void 0) {
|
|
767
|
+
const exited = new Promise((resolve) => {
|
|
768
|
+
process.once("exit", () => resolve());
|
|
769
|
+
});
|
|
770
|
+
let timer;
|
|
771
|
+
const timedOut = await Promise.race([exited.then(() => false), new Promise((resolve) => {
|
|
772
|
+
timer = setTimeout(() => resolve(true), BROWSER_KILL_GRACE_MS);
|
|
773
|
+
})]);
|
|
774
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
775
|
+
if (timedOut) await this.#terminate(process);
|
|
776
|
+
this.#process = void 0;
|
|
777
|
+
}
|
|
778
|
+
for (const ctx of this.#contexts) try {
|
|
779
|
+
await ctx.close();
|
|
780
|
+
} catch {}
|
|
781
|
+
this.#contexts = [];
|
|
782
|
+
if (client !== void 0) {
|
|
783
|
+
try {
|
|
784
|
+
await client.close();
|
|
785
|
+
} catch {}
|
|
786
|
+
this.#client = void 0;
|
|
787
|
+
}
|
|
788
|
+
} finally {
|
|
789
|
+
this.#status = "disconnected";
|
|
790
|
+
this.#connection = void 0;
|
|
791
|
+
this.#lastPid = void 0;
|
|
792
|
+
this.#emitter.emit("destroy");
|
|
793
|
+
this.#emitter.destroy();
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
#assertNotAborted() {
|
|
797
|
+
if (this.#options.signal?.aborted) throw new BrowserConnectionError("Connection aborted");
|
|
798
|
+
}
|
|
799
|
+
/**
|
|
800
|
+
* Race a promise against the connection's external AbortSignal (if any),
|
|
801
|
+
* rejecting promptly with a coded BrowserConnectionError when it fires.
|
|
802
|
+
*/
|
|
803
|
+
async #raceAbort(promise) {
|
|
804
|
+
const signal = this.#options.signal;
|
|
805
|
+
if (signal === void 0) return promise;
|
|
806
|
+
if (signal.aborted) throw new BrowserConnectionError("Connection aborted");
|
|
807
|
+
let abortWon = false;
|
|
808
|
+
return await new Promise((resolve, reject) => {
|
|
809
|
+
const onAbort = () => {
|
|
810
|
+
abortWon = true;
|
|
811
|
+
cleanup();
|
|
812
|
+
reject(new BrowserConnectionError("Connection aborted"));
|
|
813
|
+
};
|
|
814
|
+
const cleanup = () => signal.removeEventListener("abort", onAbort);
|
|
815
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
816
|
+
promise.then((value) => {
|
|
817
|
+
cleanup();
|
|
818
|
+
resolve(value);
|
|
819
|
+
}, (error) => {
|
|
820
|
+
cleanup();
|
|
821
|
+
reject(error);
|
|
822
|
+
});
|
|
823
|
+
}).catch((error) => {
|
|
824
|
+
if (abortWon) promise.catch(() => void 0);
|
|
825
|
+
throw error;
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
#reset() {
|
|
829
|
+
this.#contexts = [];
|
|
830
|
+
this.#status = "disconnected";
|
|
831
|
+
this.#connection = void 0;
|
|
832
|
+
this.#client = void 0;
|
|
833
|
+
}
|
|
834
|
+
/**
|
|
835
|
+
* Handle the owned process exiting on its own (not via destroy()/close()).
|
|
836
|
+
* The process is already gone — no kill is attempted. Cleans up orphaned
|
|
837
|
+
* state so the next connect() call can start fresh, and emits a coded
|
|
838
|
+
* `error` (cause: process exit) before `disconnect`.
|
|
839
|
+
*/
|
|
840
|
+
#handleProcessExit() {
|
|
841
|
+
if (this.#destroyed) return;
|
|
842
|
+
if (this.#status !== "connected") return;
|
|
843
|
+
this.#unbindTransport();
|
|
844
|
+
this.#unbindProcess();
|
|
845
|
+
const client = this.#client;
|
|
846
|
+
this.#process = void 0;
|
|
847
|
+
this.#lastPid = void 0;
|
|
848
|
+
this.#reset();
|
|
849
|
+
if (client !== void 0) client.close().catch(() => void 0);
|
|
850
|
+
this.#emitter.emit("error", new BrowserConnectionError("The browser process exited unexpectedly", { cause: "process-exit" }));
|
|
851
|
+
this.#emitter.emit("disconnect");
|
|
852
|
+
this.#emitter.emit("idle");
|
|
853
|
+
}
|
|
854
|
+
/**
|
|
855
|
+
* Handle a transport close/error while the owned process (if any) is
|
|
856
|
+
* still alive, or the connection is CDP-attached. Never kills the
|
|
857
|
+
* process — only the local client is reset, so the SAME instance can
|
|
858
|
+
* reconnect (e.g. rediscover on the port and reattach). Emits a coded
|
|
859
|
+
* `error` (cause: connection loss) before `disconnect`.
|
|
860
|
+
*/
|
|
861
|
+
#handleTransportLoss(deferred = false) {
|
|
862
|
+
if (this.#destroyed) return;
|
|
863
|
+
if (this.#status !== "connected") return;
|
|
864
|
+
const process = this.#process;
|
|
865
|
+
if (process !== void 0 && (process.exitCode !== null || process.signalCode !== null)) {
|
|
866
|
+
this.#handleProcessExit();
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
if (process !== void 0 && !deferred) {
|
|
870
|
+
setTimeout(() => this.#handleTransportLoss(true), 50);
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
if (process !== void 0 && process.pid !== void 0) try {
|
|
874
|
+
process.kill(0);
|
|
875
|
+
} catch {
|
|
876
|
+
this.#handleProcessExit();
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
this.#unbindTransport();
|
|
880
|
+
const client = this.#client;
|
|
881
|
+
this.#reset();
|
|
882
|
+
if (client !== void 0) client.close().catch(() => void 0);
|
|
883
|
+
this.#emitter.emit("error", new BrowserConnectionError("The CDP transport connection was lost", { cause: "connection-loss" }));
|
|
884
|
+
this.#emitter.emit("disconnect");
|
|
885
|
+
this.#emitter.emit("idle");
|
|
886
|
+
}
|
|
887
|
+
/** Subscribe to the CDP transport's close/error events for deterministic transport-loss detection. */
|
|
888
|
+
#bindTransport(transport) {
|
|
889
|
+
const onClose = () => this.#handleTransportLoss();
|
|
890
|
+
const onError = () => this.#handleTransportLoss();
|
|
891
|
+
transport.emitter.on("close", onClose);
|
|
892
|
+
transport.emitter.on("error", onError);
|
|
893
|
+
this.#transportUnbind = () => {
|
|
894
|
+
transport.emitter.off("close", onClose);
|
|
895
|
+
transport.emitter.off("error", onError);
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
#unbindTransport() {
|
|
899
|
+
this.#transportUnbind?.();
|
|
900
|
+
this.#transportUnbind = void 0;
|
|
901
|
+
}
|
|
902
|
+
/** Subscribe to a launched process's exit event for deterministic process-exit detection. */
|
|
903
|
+
#bindProcess(process) {
|
|
904
|
+
const onExit = () => this.#handleProcessExit();
|
|
905
|
+
process.once("exit", onExit);
|
|
906
|
+
this.#processUnbind = () => process.off("exit", onExit);
|
|
907
|
+
}
|
|
908
|
+
#unbindProcess() {
|
|
909
|
+
this.#processUnbind?.();
|
|
910
|
+
this.#processUnbind = void 0;
|
|
911
|
+
}
|
|
912
|
+
#timeout() {
|
|
913
|
+
return this.#options.timeout ?? _src_core.BROWSER_DEFAULT_TIMEOUT_MS;
|
|
914
|
+
}
|
|
915
|
+
async #discoverCdp() {
|
|
916
|
+
const port = this.#cdpPort;
|
|
917
|
+
const url = `${BROWSER_CDP_PROTOCOL}://${this.#cdpHost}:${port}${BROWSER_CDP_VERSION_PATH}`;
|
|
918
|
+
const controller = new AbortController();
|
|
919
|
+
const timer = setTimeout(() => controller.abort(), this.#timeout());
|
|
920
|
+
try {
|
|
921
|
+
const response = await fetch(url, { signal: controller.signal });
|
|
922
|
+
if (!response.ok) return this.#notFoundResult();
|
|
923
|
+
const info = await response.json();
|
|
924
|
+
if (!(0, _orkestrel_contract.isRecord)(info)) return this.#notFoundResult();
|
|
925
|
+
const endpoint = (0, _orkestrel_contract.isString)(info["webSocketDebuggerUrl"]) ? info["webSocketDebuggerUrl"] : void 0;
|
|
926
|
+
const browserName = (0, _orkestrel_contract.isString)(info["Browser"]) ? info["Browser"] : void 0;
|
|
927
|
+
return {
|
|
928
|
+
found: endpoint !== void 0,
|
|
929
|
+
endpoint,
|
|
930
|
+
browser: browserName,
|
|
931
|
+
connection: endpoint !== void 0 ? "cdp" : void 0
|
|
932
|
+
};
|
|
933
|
+
} catch {
|
|
934
|
+
return this.#notFoundResult();
|
|
935
|
+
} finally {
|
|
936
|
+
clearTimeout(timer);
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
#notFoundResult() {
|
|
940
|
+
return {
|
|
941
|
+
found: false,
|
|
942
|
+
endpoint: void 0,
|
|
943
|
+
browser: void 0,
|
|
944
|
+
connection: void 0
|
|
945
|
+
};
|
|
946
|
+
}
|
|
947
|
+
/**
|
|
948
|
+
* Reject when something is already listening on the configured CDP port —
|
|
949
|
+
* used ahead of a `discover: false` launch, so a caller demanding a fresh
|
|
950
|
+
* browser never silently attaches to a stranger already on that port.
|
|
951
|
+
*/
|
|
952
|
+
async #assertPortFree() {
|
|
953
|
+
if (await this.#probePort()) throw new BrowserConnectionError(`Port ${this.#cdpPort} on ${this.#cdpHost} is already occupied by another CDP endpoint`, {
|
|
954
|
+
port: this.#cdpPort,
|
|
955
|
+
host: this.#cdpHost
|
|
956
|
+
});
|
|
957
|
+
}
|
|
958
|
+
/** Very short probe of the CDP version endpoint — just enough to detect an occupied port, not full discovery. */
|
|
959
|
+
async #probePort() {
|
|
960
|
+
const url = `${BROWSER_CDP_PROTOCOL}://${this.#cdpHost}:${this.#cdpPort}${BROWSER_CDP_VERSION_PATH}`;
|
|
961
|
+
const controller = new AbortController();
|
|
962
|
+
const timer = setTimeout(() => controller.abort(), 200);
|
|
963
|
+
try {
|
|
964
|
+
return (await fetch(url, { signal: controller.signal })).ok;
|
|
965
|
+
} catch {
|
|
966
|
+
return false;
|
|
967
|
+
} finally {
|
|
968
|
+
clearTimeout(timer);
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
async #connectCdp(endpoint) {
|
|
972
|
+
const transport = createCDPTransport({
|
|
973
|
+
url: endpoint,
|
|
974
|
+
timeout: this.#timeout()
|
|
975
|
+
});
|
|
976
|
+
const client = new _src_core.CDPClient({
|
|
977
|
+
transport,
|
|
978
|
+
timeout: this.#timeout()
|
|
979
|
+
});
|
|
980
|
+
try {
|
|
981
|
+
await this.#raceAbort(client.connect());
|
|
982
|
+
this.#client = client;
|
|
983
|
+
this.#connection = "cdp";
|
|
984
|
+
this.#status = "connected";
|
|
985
|
+
this.#bindTransport(transport);
|
|
986
|
+
await this.#syncContexts();
|
|
987
|
+
this.#emitter.emit("connect", "cdp");
|
|
988
|
+
} catch (error) {
|
|
989
|
+
try {
|
|
990
|
+
await client.close();
|
|
991
|
+
} catch {}
|
|
992
|
+
throw error;
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
async #launch() {
|
|
996
|
+
if (this.#process !== void 0) throw new BrowserConnectionError("A browser process is already active on this instance");
|
|
997
|
+
const requestedEngine = this.#options.engine ?? this.#options.browsers?.engine;
|
|
998
|
+
let executable = this.#options.executable;
|
|
999
|
+
let resolvedEngine;
|
|
1000
|
+
if (executable !== void 0) resolvedEngine = parseBrowserEngine(executable) ?? "chromium";
|
|
1001
|
+
else {
|
|
1002
|
+
const found = findSystemBrowser({
|
|
1003
|
+
...this.#options.browsers,
|
|
1004
|
+
engine: requestedEngine
|
|
1005
|
+
});
|
|
1006
|
+
executable = found?.executable;
|
|
1007
|
+
resolvedEngine = found?.engine;
|
|
1008
|
+
}
|
|
1009
|
+
if (executable === void 0) throw new BrowserConnectionError("No Chromium browser found. Install Chrome, Edge, or Chromium.", requestedEngine !== void 0 ? { engine: requestedEngine } : void 0);
|
|
1010
|
+
this.#engine = resolvedEngine ?? "chromium";
|
|
1011
|
+
const headless = this.#options.headless ?? true;
|
|
1012
|
+
const profile = this.#options.profile;
|
|
1013
|
+
const extra = this.#options.args;
|
|
1014
|
+
const process = launchBrowserProcess(executable, this.#cdpPort, headless, profile, extra);
|
|
1015
|
+
this.#process = process;
|
|
1016
|
+
let transport;
|
|
1017
|
+
let client;
|
|
1018
|
+
try {
|
|
1019
|
+
transport = createCDPTransport({
|
|
1020
|
+
url: await this.#raceAbort(this.#waitForLaunch(process, executable, extra)),
|
|
1021
|
+
timeout: this.#timeout()
|
|
1022
|
+
});
|
|
1023
|
+
client = new _src_core.CDPClient({
|
|
1024
|
+
transport,
|
|
1025
|
+
timeout: this.#timeout()
|
|
1026
|
+
});
|
|
1027
|
+
await this.#raceAbort(client.connect());
|
|
1028
|
+
this.#client = client;
|
|
1029
|
+
this.#connection = profile !== void 0 ? "persistent" : "launch";
|
|
1030
|
+
this.#status = "connected";
|
|
1031
|
+
this.#bindTransport(transport);
|
|
1032
|
+
this.#bindProcess(process);
|
|
1033
|
+
await this.#syncContexts();
|
|
1034
|
+
this.#emitter.emit("launch", this.#engine);
|
|
1035
|
+
this.#emitter.emit("connect", this.#connection);
|
|
1036
|
+
} catch (error) {
|
|
1037
|
+
if (client !== void 0) try {
|
|
1038
|
+
await client.close();
|
|
1039
|
+
} catch {}
|
|
1040
|
+
await this.#terminate(process);
|
|
1041
|
+
this.#process = void 0;
|
|
1042
|
+
throw error;
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
async #waitForLaunch(process, executable, args) {
|
|
1046
|
+
const context = {
|
|
1047
|
+
executable,
|
|
1048
|
+
args
|
|
1049
|
+
};
|
|
1050
|
+
return await new Promise((resolve, reject) => {
|
|
1051
|
+
let settled = false;
|
|
1052
|
+
const finish = (callback) => {
|
|
1053
|
+
if (settled) return;
|
|
1054
|
+
settled = true;
|
|
1055
|
+
process.off("error", onError);
|
|
1056
|
+
process.off("exit", onExit);
|
|
1057
|
+
callback();
|
|
1058
|
+
};
|
|
1059
|
+
const onError = (error) => {
|
|
1060
|
+
finish(() => reject(new BrowserConnectionError(error.message, context)));
|
|
1061
|
+
};
|
|
1062
|
+
const onExit = (code, signal) => {
|
|
1063
|
+
finish(() => reject(new BrowserConnectionError(this.#formatLaunchExit(code, signal), context)));
|
|
1064
|
+
};
|
|
1065
|
+
process.once("error", onError);
|
|
1066
|
+
process.once("exit", onExit);
|
|
1067
|
+
waitForCdpReady(this.#cdpPort, this.#timeout(), this.#cdpHost).then((wsUrl) => {
|
|
1068
|
+
finish(() => resolve(wsUrl));
|
|
1069
|
+
}, (error) => {
|
|
1070
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1071
|
+
finish(() => reject(new BrowserConnectionError(message, context)));
|
|
1072
|
+
});
|
|
1073
|
+
});
|
|
1074
|
+
}
|
|
1075
|
+
#formatLaunchExit(code, signal) {
|
|
1076
|
+
if (signal !== null) return `Browser process exited before CDP became ready (signal: ${signal})`;
|
|
1077
|
+
if (code !== null) return `Browser process exited before CDP became ready (code: ${code})`;
|
|
1078
|
+
return "Browser process exited before CDP became ready";
|
|
1079
|
+
}
|
|
1080
|
+
async #syncContexts() {
|
|
1081
|
+
if (this.#client === void 0) return;
|
|
1082
|
+
const pageTargets = (await fetchCdpTargets(this.#cdpPort, this.#timeout(), this.#cdpHost)).filter((t) => t.type === "page");
|
|
1083
|
+
if (pageTargets.length > 0 && this.#contexts.length === 0) {
|
|
1084
|
+
const ctx = new _src_core.BrowserContext(this.#client, void 0, this.#options.viewport, createScreenshotWriter());
|
|
1085
|
+
await ctx.sync(pageTargets);
|
|
1086
|
+
this.#contexts.push(ctx);
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
/**
|
|
1090
|
+
* Terminate a browser process, escalating from SIGTERM to SIGKILL if it
|
|
1091
|
+
* does not exit within the grace period. Tolerates a process that has
|
|
1092
|
+
* already exited (ESRCH).
|
|
1093
|
+
*/
|
|
1094
|
+
async #terminate(process) {
|
|
1095
|
+
if (process === void 0) return;
|
|
1096
|
+
if (process.exitCode !== null || process.signalCode !== null) return;
|
|
1097
|
+
const exited = new Promise((resolve) => {
|
|
1098
|
+
process.once("exit", () => resolve());
|
|
1099
|
+
});
|
|
1100
|
+
try {
|
|
1101
|
+
process.kill("SIGTERM");
|
|
1102
|
+
} catch {
|
|
1103
|
+
return;
|
|
1104
|
+
}
|
|
1105
|
+
let timer;
|
|
1106
|
+
const timedOut = await Promise.race([exited.then(() => false), new Promise((resolve) => {
|
|
1107
|
+
timer = setTimeout(() => resolve(true), BROWSER_KILL_GRACE_MS);
|
|
1108
|
+
})]);
|
|
1109
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
1110
|
+
if (timedOut) {
|
|
1111
|
+
try {
|
|
1112
|
+
process.kill("SIGKILL");
|
|
1113
|
+
} catch {}
|
|
1114
|
+
await exited;
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
};
|
|
1118
|
+
//#endregion
|
|
1119
|
+
exports.BROWSER_CDP_LIST_PATH = BROWSER_CDP_LIST_PATH;
|
|
1120
|
+
exports.BROWSER_CDP_PROTOCOL = BROWSER_CDP_PROTOCOL;
|
|
1121
|
+
exports.BROWSER_CDP_VERSION_PATH = BROWSER_CDP_VERSION_PATH;
|
|
1122
|
+
exports.BROWSER_DEFAULT_CDP_PORT = BROWSER_DEFAULT_CDP_PORT;
|
|
1123
|
+
exports.BROWSER_DEFAULT_HOST = BROWSER_DEFAULT_HOST;
|
|
1124
|
+
exports.BROWSER_ENGINE_HINTS = BROWSER_ENGINE_HINTS;
|
|
1125
|
+
exports.BROWSER_ENV_PATH_KEYS = BROWSER_ENV_PATH_KEYS;
|
|
1126
|
+
exports.BROWSER_EXECUTABLE_NAMES = BROWSER_EXECUTABLE_NAMES;
|
|
1127
|
+
exports.BROWSER_EXECUTABLE_PATHS = BROWSER_EXECUTABLE_PATHS;
|
|
1128
|
+
exports.BROWSER_HEADLESS_ARG = BROWSER_HEADLESS_ARG;
|
|
1129
|
+
exports.BROWSER_KILL_GRACE_MS = BROWSER_KILL_GRACE_MS;
|
|
1130
|
+
exports.BROWSER_LAUNCH_ARGS = BROWSER_LAUNCH_ARGS;
|
|
1131
|
+
exports.BROWSER_NOT_FOUND_RESULT = BROWSER_NOT_FOUND_RESULT;
|
|
1132
|
+
exports.BROWSER_PORT_PROBE_TIMEOUT_MS = BROWSER_PORT_PROBE_TIMEOUT_MS;
|
|
1133
|
+
exports.BROWSER_STORE_CACHE_DIRS = BROWSER_STORE_CACHE_DIRS;
|
|
1134
|
+
exports.BROWSER_STORE_DEFAULT_DIRS = BROWSER_STORE_DEFAULT_DIRS;
|
|
1135
|
+
exports.BROWSER_STORE_ENV_KEY = BROWSER_STORE_ENV_KEY;
|
|
1136
|
+
exports.BROWSER_STORE_GLOBS = BROWSER_STORE_GLOBS;
|
|
1137
|
+
exports.BROWSER_STORE_LINK_NAME = BROWSER_STORE_LINK_NAME;
|
|
1138
|
+
exports.BROWSER_TRANSPORT_LOSS_DEFER_MS = BROWSER_TRANSPORT_LOSS_DEFER_MS;
|
|
1139
|
+
exports.BROWSER_WINDOWS_ROOT_FALLBACKS = BROWSER_WINDOWS_ROOT_FALLBACKS;
|
|
1140
|
+
exports.BROWSER_WINDOWS_SUFFIXES = BROWSER_WINDOWS_SUFFIXES;
|
|
1141
|
+
exports.Browser = Browser;
|
|
1142
|
+
exports.BrowserConnectionError = BrowserConnectionError;
|
|
1143
|
+
exports.BrowserDestroyedError = BrowserDestroyedError;
|
|
1144
|
+
exports.BrowserNotConnectedError = BrowserNotConnectedError;
|
|
1145
|
+
exports.WebSocketCDPTransport = WebSocketCDPTransport;
|
|
1146
|
+
exports.browserToEngine = browserToEngine;
|
|
1147
|
+
exports.createBrowser = createBrowser;
|
|
1148
|
+
exports.createCDPTransport = createCDPTransport;
|
|
1149
|
+
exports.createScreenshotWriter = createScreenshotWriter;
|
|
1150
|
+
exports.defaultInstallPaths = defaultInstallPaths;
|
|
1151
|
+
exports.defaultStoreBases = defaultStoreBases;
|
|
1152
|
+
exports.fetchCdpTargets = fetchCdpTargets;
|
|
1153
|
+
exports.findAllEnvOverrides = findAllEnvOverrides;
|
|
1154
|
+
exports.findAllInStore = findAllInStore;
|
|
1155
|
+
exports.findAllInstallPaths = findAllInstallPaths;
|
|
1156
|
+
exports.findEnvOverride = findEnvOverride;
|
|
1157
|
+
exports.findInStore = findInStore;
|
|
1158
|
+
exports.findInstallPath = findInstallPath;
|
|
1159
|
+
exports.findSystemBrowser = findSystemBrowser;
|
|
1160
|
+
exports.findSystemBrowsers = findSystemBrowsers;
|
|
1161
|
+
exports.isBrowserConnectionError = isBrowserConnectionError;
|
|
1162
|
+
exports.isBrowserDestroyedError = isBrowserDestroyedError;
|
|
1163
|
+
exports.isBrowserNotConnectedError = isBrowserNotConnectedError;
|
|
1164
|
+
exports.launchBrowserProcess = launchBrowserProcess;
|
|
1165
|
+
exports.normalizeExecutablePath = normalizeExecutablePath;
|
|
1166
|
+
exports.parseBrowserEngine = parseBrowserEngine;
|
|
1167
|
+
exports.probeAllPathNames = probeAllPathNames;
|
|
1168
|
+
exports.probePathNames = probePathNames;
|
|
1169
|
+
exports.waitForCdpReady = waitForCdpReady;
|
|
1170
|
+
exports.windowsRoots = windowsRoots;
|
|
1171
|
+
|
|
1172
|
+
//# sourceMappingURL=index.cjs.map
|