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