snappy-prerender 2.6.0-rc.2

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.
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Canonicalises a route to an app-relative path without query, hash or trailing
3
+ * slash. Dot segments are resolved so crawled links can never escape the output
4
+ * directory through path traversal.
5
+ */
6
+ export function normaliseRoute(input) {
7
+ let route = String(input).trim();
8
+ const hashIndex = route.indexOf('#');
9
+ if (hashIndex !== -1) route = route.slice(0, hashIndex);
10
+ const queryIndex = route.indexOf('?');
11
+ if (queryIndex !== -1) route = route.slice(0, queryIndex);
12
+ if (!route.startsWith('/')) route = `/${route}`;
13
+
14
+ const segments = [];
15
+ for (const segment of route.split('/')) {
16
+ if (segment === '' || segment === '.') continue;
17
+ if (segment === '..') {
18
+ segments.pop();
19
+ continue;
20
+ }
21
+ segments.push(segment);
22
+ }
23
+ return `/${segments.join('/')}`;
24
+ }
25
+
26
+ /**
27
+ * Matches a route against one include/exclude pattern. Strings match exactly,
28
+ * strings ending in `*` match by prefix, and RegExp patterns are tested directly.
29
+ */
30
+ export function matchesPattern(route, pattern) {
31
+ // A global or sticky RegExp keeps lastIndex between calls, so it is reset around each test.
32
+ if (pattern instanceof RegExp) {
33
+ pattern.lastIndex = 0;
34
+ const matched = pattern.test(route);
35
+ pattern.lastIndex = 0;
36
+ return matched;
37
+ }
38
+ if (pattern.endsWith('*')) return route.startsWith(pattern.slice(0, -1));
39
+ return route === pattern;
40
+ }
41
+
42
+ // Reports whether any pattern in a list matches the route.
43
+ function matchesAny(route, patterns) {
44
+ return patterns.some((pattern) => matchesPattern(route, pattern));
45
+ }
46
+ /**
47
+ * Reports whether a route is safe to map onto the output directory. Literal dot
48
+ * segments are already resolved, so this catches the encoded forms: percent-encoded
49
+ * traversal and backslashes, which would otherwise create junk directories and act
50
+ * as path separators on Windows.
51
+ */
52
+ export function isSafeRoute(route) {
53
+ if (route.includes('\\')) return false;
54
+ const lower = route.toLowerCase();
55
+ const suspicious = lower.includes('%2e') || lower.includes('%2f') || lower.includes('%5c');
56
+ if (!suspicious) return true;
57
+
58
+ let decoded;
59
+ try {
60
+ decoded = decodeURIComponent(route);
61
+ } catch {
62
+ return false;
63
+ }
64
+ if (decoded.includes('\\')) return false;
65
+ return !decoded.split('/').some((segment) => segment === '..' || segment === '.');
66
+ }
67
+
68
+ /**
69
+ * Applies include/exclude filtering and de-duplication to a list of candidate
70
+ * routes. Include only filters when the user supplied patterns beyond the default.
71
+ */
72
+ export function filterRoutes(routes, { include, exclude }) {
73
+ const seen = new Set();
74
+ const kept = [];
75
+ for (const candidate of routes) {
76
+ const route = normaliseRoute(candidate);
77
+ if (seen.has(route)) continue;
78
+ if (!isSafeRoute(route)) continue;
79
+ if (include.length > 0 && !matchesAny(route, include)) continue;
80
+ if (exclude.length > 0 && matchesAny(route, exclude)) continue;
81
+ seen.add(route);
82
+ kept.push(route);
83
+ }
84
+ return kept;
85
+ }
86
+
87
+ /**
88
+ * Extracts the app-relative route from an absolute link found in the page. Returns
89
+ * null for non-http links or links outside the configured base path.
90
+ */
91
+ export function toAppRoute(href, base) {
92
+ let url;
93
+ try {
94
+ url = new URL(href);
95
+ } catch {
96
+ return null;
97
+ }
98
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
99
+
100
+ let pathname = url.pathname;
101
+ if (base !== '/') {
102
+ const prefix = base.slice(0, -1);
103
+ if (pathname === prefix) pathname = '/';
104
+ else if (pathname.startsWith(`${prefix}/`)) pathname = pathname.slice(prefix.length);
105
+ else return null;
106
+ }
107
+ return normaliseRoute(pathname);
108
+ }
109
+
110
+ /**
111
+ * Builds the absolute URL the browser should visit for an app-relative route,
112
+ * re-applying the configured base path.
113
+ */
114
+ export function toServerUrl(route, base, origin) {
115
+ return `${origin}${toPublicPath(route, base)}`;
116
+ }
117
+
118
+ /**
119
+ * Re-applies the base path to an app-relative path, for hints that the browser
120
+ * resolves against the served origin rather than the app root.
121
+ */
122
+ export function toPublicPath(pathname, base) {
123
+ const prefix = base === '/' ? '' : base.slice(0, -1);
124
+ return `${prefix}${pathname}`;
125
+ }
126
+
127
+ /**
128
+ * Reports whether a pattern can be requested as a literal route. Wildcard and
129
+ * RegExp patterns only ever act as allowlist filters, never as crawl seeds.
130
+ */
131
+ export function isConcreteRoute(pattern) {
132
+ return typeof pattern === 'string' && !pattern.includes('*');
133
+ }
134
+
135
+ /**
136
+ * Resolves the crawl roots. Concrete include entries are seeded directly; when the
137
+ * include list only holds patterns, crawling starts from the root route. Excluded
138
+ * routes are dropped here too, so a seed cannot bypass the exclude list.
139
+ */
140
+ export function crawlRoots({ include, exclude = [] }) {
141
+ const concrete = include.filter(isConcreteRoute);
142
+ return filterRoutes(concrete.length > 0 ? concrete : ['/'], { include: [], exclude });
143
+ }
144
+
145
+ /**
146
+ * Reports whether a route still needs visiting given the crawl depth limit.
147
+ */
148
+ export function withinDepth(depth, maxDepth) {
149
+ return maxDepth === null || depth <= maxDepth;
150
+ }
@@ -0,0 +1,523 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { launchBrowser } from './browser.js';
4
+ import { resolveConfig } from './config.js';
5
+ import { prepareDestination } from './destination.js';
6
+ import { inlineCriticalCss } from './inline-css.js';
7
+ import { createLogger } from './log.js';
8
+ import { minifyHtml } from './minify.js';
9
+ import { normaliseHtml } from './normalize.js';
10
+ import {
11
+ findOutputCollisions,
12
+ routeToScreenshotFile,
13
+ writeFileIfChanged,
14
+ writeRouteHtml,
15
+ } from './output.js';
16
+ import { buildPreloadManifest } from './preload-manifest.js';
17
+ import { renderRoute } from './render.js';
18
+ import { crawlRoots, filterRoutes, normaliseRoute, toPublicPath, withinDepth } from './routes.js';
19
+ import { createPool } from './scheduler.js';
20
+ import { startCommandServer, startStaticServer } from './server.js';
21
+ import { verifyRoutes } from './verify.js';
22
+
23
+ /**
24
+ * Entry point of the prerenderer: serves the built output, crawls and renders
25
+ * routes in a real browser, post-processes and writes one file per route, then
26
+ * verifies hydration. Returns a report instead of throwing so callers decide how to
27
+ * fail.
28
+ */
29
+ export async function prerender(userOptions = {}, { log: injectedLog } = {}) {
30
+ const config = resolveConfig(userOptions);
31
+ const log = injectedLog ?? createLogger(config.logLevel);
32
+ for (const warning of config.warnings ?? []) log.warn(warning);
33
+ const sourceDir = path.resolve(config.sourceDir);
34
+ const startedAt = Date.now();
35
+
36
+ await assertSourceDir(sourceDir, config);
37
+ const outputDir = await resolveOutputDir({ sourceDir, config, log });
38
+
39
+ if (config.saveAs !== 'html' && config.verify) {
40
+ log.warn(`saveAs: '${config.saveAs}' writes images, so hydration verification is skipped`);
41
+ }
42
+
43
+ const server = config.serveCmd
44
+ ? await startCommandServer({
45
+ command: config.serveCmd,
46
+ url: config.url,
47
+ timeout: config.serveCmdTimeout,
48
+ shutdownTimeout: config.shutdownTimeout,
49
+ })
50
+ : await startStaticServer({ dir: outputDir, base: config.base });
51
+
52
+ const report = {
53
+ routes: [],
54
+ files: [],
55
+ errors: [],
56
+ pageErrors: [],
57
+ duplicateTitles: [],
58
+ verification: null,
59
+ preloadManifest: null,
60
+ truncated: null,
61
+ ok: false,
62
+ durationMs: 0,
63
+ };
64
+ const manifestEntries = [];
65
+ const state = { warnedBuildOriginMetadata: false, warnedCriticalCssSkipped: false };
66
+ let browser = null;
67
+
68
+ try {
69
+ const launched = await launchBrowser(config, log);
70
+ browser = launched.browser;
71
+ log.info(`Browser: ${launched.source}`);
72
+
73
+ const rendered = await renderAllRoutes({
74
+ browser,
75
+ server,
76
+ config,
77
+ log,
78
+ report,
79
+ manifestEntries,
80
+ outputDir,
81
+ });
82
+ const pageErrors = report.pageErrors.length;
83
+ log.info(
84
+ `Rendered ${report.routes.length} route(s)${pageErrors > 0 ? `, ${pageErrors} page error(s)` : ''}`,
85
+ );
86
+ if (pageErrors > 0 && !config.failOnPageError) {
87
+ log.warn(
88
+ 'Page errors are reported, not enforced, so the generated HTML still shipped. Set failOnPageError to fail the build instead.',
89
+ );
90
+ }
91
+
92
+ const verifiable = await writeAllRoutes({ rendered, outputDir, config, log, report, state });
93
+ logFileSummary(report, log);
94
+ logNotFoundNotice(report, config, log);
95
+ logDuplicateTitles(report, log);
96
+
97
+ if (report.truncated) {
98
+ log.error(
99
+ `maxRoutes (${report.truncated.limit}) reached after ${report.truncated.routes} route(s); output is incomplete`,
100
+ );
101
+ }
102
+
103
+ if (config.preloadManifest && !config.dryRun) {
104
+ report.preloadManifest = await writePreloadManifest({
105
+ manifestEntries,
106
+ outputDir,
107
+ config,
108
+ log,
109
+ });
110
+ }
111
+
112
+ const canVerify = config.verify && !config.dryRun && config.saveAs === 'html';
113
+ if (canVerify && config.serveCmd) {
114
+ log.warn(
115
+ 'hydration verification is skipped with serveCmd: the command server decides which document it serves, so the written files cannot be checked',
116
+ );
117
+ }
118
+ if (canVerify && !config.serveCmd && verifiable.length > 0) {
119
+ report.verification = await verifyRoutes({
120
+ browser,
121
+ origin: server.origin,
122
+ routes: verifiable.sort(),
123
+ config,
124
+ });
125
+ logVerification(report.verification, config, log);
126
+ }
127
+ } finally {
128
+ if (browser) await browser.close();
129
+ const stopped = await server.close();
130
+ if (stopped === false) {
131
+ log.warn('serveCmd process did not exit after SIGKILL and may still be running');
132
+ }
133
+ }
134
+
135
+ report.durationMs = Date.now() - startedAt;
136
+ const reRendered = report.verification?.modes['re-rendered'] ?? 0;
137
+ report.ok =
138
+ !(config.failOnError && (report.errors.length > 0 || report.truncated !== null)) &&
139
+ !(config.failOnPageError && report.pageErrors.length > 0) &&
140
+ !(config.failOnHydrationError && report.verification && !report.verification.ok) &&
141
+ !(config.failOnRerender && reRendered > 0);
142
+ return report;
143
+ }
144
+
145
+ /**
146
+ * Fails with a clear message when the output directory is missing, instead of
147
+ * letting every route fail later with an opaque browser navigation error.
148
+ */
149
+ async function assertSourceDir(sourceDir, config) {
150
+ if (config.serveCmd) return;
151
+ const stats = await fs.stat(sourceDir).catch(() => null);
152
+ if (!stats?.isDirectory()) {
153
+ throw new Error(`sourceDir does not exist or is not a directory: ${sourceDir}`);
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Resolves where generated files go. A destination is copied from the source first
159
+ * so assets sit beside the generated HTML; with serveCmd there is nothing to serve
160
+ * from disk, so the destination is only used as the write target.
161
+ */
162
+ async function resolveOutputDir({ sourceDir, config, log }) {
163
+ if (config.serveCmd) return path.resolve(config.destination ?? config.sourceDir);
164
+ return prepareDestination({
165
+ sourceDir,
166
+ destination: config.destination ? path.resolve(config.destination) : null,
167
+ log,
168
+ });
169
+ }
170
+
171
+ /**
172
+ * Runs the crawl and render pool. Each rendered page contributes its output, any new
173
+ * same-origin routes, and the resources recorded for the link-hint features.
174
+ */
175
+ async function renderAllRoutes({
176
+ browser,
177
+ server,
178
+ config,
179
+ log,
180
+ report,
181
+ manifestEntries,
182
+ outputDir,
183
+ }) {
184
+ const visited = new Set();
185
+ const scheduled = new Set();
186
+ const rendered = new Map();
187
+ const cap = config.maxRoutes ?? Number.POSITIVE_INFINITY;
188
+
189
+ // Queues routes up to maxRoutes, flagging the report the first time the cap bites.
190
+ const accept = (routes) => {
191
+ const accepted = [];
192
+ for (const route of routes) {
193
+ if (scheduled.has(route)) continue;
194
+ if (scheduled.size >= cap) {
195
+ report.truncated = { limit: config.maxRoutes, routes: scheduled.size };
196
+ break;
197
+ }
198
+ scheduled.add(route);
199
+ accepted.push(route);
200
+ }
201
+ return accepted;
202
+ };
203
+
204
+ const pool = createPool({
205
+ concurrency: config.concurrency,
206
+ handler: async ({ route, depth }) => {
207
+ if (visited.has(route)) return;
208
+ visited.add(route);
209
+ log.debug(`rendering ${route}`);
210
+ try {
211
+ const screenshotPath =
212
+ config.saveAs !== 'html' && !config.dryRun
213
+ ? path.join(outputDir, routeToScreenshotFile(route, config))
214
+ : null;
215
+ const result = await renderRoute({
216
+ browser,
217
+ origin: server.origin,
218
+ route,
219
+ config,
220
+ screenshotPath,
221
+ });
222
+ rendered.set(route, result);
223
+ report.routes.push(route);
224
+ manifestEntries.push({
225
+ route,
226
+ scripts: [...result.collector.scripts],
227
+ styles: [...result.collector.styles],
228
+ });
229
+ for (const message of result.pageErrors) {
230
+ report.pageErrors.push({ route, message });
231
+ log.warn(` page error on ${route}: ${message}`);
232
+ }
233
+ if (config.crawl && withinDepth(depth + 1, config.maxDepth)) {
234
+ const discovered = filterRoutes(result.links, {
235
+ include: config.includeProvided ? config.include : [],
236
+ exclude: config.exclude,
237
+ }).filter((candidate) => !scheduled.has(candidate));
238
+ pool.push(
239
+ accept(discovered).map((candidate) => ({ route: candidate, depth: depth + 1 })),
240
+ );
241
+ }
242
+ } catch (error) {
243
+ report.errors.push({ route, message: error.message });
244
+ log.error(` failed ${route}: ${error.message}`);
245
+ }
246
+ },
247
+ });
248
+
249
+ const roots = accept(crawlRoots(config));
250
+ pool.push(roots.map((route) => ({ route, depth: 0 })));
251
+ const { errors } = await pool.run();
252
+ for (const { item, error } of errors) {
253
+ report.errors.push({ route: item.route, message: error.message });
254
+ log.error(` pool error on ${item.route}: ${error.message}`);
255
+ }
256
+ return rendered;
257
+ }
258
+
259
+ /**
260
+ * Writes every rendered route and returns the routes that can be verified. A write
261
+ * failure is recorded against its route instead of aborting the remaining writes.
262
+ */
263
+ async function writeAllRoutes({ rendered, outputDir, config, log, report, state }) {
264
+ const sorted = [...report.routes].sort();
265
+ const conflicted = reportOutputCollisions({ routes: sorted, config, log, report });
266
+
267
+ await writeStateScripts({ rendered, outputDir, config, log, report });
268
+
269
+ const verifiable = [];
270
+ for (const route of sorted) {
271
+ if (conflicted.has(route)) continue;
272
+ try {
273
+ const file = await writeRouteOutput({
274
+ route,
275
+ result: rendered.get(route),
276
+ outputDir,
277
+ config,
278
+ log,
279
+ state,
280
+ report,
281
+ });
282
+ report.files.push(file);
283
+ verifiable.push(route);
284
+ } catch (error) {
285
+ report.errors.push({ route, message: `write failed: ${error.message}` });
286
+ log.error(` write failed for ${route}: ${error.message}`);
287
+ }
288
+ }
289
+ return verifiable;
290
+ }
291
+
292
+ /**
293
+ * Writes the captured-state scripts routes reference when externalScripts is on. The payload
294
+ * is content-addressed, so routes that inject the same state share one file and a rerun
295
+ * leaves it untouched.
296
+ */
297
+ async function writeStateScripts({ rendered, outputDir, config, log, report }) {
298
+ const seen = new Set();
299
+ for (const [route, result] of rendered) {
300
+ const stateScript = result?.stateScript;
301
+ if (!stateScript || seen.has(stateScript.file)) continue;
302
+ seen.add(stateScript.file);
303
+ if (config.dryRun) {
304
+ report.files.push({
305
+ route,
306
+ file: stateScript.file,
307
+ status: 'dry-run',
308
+ bytes: Buffer.byteLength(stateScript.contents),
309
+ });
310
+ continue;
311
+ }
312
+ try {
313
+ const written = await writeFileIfChanged({
314
+ dir: outputDir,
315
+ file: stateScript.file,
316
+ contents: stateScript.contents,
317
+ });
318
+ report.files.push({ route, ...written });
319
+ log.debug(` wrote ${stateScript.file}: ${written.status}`);
320
+ } catch (error) {
321
+ report.errors.push({ route, message: `write failed: ${error.message}` });
322
+ log.error(` write failed for ${stateScript.file}: ${error.message}`);
323
+ }
324
+ }
325
+ }
326
+
327
+ /**
328
+ * Records an error for every route involved in an output file collision and returns
329
+ * the routes to skip, so an ambiguous write can never overwrite another route.
330
+ */
331
+ function reportOutputCollisions({ routes, config, log, report }) {
332
+ const conflicted = new Set();
333
+ for (const [file, colliding] of findOutputCollisions(routes, config)) {
334
+ for (const route of colliding) {
335
+ conflicted.add(route);
336
+ const others = colliding.filter((other) => other !== route).join(', ');
337
+ report.errors.push({
338
+ route,
339
+ message: `output file collision: ${file} is also written by ${others}`,
340
+ });
341
+ }
342
+ log.error(` output file collision on ${file}: ${colliding.join(', ')}`);
343
+ }
344
+ return conflicted;
345
+ }
346
+
347
+ /**
348
+ * Applies the configured post-processing to one rendered route and writes it: DOM
349
+ * cleanups and link hints, critical CSS extraction, then optional minification.
350
+ */
351
+ async function writeRouteOutput({ route, result, outputDir, config, log, state, report }) {
352
+ if (result.screenshot) {
353
+ const file = routeToScreenshotFile(route, config);
354
+ const stats = await fs.stat(path.join(outputDir, file));
355
+ return { route, file, status: 'written', bytes: stats.size };
356
+ }
357
+
358
+ const { html, stats } = normaliseHtml(result.html, {
359
+ removeStyleTags: config.removeStyleTags,
360
+ removeScriptTags: config.removeScriptTags,
361
+ asyncScriptTags: config.asyncScriptTags,
362
+ removeBlobs: config.removeBlobs,
363
+ preconnectOrigins: config.preconnectThirdParty
364
+ ? [...result.collector.thirdPartyOrigins].sort()
365
+ : [],
366
+ preloadImages: config.preloadImages
367
+ ? [...result.collector.images].sort().map((image) => toPublicPath(image, config.base))
368
+ : [],
369
+ });
370
+ if (
371
+ stats.removedElements > 0 ||
372
+ stats.dedupedStyles > 0 ||
373
+ stats.hints > 0 ||
374
+ stats.textSeparators > 0
375
+ ) {
376
+ log.debug(
377
+ ` cleaned ${route}: ${stats.removedElements} element(s) removed, ${stats.dedupedStyles} duplicate style(s), ${stats.hints} hint(s), ${stats.textSeparators} text separator(s)`,
378
+ );
379
+ }
380
+ if ((result.inlineCssSkipped ?? []).length > 0) {
381
+ log.debug(` inline css: left ${result.inlineCssSkipped.length} stylesheet(s) as links`);
382
+ }
383
+
384
+ let output = html;
385
+ if (config.inlineCss === 'critical') {
386
+ output = await applyCriticalCss({ output, outputDir, config, log, state });
387
+ }
388
+ if (config.minifyHtml) {
389
+ output = await minifyHtml(output, config.minifyHtml, config.minifyCss);
390
+ }
391
+ warnOnBuildOriginMetadata({ origins: stats.metadataOrigins, route, log, state });
392
+ if (stats.titleElements > 1) report.duplicateTitles.push(route);
393
+ return writeRouteHtml({ dir: outputDir, route, html: output, config });
394
+ }
395
+
396
+ /**
397
+ * Inlines critical CSS, or explains once why it cannot: the strategy reads stylesheets from
398
+ * the output directory, which a command server does not have to serve.
399
+ */
400
+ async function applyCriticalCss({ output, outputDir, config, log, state }) {
401
+ if (!config.serveCmd) {
402
+ return inlineCriticalCss({
403
+ html: output,
404
+ outputDir,
405
+ base: config.base,
406
+ preload: config.criticalCssPreload,
407
+ });
408
+ }
409
+ if (!state.warnedCriticalCssSkipped) {
410
+ state.warnedCriticalCssSkipped = true;
411
+ log.warn(
412
+ "inlineCss: 'critical' is skipped with serveCmd: stylesheets are read from the output directory, which the command server does not have to serve",
413
+ );
414
+ }
415
+ return output;
416
+ }
417
+
418
+ /**
419
+ * Warns once when metadata contains the build server's origin, which means the page was
420
+ * composed without a public siteUrl and og:url, og:image or canonical would ship wrong.
421
+ */
422
+ function warnOnBuildOriginMetadata({ origins, route, log, state }) {
423
+ if (state.warnedBuildOriginMetadata || origins.length === 0) return;
424
+ state.warnedBuildOriginMetadata = true;
425
+ log.warn(
426
+ `metadata on ${route} points at the build server (${origins.join(', ')}); set metadata.siteUrl so og:url, og:image and canonical match the deployed site`,
427
+ );
428
+ }
429
+
430
+ // Notes routes whose output carries more than one title, which is what React 19 native
431
+ // metadata and the head layer together produce.
432
+ function logDuplicateTitles(report, log) {
433
+ if (report.duplicateTitles.length === 0) return;
434
+ const sample = report.duplicateTitles.slice(0, 5).join(', ');
435
+ log.warn(
436
+ `${report.duplicateTitles.length} route(s) contain more than one <title> element: ${sample}${report.duplicateTitles.length > 5 ? ', …' : ''}. React 19 native metadata and the head layer can each write one, so use one system.`,
437
+ );
438
+ }
439
+
440
+ /**
441
+ * Writes the preload manifest: per route, the scripts and stylesheets it loaded,
442
+ * as Link header values a host can serve for Early Hints or HTTP headers.
443
+ */
444
+ async function writePreloadManifest({ manifestEntries, outputDir, config, log }) {
445
+ const manifest = buildPreloadManifest(manifestEntries, {
446
+ ignoreForPreload: config.ignoreForPreload,
447
+ base: config.base,
448
+ });
449
+ const file = 'preload-manifest.json';
450
+ await fs.writeFile(path.join(outputDir, file), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
451
+ log.info(`Wrote preload manifest covering ${manifest.length} route(s)`);
452
+ return { file, routes: manifest.length };
453
+ }
454
+
455
+ // Notes when a not-found route is configured but was never rendered, so no 404.html
456
+ // was written and a static host would serve its own default page instead.
457
+ function logNotFoundNotice(report, config, log) {
458
+ if (!config.notFoundRoute || config.dryRun || config.saveAs !== 'html') return;
459
+ const notFoundRoute = normaliseRoute(config.notFoundRoute);
460
+ if (report.routes.includes(notFoundRoute)) return;
461
+ log.warn(
462
+ `no 404.html was written: the notFoundRoute (${notFoundRoute}) was not prerendered. Add it to include, or point notFoundRoute at a route you do prerender.`,
463
+ );
464
+ }
465
+
466
+ // Prints the write outcome counts, including a dedicated dry-run wording.
467
+ function logFileSummary(report, log) {
468
+ const written = report.files.filter((file) => file.status === 'written').length;
469
+ const unchanged = report.files.filter((file) => file.status === 'unchanged').length;
470
+ const dryRun = report.files.filter((file) => file.status === 'dry-run').length;
471
+ const failed = report.errors.length > 0 ? `, ${report.errors.length} failed route(s)` : '';
472
+
473
+ if (dryRun > 0 && written === 0 && unchanged === 0) {
474
+ log.info(`Dry run: rendered ${dryRun} file(s), nothing written${failed}`);
475
+ return;
476
+ }
477
+ const parts = [`Wrote ${written} file(s)`];
478
+ if (unchanged > 0) parts.push(`${unchanged} unchanged`);
479
+ log.info(parts.join(', ') + failed);
480
+ }
481
+
482
+ // Reports verification results per route, warns when routes discarded their prerendered
483
+ // markup, surfaces errors the client logged while booting, and notes when hydration failures
484
+ // were reported but not enforced.
485
+ function logVerification(verification, config, log) {
486
+ if (verification.ok) {
487
+ log.success(`Hydration verified for ${verification.routes.length} route(s)`);
488
+ } else {
489
+ const failed = verification.routes.filter((route) => !route.ok);
490
+ for (const entry of failed) {
491
+ log.warn(`Hydration failed on ${entry.route}:`);
492
+ for (const message of entry.hydrationErrors) log.warn(` ${message.split('\n')[0]}`);
493
+ }
494
+ for (const error of verification.errors) {
495
+ log.warn(`Verification could not run on ${error.route}: ${error.message}`);
496
+ }
497
+ if (failed.length > 0 && !config.failOnHydrationError) {
498
+ log.warn(
499
+ `Hydration failures are reported, not enforced, so the prerendered HTML still shipped. Set failOnHydrationError to fail the build instead.`,
500
+ );
501
+ }
502
+ }
503
+
504
+ for (const entry of verification.routes) {
505
+ for (const message of entry.pageErrors) {
506
+ log.warn(` page error on ${entry.route} while verifying: ${message.split('\n')[0]}`);
507
+ }
508
+ for (const message of entry.consoleErrors.slice(0, 3)) {
509
+ log.debug(` console error on ${entry.route}: ${message.split('\n')[0]}`);
510
+ }
511
+ }
512
+
513
+ const reRendered = verification.routes.filter((entry) => entry.mode === 're-rendered');
514
+ if (reRendered.length > 0) {
515
+ const sample = reRendered
516
+ .slice(0, 5)
517
+ .map((entry) => entry.route)
518
+ .join(', ');
519
+ log.warn(
520
+ `${reRendered.length} route(s) re-rendered instead of hydrating, so the prerendered markup was discarded on boot: ${sample}${reRendered.length > 5 ? ', …' : ''}`,
521
+ );
522
+ }
523
+ }