@descryy/runtime-browser 0.5.2 → 0.5.3
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-action-collector.d.ts +11 -3
- package/dist/browser-action-collector.d.ts.map +1 -1
- package/dist/browser-action-collector.js +102 -11
- package/dist/browser-action-collector.js.map +1 -1
- package/dist/browser-console-collector.d.ts +2 -0
- package/dist/browser-console-collector.d.ts.map +1 -1
- package/dist/browser-console-collector.js +60 -6
- package/dist/browser-console-collector.js.map +1 -1
- package/dist/browser-network-collector.d.ts +12 -1
- package/dist/browser-network-collector.d.ts.map +1 -1
- package/dist/browser-network-collector.js +167 -9
- package/dist/browser-network-collector.js.map +1 -1
- package/dist/browser-recording-collector.d.ts +8 -1
- package/dist/browser-recording-collector.d.ts.map +1 -1
- package/dist/browser-recording-collector.js +141 -17
- package/dist/browser-recording-collector.js.map +1 -1
- package/dist/browser-session.d.ts +131 -5
- package/dist/browser-session.d.ts.map +1 -1
- package/dist/browser-session.js +547 -95
- package/dist/browser-session.js.map +1 -1
- package/dist/dom-evidence.d.ts +6 -0
- package/dist/dom-evidence.d.ts.map +1 -1
- package/dist/dom-evidence.js +40 -11
- package/dist/dom-evidence.js.map +1 -1
- package/dist/frontend-initiator-capture.d.ts +5 -1
- package/dist/frontend-initiator-capture.d.ts.map +1 -1
- package/dist/frontend-initiator-capture.js +72 -10
- package/dist/frontend-initiator-capture.js.map +1 -1
- package/dist/index.d.ts +9 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -7
- package/dist/index.js.map +1 -1
- package/dist/network-correlation.d.ts +2 -11
- package/dist/network-correlation.d.ts.map +1 -1
- package/dist/network-correlation.js +1 -25
- package/dist/network-correlation.js.map +1 -1
- package/dist/script-url-mapping.d.ts +1 -0
- package/dist/script-url-mapping.d.ts.map +1 -1
- package/dist/script-url-mapping.js +7 -0
- package/dist/script-url-mapping.js.map +1 -1
- package/dist/secret-fields.d.ts +37 -0
- package/dist/secret-fields.d.ts.map +1 -0
- package/dist/secret-fields.js +121 -0
- package/dist/secret-fields.js.map +1 -0
- package/package.json +6 -6
package/dist/browser-session.js
CHANGED
|
@@ -12,10 +12,104 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { chromium } from "playwright-core";
|
|
14
14
|
import { randomUUID } from "node:crypto";
|
|
15
|
-
import { existsSync } from "node:fs";
|
|
16
|
-
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
15
|
+
import { existsSync, rmSync } from "node:fs";
|
|
16
|
+
import { chmod, mkdir, mkdtemp, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
17
17
|
import { tmpdir } from "node:os";
|
|
18
|
-
import { join } from "node:path";
|
|
18
|
+
import { dirname, join } from "node:path";
|
|
19
|
+
export const DEFAULT_MAX_AUXILIARY_PAGES = 4;
|
|
20
|
+
export class AuxiliaryPageLimitError extends Error {
|
|
21
|
+
limit;
|
|
22
|
+
constructor(limit) {
|
|
23
|
+
super(`This session already has ${limit} auxiliary ${limit === 1 ? "page" : "pages"} open and allows at most ${limit} auxiliary ` +
|
|
24
|
+
`${limit === 1 ? "page" : "pages"} at once -- close one (page.close()) before opening another, or launch the session with a ` +
|
|
25
|
+
"larger maxAuxiliaryPages.");
|
|
26
|
+
this.name = "AuxiliaryPageLimitError";
|
|
27
|
+
this.limit = limit;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function auxiliaryPageLimitOf(options) {
|
|
31
|
+
const limit = options.maxAuxiliaryPages;
|
|
32
|
+
if (limit === undefined)
|
|
33
|
+
return DEFAULT_MAX_AUXILIARY_PAGES;
|
|
34
|
+
if (!Number.isInteger(limit) || limit < 1) {
|
|
35
|
+
throw new Error(`maxAuxiliaryPages must be a positive whole number, got ${String(limit)}`);
|
|
36
|
+
}
|
|
37
|
+
return limit;
|
|
38
|
+
}
|
|
39
|
+
export const MAX_DIALOG_EVENTS = 200;
|
|
40
|
+
export const MAX_DIALOG_MESSAGE_CHARS = 2_000;
|
|
41
|
+
export function dialogDecision(kind, policy) {
|
|
42
|
+
if (kind === "alert" || kind === "beforeunload")
|
|
43
|
+
return "accept";
|
|
44
|
+
if (kind === "confirm")
|
|
45
|
+
return policy?.confirm ?? "dismiss";
|
|
46
|
+
if (kind === "prompt")
|
|
47
|
+
return policy?.prompt ?? "dismiss";
|
|
48
|
+
return "dismiss";
|
|
49
|
+
}
|
|
50
|
+
function dialogKindOf(type) {
|
|
51
|
+
return type === "alert" || type === "confirm" || type === "prompt" || type === "beforeunload" ? type : "alert";
|
|
52
|
+
}
|
|
53
|
+
function dialogPageUrl(dialog) {
|
|
54
|
+
try {
|
|
55
|
+
return dialog.page()?.url() ?? "";
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return "";
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
export async function answerDialog(dialog, policy, events) {
|
|
62
|
+
const decision = dialogDecision(dialog.type(), policy);
|
|
63
|
+
let response;
|
|
64
|
+
try {
|
|
65
|
+
if (decision === "accept") {
|
|
66
|
+
await (dialog.type() === "prompt" ? dialog.accept(policy?.promptText ?? "") : dialog.accept());
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
await dialog.dismiss();
|
|
70
|
+
}
|
|
71
|
+
response = decision === "accept" ? "accepted" : "dismissed";
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
response = "unanswered";
|
|
75
|
+
}
|
|
76
|
+
const message = dialog.message();
|
|
77
|
+
if (events.length >= MAX_DIALOG_EVENTS)
|
|
78
|
+
events.shift();
|
|
79
|
+
events.push({
|
|
80
|
+
kind: dialogKindOf(dialog.type()),
|
|
81
|
+
message: message.length > MAX_DIALOG_MESSAGE_CHARS ? `${message.slice(0, MAX_DIALOG_MESSAGE_CHARS)}…` : message,
|
|
82
|
+
url: dialogPageUrl(dialog),
|
|
83
|
+
response,
|
|
84
|
+
at: new Date().toISOString(),
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
export const MAX_VIDEO_BYTES = 64 * 1024 * 1024;
|
|
88
|
+
export class VideoTooLargeError extends Error {
|
|
89
|
+
sizeBytes;
|
|
90
|
+
maxBytes;
|
|
91
|
+
constructor(sizeBytes, maxBytes) {
|
|
92
|
+
super(`the recorded video is ${String(sizeBytes)} bytes, over the ${String(maxBytes)}-byte limit, so it was discarded rather than loaded into memory`);
|
|
93
|
+
this.name = "VideoTooLargeError";
|
|
94
|
+
this.sizeBytes = sizeBytes;
|
|
95
|
+
this.maxBytes = maxBytes;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* An origin as the browser compares it: lower-case scheme and host, default
|
|
100
|
+
* port dropped, an IDN host in punycode, no path. `null` for anything with an
|
|
101
|
+
* opaque origin (`data:`, `file:`, `about:`) or that does not parse -- such a
|
|
102
|
+
* URL is never a consented origin and never equals one.
|
|
103
|
+
*/
|
|
104
|
+
export function normalizeOrigin(url) {
|
|
105
|
+
try {
|
|
106
|
+
const origin = new URL(url).origin;
|
|
107
|
+
return origin === "null" ? null : origin;
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
19
113
|
export const CHROMIUM_INSTALL_REMEDY = "npx playwright install chromium";
|
|
20
114
|
/**
|
|
21
115
|
* Resolved at launch time, not import time -- a module-level check would
|
|
@@ -55,6 +149,9 @@ export class BrowserUnavailableError extends Error {
|
|
|
55
149
|
* only rethrow.
|
|
56
150
|
*/
|
|
57
151
|
export async function launchBrowserSessionOrRefuse(options = {}) {
|
|
152
|
+
// Before anything is launched, so a bad origin list leaves no browser behind.
|
|
153
|
+
consentedOriginsOf(options);
|
|
154
|
+
auxiliaryPageLimitOf(options);
|
|
58
155
|
const chromiumBinary = resolveChromiumExecutable();
|
|
59
156
|
if (!chromiumBinary.available) {
|
|
60
157
|
const { available: _available, ...unavailable } = chromiumBinary;
|
|
@@ -70,11 +167,92 @@ export async function launchBrowserSession(options = {}) {
|
|
|
70
167
|
}
|
|
71
168
|
return launch.session;
|
|
72
169
|
}
|
|
170
|
+
const SANDBOX_LAUNCH_FAILURE = /sandbox|namespace|suid|zygote/i;
|
|
171
|
+
async function launchPreferringSandbox(launch, base) {
|
|
172
|
+
try {
|
|
173
|
+
return { browser: await launch({ ...base, chromiumSandbox: true }), sandbox: { enabled: true, fallbackReason: null } };
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
177
|
+
if (!SANDBOX_LAUNCH_FAILURE.test(detail))
|
|
178
|
+
throw error;
|
|
179
|
+
const firstLine = detail.split("\n").find((line) => SANDBOX_LAUNCH_FAILURE.test(line))?.trim() ?? detail.split("\n")[0] ?? detail;
|
|
180
|
+
return {
|
|
181
|
+
browser: await launch({ ...base, chromiumSandbox: false }),
|
|
182
|
+
sandbox: { enabled: false, fallbackReason: `Chromium could not start with its sandbox (${firstLine}), so it was launched without one: a renderer exploit would run with this process's privileges.` },
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
}
|
|
73
186
|
async function launchWithExecutable(options, executablePath) {
|
|
74
|
-
const browser = await chromium.launch(
|
|
75
|
-
|
|
76
|
-
|
|
187
|
+
const { browser, sandbox } = await launchPreferringSandbox((launchOptions) => chromium.launch(launchOptions), {
|
|
188
|
+
headless: options.headless ?? true,
|
|
189
|
+
executablePath,
|
|
77
190
|
});
|
|
191
|
+
try {
|
|
192
|
+
return await createSessionFromBrowser(browser, options, async () => {
|
|
193
|
+
await browser.close();
|
|
194
|
+
}, sandbox);
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
await browser.close().catch(() => undefined);
|
|
198
|
+
throw error;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
const guardOriginOf = normalizeOrigin;
|
|
202
|
+
/** The consented origins, normalised, or null when the session is not confined. Throws on an entry that is not an origin: confining to nothing would block every page silently. */
|
|
203
|
+
function consentedOriginsOf(options) {
|
|
204
|
+
if (options.confineToOrigins === undefined)
|
|
205
|
+
return null;
|
|
206
|
+
if (options.confineToOrigins.length === 0)
|
|
207
|
+
throw new Error("confineToOrigins is empty: a confined session needs at least one origin");
|
|
208
|
+
const origins = new Set();
|
|
209
|
+
for (const raw of options.confineToOrigins) {
|
|
210
|
+
const origin = normalizeOrigin(raw);
|
|
211
|
+
if (origin === null)
|
|
212
|
+
throw new Error(`confineToOrigins: "${raw}" is not a valid origin (it has no host, or its origin is opaque)`);
|
|
213
|
+
origins.add(origin);
|
|
214
|
+
}
|
|
215
|
+
return origins;
|
|
216
|
+
}
|
|
217
|
+
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
218
|
+
function isInertUrl(url) {
|
|
219
|
+
return url === "about:blank" || url.startsWith("chrome-error://");
|
|
220
|
+
}
|
|
221
|
+
/** A page load of a top-level frame (the session's page or a popup). A service-worker request has no frame, and `frame()` throws for it. */
|
|
222
|
+
function isTopLevelPageLoad(request) {
|
|
223
|
+
if (!request.isNavigationRequest())
|
|
224
|
+
return false;
|
|
225
|
+
try {
|
|
226
|
+
return request.frame().parentFrame() === null;
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Checks every redirect a page load of the session's own page answers with, before the browser
|
|
234
|
+
* follows it. Playwright continues redirect hops inside the browser without calling a route
|
|
235
|
+
* handler again, so this sits one level down: Chromium's Fetch domain, at the response stage, for
|
|
236
|
+
* documents. That stage reports each redirect response, the first hop and every later one, while
|
|
237
|
+
* the browser is still holding it. A hop `allow` refuses is failed as aborted, so its target is
|
|
238
|
+
* never requested and the page stays where it was. Everything else is continued unchanged, so the
|
|
239
|
+
* browser still loads every document itself.
|
|
240
|
+
*/
|
|
241
|
+
async function guardRedirects(page, allow) {
|
|
242
|
+
const cdp = await page.context().newCDPSession(page);
|
|
243
|
+
const tree = (await cdp.send("Page.getFrameTree"));
|
|
244
|
+
const mainFrameId = tree.frameTree.frame.id;
|
|
245
|
+
cdp.on("Fetch.requestPaused", (event) => {
|
|
246
|
+
const status = event.responseStatusCode;
|
|
247
|
+
const location = event.responseHeaders?.find((header) => header.name.toLowerCase() === "location")?.value;
|
|
248
|
+
const refused = event.frameId === mainFrameId && status !== undefined && REDIRECT_STATUSES.has(status) && location !== undefined && !allow(location, event.request.url);
|
|
249
|
+
// Always answered: a paused request that is never continued or failed hangs the page.
|
|
250
|
+
const answer = refused
|
|
251
|
+
? cdp.send("Fetch.failRequest", { requestId: event.requestId, errorReason: "Aborted" })
|
|
252
|
+
: cdp.send("Fetch.continueRequest", { requestId: event.requestId });
|
|
253
|
+
answer.catch(() => undefined);
|
|
254
|
+
});
|
|
255
|
+
await cdp.send("Fetch.enable", { patterns: [{ urlPattern: "*", resourceType: "Document", requestStage: "Response" }] });
|
|
78
256
|
}
|
|
79
257
|
/**
|
|
80
258
|
* The context/page half of a session's launch, factored out of
|
|
@@ -89,14 +267,55 @@ async function launchWithExecutable(options, executablePath) {
|
|
|
89
267
|
* context's slot back to the manager and leaves the browser running for the next
|
|
90
268
|
* caller.
|
|
91
269
|
*/
|
|
92
|
-
|
|
270
|
+
const uncollectedVideoDirs = new Set();
|
|
271
|
+
let videoDirExitHookInstalled = false;
|
|
272
|
+
function removeAtExit(dir) {
|
|
273
|
+
uncollectedVideoDirs.add(dir);
|
|
274
|
+
if (videoDirExitHookInstalled)
|
|
275
|
+
return;
|
|
276
|
+
videoDirExitHookInstalled = true;
|
|
277
|
+
process.once("exit", () => {
|
|
278
|
+
for (const pending of uncollectedVideoDirs) {
|
|
279
|
+
try {
|
|
280
|
+
rmSync(pending, { recursive: true, force: true });
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
async function createSessionFromBrowser(browser, options, afterContextClosed, sandbox) {
|
|
289
|
+
const created = { context: null, videoDir: null };
|
|
290
|
+
try {
|
|
291
|
+
return await assembleSession(browser, options, afterContextClosed, created, sandbox);
|
|
292
|
+
}
|
|
293
|
+
catch (error) {
|
|
294
|
+
if (created.context !== null)
|
|
295
|
+
await created.context.close().catch(() => undefined);
|
|
296
|
+
if (created.videoDir !== null) {
|
|
297
|
+
await rm(created.videoDir, { recursive: true, force: true }).catch(() => undefined);
|
|
298
|
+
uncollectedVideoDirs.delete(created.videoDir);
|
|
299
|
+
}
|
|
300
|
+
throw error;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
async function assembleSession(browser, options, afterContextClosed, created, sandbox) {
|
|
93
304
|
const sessionId = randomUUID();
|
|
94
305
|
// session-scoped temp dir, never caller-supplied (see BrowserConfiguration.recordVideo).
|
|
95
306
|
// created before newContext(): Playwright's recordVideo.dir must exist and be known at context-creation time.
|
|
96
307
|
const videoDir = options.recordVideo === true ? await mkdtemp(join(tmpdir(), `descry-video-${sessionId}-`)) : null;
|
|
308
|
+
created.videoDir = videoDir;
|
|
309
|
+
if (videoDir !== null)
|
|
310
|
+
removeAtExit(videoDir);
|
|
97
311
|
const auth = options.auth;
|
|
312
|
+
const consented = consentedOriginsOf(options);
|
|
98
313
|
const context = await browser.newContext({
|
|
314
|
+
acceptDownloads: false,
|
|
99
315
|
...(videoDir === null ? {} : { recordVideo: { dir: videoDir } }),
|
|
316
|
+
// A service worker's requests bypass `context.route`, so in a confined session one could
|
|
317
|
+
// navigate or fetch where the guard below never looks.
|
|
318
|
+
...(consented === null ? {} : { serviceWorkers: "block" }),
|
|
100
319
|
// Built rather than passed through: what a caller names is a cookie and a
|
|
101
320
|
// key/value map for one origin, and Playwright's own storage-state shape
|
|
102
321
|
// wants a domain per cookie and an array per origin. Deriving the domain
|
|
@@ -104,27 +323,138 @@ async function createSessionFromBrowser(browser, options, afterContextClosed) {
|
|
|
104
323
|
// a property of the code rather than of a caller getting it right.
|
|
105
324
|
...(auth === undefined ? {} : { storageState: seedStateFor(auth) }),
|
|
106
325
|
});
|
|
326
|
+
created.context = context;
|
|
327
|
+
const dialogEvents = [];
|
|
328
|
+
context.on("dialog", (dialog) => {
|
|
329
|
+
void answerDialog(dialog, options.dialogs, dialogEvents);
|
|
330
|
+
});
|
|
107
331
|
const page = await context.newPage();
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
332
|
+
const isConsented = (origin) => origin !== null && consented !== null && consented.has(origin);
|
|
333
|
+
const blockedByGuard = new WeakSet();
|
|
334
|
+
// A redirect is stopped below Playwright, which knows the hop by URL only. The page load that
|
|
335
|
+
// then fails is matched to it here -- registered before any collector's listener, so the
|
|
336
|
+
// request is marked by the time a collector asks.
|
|
337
|
+
const blockedRedirectSources = new Set();
|
|
338
|
+
const markBlockedRedirects = (target) => {
|
|
339
|
+
target.on("requestfailed", (request) => {
|
|
340
|
+
if (!request.isNavigationRequest() || !blockedRedirectSources.delete(request.url()))
|
|
341
|
+
return;
|
|
342
|
+
blockedByGuard.add(request);
|
|
343
|
+
});
|
|
344
|
+
};
|
|
345
|
+
markBlockedRedirects(page);
|
|
346
|
+
const offOriginEvents = [];
|
|
347
|
+
const recordOffOrigin = (kind, url) => {
|
|
348
|
+
offOriginEvents.push({ kind, url, at: new Date().toISOString() });
|
|
349
|
+
};
|
|
350
|
+
// Only installed when there is a header to send or an origin to hold. Route
|
|
351
|
+
// interception is not free -- it puts a handler in front of every request
|
|
352
|
+
// the context makes -- and a session with neither must behave exactly as it
|
|
353
|
+
// did before these options existed. `context.setExtraHTTPHeaders` would have
|
|
354
|
+
// been one line, and would have sent the credential to every host the page
|
|
355
|
+
// touches.
|
|
356
|
+
const headers = auth?.extraHttpHeaders !== undefined && Object.keys(auth.extraHttpHeaders).length > 0 ? auth.extraHttpHeaders : undefined;
|
|
357
|
+
if (headers !== undefined || consented !== null) {
|
|
116
358
|
await context.route("**/*", async (route) => {
|
|
117
|
-
|
|
359
|
+
const request = route.request();
|
|
360
|
+
const requestOrigin = guardOriginOf(request.url());
|
|
361
|
+
const topLevel = consented !== null && isTopLevelPageLoad(request);
|
|
362
|
+
if (topLevel && !isConsented(requestOrigin)) {
|
|
363
|
+
blockedByGuard.add(request);
|
|
364
|
+
recordOffOrigin("blocked", request.url());
|
|
365
|
+
// "aborted" (net::ERR_ABORTED), not "blockedbyclient": Chromium
|
|
366
|
+
// commits an error page for most top-level failures, which would
|
|
367
|
+
// replace the app's page; an aborted navigation leaves it in place.
|
|
368
|
+
await route.abort("aborted");
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
// A request off the consented origin continues untouched -- not
|
|
372
|
+
// blocked. The header seam exists to stop a credential travelling.
|
|
373
|
+
const sameOrigin = auth !== undefined && requestOrigin === normalizeOrigin(auth.origin);
|
|
374
|
+
const credentialNames = headers === undefined ? [] : Object.keys(lowerCased(headers));
|
|
375
|
+
const carriedAcrossRedirect = headers !== undefined && !sameOrigin && request.redirectedFrom() !== null && credentialNames.some((name) => name in request.headers());
|
|
376
|
+
const sentHeaders = headers !== undefined && sameOrigin
|
|
377
|
+
? { ...request.headers(), ...lowerCased(headers) }
|
|
378
|
+
: carriedAcrossRedirect
|
|
379
|
+
? Object.fromEntries(Object.entries(request.headers()).filter(([name]) => !credentialNames.includes(name.toLowerCase())))
|
|
380
|
+
: undefined;
|
|
381
|
+
// Continued, never fetched and fulfilled here: a document the browser did not load itself
|
|
382
|
+
// has no address space, so Chromium's local-network checks then block every loopback
|
|
383
|
+
// subresource and frame it asks for -- a local app would lose its own API on another port.
|
|
384
|
+
await route.continue(sentHeaders === undefined ? {} : { headers: sentHeaders });
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
const confineNavigation = async (target) => {
|
|
388
|
+
await guardRedirects(target, (location, from) => {
|
|
389
|
+
let destination;
|
|
118
390
|
try {
|
|
119
|
-
|
|
391
|
+
destination = new URL(location, from).href;
|
|
120
392
|
}
|
|
121
393
|
catch {
|
|
122
|
-
|
|
394
|
+
destination = null;
|
|
123
395
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
396
|
+
if (destination !== null && isConsented(guardOriginOf(destination)))
|
|
397
|
+
return true;
|
|
398
|
+
blockedRedirectSources.add(from);
|
|
399
|
+
recordOffOrigin("redirect-blocked", destination ?? location);
|
|
400
|
+
return false;
|
|
401
|
+
});
|
|
402
|
+
// Defence in depth, not the mechanism: every route the page can take off the
|
|
403
|
+
// list is stopped before it is sent above. If this ever fires, one was missed.
|
|
404
|
+
target.on("framenavigated", (frame) => {
|
|
405
|
+
if (frame !== target.mainFrame())
|
|
406
|
+
return;
|
|
407
|
+
const url = frame.url();
|
|
408
|
+
if (isInertUrl(url) || isConsented(guardOriginOf(url)))
|
|
409
|
+
return;
|
|
410
|
+
recordOffOrigin("landed", url);
|
|
411
|
+
void target.goto("about:blank").catch(() => undefined);
|
|
412
|
+
});
|
|
413
|
+
};
|
|
414
|
+
const auxiliaryLimit = auxiliaryPageLimitOf(options);
|
|
415
|
+
const liveAuxiliaryPages = new Set();
|
|
416
|
+
const issuedAuxiliaryPages = new WeakSet();
|
|
417
|
+
let auxiliaryQueue = Promise.resolve();
|
|
418
|
+
let auxiliaryRequestsPending = 0;
|
|
419
|
+
let closing = null;
|
|
420
|
+
const isClosing = () => closing !== null;
|
|
421
|
+
if (consented !== null) {
|
|
422
|
+
await confineNavigation(page);
|
|
423
|
+
// This session drives its own page and the auxiliary pages it opened itself. Any other
|
|
424
|
+
// page -- a popup, same origin or not -- is one nothing is driving, so it is closed and
|
|
425
|
+
// recorded rather than left running where no one is watching. While an auxiliary page
|
|
426
|
+
// is being created the verdict waits for it, so the page is recognised by identity
|
|
427
|
+
// before the guard decides; nothing about a page (an absent opener included) can make
|
|
428
|
+
// a popup pass for one.
|
|
429
|
+
context.on("page", (opened) => {
|
|
430
|
+
if (opened === page)
|
|
431
|
+
return;
|
|
432
|
+
const decide = () => {
|
|
433
|
+
if (issuedAuxiliaryPages.has(opened))
|
|
434
|
+
return;
|
|
435
|
+
recordOffOrigin("popup-closed", opened.url());
|
|
436
|
+
void opened.close().catch(() => undefined);
|
|
437
|
+
};
|
|
438
|
+
if (auxiliaryRequestsPending === 0)
|
|
439
|
+
decide();
|
|
440
|
+
else
|
|
441
|
+
void auxiliaryQueue.then(decide);
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
const unobservedPopups = [];
|
|
445
|
+
if (consented === null) {
|
|
446
|
+
context.on("page", (opened) => {
|
|
447
|
+
if (opened === page || issuedAuxiliaryPages.has(opened))
|
|
448
|
+
return;
|
|
449
|
+
const record = () => {
|
|
450
|
+
if (issuedAuxiliaryPages.has(opened))
|
|
451
|
+
return;
|
|
452
|
+
unobservedPopups.push({ kind: "popup-unobserved", url: opened.url(), at: new Date().toISOString() });
|
|
453
|
+
};
|
|
454
|
+
if (auxiliaryRequestsPending === 0)
|
|
455
|
+
record();
|
|
456
|
+
else
|
|
457
|
+
void auxiliaryQueue.then(record);
|
|
128
458
|
});
|
|
129
459
|
}
|
|
130
460
|
const startedAt = new Date().toISOString();
|
|
@@ -132,14 +462,69 @@ async function createSessionFromBrowser(browser, options, afterContextClosed) {
|
|
|
132
462
|
page.on("crash", () => {
|
|
133
463
|
crashed = true;
|
|
134
464
|
});
|
|
465
|
+
const createAuxiliaryPage = async () => {
|
|
466
|
+
if (isClosing()) {
|
|
467
|
+
throw new Error("openAuxiliaryPage() called after close() -- the session is closed, so there is no browser context to open a page in.");
|
|
468
|
+
}
|
|
469
|
+
if (liveAuxiliaryPages.size >= auxiliaryLimit)
|
|
470
|
+
throw new AuxiliaryPageLimitError(auxiliaryLimit);
|
|
471
|
+
let auxiliary;
|
|
472
|
+
try {
|
|
473
|
+
auxiliary = await context.newPage();
|
|
474
|
+
}
|
|
475
|
+
catch (error) {
|
|
476
|
+
if (isClosing()) {
|
|
477
|
+
throw new Error("openAuxiliaryPage() was interrupted by close() -- the session is closed, so the page was not opened.", { cause: error });
|
|
478
|
+
}
|
|
479
|
+
throw new Error(`openAuxiliaryPage() could not open a page: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
480
|
+
}
|
|
481
|
+
issuedAuxiliaryPages.add(auxiliary);
|
|
482
|
+
liveAuxiliaryPages.add(auxiliary);
|
|
483
|
+
auxiliary.on("close", () => {
|
|
484
|
+
liveAuxiliaryPages.delete(auxiliary);
|
|
485
|
+
});
|
|
486
|
+
try {
|
|
487
|
+
markBlockedRedirects(auxiliary);
|
|
488
|
+
if (consented !== null)
|
|
489
|
+
await confineNavigation(auxiliary);
|
|
490
|
+
}
|
|
491
|
+
catch (error) {
|
|
492
|
+
await auxiliary.close().catch(() => undefined);
|
|
493
|
+
throw new Error(`openAuxiliaryPage() could not apply the session's origin restrictions to the new page, so it was closed: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
494
|
+
}
|
|
495
|
+
return auxiliary;
|
|
496
|
+
};
|
|
135
497
|
return {
|
|
136
498
|
sessionId,
|
|
137
499
|
page,
|
|
138
500
|
startedAt,
|
|
139
501
|
isUsable: () => !crashed && !page.isClosed(),
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
502
|
+
wasBlockedByOriginGuard: (request) => blockedByGuard.has(request),
|
|
503
|
+
drainOffOriginEvents: () => offOriginEvents.splice(0, offOriginEvents.length),
|
|
504
|
+
drainDialogEvents: () => dialogEvents.splice(0, dialogEvents.length),
|
|
505
|
+
drainUnobservedPopups: () => unobservedPopups.splice(0, unobservedPopups.length),
|
|
506
|
+
sandbox,
|
|
507
|
+
consentedOrigins: () => (consented === null ? null : [...consented]),
|
|
508
|
+
openAuxiliaryPage() {
|
|
509
|
+
auxiliaryRequestsPending += 1;
|
|
510
|
+
const opened = auxiliaryQueue.then(createAuxiliaryPage);
|
|
511
|
+
auxiliaryQueue = opened
|
|
512
|
+
.then(() => undefined, () => undefined)
|
|
513
|
+
.then(() => {
|
|
514
|
+
auxiliaryRequestsPending -= 1;
|
|
515
|
+
});
|
|
516
|
+
return opened;
|
|
517
|
+
},
|
|
518
|
+
close() {
|
|
519
|
+
closing ??= (async () => {
|
|
520
|
+
try {
|
|
521
|
+
await context.close();
|
|
522
|
+
}
|
|
523
|
+
finally {
|
|
524
|
+
await afterContextClosed();
|
|
525
|
+
}
|
|
526
|
+
})();
|
|
527
|
+
return closing;
|
|
143
528
|
},
|
|
144
529
|
async saveStorageState(path) {
|
|
145
530
|
if (page.isClosed()) {
|
|
@@ -147,14 +532,22 @@ async function createSessionFromBrowser(browser, options, afterContextClosed) {
|
|
|
147
532
|
"read, so this would write an empty file that looks like a session with nothing in it. Call it " +
|
|
148
533
|
"while the session is still open.");
|
|
149
534
|
}
|
|
150
|
-
await context.storageState(
|
|
535
|
+
const state = await context.storageState();
|
|
536
|
+
await writeOwnerOnlyFile(path, JSON.stringify(state));
|
|
151
537
|
},
|
|
152
538
|
async injectStorageState(seed) {
|
|
153
539
|
if (page.isClosed()) {
|
|
154
540
|
throw new Error("injectStorageState() called after close() -- there is no live browser context to inject into. Call " +
|
|
155
541
|
"it while the session is still open.");
|
|
156
542
|
}
|
|
157
|
-
const
|
|
543
|
+
const seedOrigin = normalizeOrigin(seed.origin);
|
|
544
|
+
if (seedOrigin === null) {
|
|
545
|
+
throw new Error(`injectStorageState: "${seed.origin}" is not a valid origin (it has no host, or its origin is opaque), so no cookie domain can be derived from it.`);
|
|
546
|
+
}
|
|
547
|
+
if (consented !== null && !consented.has(seedOrigin)) {
|
|
548
|
+
throw new Error(`injectStorageState: ${seedOrigin} is not among this session's consented origins (${[...consented].join(", ")}); a credential is only injected for an origin the session was consented to.`);
|
|
549
|
+
}
|
|
550
|
+
const host = hostnameOf(seedOrigin);
|
|
158
551
|
const cookies = (seed.cookies ?? []).map((cookie) => ({
|
|
159
552
|
name: cookie.name,
|
|
160
553
|
value: cookie.value,
|
|
@@ -174,7 +567,7 @@ async function createSessionFromBrowser(browser, options, afterContextClosed) {
|
|
|
174
567
|
let localStorageSkipped = null;
|
|
175
568
|
if (storedEntries.length > 0) {
|
|
176
569
|
const currentOrigin = originOf(page.url());
|
|
177
|
-
if (currentOrigin ===
|
|
570
|
+
if (currentOrigin === seedOrigin) {
|
|
178
571
|
await page.evaluate((entries) => {
|
|
179
572
|
// `localStorage` isn't declared under this project's Node-only
|
|
180
573
|
// `lib` (ES2023, no `dom` -- see tsconfig.base.json's own
|
|
@@ -193,7 +586,7 @@ async function createSessionFromBrowser(browser, options, afterContextClosed) {
|
|
|
193
586
|
else {
|
|
194
587
|
localStorageSkipped =
|
|
195
588
|
`the page is currently on ${currentOrigin ?? "an origin that could not be read from its URL"}, not ` +
|
|
196
|
-
`${
|
|
589
|
+
`${seedOrigin} -- localStorage can only be written for the origin a page is actually showing, so ` +
|
|
197
590
|
`${storedEntries.length} ${storedEntries.length === 1 ? "entry was" : "entries were"} not injected. ` +
|
|
198
591
|
"Navigate to that origin first, then call injectStorageState again.";
|
|
199
592
|
}
|
|
@@ -214,13 +607,33 @@ async function createSessionFromBrowser(browser, options, afterContextClosed) {
|
|
|
214
607
|
if (video === null)
|
|
215
608
|
return null;
|
|
216
609
|
const videoFilePath = await video.path();
|
|
610
|
+
const { size } = await stat(videoFilePath);
|
|
611
|
+
if (size > MAX_VIDEO_BYTES) {
|
|
612
|
+
await rm(videoDir, { recursive: true, force: true }).catch(() => { });
|
|
613
|
+
uncollectedVideoDirs.delete(videoDir);
|
|
614
|
+
throw new VideoTooLargeError(size, MAX_VIDEO_BYTES);
|
|
615
|
+
}
|
|
217
616
|
const bytes = await readFile(videoFilePath);
|
|
218
617
|
// best-effort: bytes are already captured, so a cleanup failure here must not fail the call.
|
|
219
618
|
await rm(videoDir, { recursive: true, force: true }).catch(() => { });
|
|
619
|
+
uncollectedVideoDirs.delete(videoDir);
|
|
220
620
|
return bytes;
|
|
221
621
|
},
|
|
222
622
|
};
|
|
223
623
|
}
|
|
624
|
+
async function writeOwnerOnlyFile(path, contents) {
|
|
625
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
626
|
+
const temporaryPath = `${path}.${randomUUID()}.tmp`;
|
|
627
|
+
try {
|
|
628
|
+
await writeFile(temporaryPath, contents, { mode: 0o600, flag: "wx" });
|
|
629
|
+
await chmod(temporaryPath, 0o600);
|
|
630
|
+
await rename(temporaryPath, path);
|
|
631
|
+
}
|
|
632
|
+
catch (error) {
|
|
633
|
+
await rm(temporaryPath, { force: true }).catch(() => undefined);
|
|
634
|
+
throw error;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
224
637
|
/**
|
|
225
638
|
* A caller's one-origin seed as a browser's own storage-state shape.
|
|
226
639
|
*
|
|
@@ -311,93 +724,132 @@ function lowerCased(headers) {
|
|
|
311
724
|
export const SHARED_BROWSER_IDLE_TIMEOUT_MS = 5 * 60 * 1000; // starting value, not a measurement -- see RT-264. Chosen to match
|
|
312
725
|
/** See the module doc above this constant — NOT SHIPPABLE until the isolation test
|
|
313
726
|
* (`browser-session-reuse.test.ts`) has actually been run and is green. */
|
|
314
|
-
export function createSharedBrowserManager(idleTimeoutMs = SHARED_BROWSER_IDLE_TIMEOUT_MS) {
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
727
|
+
export function createSharedBrowserManager(idleTimeoutMs = SHARED_BROWSER_IDLE_TIMEOUT_MS, seams = {}) {
|
|
728
|
+
const launch = seams.launch ?? ((options) => chromium.launch(options));
|
|
729
|
+
const resolveExecutable = seams.resolveExecutable ?? resolveChromiumExecutable;
|
|
730
|
+
const newSlot = (headless) => ({
|
|
731
|
+
headless,
|
|
732
|
+
browser: null,
|
|
733
|
+
sandbox: { enabled: true, fallbackReason: null },
|
|
734
|
+
launchInFlight: null,
|
|
735
|
+
liveCount: 0,
|
|
736
|
+
pending: 0,
|
|
737
|
+
idleTimer: null,
|
|
738
|
+
lostUnexpectedly: false,
|
|
739
|
+
});
|
|
740
|
+
const slots = { headless: newSlot(true), headed: newSlot(false) };
|
|
741
|
+
const slotFor = (headless) => (headless ? slots.headless : slots.headed);
|
|
742
|
+
function cancelIdleTimer(slot) {
|
|
743
|
+
if (slot.idleTimer !== null) {
|
|
744
|
+
clearTimeout(slot.idleTimer);
|
|
745
|
+
slot.idleTimer = null;
|
|
323
746
|
}
|
|
324
747
|
}
|
|
325
|
-
function armIdleTimerIfIdle() {
|
|
326
|
-
cancelIdleTimer();
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
browser = null;
|
|
748
|
+
function armIdleTimerIfIdle(slot) {
|
|
749
|
+
cancelIdleTimer(slot);
|
|
750
|
+
if (slot.liveCount > 0 || slot.pending > 0 || slot.browser === null)
|
|
751
|
+
return;
|
|
752
|
+
slot.idleTimer = setTimeout(() => {
|
|
753
|
+
slot.idleTimer = null;
|
|
754
|
+
if (slot.liveCount === 0 && slot.pending === 0 && slot.browser !== null) {
|
|
755
|
+
const toClose = slot.browser;
|
|
756
|
+
slot.browser = null;
|
|
335
757
|
void toClose.close().catch(() => { });
|
|
336
758
|
}
|
|
337
759
|
}, idleTimeoutMs);
|
|
338
760
|
}
|
|
339
|
-
async function ensureBrowser() {
|
|
340
|
-
if (browser !== null)
|
|
341
|
-
return { ok: true, browser, relaunched: false };
|
|
342
|
-
if (
|
|
343
|
-
|
|
344
|
-
|
|
761
|
+
async function ensureBrowser(slot) {
|
|
762
|
+
if (slot.browser !== null && slot.browser.isConnected())
|
|
763
|
+
return { ok: true, browser: slot.browser, relaunched: false, launched: false };
|
|
764
|
+
if (slot.browser !== null) {
|
|
765
|
+
slot.browser = null;
|
|
766
|
+
slot.lostUnexpectedly = true;
|
|
767
|
+
}
|
|
768
|
+
let inFlight = slot.launchInFlight;
|
|
769
|
+
if (inFlight === null) {
|
|
770
|
+
inFlight = (async () => {
|
|
771
|
+
const chromiumBinary = resolveExecutable();
|
|
345
772
|
if (!chromiumBinary.available) {
|
|
346
773
|
const { available: _available, ...unavailable } = chromiumBinary;
|
|
347
774
|
return { ok: false, ...unavailable };
|
|
348
775
|
}
|
|
349
|
-
const launched = await
|
|
350
|
-
|
|
351
|
-
// here so the NEXT acquireSession() relaunches rather than resolving a dead
|
|
352
|
-
// handle. Nulling `browser` (guarded by identity, in case a relaunch has
|
|
353
|
-
// already replaced it by the time this fires) is the only action taken; a
|
|
354
|
-
// live session already holding a context on it is left to fail on its own
|
|
355
|
-
// next call.
|
|
776
|
+
const { browser: launched, sandbox } = await launchPreferringSandbox(launch, { headless: slot.headless, executablePath: chromiumBinary.executablePath });
|
|
777
|
+
slot.sandbox = sandbox;
|
|
356
778
|
launched.on("disconnected", () => {
|
|
357
|
-
if (browser === launched)
|
|
358
|
-
browser = null;
|
|
779
|
+
if (slot.browser === launched) {
|
|
780
|
+
slot.browser = null;
|
|
781
|
+
slot.lostUnexpectedly = true;
|
|
782
|
+
}
|
|
359
783
|
});
|
|
360
|
-
|
|
784
|
+
const relaunched = slot.lostUnexpectedly;
|
|
785
|
+
slot.lostUnexpectedly = false;
|
|
786
|
+
slot.browser = launched;
|
|
787
|
+
return { ok: true, browser: launched, relaunched };
|
|
361
788
|
})();
|
|
789
|
+
slot.launchInFlight = inFlight;
|
|
790
|
+
}
|
|
791
|
+
try {
|
|
792
|
+
const result = await inFlight;
|
|
793
|
+
return result.ok ? { ...result, launched: true } : result;
|
|
794
|
+
}
|
|
795
|
+
finally {
|
|
796
|
+
if (slot.launchInFlight === inFlight)
|
|
797
|
+
slot.launchInFlight = null;
|
|
362
798
|
}
|
|
363
|
-
const result = await launchInFlight;
|
|
364
|
-
launchInFlight = null;
|
|
365
|
-
if (!result.ok)
|
|
366
|
-
return result;
|
|
367
|
-
const relaunched = browser === null;
|
|
368
|
-
browser = result.browser;
|
|
369
|
-
return { ok: true, browser: result.browser, relaunched };
|
|
370
799
|
}
|
|
371
800
|
return {
|
|
372
801
|
async acquireSession(options = {}) {
|
|
373
|
-
|
|
374
|
-
const
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
802
|
+
consentedOriginsOf(options);
|
|
803
|
+
const slot = slotFor(options.headless ?? true);
|
|
804
|
+
cancelIdleTimer(slot);
|
|
805
|
+
slot.pending += 1;
|
|
806
|
+
try {
|
|
807
|
+
const ensured = await ensureBrowser(slot);
|
|
808
|
+
if (!ensured.ok) {
|
|
809
|
+
return {
|
|
810
|
+
launched: false,
|
|
811
|
+
refusal: ensured.refusal,
|
|
812
|
+
message: ensured.message,
|
|
813
|
+
remedy: ensured.remedy,
|
|
814
|
+
expectedExecutablePath: ensured.expectedExecutablePath,
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
slot.liveCount += 1;
|
|
818
|
+
let released = false;
|
|
819
|
+
const release = async () => {
|
|
820
|
+
if (released)
|
|
821
|
+
return;
|
|
822
|
+
released = true;
|
|
823
|
+
slot.liveCount = Math.max(0, slot.liveCount - 1);
|
|
824
|
+
armIdleTimerIfIdle(slot);
|
|
382
825
|
};
|
|
826
|
+
let session;
|
|
827
|
+
try {
|
|
828
|
+
session = await createSessionFromBrowser(ensured.browser, options, release, slot.sandbox);
|
|
829
|
+
}
|
|
830
|
+
catch (error) {
|
|
831
|
+
await release();
|
|
832
|
+
throw error;
|
|
833
|
+
}
|
|
834
|
+
return { launched: true, session, relaunchedBrowser: ensured.relaunched, launchedBrowser: ensured.launched === true };
|
|
835
|
+
}
|
|
836
|
+
finally {
|
|
837
|
+
slot.pending = Math.max(0, slot.pending - 1);
|
|
838
|
+
armIdleTimerIfIdle(slot);
|
|
383
839
|
}
|
|
384
|
-
liveCount += 1;
|
|
385
|
-
const session = await createSessionFromBrowser(ensured.browser, options, async () => {
|
|
386
|
-
liveCount = Math.max(0, liveCount - 1);
|
|
387
|
-
if (liveCount === 0)
|
|
388
|
-
armIdleTimerIfIdle();
|
|
389
|
-
});
|
|
390
|
-
return { launched: true, session, relaunchedBrowser: ensured.relaunched };
|
|
391
840
|
},
|
|
392
|
-
liveSessionCount: () => liveCount,
|
|
841
|
+
liveSessionCount: () => slots.headless.liveCount + slots.headed.liveCount,
|
|
393
842
|
async dispose() {
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
browser
|
|
399
|
-
|
|
400
|
-
|
|
843
|
+
await Promise.all([slots.headless, slots.headed].map(async (slot) => {
|
|
844
|
+
cancelIdleTimer(slot);
|
|
845
|
+
slot.liveCount = 0;
|
|
846
|
+
slot.lostUnexpectedly = false;
|
|
847
|
+
if (slot.browser !== null) {
|
|
848
|
+
const toClose = slot.browser;
|
|
849
|
+
slot.browser = null;
|
|
850
|
+
await toClose.close().catch(() => { });
|
|
851
|
+
}
|
|
852
|
+
}));
|
|
401
853
|
},
|
|
402
854
|
};
|
|
403
855
|
}
|