@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.
Files changed (49) hide show
  1. package/dist/browser-action-collector.d.ts +11 -3
  2. package/dist/browser-action-collector.d.ts.map +1 -1
  3. package/dist/browser-action-collector.js +102 -11
  4. package/dist/browser-action-collector.js.map +1 -1
  5. package/dist/browser-console-collector.d.ts +2 -0
  6. package/dist/browser-console-collector.d.ts.map +1 -1
  7. package/dist/browser-console-collector.js +60 -6
  8. package/dist/browser-console-collector.js.map +1 -1
  9. package/dist/browser-network-collector.d.ts +12 -1
  10. package/dist/browser-network-collector.d.ts.map +1 -1
  11. package/dist/browser-network-collector.js +167 -9
  12. package/dist/browser-network-collector.js.map +1 -1
  13. package/dist/browser-recording-collector.d.ts +8 -1
  14. package/dist/browser-recording-collector.d.ts.map +1 -1
  15. package/dist/browser-recording-collector.js +141 -17
  16. package/dist/browser-recording-collector.js.map +1 -1
  17. package/dist/browser-session.d.ts +131 -5
  18. package/dist/browser-session.d.ts.map +1 -1
  19. package/dist/browser-session.js +534 -94
  20. package/dist/browser-session.js.map +1 -1
  21. package/dist/dom-evidence.d.ts +6 -0
  22. package/dist/dom-evidence.d.ts.map +1 -1
  23. package/dist/dom-evidence.js +40 -11
  24. package/dist/dom-evidence.js.map +1 -1
  25. package/dist/frontend-initiator-capture.d.ts +5 -1
  26. package/dist/frontend-initiator-capture.d.ts.map +1 -1
  27. package/dist/frontend-initiator-capture.js +72 -10
  28. package/dist/frontend-initiator-capture.js.map +1 -1
  29. package/dist/index.d.ts +9 -8
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +7 -7
  32. package/dist/index.js.map +1 -1
  33. package/dist/network-correlation.d.ts +2 -11
  34. package/dist/network-correlation.d.ts.map +1 -1
  35. package/dist/network-correlation.js +1 -25
  36. package/dist/network-correlation.js.map +1 -1
  37. package/dist/owner-only-file.d.ts +2 -0
  38. package/dist/owner-only-file.d.ts.map +1 -0
  39. package/dist/owner-only-file.js +36 -0
  40. package/dist/owner-only-file.js.map +1 -0
  41. package/dist/script-url-mapping.d.ts +1 -0
  42. package/dist/script-url-mapping.d.ts.map +1 -1
  43. package/dist/script-url-mapping.js +7 -0
  44. package/dist/script-url-mapping.js.map +1 -1
  45. package/dist/secret-fields.d.ts +37 -0
  46. package/dist/secret-fields.d.ts.map +1 -0
  47. package/dist/secret-fields.js +121 -0
  48. package/dist/secret-fields.js.map +1 -0
  49. package/package.json +6 -6
@@ -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({ headless: options.headless ?? true, executablePath });
75
- return createSessionFromBrowser(browser, options, async () => {
76
- await browser.close();
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
- async function createSessionFromBrowser(browser, options, afterContextClosed) {
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
- // Only installed when there is a header to send. Route interception is not
109
- // free -- it puts a handler in front of every request the context makes --
110
- // and a session with no header must behave exactly as it did before this
111
- // option existed. `context.setExtraHTTPHeaders` would have been one line,
112
- // and would have sent the credential to every host the page touches.
113
- if (auth?.extraHttpHeaders !== undefined && Object.keys(auth.extraHttpHeaders).length > 0) {
114
- const headers = auth.extraHttpHeaders;
115
- const consented = auth.origin;
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
- let sameOrigin = false;
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
- sameOrigin = new URL(route.request().url()).origin === consented;
392
+ destination = new URL(location, from).href;
120
393
  }
121
394
  catch {
122
- sameOrigin = false;
395
+ destination = null;
123
396
  }
124
- // A request off the consented origin continues untouched -- not
125
- // blocked. This seam exists to stop a credential travelling, not to
126
- // become a second, undisclosed network policy.
127
- await route.continue(sameOrigin ? { headers: { ...route.request().headers(), ...lowerCased(headers) } } : {});
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
- async close() {
141
- await context.close();
142
- await afterContextClosed();
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({ path });
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 host = hostnameOf(seed.origin);
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 === seed.origin) {
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
- `${seed.origin} -- localStorage can only be written for the origin a page is actually showing, so ` +
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
- let browser = null;
316
- let launchInFlight = null;
317
- let liveCount = 0;
318
- let idleTimer = null;
319
- function cancelIdleTimer() {
320
- if (idleTimer !== null) {
321
- clearTimeout(idleTimer);
322
- idleTimer = null;
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
- idleTimer = setTimeout(() => {
328
- // Re-checked at fire time, not assumed from when the timer was armed -- an
329
- // acquireSession() in between already cancelled this timer via
330
- // cancelIdleTimer(), so reaching here with liveCount still 0 means the
331
- // browser really has sat idle for the whole window.
332
- if (liveCount === 0 && browser !== null) {
333
- const toClose = browser;
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 (launchInFlight === null) {
343
- launchInFlight = (async () => {
344
- const chromiumBinary = resolveChromiumExecutable();
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 chromium.launch({ headless: true, executablePath: chromiumBinary.executablePath });
350
- // The shared browser process itself dying (not one page/context) -- noticed
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
- return { ok: true, browser: launched };
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
- cancelIdleTimer();
374
- const ensured = await ensureBrowser();
375
- if (!ensured.ok) {
376
- return {
377
- launched: false,
378
- refusal: ensured.refusal,
379
- message: ensured.message,
380
- remedy: ensured.remedy,
381
- expectedExecutablePath: ensured.expectedExecutablePath,
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
- cancelIdleTimer();
395
- liveCount = 0;
396
- if (browser !== null) {
397
- const toClose = browser;
398
- browser = null;
399
- await toClose.close().catch(() => { });
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
  }