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.
- package/CHANGELOG.md +191 -0
- package/README.md +149 -107
- package/docs/limitations.md +58 -0
- package/docs/react-snap-migration.md +45 -0
- package/docs/report.md +102 -0
- package/package.json +9 -5
- package/src/cli-init.js +205 -0
- package/src/cli.js +159 -168
- package/src/core/artifacts.js +112 -0
- package/src/core/audit/index.js +114 -0
- package/src/core/audit/links.js +63 -0
- package/src/core/audit/metadata.js +88 -0
- package/src/core/audit/sitemap.js +39 -0
- package/src/core/blocking.js +53 -1
- package/src/core/browser.js +109 -39
- package/src/core/build-origin.js +19 -0
- package/src/core/config.js +256 -6
- package/src/core/crawl.js +300 -0
- package/src/core/csp.js +127 -0
- package/src/core/css-url.js +34 -0
- package/src/core/destination.js +28 -3
- package/src/core/frameworks.js +94 -0
- package/src/core/hydration-diff.js +93 -0
- package/src/core/init.js +187 -31
- package/src/core/inline-css.js +34 -13
- package/src/core/metadata-origin.js +11 -2
- package/src/core/normalize.js +59 -2
- package/src/core/output.js +32 -9
- package/src/core/page-content.js +13 -0
- package/src/core/page-meta.js +108 -0
- package/src/core/preload-links.js +120 -0
- package/src/core/preload-manifest.js +61 -7
- package/src/core/prune.js +65 -0
- package/src/core/render.js +147 -11
- package/src/core/report.js +108 -0
- package/src/core/resources.js +6 -1
- package/src/core/route-config.js +17 -0
- package/src/core/route-sources.js +209 -0
- package/src/core/routes.js +9 -0
- package/src/core/run.js +186 -358
- package/src/core/server.js +30 -4
- package/src/core/sitemap.js +128 -20
- package/src/core/url-policy.js +32 -0
- package/src/core/verify.js +103 -49
- package/src/core/write.js +363 -0
- package/src/head/index.js +2 -2
- package/src/head/manager.js +54 -13
- package/src/head/tags.js +71 -32
package/src/core/config.js
CHANGED
|
@@ -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 =
|
|
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)
|
|
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
|
-
|
|
204
|
-
|
|
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
|
+
}
|