snappy-prerender 2.6.0-rc.2 → 2.6.0

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/src/cli.js CHANGED
@@ -30,16 +30,33 @@ Options:
30
30
  --max-depth <n> Crawl depth limit, 0 renders roots only (default: unlimited)
31
31
  --max-routes <n> Stop after this many routes and fail the run (default: unlimited)
32
32
  --concurrency <n> Parallel browser pages (default: derived from CPU count)
33
- --timeout <ms> Per-route timeout (default: 30000)
33
+ --timeout <ms> Per-route navigation/settle timeout (default: 30000)
34
+ --quiet-period <ms> Network must be idle this long before capture (default: 500)
35
+ --scroll-step-delay <ms> Delay between scroll steps (default: 100)
36
+ --viewport <WxH> Browser viewport (default: 1280x720)
37
+ --ready-selector <sel> Selector awaited when set
38
+ --ready-flag <name> Window flag awaited when defined (default: __prerenderReady)
39
+ --no-headless Run the browser visibly
34
40
  --wait-for <selector> Wait for a selector on every route before capture
35
41
  --browser <choice> auto | chrome | msedge | chromium | path (default: auto)
36
42
  --no-browser-download Refuse the Chrome for Testing fallback download
37
43
  --browser-download-hash <sha256> Pin the fallback download to a SHA-256 digest
44
+ --browser-download-version <build> Pin the Chrome for Testing build id
38
45
  --storage-state <file> Playwright storage state for authenticated routes
39
46
  --no-verify Skip the hydration verification pass
47
+ --verify-changed-only Skip verification for routes whose HTML did not change
40
48
  --fail-on-hydration-error Fail the build when hydration errors are found
41
49
  --fail-on-rerender Fail when a route re-renders instead of hydrating
42
50
  --fail-on-page-error Fail when a page throws while it is being rendered
51
+ --no-fail-on-error Report route errors without failing the run
52
+ --fail-on-empty-page Fail when a route renders no app content (default: on)
53
+ --no-fail-on-empty-page Allow an empty page and report it instead
54
+ --fail-on-console-error Fail when the browser logs a console error while rendering
55
+ --fail-on-failed-request Fail when a same-origin request fails while rendering
56
+ --fail-on-duplicate-content Fail when two routes produce identical HTML
57
+ --fail-on-build-origin-metadata Fail when metadata still points at the prerender server
58
+ --fail-on-undetected Fail when verification cannot find a framework root
59
+ --fail-on-verification-error Fail when the verification pass cannot run
43
60
  --block-third-party Block third-party requests while rendering
44
61
  --no-block-third-party Allow third-party requests (the default)
45
62
  --allowed-hosts <hosts> Third-party hosts to allow while blocking the rest, comma separated
@@ -50,6 +67,7 @@ Options:
50
67
  --flat Write about.html instead of about/index.html
51
68
  --not-found <route> Route emitted as 404.html (default: /404)
52
69
  --destination <dir> Write output to another directory (default: in place)
70
+ --clean-destination Empty the destination before copying the build output
53
71
  --save-as <format> html | png | jpeg (default: html)
54
72
  --user-agent <value> User agent used while rendering (default: SnappyPrerender)
55
73
  --metadata-site-url <url> Public site URL for canonical and og:url
@@ -57,6 +75,7 @@ Options:
57
75
  --metadata-title-template <t> Title template, for example '%s | Example'
58
76
  --metadata-default-image <url> Share image for routes without one
59
77
  --metadata-trailing-slash <mode> preserve | always | never
78
+ --metadata-base <path> Base path used for absolute metadata URLs
60
79
  --inline-css <strategy> inline | critical (critical needs the beasties package)
61
80
  --critical-css-preload <mode> Swap for deferred CSS: none | body | media | swap | swap-high | swap-low | js | js-lazy
62
81
  --minify-html Minify the generated HTML
@@ -65,18 +84,25 @@ Options:
65
84
  --no-preconnect-third-party Disable preconnect hints
66
85
  --preload-images Add preload hints for images on the page
67
86
  --preload-manifest Write preload-manifest.json with Link header hints
87
+ --sitemap Write sitemap.xml from the rendered routes (needs metadata.siteUrl)
68
88
  --ignore-for-preload <names> File names excluded from the manifest, comma separated
69
89
  --cache-ajax-requests Expose captured JSON responses as window.snapStore
90
+ --max-cached-bytes <n> Largest JSON response cached per request (default: 5242880)
70
91
  --remove-scripts Strip every script tag from the output
71
92
  --remove-styles Strip every style tag from the output
72
93
  --async-scripts Mark external scripts async
73
94
  --external-scripts Write injected state scripts as same-origin files, for a strict CSP
74
95
  --csp <mode> off | strict: strict makes the output work under script-src 'self'
75
96
  --no-remove-blobs Keep blob stylesheet links instead of dropping them
97
+ --remove-attributes <names> Drop these attributes from the output, comma separated
98
+ --no-strip-build-origin-metadata Keep tags that point at the prerender server
99
+ --prune-stale-routes Delete pages listed by the previous run's manifest
76
100
  --ignore-https-errors Ignore TLS errors while rendering
77
101
  --browser-args <args> Extra browser launch arguments, comma separated
78
102
  --url <url> App URL to connect to when using --serve-cmd
79
103
  --serve-cmd <command> Command that starts the app server instead of static serving
104
+ --serve-cmd-timeout <ms> How long to wait for the command server (default: 30000)
105
+ --shutdown-timeout <ms> Grace period before force-killing the server (default: 5000)
80
106
  --dry-run Render and report without writing files
81
107
  --log-level <level> silent | error | warn | info | debug (default: info)
82
108
  --yes, -y Accept every default, for a shell without a terminal
@@ -102,15 +128,34 @@ async function main() {
102
128
  'max-routes': { type: 'string' },
103
129
  concurrency: { type: 'string' },
104
130
  timeout: { type: 'string' },
131
+ 'quiet-period': { type: 'string' },
132
+ 'scroll-step-delay': { type: 'string' },
133
+ 'max-cached-bytes': { type: 'string' },
134
+ 'serve-cmd-timeout': { type: 'string' },
135
+ 'shutdown-timeout': { type: 'string' },
136
+ viewport: { type: 'string' },
137
+ 'ready-flag': { type: 'string' },
138
+ 'ready-selector': { type: 'string' },
139
+ headless: { type: 'boolean' },
105
140
  'wait-for': { type: 'string' },
106
141
  browser: { type: 'string' },
107
142
  'no-browser-download': { type: 'boolean' },
108
143
  'browser-download-hash': { type: 'string' },
144
+ 'browser-download-version': { type: 'string' },
109
145
  'storage-state': { type: 'string' },
110
146
  'no-verify': { type: 'boolean' },
147
+ 'verify-changed-only': { type: 'boolean' },
111
148
  'fail-on-hydration-error': { type: 'boolean' },
149
+ 'fail-on-error': { type: 'boolean' },
112
150
  'fail-on-rerender': { type: 'boolean' },
113
151
  'fail-on-page-error': { type: 'boolean' },
152
+ 'fail-on-empty-page': { type: 'boolean' },
153
+ 'fail-on-console-error': { type: 'boolean' },
154
+ 'fail-on-failed-request': { type: 'boolean' },
155
+ 'fail-on-duplicate-content': { type: 'boolean' },
156
+ 'fail-on-build-origin-metadata': { type: 'boolean' },
157
+ 'fail-on-undetected': { type: 'boolean' },
158
+ 'fail-on-verification-error': { type: 'boolean' },
114
159
  'no-block-third-party': { type: 'boolean' },
115
160
  'block-third-party': { type: 'boolean' },
116
161
  'allowed-hosts': { type: 'string', multiple: true },
@@ -121,6 +166,7 @@ async function main() {
121
166
  flat: { type: 'boolean' },
122
167
  'not-found': { type: 'string' },
123
168
  destination: { type: 'string' },
169
+ 'clean-destination': { type: 'boolean' },
124
170
  'save-as': { type: 'string' },
125
171
  'user-agent': { type: 'string' },
126
172
  'metadata-site-url': { type: 'string' },
@@ -128,14 +174,17 @@ async function main() {
128
174
  'metadata-title-template': { type: 'string' },
129
175
  'metadata-default-image': { type: 'string' },
130
176
  'metadata-trailing-slash': { type: 'string' },
177
+ 'metadata-base': { type: 'string' },
131
178
  'inline-css': { type: 'string' },
132
179
  'critical-css-preload': { type: 'string' },
133
180
  csp: { type: 'string' },
134
181
  'minify-html': { type: 'boolean' },
135
182
  'minify-css': { type: 'boolean' },
136
183
  'no-preconnect-third-party': { type: 'boolean' },
184
+ 'preconnect-third-party': { type: 'boolean' },
137
185
  'preload-images': { type: 'boolean' },
138
186
  'preload-manifest': { type: 'boolean' },
187
+ sitemap: { type: 'boolean' },
139
188
  'ignore-for-preload': { type: 'string', multiple: true },
140
189
  'cache-ajax-requests': { type: 'boolean' },
141
190
  'remove-scripts': { type: 'boolean' },
@@ -143,6 +192,9 @@ async function main() {
143
192
  'async-scripts': { type: 'boolean' },
144
193
  'external-scripts': { type: 'boolean' },
145
194
  'no-remove-blobs': { type: 'boolean' },
195
+ 'remove-attributes': { type: 'string', multiple: true },
196
+ 'no-strip-build-origin-metadata': { type: 'boolean' },
197
+ 'prune-stale-routes': { type: 'boolean' },
146
198
  'ignore-https-errors': { type: 'boolean' },
147
199
  'browser-args': { type: 'string', multiple: true },
148
200
  url: { type: 'string' },
@@ -199,17 +251,52 @@ function cliOptions({ values, positionals }) {
199
251
  if (values['max-routes'] !== undefined) options.maxRoutes = Number(values['max-routes']);
200
252
  if (values.concurrency !== undefined) options.concurrency = Number(values.concurrency);
201
253
  if (values.timeout !== undefined) options.timeout = Number(values.timeout);
254
+ if (values['quiet-period'] !== undefined) options.quietPeriod = Number(values['quiet-period']);
255
+ if (values['scroll-step-delay'] !== undefined) {
256
+ options.scrollStepDelay = Number(values['scroll-step-delay']);
257
+ }
258
+ if (values['max-cached-bytes'] !== undefined) {
259
+ options.maxCachedBytes = Number(values['max-cached-bytes']);
260
+ }
261
+ if (values['serve-cmd-timeout'] !== undefined) {
262
+ options.serveCmdTimeout = Number(values['serve-cmd-timeout']);
263
+ }
264
+ if (values['shutdown-timeout'] !== undefined) {
265
+ options.shutdownTimeout = Number(values['shutdown-timeout']);
266
+ }
267
+ if (values.viewport !== undefined) {
268
+ const match = /^(\d+)x(\d+)$/i.exec(values.viewport);
269
+ if (!match) throw new Error('viewport must look like 1280x720');
270
+ options.viewport = { width: Number(match[1]), height: Number(match[2]) };
271
+ }
272
+ if (values['ready-selector'] !== undefined) options.readySelector = values['ready-selector'];
273
+ if (values['ready-flag'] !== undefined) options.readyFlag = values['ready-flag'];
274
+ if (values.headless !== undefined) options.headless = values.headless;
202
275
  if (values['wait-for'] !== undefined) options.waitFor = values['wait-for'];
203
276
  if (values.browser !== undefined) options.browser = values.browser;
204
277
  if (values['no-browser-download']) options.browserDownload = false;
205
278
  if (values['browser-download-hash'] !== undefined) {
206
279
  options.browserDownloadHash = values['browser-download-hash'];
207
280
  }
281
+ if (values['browser-download-version'] !== undefined) {
282
+ options.browserDownloadVersion = values['browser-download-version'];
283
+ }
208
284
  if (values['storage-state'] !== undefined) options.storageState = values['storage-state'];
209
285
  if (values['no-verify']) options.verify = false;
286
+ if (values['verify-changed-only']) options.verifyChangedOnly = true;
210
287
  if (values['fail-on-hydration-error']) options.failOnHydrationError = true;
211
288
  if (values['fail-on-rerender']) options.failOnRerender = true;
212
289
  if (values['fail-on-page-error']) options.failOnPageError = true;
290
+ if (values['fail-on-error'] !== undefined) options.failOnError = values['fail-on-error'];
291
+ if (values['fail-on-empty-page'] !== undefined) {
292
+ options.failOnEmptyPage = values['fail-on-empty-page'];
293
+ }
294
+ if (values['fail-on-console-error']) options.failOnConsoleError = true;
295
+ if (values['fail-on-failed-request']) options.failOnFailedRequest = true;
296
+ if (values['fail-on-duplicate-content']) options.failOnDuplicateContent = true;
297
+ if (values['fail-on-build-origin-metadata']) options.failOnBuildOriginMetadata = true;
298
+ if (values['fail-on-undetected']) options.failOnUndetected = true;
299
+ if (values['fail-on-verification-error']) options.failOnVerificationError = true;
213
300
  if (values['no-block-third-party']) options.blockThirdParty = false;
214
301
  if (values['block-third-party']) options.blockThirdParty = true;
215
302
  if (values['allowed-hosts'] !== undefined)
@@ -221,6 +308,7 @@ function cliOptions({ values, positionals }) {
221
308
  if (values.flat) options.flatOutput = true;
222
309
  if (values['not-found'] !== undefined) options.notFoundRoute = values['not-found'];
223
310
  if (values.destination !== undefined) options.destination = values.destination;
311
+ if (values['clean-destination']) options.cleanDestination = true;
224
312
  if (values['save-as'] !== undefined) options.saveAs = values['save-as'];
225
313
  if (values['user-agent'] !== undefined) options.userAgent = values['user-agent'];
226
314
  const metadata = metadataOptions(values);
@@ -234,8 +322,10 @@ function cliOptions({ values, positionals }) {
234
322
  if (values['minify-html']) options.minifyHtml = true;
235
323
  if (values['minify-css']) options.minifyCss = true;
236
324
  if (values['no-preconnect-third-party']) options.preconnectThirdParty = false;
325
+ if (values['preconnect-third-party']) options.preconnectThirdParty = true;
237
326
  if (values['preload-images']) options.preloadImages = true;
238
327
  if (values['preload-manifest']) options.preloadManifest = true;
328
+ if (values.sitemap) options.sitemap = true;
239
329
  if (values['ignore-for-preload'] !== undefined) {
240
330
  options.ignoreForPreload = splitList(values['ignore-for-preload']);
241
331
  }
@@ -245,6 +335,11 @@ function cliOptions({ values, positionals }) {
245
335
  if (values['async-scripts']) options.asyncScriptTags = true;
246
336
  if (values['external-scripts']) options.externalScripts = true;
247
337
  if (values['no-remove-blobs']) options.removeBlobs = false;
338
+ if (values['remove-attributes'] !== undefined) {
339
+ options.removeAttributes = splitList(values['remove-attributes']);
340
+ }
341
+ if (values['no-strip-build-origin-metadata']) options.stripBuildOriginMetadata = false;
342
+ if (values['prune-stale-routes']) options.pruneStaleRoutes = true;
248
343
  if (values['ignore-https-errors']) options.ignoreHTTPSErrors = true;
249
344
  if (values['browser-args'] !== undefined) options.browserArgs = splitList(values['browser-args']);
250
345
  if (values.url !== undefined) options.url = values.url;
@@ -287,14 +382,14 @@ async function runInit({ values }) {
287
382
  force: values.force === true,
288
383
  });
289
384
  log.success(`Wrote ${CONFIG_FILE}`);
290
- printNextSteps({ detected, log });
385
+ printNextSteps({ detected, log, tag: readVersion().includes('-') ? '@next' : '' });
291
386
  }
292
387
 
293
388
  // Turns the flags the CLI already understands into the wizard's starting answers.
294
389
  function defaultsFromFlags(detected, flags) {
295
390
  return {
296
391
  sourceDir: flags.sourceDir ?? detected.sourceDir,
297
- base: flags.base ?? '/',
392
+ base: flags.base ?? detected.base ?? '/',
298
393
  include: flags.include ?? ['/'],
299
394
  crawl: flags.crawl ?? true,
300
395
  siteUrl: flags.metadata?.siteUrl ?? '',
@@ -377,9 +472,9 @@ function validatedAnswers(answers, log) {
377
472
  }
378
473
 
379
474
  // Prints what to do next: install if needed, run it, and where the head layer goes.
380
- function printNextSteps({ detected, log }) {
475
+ function printNextSteps({ detected, log, tag = '' }) {
381
476
  const run = runnerCommand(detected.runner, PACKAGE_NAME);
382
- const install = installCommand(detected.runner);
477
+ const install = installCommand(detected.runner, tag);
383
478
  log.info('');
384
479
  log.info('Next steps:');
385
480
  if (!detected.installed) log.info(` Install ${install}`);
@@ -408,10 +503,11 @@ function runnerCommand(runner, binary) {
408
503
  }
409
504
 
410
505
  // Builds the command that installs this package as a dev dependency.
411
- function installCommand(runner) {
412
- if (runner === 'pnpm') return `pnpm add -D ${PACKAGE_NAME}`;
413
- if (runner === 'yarn') return `yarn add -D ${PACKAGE_NAME}`;
414
- return `npm install --save-dev ${PACKAGE_NAME}`;
506
+ function installCommand(runner, tag = '') {
507
+ const pkg = `${PACKAGE_NAME}${tag}`;
508
+ if (runner === 'pnpm') return `pnpm add -D ${pkg}`;
509
+ if (runner === 'yarn') return `yarn add -D ${pkg}`;
510
+ return `npm install --save-dev ${pkg}`;
415
511
  }
416
512
 
417
513
  // Collects the metadata flags, or null when none was passed so a config file value survives.
@@ -430,12 +526,15 @@ function metadataOptions(values) {
430
526
  if (values['metadata-trailing-slash'] !== undefined) {
431
527
  metadata.trailingSlash = values['metadata-trailing-slash'];
432
528
  }
529
+ if (values['metadata-base'] !== undefined) metadata.base = values['metadata-base'];
433
530
  return Object.keys(metadata).length > 0 ? metadata : null;
434
531
  }
435
532
 
436
533
  /**
437
534
  * Loads a JS config file. An explicitly requested file must exist; the implicit
438
- * snappy.config.js is optional. The default export must be a plain options object.
535
+ * snappy.config.js is optional. The default export is either a plain options object or an
536
+ * async function that returns one, which is how routes are computed from a CMS, a sitemap or
537
+ * a file listing without a separate prebuild script.
439
538
  */
440
539
  async function loadConfigFile(explicitPath) {
441
540
  if (explicitPath === undefined) {
@@ -445,9 +544,17 @@ async function loadConfigFile(explicitPath) {
445
544
  throw new Error(`Config file not found: ${explicitPath}`);
446
545
  }
447
546
  const module = await import(pathToFileURL(explicitPath).href);
448
- const loaded = module.default ?? {};
547
+ const exported = module.default ?? {};
548
+ let loaded;
549
+ try {
550
+ loaded = typeof exported === 'function' ? await exported() : exported;
551
+ } catch (error) {
552
+ throw new Error(`Config file failed to load: ${error.message}`);
553
+ }
449
554
  if (typeof loaded !== 'object' || loaded === null || Array.isArray(loaded)) {
450
- throw new Error(`Config file must export a plain options object: ${explicitPath}`);
555
+ throw new Error(
556
+ `Config file must export a plain options object or a function returning one: ${explicitPath}`,
557
+ );
451
558
  }
452
559
  return loaded;
453
560
  }
@@ -0,0 +1,110 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { writeFileIfChanged } from './output.js';
4
+ import { buildPreloadManifest } from './preload-manifest.js';
5
+ import { toPublicPath } from './routes.js';
6
+ import { buildSitemap, withSitemapDirective } from './sitemap.js';
7
+
8
+ /**
9
+ * Writes the preload manifest: per route, the scripts and stylesheets the final HTML
10
+ * references, as Link header values a host can serve for Early Hints or HTTP headers.
11
+ */
12
+ export async function writePreloadManifest({ manifestEntries, outputDir, config, log, report }) {
13
+ const manifest = buildPreloadManifest(manifestEntries, {
14
+ ignoreForPreload: config.ignoreForPreload,
15
+ base: config.base,
16
+ flatOutput: config.flatOutput,
17
+ trailingSlash: config.metadata?.trailingSlash,
18
+ notFoundRoute: config.notFoundRoute,
19
+ });
20
+ const file = 'preload-manifest.json';
21
+ const contents = `${JSON.stringify(manifest, null, 2)}\n`;
22
+ if (config.dryRun) {
23
+ report.files.push({ route: null, file, status: 'dry-run', bytes: Buffer.byteLength(contents) });
24
+ log.info(`Dry run: would write preload manifest covering ${manifest.length} route(s)`);
25
+ return { file, routes: manifest.length, status: 'dry-run' };
26
+ }
27
+ const written = await writeFileIfChanged({ dir: outputDir, file, contents });
28
+ report.files.push({ route: null, ...written });
29
+ log.info(
30
+ `${written.status === 'unchanged' ? 'Unchanged' : 'Wrote'} preload manifest covering ${manifest.length} route(s)`,
31
+ );
32
+ return { file, routes: manifest.length, status: written.status };
33
+ }
34
+
35
+ /**
36
+ * Writes sitemap.xml from the routes the run produced, and points an existing robots.txt at
37
+ * it. Every loc must be absolute, which is why the option needs metadata.siteUrl.
38
+ */
39
+ export async function writeSitemap({ outputDir, config, log, report }) {
40
+ if (config.saveAs !== 'html') {
41
+ log.warn(
42
+ `sitemap is skipped when saveAs writes ${config.saveAs} files, as there are no pages to list`,
43
+ );
44
+ return;
45
+ }
46
+ const sitemap = buildSitemap({ files: report.files, config });
47
+ if (!sitemap) {
48
+ log.warn('sitemap is skipped: no pages were written');
49
+ return;
50
+ }
51
+
52
+ const siteUrl = config.metadata.siteUrl.replace(/\/$/, '');
53
+ const sitemapUrl = `${siteUrl}${toPublicPath('/sitemap.xml', config.base)}`;
54
+ const robots = await fs.readFile(path.join(outputDir, 'robots.txt'), 'utf8').catch(() => null);
55
+ const patched = robots === null ? null : withSitemapDirective(robots, sitemapUrl);
56
+ const names = sitemap.files.map((entry) => entry.name);
57
+
58
+ if (config.dryRun) {
59
+ for (const entry of sitemap.files) {
60
+ report.files.push({
61
+ route: null,
62
+ file: entry.name,
63
+ status: 'dry-run',
64
+ bytes: Buffer.byteLength(entry.contents),
65
+ });
66
+ }
67
+ report.sitemap = {
68
+ file: 'sitemap.xml',
69
+ routes: sitemap.routes,
70
+ robotsUpdated: patched !== null,
71
+ files: names,
72
+ };
73
+ log.info(`Dry run: would write ${names.join(', ')} listing ${sitemap.routes} route(s)`);
74
+ return;
75
+ }
76
+
77
+ const statuses = [];
78
+ for (const entry of sitemap.files) {
79
+ const written = await writeFileIfChanged({
80
+ dir: outputDir,
81
+ file: entry.name,
82
+ contents: entry.contents,
83
+ });
84
+ statuses.push(written.status);
85
+ report.files.push({ route: null, ...written });
86
+ }
87
+ report.sitemap = {
88
+ file: 'sitemap.xml',
89
+ routes: sitemap.routes,
90
+ robotsUpdated: patched !== null,
91
+ files: names,
92
+ };
93
+ log.info(
94
+ `${statuses.every((status) => status === 'unchanged') ? 'Unchanged' : 'Wrote'} ${names.join(', ')} listing ${sitemap.routes} route(s)`,
95
+ );
96
+
97
+ if (robots === null) {
98
+ log.info(`Add "Sitemap: ${sitemapUrl}" to a robots.txt so crawlers find it`);
99
+ return;
100
+ }
101
+ if (patched !== null) {
102
+ const written = await writeFileIfChanged({
103
+ dir: outputDir,
104
+ file: 'robots.txt',
105
+ contents: patched,
106
+ });
107
+ report.files.push({ route: null, ...written });
108
+ log.info('Added the sitemap location to robots.txt');
109
+ }
110
+ }
@@ -1,3 +1,36 @@
1
+ /**
2
+ * Reports whether a host is on the allowlist. Entries may be a hostname, a hostname
3
+ * with a port, a leading-dot or wildcard suffix, or a full URL; matching is
4
+ * case-insensitive.
5
+ */
6
+ export function isHostAllowed(hostname, port, allowedHosts) {
7
+ const host = hostname.toLowerCase();
8
+ const withPort = port ? `${host}:${port}` : host;
9
+ for (const raw of allowedHosts) {
10
+ const entry = String(raw).trim().toLowerCase();
11
+ if (entry.length === 0) continue;
12
+ if (entry.includes('://')) {
13
+ try {
14
+ const parsed = new URL(entry);
15
+ if (parsed.hostname.toLowerCase() === host && (!parsed.port || parsed.port === port)) {
16
+ return true;
17
+ }
18
+ } catch {
19
+ // A malformed entry is simply not a match.
20
+ }
21
+ continue;
22
+ }
23
+ if (entry === withPort || entry === host) return true;
24
+ if (entry.startsWith('*.') && host.endsWith(entry.slice(1)) && host !== entry.slice(2)) {
25
+ return true;
26
+ }
27
+ if (entry.startsWith('.') && host.endsWith(entry) && host !== entry.slice(1)) {
28
+ return true;
29
+ }
30
+ }
31
+ return false;
32
+ }
33
+
1
34
  /**
2
35
  * Aborts requests to third-party origins so analytics, ads and external widgets
3
36
  * cannot mutate the DOM mid-capture or make output non-deterministic. Used by both
@@ -15,11 +48,30 @@ export async function blockThirdPartyRequests(page, origin, allowedHosts) {
15
48
  return allow(route);
16
49
  }
17
50
  if (parsed.origin === origin) return allow(route);
18
- if (allowed.has(parsed.hostname.toLowerCase())) return allow(route);
51
+ if (isHostAllowed(parsed.hostname, parsed.port, allowed)) return allow(route);
19
52
  // A route can already be handled or cancelled, and that rejection is not this hook's to
20
53
  // report, so it is swallowed rather than surfacing as an unhandled rejection.
21
54
  return route.abort().catch(() => {});
22
55
  });
56
+
57
+ // page.route does not see WebSockets; route them separately when the Playwright
58
+ // version provides the hook.
59
+ if (typeof page.routeWebSocket === 'function') {
60
+ await page.routeWebSocket('**/*', (socket) => {
61
+ let parsed;
62
+ try {
63
+ parsed = new URL(socket.url());
64
+ } catch {
65
+ socket.close();
66
+ return;
67
+ }
68
+ if (parsed.origin === origin || isHostAllowed(parsed.hostname, parsed.port, allowed)) {
69
+ socket.connectToServer();
70
+ } else {
71
+ socket.close();
72
+ }
73
+ });
74
+ }
23
75
  }
24
76
 
25
77
  // Continues a request, ignoring the rejection that follows an already-handled route.