snappy-prerender 2.6.0-rc.3 → 2.7.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.
Files changed (48) hide show
  1. package/CHANGELOG.md +191 -0
  2. package/README.md +149 -107
  3. package/docs/limitations.md +58 -0
  4. package/docs/react-snap-migration.md +45 -0
  5. package/docs/report.md +102 -0
  6. package/package.json +9 -5
  7. package/src/cli-init.js +205 -0
  8. package/src/cli.js +159 -168
  9. package/src/core/artifacts.js +112 -0
  10. package/src/core/audit/index.js +114 -0
  11. package/src/core/audit/links.js +63 -0
  12. package/src/core/audit/metadata.js +88 -0
  13. package/src/core/audit/sitemap.js +39 -0
  14. package/src/core/blocking.js +53 -1
  15. package/src/core/browser.js +109 -39
  16. package/src/core/build-origin.js +19 -0
  17. package/src/core/config.js +256 -6
  18. package/src/core/crawl.js +300 -0
  19. package/src/core/csp.js +127 -0
  20. package/src/core/css-url.js +34 -0
  21. package/src/core/destination.js +28 -3
  22. package/src/core/frameworks.js +94 -0
  23. package/src/core/hydration-diff.js +93 -0
  24. package/src/core/init.js +187 -31
  25. package/src/core/inline-css.js +34 -13
  26. package/src/core/metadata-origin.js +11 -2
  27. package/src/core/normalize.js +59 -2
  28. package/src/core/output.js +32 -9
  29. package/src/core/page-content.js +13 -0
  30. package/src/core/page-meta.js +108 -0
  31. package/src/core/preload-links.js +120 -0
  32. package/src/core/preload-manifest.js +61 -7
  33. package/src/core/prune.js +65 -0
  34. package/src/core/render.js +147 -11
  35. package/src/core/report.js +108 -0
  36. package/src/core/resources.js +6 -1
  37. package/src/core/route-config.js +17 -0
  38. package/src/core/route-sources.js +209 -0
  39. package/src/core/routes.js +9 -0
  40. package/src/core/run.js +186 -358
  41. package/src/core/server.js +30 -4
  42. package/src/core/sitemap.js +128 -20
  43. package/src/core/url-policy.js +32 -0
  44. package/src/core/verify.js +103 -49
  45. package/src/core/write.js +363 -0
  46. package/src/head/index.js +2 -2
  47. package/src/head/manager.js +54 -13
  48. package/src/head/tags.js +71 -32
@@ -11,7 +11,8 @@ export const MAX_DEFAULT_CONCURRENCY = 8;
11
11
  const LOG_LEVELS = ['silent', 'error', 'warn', 'info', 'debug'];
12
12
  const BROWSER_CHOICES = ['auto', 'chrome', 'msedge', 'chromium'];
13
13
  const INLINE_CSS_STRATEGIES = [false, true, 'inline', 'critical'];
14
- const CSP_MODES = ['off', 'strict'];
14
+ const CSP_MODES = ['off', 'strict', 'hash'];
15
+ const FRAMEWORK_CHOICES = ['auto', 'react', 'vue', 'svelte', 'preact'];
15
16
  const SAVE_AS_CHOICES = ['html', 'png', 'jpeg'];
16
17
  const CRITICAL_CSS_PRELOAD = ['body', 'media', 'swap', 'swap-high', 'swap-low', 'js', 'js-lazy'];
17
18
  const NULLABLE_KEYS = [
@@ -24,8 +25,13 @@ const NULLABLE_KEYS = [
24
25
  'userAgent',
25
26
  'destination',
26
27
  'browserDownloadHash',
28
+ 'browserDownloadVersion',
27
29
  'url',
28
30
  'serveCmd',
31
+ 'reportPath',
32
+ 'routesFile',
33
+ 'routesFromSitemap',
34
+ 'transformHtml',
29
35
  ];
30
36
  const ARRAY_KEYS = [
31
37
  'include',
@@ -34,11 +40,15 @@ const ARRAY_KEYS = [
34
40
  'browserArgs',
35
41
  'ignoreForPreload',
36
42
  'removeAttributes',
43
+ 'auditIgnoreLinks',
44
+ 'routePriority',
45
+ 'routeOverrides',
37
46
  ];
38
47
 
39
48
  export const DEFAULTS = Object.freeze({
40
49
  sourceDir: 'dist',
41
50
  destination: null,
51
+ cleanDestination: false,
42
52
  base: '/',
43
53
  include: ['/'],
44
54
  exclude: [],
@@ -59,6 +69,7 @@ export const DEFAULTS = Object.freeze({
59
69
  browser: 'auto',
60
70
  browserDownload: true,
61
71
  browserDownloadHash: null,
72
+ browserDownloadVersion: null,
62
73
  browserArgs: [],
63
74
  headless: true,
64
75
  ignoreHTTPSErrors: false,
@@ -84,16 +95,40 @@ export const DEFAULTS = Object.freeze({
84
95
  preloadImages: false,
85
96
  preloadManifest: false,
86
97
  sitemap: false,
98
+ imageSitemap: false,
87
99
  ignoreForPreload: ['service-worker.js'],
88
100
  cacheAjaxRequests: false,
89
101
  maxCachedBytes: 5 * 1024 * 1024,
90
102
  flatOutput: false,
91
103
  notFoundRoute: '/404',
104
+ audit: true,
105
+ failOnAuditError: false,
106
+ auditIgnoreLinks: [],
107
+ reportPath: null,
108
+ routePriority: [],
109
+ routeOverrides: [],
110
+ transformHtml: null,
111
+ routesFile: null,
112
+ routesFromSitemap: null,
113
+ preloadLinks: false,
114
+ retries: 0,
115
+ retryDelay: 500,
116
+ traceOnError: false,
117
+ framework: 'auto',
92
118
  verify: true,
93
119
  verifyChangedOnly: false,
94
120
  failOnHydrationError: false,
95
121
  failOnRerender: false,
96
122
  failOnPageError: false,
123
+ failOnEmptyPage: true,
124
+ failOnUndetected: false,
125
+ failOnVerificationError: false,
126
+ failOnConsoleError: false,
127
+ failOnFailedRequest: false,
128
+ failOnDuplicateContent: false,
129
+ stripBuildOriginMetadata: true,
130
+ failOnBuildOriginMetadata: false,
131
+ pruneStaleRoutes: false,
97
132
  metadata: null,
98
133
  failOnError: true,
99
134
  dryRun: false,
@@ -107,6 +142,22 @@ export const DEFAULTS = Object.freeze({
107
142
  // below decide what is allowed.
108
143
  const MULTI_TYPE_KEYS = new Set(['inlineCss', 'minifyHtml', 'minifyCss']);
109
144
 
145
+ // Freeze the nested list defaults as well, so a consumer cannot mutate the defaults
146
+ // used by every later run in the same process.
147
+ for (const key of [
148
+ 'include',
149
+ 'exclude',
150
+ 'allowedHosts',
151
+ 'browserArgs',
152
+ 'ignoreForPreload',
153
+ 'removeAttributes',
154
+ 'auditIgnoreLinks',
155
+ 'routePriority',
156
+ 'routeOverrides',
157
+ ]) {
158
+ Object.freeze(DEFAULTS[key]);
159
+ }
160
+
110
161
  // Every boolean option is validated, derived from the defaults so a new flag cannot be
111
162
  // forgotten in a hand-maintained list.
112
163
  const BOOLEAN_KEYS = Object.entries(DEFAULTS)
@@ -126,18 +177,126 @@ export function resolveConfig(userOptions = {}) {
126
177
  config.concurrency =
127
178
  userOptions.concurrency ??
128
179
  Math.max(1, Math.min(os.availableParallelism(), MAX_DEFAULT_CONCURRENCY));
129
- config.includeProvided = userOptions.include !== undefined;
180
+ config.includeProvided =
181
+ userOptions.include !== undefined &&
182
+ !(config.include.length === 1 && config.include[0] === '/');
130
183
  // The CSP dial decides the defaults for the pieces that can move. Explicit options still
131
184
  // win, so a project can take 'strict' and put one part back.
132
- if (userOptions.externalScripts === undefined) config.externalScripts = config.csp === 'strict';
185
+ if (userOptions.externalScripts === undefined) {
186
+ config.externalScripts = config.csp === 'strict' || config.csp === 'hash';
187
+ }
133
188
  if (userOptions.criticalCssPreload === undefined) {
134
- config.criticalCssPreload = config.csp === 'strict' ? false : 'media';
189
+ config.criticalCssPreload = config.csp === 'strict' || config.csp === 'hash' ? false : 'media';
135
190
  }
136
191
  config.warnings = [];
137
192
  validateConfig(config);
193
+ addConfigWarnings(config, userOptions);
138
194
  return config;
139
195
  }
140
196
 
197
+ /**
198
+ * Reports options that do nothing in the current combination, so a build does not
199
+ * quietly skip a gate the caller believes is active.
200
+ */
201
+ function addConfigWarnings(config, userOptions) {
202
+ const warn = (message) => config.warnings.push(message);
203
+ for (const key of Object.keys(userOptions)) {
204
+ if (!Object.hasOwn(DEFAULTS, key)) {
205
+ warn(`unknown option "${key}" is ignored; check the spelling`);
206
+ }
207
+ }
208
+ if (!config.verify) {
209
+ for (const key of [
210
+ 'verifyChangedOnly',
211
+ 'failOnHydrationError',
212
+ 'failOnRerender',
213
+ 'failOnUndetected',
214
+ 'failOnVerificationError',
215
+ ]) {
216
+ if (config[key]) warn(`${key} has no effect because verify is false`);
217
+ }
218
+ }
219
+ if (config.verify && (config.serveCmd || config.saveAs !== 'html')) {
220
+ for (const key of [
221
+ 'failOnHydrationError',
222
+ 'failOnRerender',
223
+ 'failOnUndetected',
224
+ 'failOnVerificationError',
225
+ ]) {
226
+ if (config[key]) {
227
+ warn(
228
+ `${key} has no effect because hydration verification is skipped with ${config.serveCmd ? 'serveCmd' : `saveAs: '${config.saveAs}'`}`,
229
+ );
230
+ }
231
+ }
232
+ if (userOptions.framework !== undefined) {
233
+ warn(
234
+ `framework has no effect because hydration verification is skipped with ${config.serveCmd ? 'serveCmd' : `saveAs: '${config.saveAs}'`}`,
235
+ );
236
+ }
237
+ }
238
+ if (!config.verify && userOptions.framework !== undefined) {
239
+ warn('framework has no effect because verify is false');
240
+ }
241
+ if (!config.audit && config.failOnAuditError) {
242
+ warn('failOnAuditError has no effect because audit is false');
243
+ }
244
+ if (!config.audit && config.auditIgnoreLinks.length > 0) {
245
+ warn('auditIgnoreLinks has no effect because audit is false');
246
+ }
247
+ if (config.preloadLinks && config.saveAs !== 'html') {
248
+ warn(`preloadLinks has no effect when saveAs is '${config.saveAs}'`);
249
+ }
250
+ if (config.preloadLinks && config.removeScriptTags) {
251
+ warn('preloadLinks skips script hints because removeScriptTags strips every script');
252
+ }
253
+ if (config.imageSitemap && !config.sitemap) {
254
+ warn('imageSitemap has no effect because sitemap is false');
255
+ }
256
+ if (config.traceOnError && config.dryRun) {
257
+ warn('traceOnError has no effect in dry-run mode');
258
+ }
259
+ if (
260
+ userOptions.criticalCssPreload !== undefined &&
261
+ config.criticalCssPreload !== false &&
262
+ config.inlineCss !== 'critical'
263
+ ) {
264
+ warn('criticalCssPreload has no effect unless inlineCss is "critical"');
265
+ }
266
+ if (config.minifyCss && !config.minifyHtml && !config.inlineCss) {
267
+ warn('minifyCss has no effect unless minifyHtml or inlineCss is enabled');
268
+ }
269
+ if (config.allowedHosts.length > 0 && !config.blockThirdParty) {
270
+ warn('allowedHosts has no effect unless blockThirdParty is true');
271
+ }
272
+ if (config.csp === 'strict' && config.inlineCss) {
273
+ warn(
274
+ "csp controls script-src; inlineCss emits inline styles, which need style-src 'unsafe-inline' or hashes",
275
+ );
276
+ }
277
+ if (
278
+ (config.csp === 'strict' || config.csp === 'hash') &&
279
+ userOptions.criticalCssPreload !== undefined &&
280
+ config.criticalCssPreload !== false
281
+ ) {
282
+ warn(
283
+ "criticalCssPreload emits an inline onload handler that a script-src 'self' policy blocks",
284
+ );
285
+ }
286
+ if (config.removeStyleTags && config.inlineCss === 'critical') {
287
+ warn('removeStyleTags removes CSS-in-JS styles before critical CSS is extracted');
288
+ }
289
+ if (config.sitemap && config.saveAs !== 'html') {
290
+ warn(`sitemap has no effect when saveAs is '${config.saveAs}'`);
291
+ }
292
+ const metadataBase = config.metadata?.base;
293
+ if (metadataBase && metadataBase !== config.base) {
294
+ warn(
295
+ `metadata.base (${metadataBase}) differs from base (${config.base}); canonical, social URLs and routing will disagree`,
296
+ );
297
+ }
298
+ }
299
+
141
300
  /**
142
301
  * Ensures the base path always carries a leading and trailing slash so route and
143
302
  * URL helpers can strip or append it without branching on user input shape.
@@ -200,9 +359,25 @@ export function validateConfig(config) {
200
359
  stringList(config.browserArgs, 'browserArgs');
201
360
  stringList(config.ignoreForPreload, 'ignoreForPreload');
202
361
  patternList(config.removeAttributes, 'removeAttributes');
203
- for (const key of ['readyFlag', 'readySelector', 'waitFor', 'notFoundRoute']) nullableString(key);
204
- for (const key of ['userAgent', 'destination']) nullableString(key);
362
+ patternList(config.auditIgnoreLinks, 'auditIgnoreLinks');
363
+ patternList(config.routePriority, 'routePriority');
364
+ for (const key of [
365
+ 'readyFlag',
366
+ 'readySelector',
367
+ 'waitFor',
368
+ 'notFoundRoute',
369
+ 'reportPath',
370
+ 'routesFile',
371
+ 'routesFromSitemap',
372
+ ]) {
373
+ nullableString(key);
374
+ }
375
+ for (const key of ['userAgent', 'destination', 'browserDownloadVersion']) nullableString(key);
376
+ if (config.transformHtml !== null && typeof config.transformHtml !== 'function') {
377
+ throw new TypeError('transformHtml must be a function or null');
378
+ }
205
379
  for (const key of BOOLEAN_KEYS) booleanOption(key);
380
+ validateRouteOverrides(config.routeOverrides);
206
381
 
207
382
  if (config.browserDownloadHash !== null && !/^[a-f0-9]{64}$/i.test(config.browserDownloadHash)) {
208
383
  throw new TypeError('browserDownloadHash must be a 64 character SHA-256 hex digest');
@@ -228,6 +403,12 @@ export function validateConfig(config) {
228
403
  if (config.maxRoutes !== null && (!Number.isInteger(config.maxRoutes) || config.maxRoutes < 1)) {
229
404
  throw new TypeError('maxRoutes must be null or a positive integer');
230
405
  }
406
+ if (!Number.isInteger(config.retries) || config.retries < 0) {
407
+ throw new TypeError('retries must be a non-negative integer');
408
+ }
409
+ if (!Number.isFinite(config.retryDelay) || config.retryDelay <= 0) {
410
+ throw new TypeError('retryDelay must be a positive number of milliseconds');
411
+ }
231
412
  if (!Number.isFinite(config.maxCachedBytes) || config.maxCachedBytes <= 0) {
232
413
  throw new TypeError('maxCachedBytes must be a positive number of bytes');
233
414
  }
@@ -244,6 +425,9 @@ export function validateConfig(config) {
244
425
  `browser must be one of: ${BROWSER_CHOICES.join(', ')} or a path to an executable`,
245
426
  );
246
427
  }
428
+ if (!FRAMEWORK_CHOICES.includes(config.framework)) {
429
+ throw new TypeError(`framework must be one of: ${FRAMEWORK_CHOICES.join(', ')}`);
430
+ }
247
431
  if (config.storageState !== null) {
248
432
  if (typeof config.storageState !== 'string' || !fs.existsSync(config.storageState)) {
249
433
  throw new TypeError('storageState must point to an existing Playwright storage state file');
@@ -257,6 +441,14 @@ export function validateConfig(config) {
257
441
  if (config.url !== null && !/^https?:\/\//.test(config.url)) {
258
442
  throw new TypeError('url must be an absolute http(s) URL');
259
443
  }
444
+ if (config.serveCmd !== null && config.url) {
445
+ const parsed = new URL(config.url);
446
+ if (parsed.pathname !== '/' && parsed.pathname !== '') {
447
+ throw new TypeError(
448
+ 'url must be an origin without a path when serveCmd is used; set base for a sub-path deployment',
449
+ );
450
+ }
451
+ }
260
452
  if (!INLINE_CSS_STRATEGIES.includes(config.inlineCss)) {
261
453
  throw new TypeError("inlineCss must be false, true, 'inline' or 'critical'");
262
454
  }
@@ -281,12 +473,62 @@ export function validateConfig(config) {
281
473
  if (!SAVE_AS_CHOICES.includes(config.saveAs)) {
282
474
  throw new TypeError(`saveAs must be one of: ${SAVE_AS_CHOICES.join(', ')}`);
283
475
  }
476
+ if (config.removeStyleTags && (config.inlineCss === true || config.inlineCss === 'inline')) {
477
+ throw new TypeError(
478
+ "removeStyleTags cannot be combined with inlineCss 'inline' or true: it would strip the CSS the inline step just added",
479
+ );
480
+ }
284
481
  validateMetadata(config);
285
482
  if (config.sitemap && !config.metadata?.siteUrl) {
286
483
  throw new TypeError('sitemap needs metadata.siteUrl so the sitemap can list absolute URLs');
287
484
  }
288
485
  }
289
486
 
487
+ // Per-route overrides may only change the settings that make sense for one route.
488
+ const ROUTE_OVERRIDE_KEYS = new Set([
489
+ 'match',
490
+ 'timeout',
491
+ 'waitFor',
492
+ 'readySelector',
493
+ 'readyFlag',
494
+ 'crawl',
495
+ 'storageState',
496
+ ]);
497
+
498
+ /**
499
+ * Validates per-route overrides. Matching patterns stay strings or RegExp; every
500
+ * override key is checked so a typo cannot silently leave the base config in place.
501
+ */
502
+ function validateRouteOverrides(overrides) {
503
+ for (const override of overrides) {
504
+ if (typeof override !== 'object' || override === null || Array.isArray(override)) {
505
+ throw new TypeError('routeOverrides entries must be objects');
506
+ }
507
+ for (const key of Object.keys(override)) {
508
+ if (!ROUTE_OVERRIDE_KEYS.has(key)) {
509
+ throw new TypeError(`routeOverrides.${key} is not a recognised option`);
510
+ }
511
+ }
512
+ if (!(typeof override.match === 'string' || override.match instanceof RegExp)) {
513
+ throw new TypeError('routeOverrides entries need a string or RegExp match');
514
+ }
515
+ if (override.timeout !== undefined) {
516
+ if (!Number.isFinite(override.timeout) || override.timeout <= 0) {
517
+ throw new TypeError('routeOverrides.timeout must be a positive number of milliseconds');
518
+ }
519
+ }
520
+ for (const key of ['waitFor', 'readySelector', 'readyFlag', 'storageState']) {
521
+ const value = override[key];
522
+ if (value !== undefined && typeof value !== 'string') {
523
+ throw new TypeError(`routeOverrides.${key} must be a string`);
524
+ }
525
+ }
526
+ if (override.crawl !== undefined && typeof override.crawl !== 'boolean') {
527
+ throw new TypeError('routeOverrides.crawl must be a boolean');
528
+ }
529
+ }
530
+ }
531
+
290
532
  // The metadata keys the head component reads, so a typo is reported instead of ignored.
291
533
  const METADATA_KEYS = new Set([
292
534
  'siteUrl',
@@ -319,6 +561,14 @@ function validateMetadata(config) {
319
561
  if (metadata.siteUrl && !/^https?:\/\//.test(metadata.siteUrl)) {
320
562
  throw new TypeError('metadata.siteUrl must be an absolute http(s) URL');
321
563
  }
564
+ if (metadata.siteUrl) {
565
+ const parsed = new URL(metadata.siteUrl);
566
+ if (parsed.pathname !== '/' && parsed.pathname !== '') {
567
+ throw new TypeError(
568
+ 'metadata.siteUrl must be an origin without a path; set base for a sub-path deployment',
569
+ );
570
+ }
571
+ }
322
572
  if (metadata.base && !metadata.base.startsWith('/')) {
323
573
  throw new TypeError('metadata.base must start with a slash, for example /app/');
324
574
  }
@@ -0,0 +1,300 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { renderRoute } from './render.js';
4
+ import { resolveRouteConfig } from './route-config.js';
5
+ import {
6
+ crawlRoots,
7
+ filterRoutes,
8
+ isIncludedRoute,
9
+ matchesPattern,
10
+ normaliseRoute,
11
+ withinDepth,
12
+ } from './routes.js';
13
+ import { createPool, mapLimit } from './scheduler.js';
14
+
15
+ /**
16
+ * Runs the crawl and render pool. Each rendered page contributes its output, any new
17
+ * same-origin routes, and the resources recorded for the link-hint features.
18
+ */
19
+ export async function renderAllRoutes({
20
+ browser,
21
+ server,
22
+ config,
23
+ log,
24
+ report,
25
+ outputDir,
26
+ sourceSeeds = [],
27
+ }) {
28
+ const visited = new Set();
29
+ const scheduled = new Set();
30
+ const rendered = new Map();
31
+ const cap = config.maxRoutes ?? Number.POSITIVE_INFINITY;
32
+ let nextOrder = 0;
33
+
34
+ // Queues routes up to maxRoutes, recording exactly which candidates did not fit.
35
+ // A seed used only to reach wildcard routes is given output: false and is rendered
36
+ // for its links without being written.
37
+ const accept = (routes, outputFor = () => true) => {
38
+ const accepted = [];
39
+ const dropped = [];
40
+ for (const route of routes) {
41
+ if (scheduled.has(route)) continue;
42
+ if (scheduled.size >= cap) {
43
+ dropped.push(route);
44
+ continue;
45
+ }
46
+ scheduled.add(route);
47
+ accepted.push({ route, output: outputFor(route), order: nextOrder });
48
+ nextOrder += 1;
49
+ }
50
+ if (dropped.length > 0) {
51
+ const previous = report.truncated?.droppedRoutes ?? [];
52
+ report.truncated = {
53
+ limit: config.maxRoutes,
54
+ routes: scheduled.size,
55
+ droppedRoutes: [...new Set([...previous, ...dropped])].sort(),
56
+ };
57
+ }
58
+ return accepted;
59
+ };
60
+
61
+ const renderOne = async ({ route, output, depth = 0, order = null }) => {
62
+ if (visited.has(route)) return null;
63
+ visited.add(route);
64
+ log.debug(`rendering ${route}${output ? '' : ' as a crawl seed'}`);
65
+ const routeConfig = resolveRouteConfig(config, route);
66
+ const result = await renderWithRetries({
67
+ browser,
68
+ server,
69
+ route,
70
+ routeConfig,
71
+ config,
72
+ log,
73
+ report,
74
+ outputDir,
75
+ screenshot: config.saveAs !== 'html',
76
+ });
77
+ if (!result) return null;
78
+ result.depth = depth;
79
+ result.order = order;
80
+ try {
81
+ const notFoundRoute = config.notFoundRoute ? normaliseRoute(config.notFoundRoute) : null;
82
+ const isNotFoundRoute = notFoundRoute !== null && route === notFoundRoute;
83
+ if (result.statusCode === 404 && !isNotFoundRoute) {
84
+ report.errors.push({ route, message: 'returned 404' });
85
+ log.error(` route ${route} returned 404`);
86
+ return null;
87
+ }
88
+ if (isNotFoundRoute) {
89
+ result.notFound = true;
90
+ if (!report.notFoundRoutes.includes(route)) report.notFoundRoutes.push(route);
91
+ if (result.statusCode !== null && result.statusCode !== 404) {
92
+ log.warn(` ignored unknown prerender-status-code ${result.statusCode} on ${route}`);
93
+ }
94
+ } else if (result.statusCode !== null) {
95
+ log.warn(` ignored unknown prerender-status-code ${result.statusCode} on ${route}`);
96
+ }
97
+ if (output && result.empty) {
98
+ report.emptyPages.push(route);
99
+ if (config.failOnEmptyPage) {
100
+ report.errors.push({ route, message: 'rendered an empty page' });
101
+ log.error(` empty page ${route}: no app content was found`);
102
+ return null;
103
+ }
104
+ log.warn(` empty page ${route}: no app content was found`);
105
+ }
106
+ if (output) {
107
+ rendered.set(route, result);
108
+ report.routes.push(route);
109
+ for (const to of result.links) report.links.push({ from: route, to });
110
+ }
111
+ for (const message of result.pageErrors) {
112
+ report.pageErrors.push({ route, message });
113
+ log.warn(` page error on ${route}: ${message}`);
114
+ }
115
+ for (const message of result.consoleErrors) {
116
+ report.consoleErrors.push({ route, message });
117
+ }
118
+ if (result.consoleErrors.length > 0) {
119
+ log.debug(` console error on ${route}: ${result.consoleErrors[0]}`);
120
+ if (config.failOnConsoleError) {
121
+ report.errors.push({ route, message: `console error: ${result.consoleErrors[0]}` });
122
+ }
123
+ }
124
+ for (const failure of result.failedRequests) {
125
+ report.failedRequests.push({ route, ...failure });
126
+ const detail = failure.status ? `HTTP ${failure.status}` : failure.error;
127
+ log.warn(` failed request on ${route}: ${failure.url} (${detail})`);
128
+ }
129
+ if (result.failedRequests.length > 0 && config.failOnFailedRequest) {
130
+ const first = result.failedRequests[0];
131
+ report.errors.push({
132
+ route,
133
+ message: `failed request: ${first.url}${first.status ? ` (HTTP ${first.status})` : ''}`,
134
+ });
135
+ }
136
+ return { result, routeConfig };
137
+ } catch (error) {
138
+ report.errors.push({ route, message: error.message });
139
+ log.error(` failed ${route}: ${error.message}`);
140
+ return null;
141
+ }
142
+ };
143
+
144
+ const outputFor = (route) => !config.includeProvided || isIncludedRoute(route, config.include);
145
+ const candidates = [];
146
+ if (sourceSeeds.length > 0) candidates.push(...sourceSeeds);
147
+ if (sourceSeeds.length === 0 || config.includeProvided) {
148
+ candidates.push(...crawlRoots(config));
149
+ }
150
+ const roots = accept([...new Set(sortRoutes(candidates, config))], outputFor);
151
+
152
+ const deterministic = config.maxRoutes !== null || config.routePriority.length > 0;
153
+ if (deterministic) {
154
+ await renderWaves({ config, accept, renderOne, roots, report, log });
155
+ } else {
156
+ const pool = createPool({
157
+ concurrency: config.concurrency,
158
+ handler: async ({ route, depth, output, order }) => {
159
+ const renderedRoute = await renderOne({ route, output, depth, order });
160
+ if (!renderedRoute) return;
161
+ const { result, routeConfig } = renderedRoute;
162
+ if (routeConfig.crawl && withinDepth(depth + 1, routeConfig.maxDepth)) {
163
+ const discovered = filterRoutes(result.links, {
164
+ include: config.includeProvided ? config.include : [],
165
+ exclude: config.exclude,
166
+ }).filter((candidate) => !scheduled.has(candidate));
167
+ const accepted = accept(sortRoutes(discovered, config));
168
+ pool.push(accepted.map((item) => ({ ...item, depth: depth + 1 })));
169
+ }
170
+ },
171
+ });
172
+ pool.push(roots.map((item) => ({ ...item, depth: 0 })));
173
+ const { errors } = await pool.run();
174
+ for (const { item, error } of errors) {
175
+ report.errors.push({ route: item.route, message: error.message });
176
+ log.error(` pool error on ${item.route}: ${error.message}`);
177
+ }
178
+ }
179
+ return rendered;
180
+ }
181
+
182
+ /**
183
+ * Renders one route with the configured retry policy. A retryable failure is retried
184
+ * after retryDelay; the final failure records the attempt count and, when traceOnError
185
+ * is on, a Playwright trace written under the output directory.
186
+ */
187
+ async function renderWithRetries({
188
+ browser,
189
+ server,
190
+ route,
191
+ routeConfig,
192
+ config,
193
+ log,
194
+ report,
195
+ outputDir,
196
+ screenshot,
197
+ }) {
198
+ const attempts = config.retries + 1;
199
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
200
+ const traceFile =
201
+ config.traceOnError && !config.dryRun && attempt === attempts
202
+ ? await prepareTraceFile(outputDir, route, attempt)
203
+ : null;
204
+ try {
205
+ const result = await renderRoute({
206
+ browser,
207
+ origin: server.origin,
208
+ route,
209
+ config: routeConfig,
210
+ screenshot,
211
+ traceFile,
212
+ });
213
+ if (attempt > 1) log.info(` ${route}: succeeded on attempt ${attempt}`);
214
+ result.attempts = attempt;
215
+ return result;
216
+ } catch (error) {
217
+ if (error.tracePath) {
218
+ report.traces.push({ route, attempt, file: error.tracePath });
219
+ log.warn(` trace for ${route}: ${error.tracePath}`);
220
+ }
221
+ if (error.retryable !== false && attempt < attempts) {
222
+ log.warn(
223
+ ` attempt ${attempt}/${attempts} failed for ${route}: ${error.message}; retrying`,
224
+ );
225
+ await delay(config.retryDelay);
226
+ continue;
227
+ }
228
+ report.errors.push({ route, message: error.message, attempts: attempt });
229
+ log.error(` failed ${route} after ${attempt} attempt(s): ${error.message}`);
230
+ return null;
231
+ }
232
+ }
233
+ return null;
234
+ }
235
+
236
+ // Creates the trace directory and returns the archive path for one failed attempt.
237
+ async function prepareTraceFile(outputDir, route, attempt) {
238
+ const slug = route
239
+ .replace(/^\/+|\/+$/g, '')
240
+ .replace(/[^a-z0-9._-]+/gi, '-')
241
+ .toLowerCase();
242
+ const dir = path.join(outputDir, 'snappy-traces');
243
+ await fs.mkdir(dir, { recursive: true });
244
+ return path.join(dir, `${slug || 'root'}-${attempt}.zip`);
245
+ }
246
+
247
+ // Waits between retry attempts.
248
+ function delay(ms) {
249
+ return new Promise((resolve) => setTimeout(resolve, ms));
250
+ }
251
+
252
+ /**
253
+ * Deterministic BFS crawl used when maxRoutes or routePriority is set. Each depth level
254
+ * is sorted, capped and rendered as a wave, so the accepted set never depends on which
255
+ * concurrent page happened to finish first.
256
+ */
257
+ async function renderWaves({ config, accept, renderOne, roots, report, log }) {
258
+ let frontier = roots.map((item) => ({ ...item, depth: 0 }));
259
+ let depth = 0;
260
+ while (frontier.length > 0) {
261
+ const { results, errors } = await mapLimit(frontier, config.concurrency, async (item) => ({
262
+ item,
263
+ rendered: await renderOne(item),
264
+ }));
265
+ for (const { item, error } of errors) {
266
+ report.errors.push({ route: item.route, message: error.message });
267
+ log.error(` pool error on ${item.route}: ${error.message}`);
268
+ }
269
+ const discovered = [];
270
+ for (const { rendered: renderedRoute } of results) {
271
+ if (!renderedRoute) continue;
272
+ if (!renderedRoute.routeConfig.crawl) continue;
273
+ if (!withinDepth(depth + 1, config.maxDepth)) continue;
274
+ discovered.push(
275
+ ...filterRoutes(renderedRoute.result.links, {
276
+ include: config.includeProvided ? config.include : [],
277
+ exclude: config.exclude,
278
+ }),
279
+ );
280
+ }
281
+ if (!withinDepth(depth + 1, config.maxDepth)) break;
282
+ const next = accept(sortRoutes(discovered, config));
283
+ frontier = next.map((item) => ({ ...item, depth: depth + 1 }));
284
+ depth += 1;
285
+ }
286
+ }
287
+
288
+ // Orders routes by routePriority first, then by path for a stable frontier.
289
+ function sortRoutes(routes, config) {
290
+ return [...routes].sort((a, b) => sortRoute(a, b, config));
291
+ }
292
+
293
+ function sortRoute(a, b, config) {
294
+ const rank = (route) => {
295
+ const index = config.routePriority.findIndex((pattern) => matchesPattern(route, pattern));
296
+ return index === -1 ? Number.MAX_SAFE_INTEGER : index;
297
+ };
298
+ const difference = rank(a) - rank(b);
299
+ return difference === 0 ? a.localeCompare(b) : difference;
300
+ }