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/CHANGELOG.md +140 -0
- package/README.md +96 -19
- package/package.json +11 -6
- package/src/cli.js +119 -12
- package/src/core/artifacts.js +110 -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 +143 -3
- package/src/core/css-url.js +34 -0
- package/src/core/destination.js +28 -3
- package/src/core/init.js +44 -1
- package/src/core/inline-css.js +34 -13
- package/src/core/metadata-origin.js +11 -2
- package/src/core/normalize.js +71 -1
- package/src/core/output.js +32 -9
- package/src/core/page-content.js +13 -0
- package/src/core/preload-manifest.js +61 -7
- package/src/core/prune.js +65 -0
- package/src/core/render.js +64 -10
- package/src/core/resources.js +1 -0
- package/src/core/routes.js +9 -0
- package/src/core/run.js +191 -241
- package/src/core/server.js +30 -4
- package/src/core/sitemap.js +94 -0
- package/src/core/url-policy.js +32 -0
- package/src/core/verify.js +18 -4
- package/src/core/write.js +278 -0
- package/src/head/index.js +1 -1
- package/src/head/tags.js +19 -26
package/src/core/run.js
CHANGED
|
@@ -1,30 +1,31 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { writePreloadManifest, writeSitemap } from './artifacts.js';
|
|
3
4
|
import { launchBrowser } from './browser.js';
|
|
4
5
|
import { resolveConfig } from './config.js';
|
|
5
6
|
import { prepareDestination } from './destination.js';
|
|
6
|
-
import { inlineCriticalCss } from './inline-css.js';
|
|
7
7
|
import { createLogger } from './log.js';
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
findOutputCollisions,
|
|
12
|
-
routeToScreenshotFile,
|
|
13
|
-
writeFileIfChanged,
|
|
14
|
-
writeRouteHtml,
|
|
15
|
-
} from './output.js';
|
|
16
|
-
import { buildPreloadManifest } from './preload-manifest.js';
|
|
8
|
+
import { routeToFile } from './output.js';
|
|
9
|
+
import { pruneStaleFiles, readPruneManifest, writePruneManifest } from './prune.js';
|
|
17
10
|
import { renderRoute } from './render.js';
|
|
18
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
crawlRoots,
|
|
13
|
+
filterRoutes,
|
|
14
|
+
isIncludedRoute,
|
|
15
|
+
normaliseRoute,
|
|
16
|
+
withinDepth,
|
|
17
|
+
} from './routes.js';
|
|
19
18
|
import { createPool } from './scheduler.js';
|
|
20
19
|
import { startCommandServer, startStaticServer } from './server.js';
|
|
21
20
|
import { verifyRoutes } from './verify.js';
|
|
21
|
+
import { writeAllRoutes } from './write.js';
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
24
|
* Entry point of the prerenderer: serves the built output, crawls and renders
|
|
25
25
|
* routes in a real browser, post-processes and writes one file per route, then
|
|
26
|
-
* verifies hydration.
|
|
27
|
-
*
|
|
26
|
+
* verifies hydration. Route, page, verification and artifact failures are returned
|
|
27
|
+
* in the report; setup errors such as invalid config, a missing source directory or
|
|
28
|
+
* a browser that cannot start are thrown so the caller can stop immediately.
|
|
28
29
|
*/
|
|
29
30
|
export async function prerender(userOptions = {}, { log: injectedLog } = {}) {
|
|
30
31
|
const config = resolveConfig(userOptions);
|
|
@@ -35,6 +36,8 @@ export async function prerender(userOptions = {}, { log: injectedLog } = {}) {
|
|
|
35
36
|
|
|
36
37
|
await assertSourceDir(sourceDir, config);
|
|
37
38
|
const outputDir = await resolveOutputDir({ sourceDir, config, log });
|
|
39
|
+
const previousManifest =
|
|
40
|
+
config.pruneStaleRoutes && !config.dryRun ? await readPruneManifest(outputDir) : null;
|
|
38
41
|
|
|
39
42
|
if (config.saveAs !== 'html' && config.verify) {
|
|
40
43
|
log.warn(`saveAs: '${config.saveAs}' writes images, so hydration verification is skipped`);
|
|
@@ -54,9 +57,15 @@ export async function prerender(userOptions = {}, { log: injectedLog } = {}) {
|
|
|
54
57
|
files: [],
|
|
55
58
|
errors: [],
|
|
56
59
|
pageErrors: [],
|
|
60
|
+
consoleErrors: [],
|
|
61
|
+
failedRequests: [],
|
|
62
|
+
emptyPages: [],
|
|
57
63
|
duplicateTitles: [],
|
|
64
|
+
duplicatePages: [],
|
|
58
65
|
verification: null,
|
|
59
66
|
preloadManifest: null,
|
|
67
|
+
sitemap: null,
|
|
68
|
+
pruned: [],
|
|
60
69
|
truncated: null,
|
|
61
70
|
ok: false,
|
|
62
71
|
durationMs: 0,
|
|
@@ -76,9 +85,11 @@ export async function prerender(userOptions = {}, { log: injectedLog } = {}) {
|
|
|
76
85
|
config,
|
|
77
86
|
log,
|
|
78
87
|
report,
|
|
79
|
-
manifestEntries,
|
|
80
|
-
outputDir,
|
|
81
88
|
});
|
|
89
|
+
if (report.routes.length === 0) {
|
|
90
|
+
report.errors.push({ route: null, message: 'no routes were rendered' });
|
|
91
|
+
log.error('No routes were rendered; check include, exclude and sourceDir');
|
|
92
|
+
}
|
|
82
93
|
const pageErrors = report.pageErrors.length;
|
|
83
94
|
log.info(
|
|
84
95
|
`Rendered ${report.routes.length} route(s)${pageErrors > 0 ? `, ${pageErrors} page error(s)` : ''}`,
|
|
@@ -89,24 +100,50 @@ export async function prerender(userOptions = {}, { log: injectedLog } = {}) {
|
|
|
89
100
|
);
|
|
90
101
|
}
|
|
91
102
|
|
|
92
|
-
const verifiable = await writeAllRoutes({
|
|
103
|
+
const verifiable = await writeAllRoutes({
|
|
104
|
+
rendered,
|
|
105
|
+
outputDir,
|
|
106
|
+
config,
|
|
107
|
+
log,
|
|
108
|
+
report,
|
|
109
|
+
state,
|
|
110
|
+
manifestEntries,
|
|
111
|
+
});
|
|
93
112
|
logFileSummary(report, log);
|
|
94
113
|
logNotFoundNotice(report, config, log);
|
|
95
114
|
logDuplicateTitles(report, log);
|
|
96
115
|
|
|
116
|
+
if (config.sitemap) {
|
|
117
|
+
try {
|
|
118
|
+
await writeSitemap({ outputDir, config, log, report });
|
|
119
|
+
} catch (error) {
|
|
120
|
+
report.errors.push({ route: null, message: `sitemap write failed: ${error.message}` });
|
|
121
|
+
log.error(`sitemap write failed: ${error.message}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
97
125
|
if (report.truncated) {
|
|
98
126
|
log.error(
|
|
99
127
|
`maxRoutes (${report.truncated.limit}) reached after ${report.truncated.routes} route(s); output is incomplete`,
|
|
100
128
|
);
|
|
101
129
|
}
|
|
102
130
|
|
|
103
|
-
if (config.preloadManifest
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
131
|
+
if (config.preloadManifest) {
|
|
132
|
+
try {
|
|
133
|
+
report.preloadManifest = await writePreloadManifest({
|
|
134
|
+
manifestEntries,
|
|
135
|
+
outputDir,
|
|
136
|
+
config,
|
|
137
|
+
log,
|
|
138
|
+
report,
|
|
139
|
+
});
|
|
140
|
+
} catch (error) {
|
|
141
|
+
report.errors.push({
|
|
142
|
+
route: null,
|
|
143
|
+
message: `preload manifest write failed: ${error.message}`,
|
|
144
|
+
});
|
|
145
|
+
log.error(`preload manifest write failed: ${error.message}`);
|
|
146
|
+
}
|
|
110
147
|
}
|
|
111
148
|
|
|
112
149
|
const canVerify = config.verify && !config.dryRun && config.saveAs === 'html';
|
|
@@ -115,15 +152,56 @@ export async function prerender(userOptions = {}, { log: injectedLog } = {}) {
|
|
|
115
152
|
'hydration verification is skipped with serveCmd: the command server decides which document it serves, so the written files cannot be checked',
|
|
116
153
|
);
|
|
117
154
|
}
|
|
118
|
-
|
|
155
|
+
// With verifyChangedOnly, a route whose HTML file was left untouched is not reloaded: the HTML
|
|
156
|
+
// and the bundle are the same as the last time it was verified.
|
|
157
|
+
const toVerify = config.verifyChangedOnly
|
|
158
|
+
? verifiable.filter((route) => {
|
|
159
|
+
const file = routeToFile(route, config);
|
|
160
|
+
const entry = report.files.find(
|
|
161
|
+
(candidate) => candidate.route === route && candidate.file === file,
|
|
162
|
+
);
|
|
163
|
+
return entry?.status !== 'unchanged';
|
|
164
|
+
})
|
|
165
|
+
: verifiable;
|
|
166
|
+
const skipped = verifiable.length - toVerify.length;
|
|
167
|
+
if (canVerify && !config.serveCmd && skipped > 0) {
|
|
168
|
+
log.info(`Verification skipped for ${skipped} unchanged route(s), as verifyChangedOnly asks`);
|
|
169
|
+
}
|
|
170
|
+
if (canVerify && !config.serveCmd && toVerify.length > 0) {
|
|
119
171
|
report.verification = await verifyRoutes({
|
|
120
172
|
browser,
|
|
121
173
|
origin: server.origin,
|
|
122
|
-
routes:
|
|
174
|
+
routes: toVerify.sort(),
|
|
123
175
|
config,
|
|
124
176
|
});
|
|
177
|
+
report.verification.skipped = skipped;
|
|
125
178
|
logVerification(report.verification, config, log);
|
|
126
179
|
}
|
|
180
|
+
|
|
181
|
+
if (
|
|
182
|
+
config.pruneStaleRoutes &&
|
|
183
|
+
!config.dryRun &&
|
|
184
|
+
report.errors.length === 0 &&
|
|
185
|
+
report.truncated === null
|
|
186
|
+
) {
|
|
187
|
+
try {
|
|
188
|
+
report.pruned = await pruneStaleFiles({
|
|
189
|
+
dir: outputDir,
|
|
190
|
+
previous: previousManifest,
|
|
191
|
+
current: report.files.map((file) => file.file).filter(Boolean),
|
|
192
|
+
log,
|
|
193
|
+
});
|
|
194
|
+
await writePruneManifest({
|
|
195
|
+
dir: outputDir,
|
|
196
|
+
files: report.files.map((file) => file.file).filter(Boolean),
|
|
197
|
+
});
|
|
198
|
+
if (report.pruned.length > 0) {
|
|
199
|
+
log.info(`Pruned ${report.pruned.length} stale file(s)`);
|
|
200
|
+
}
|
|
201
|
+
} catch (error) {
|
|
202
|
+
log.warn(`could not prune stale output: ${error.message}`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
127
205
|
} finally {
|
|
128
206
|
if (browser) await browser.close();
|
|
129
207
|
const stopped = await server.close();
|
|
@@ -134,10 +212,19 @@ export async function prerender(userOptions = {}, { log: injectedLog } = {}) {
|
|
|
134
212
|
|
|
135
213
|
report.durationMs = Date.now() - startedAt;
|
|
136
214
|
const reRendered = report.verification?.modes['re-rendered'] ?? 0;
|
|
215
|
+
const undetected = report.verification
|
|
216
|
+
? (report.verification.modes.undetected ?? 0) + (report.verification.modes.unknown ?? 0)
|
|
217
|
+
: 0;
|
|
137
218
|
report.ok =
|
|
138
219
|
!(config.failOnError && (report.errors.length > 0 || report.truncated !== null)) &&
|
|
139
220
|
!(config.failOnPageError && report.pageErrors.length > 0) &&
|
|
221
|
+
!(config.failOnEmptyPage && report.emptyPages.length > 0) &&
|
|
222
|
+
!(config.failOnConsoleError && report.consoleErrors.length > 0) &&
|
|
223
|
+
!(config.failOnFailedRequest && report.failedRequests.length > 0) &&
|
|
224
|
+
!(config.failOnDuplicateContent && report.duplicatePages.length > 0) &&
|
|
140
225
|
!(config.failOnHydrationError && report.verification && !report.verification.ok) &&
|
|
226
|
+
!(config.failOnVerificationError && report.verification?.errors.length > 0) &&
|
|
227
|
+
!(config.failOnUndetected && undetected > 0) &&
|
|
141
228
|
!(config.failOnRerender && reRendered > 0);
|
|
142
229
|
return report;
|
|
143
230
|
}
|
|
@@ -152,6 +239,19 @@ async function assertSourceDir(sourceDir, config) {
|
|
|
152
239
|
if (!stats?.isDirectory()) {
|
|
153
240
|
throw new Error(`sourceDir does not exist or is not a directory: ${sourceDir}`);
|
|
154
241
|
}
|
|
242
|
+
const hasIndex = await Promise.all(
|
|
243
|
+
['index.html', 'index.htm'].map((name) =>
|
|
244
|
+
fs
|
|
245
|
+
.stat(path.join(sourceDir, name))
|
|
246
|
+
.then((entry) => entry.isFile())
|
|
247
|
+
.catch(() => false),
|
|
248
|
+
),
|
|
249
|
+
);
|
|
250
|
+
if (!hasIndex.some(Boolean)) {
|
|
251
|
+
throw new Error(
|
|
252
|
+
`sourceDir has no index.html: ${sourceDir}. Point sourceDir at the built app, not its assets folder.`,
|
|
253
|
+
);
|
|
254
|
+
}
|
|
155
255
|
}
|
|
156
256
|
|
|
157
257
|
/**
|
|
@@ -165,6 +265,7 @@ async function resolveOutputDir({ sourceDir, config, log }) {
|
|
|
165
265
|
sourceDir,
|
|
166
266
|
destination: config.destination ? path.resolve(config.destination) : null,
|
|
167
267
|
log,
|
|
268
|
+
cleanDestination: config.cleanDestination,
|
|
168
269
|
});
|
|
169
270
|
}
|
|
170
271
|
|
|
@@ -172,22 +273,16 @@ async function resolveOutputDir({ sourceDir, config, log }) {
|
|
|
172
273
|
* Runs the crawl and render pool. Each rendered page contributes its output, any new
|
|
173
274
|
* same-origin routes, and the resources recorded for the link-hint features.
|
|
174
275
|
*/
|
|
175
|
-
async function renderAllRoutes({
|
|
176
|
-
browser,
|
|
177
|
-
server,
|
|
178
|
-
config,
|
|
179
|
-
log,
|
|
180
|
-
report,
|
|
181
|
-
manifestEntries,
|
|
182
|
-
outputDir,
|
|
183
|
-
}) {
|
|
276
|
+
async function renderAllRoutes({ browser, server, config, log, report }) {
|
|
184
277
|
const visited = new Set();
|
|
185
278
|
const scheduled = new Set();
|
|
186
279
|
const rendered = new Map();
|
|
187
280
|
const cap = config.maxRoutes ?? Number.POSITIVE_INFINITY;
|
|
188
281
|
|
|
189
282
|
// Queues routes up to maxRoutes, flagging the report the first time the cap bites.
|
|
190
|
-
|
|
283
|
+
// A seed used only to reach wildcard routes is given output: false and is rendered
|
|
284
|
+
// for its links without being written.
|
|
285
|
+
const accept = (routes, outputFor = () => true) => {
|
|
191
286
|
const accepted = [];
|
|
192
287
|
for (const route of routes) {
|
|
193
288
|
if (scheduled.has(route)) continue;
|
|
@@ -196,48 +291,69 @@ async function renderAllRoutes({
|
|
|
196
291
|
break;
|
|
197
292
|
}
|
|
198
293
|
scheduled.add(route);
|
|
199
|
-
accepted.push(route);
|
|
294
|
+
accepted.push({ route, output: outputFor(route) });
|
|
200
295
|
}
|
|
201
296
|
return accepted;
|
|
202
297
|
};
|
|
203
298
|
|
|
204
299
|
const pool = createPool({
|
|
205
300
|
concurrency: config.concurrency,
|
|
206
|
-
handler: async ({ route, depth }) => {
|
|
301
|
+
handler: async ({ route, depth, output }) => {
|
|
207
302
|
if (visited.has(route)) return;
|
|
208
303
|
visited.add(route);
|
|
209
|
-
log.debug(`rendering ${route}`);
|
|
304
|
+
log.debug(`rendering ${route}${output ? '' : ' as a crawl seed'}`);
|
|
210
305
|
try {
|
|
211
|
-
const screenshotPath =
|
|
212
|
-
config.saveAs !== 'html' && !config.dryRun
|
|
213
|
-
? path.join(outputDir, routeToScreenshotFile(route, config))
|
|
214
|
-
: null;
|
|
215
306
|
const result = await renderRoute({
|
|
216
307
|
browser,
|
|
217
308
|
origin: server.origin,
|
|
218
309
|
route,
|
|
219
310
|
config,
|
|
220
|
-
|
|
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],
|
|
311
|
+
screenshot: config.saveAs !== 'html',
|
|
228
312
|
});
|
|
313
|
+
if (output && result.empty) {
|
|
314
|
+
report.emptyPages.push(route);
|
|
315
|
+
if (config.failOnEmptyPage) {
|
|
316
|
+
report.errors.push({ route, message: 'rendered an empty page' });
|
|
317
|
+
log.error(` empty page ${route}: no app content was found`);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
log.warn(` empty page ${route}: no app content was found`);
|
|
321
|
+
}
|
|
322
|
+
if (output) {
|
|
323
|
+
rendered.set(route, result);
|
|
324
|
+
report.routes.push(route);
|
|
325
|
+
}
|
|
229
326
|
for (const message of result.pageErrors) {
|
|
230
327
|
report.pageErrors.push({ route, message });
|
|
231
328
|
log.warn(` page error on ${route}: ${message}`);
|
|
232
329
|
}
|
|
330
|
+
for (const message of result.consoleErrors) {
|
|
331
|
+
report.consoleErrors.push({ route, message });
|
|
332
|
+
}
|
|
333
|
+
if (result.consoleErrors.length > 0) {
|
|
334
|
+
log.debug(` console error on ${route}: ${result.consoleErrors[0]}`);
|
|
335
|
+
if (config.failOnConsoleError) {
|
|
336
|
+
report.errors.push({ route, message: `console error: ${result.consoleErrors[0]}` });
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
for (const failure of result.failedRequests) {
|
|
340
|
+
report.failedRequests.push({ route, ...failure });
|
|
341
|
+
const detail = failure.status ? `HTTP ${failure.status}` : failure.error;
|
|
342
|
+
log.warn(` failed request on ${route}: ${failure.url} (${detail})`);
|
|
343
|
+
}
|
|
344
|
+
if (result.failedRequests.length > 0 && config.failOnFailedRequest) {
|
|
345
|
+
const first = result.failedRequests[0];
|
|
346
|
+
report.errors.push({
|
|
347
|
+
route,
|
|
348
|
+
message: `failed request: ${first.url}${first.status ? ` (HTTP ${first.status})` : ''}`,
|
|
349
|
+
});
|
|
350
|
+
}
|
|
233
351
|
if (config.crawl && withinDepth(depth + 1, config.maxDepth)) {
|
|
234
352
|
const discovered = filterRoutes(result.links, {
|
|
235
353
|
include: config.includeProvided ? config.include : [],
|
|
236
354
|
exclude: config.exclude,
|
|
237
355
|
}).filter((candidate) => !scheduled.has(candidate));
|
|
238
|
-
pool.push(
|
|
239
|
-
accept(discovered).map((candidate) => ({ route: candidate, depth: depth + 1 })),
|
|
240
|
-
);
|
|
356
|
+
pool.push(accept(discovered).map((item) => ({ ...item, depth: depth + 1 })));
|
|
241
357
|
}
|
|
242
358
|
} catch (error) {
|
|
243
359
|
report.errors.push({ route, message: error.message });
|
|
@@ -246,8 +362,11 @@ async function renderAllRoutes({
|
|
|
246
362
|
},
|
|
247
363
|
});
|
|
248
364
|
|
|
249
|
-
const roots = accept(
|
|
250
|
-
|
|
365
|
+
const roots = accept(
|
|
366
|
+
crawlRoots(config),
|
|
367
|
+
(route) => !config.includeProvided || isIncludedRoute(route, config.include),
|
|
368
|
+
);
|
|
369
|
+
pool.push(roots.map((item) => ({ ...item, depth: 0 })));
|
|
251
370
|
const { errors } = await pool.run();
|
|
252
371
|
for (const { item, error } of errors) {
|
|
253
372
|
report.errors.push({ route: item.route, message: error.message });
|
|
@@ -256,177 +375,6 @@ async function renderAllRoutes({
|
|
|
256
375
|
return rendered;
|
|
257
376
|
}
|
|
258
377
|
|
|
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
378
|
// Notes routes whose output carries more than one title, which is what React 19 native
|
|
431
379
|
// metadata and the head layer together produce.
|
|
432
380
|
function logDuplicateTitles(report, log) {
|
|
@@ -437,21 +385,6 @@ function logDuplicateTitles(report, log) {
|
|
|
437
385
|
);
|
|
438
386
|
}
|
|
439
387
|
|
|
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
388
|
// Notes when a not-found route is configured but was never rendered, so no 404.html
|
|
456
389
|
// was written and a static host would serve its own default page instead.
|
|
457
390
|
function logNotFoundNotice(report, config, log) {
|
|
@@ -483,12 +416,24 @@ function logFileSummary(report, log) {
|
|
|
483
416
|
// markup, surfaces errors the client logged while booting, and notes when hydration failures
|
|
484
417
|
// were reported but not enforced.
|
|
485
418
|
function logVerification(verification, config, log) {
|
|
419
|
+
const modes = verification.modes ?? {};
|
|
420
|
+
log.info(
|
|
421
|
+
`Hydration modes: ${modes.hydrated ?? 0} hydrated, ${modes['re-rendered'] ?? 0} re-rendered, ${modes.undetected ?? 0} undetected, ${modes.unknown ?? 0} unknown`,
|
|
422
|
+
);
|
|
423
|
+
const notDetected = (modes.undetected ?? 0) + (modes.unknown ?? 0);
|
|
424
|
+
if (notDetected > 0) {
|
|
425
|
+
log.warn(
|
|
426
|
+
`${notDetected} route(s) could not be classified for hydration. Check that the app mounts into #root, #app or #__next${config.failOnUndetected ? '' : '; set failOnUndetected to fail the build'}.`,
|
|
427
|
+
);
|
|
428
|
+
}
|
|
486
429
|
if (verification.ok) {
|
|
487
430
|
log.success(`Hydration verified for ${verification.routes.length} route(s)`);
|
|
488
431
|
} else {
|
|
489
432
|
const failed = verification.routes.filter((route) => !route.ok);
|
|
490
433
|
for (const entry of failed) {
|
|
491
434
|
log.warn(`Hydration failed on ${entry.route}:`);
|
|
435
|
+
if (entry.status && entry.status >= 400) log.warn(` HTTP ${entry.status}`);
|
|
436
|
+
if (entry.empty) log.warn(' the page rendered no app content');
|
|
492
437
|
for (const message of entry.hydrationErrors) log.warn(` ${message.split('\n')[0]}`);
|
|
493
438
|
}
|
|
494
439
|
for (const error of verification.errors) {
|
|
@@ -499,6 +444,11 @@ function logVerification(verification, config, log) {
|
|
|
499
444
|
`Hydration failures are reported, not enforced, so the prerendered HTML still shipped. Set failOnHydrationError to fail the build instead.`,
|
|
500
445
|
);
|
|
501
446
|
}
|
|
447
|
+
if (verification.errors.length > 0 && !config.failOnVerificationError) {
|
|
448
|
+
log.warn(
|
|
449
|
+
'Verification could not run on some routes; the prerendered HTML still shipped. Set failOnVerificationError to fail the build instead.',
|
|
450
|
+
);
|
|
451
|
+
}
|
|
502
452
|
}
|
|
503
453
|
|
|
504
454
|
for (const entry of verification.routes) {
|
package/src/core/server.js
CHANGED
|
@@ -8,18 +8,32 @@ import sirv from 'sirv';
|
|
|
8
8
|
* an ephemeral loopback port so parallel runs never collide.
|
|
9
9
|
*/
|
|
10
10
|
export async function startStaticServer({ dir, base }) {
|
|
11
|
-
const handler = sirv(dir, { dev: true, etag: true
|
|
11
|
+
const handler = sirv(dir, { dev: true, etag: true });
|
|
12
|
+
const indexHandler = sirv(dir, { dev: true, etag: true });
|
|
12
13
|
const server = http.createServer((request, response) => {
|
|
13
14
|
if (base !== '/') {
|
|
14
15
|
const stripped = stripBase(request.url, base);
|
|
15
16
|
if (stripped === null) {
|
|
16
|
-
response
|
|
17
|
-
response.end('Not found');
|
|
17
|
+
notFound(response);
|
|
18
18
|
return;
|
|
19
19
|
}
|
|
20
20
|
request.url = stripped;
|
|
21
21
|
}
|
|
22
|
-
handler(request, response)
|
|
22
|
+
handler(request, response, () => {
|
|
23
|
+
// Navigation requests can be SPA routes that only exist in the router. Assets,
|
|
24
|
+
// fetches and websockets ask for something else and must see their real 404.
|
|
25
|
+
if (!acceptsHtml(request)) {
|
|
26
|
+
notFound(response);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const original = request.url;
|
|
30
|
+
request.url = '/index.html';
|
|
31
|
+
try {
|
|
32
|
+
indexHandler(request, response, () => notFound(response));
|
|
33
|
+
} finally {
|
|
34
|
+
request.url = original;
|
|
35
|
+
}
|
|
36
|
+
});
|
|
23
37
|
});
|
|
24
38
|
|
|
25
39
|
await new Promise((resolve, reject) => {
|
|
@@ -37,6 +51,18 @@ export async function startStaticServer({ dir, base }) {
|
|
|
37
51
|
};
|
|
38
52
|
}
|
|
39
53
|
|
|
54
|
+
// Reports whether a request is a document navigation rather than an asset or fetch.
|
|
55
|
+
function acceptsHtml(request) {
|
|
56
|
+
const accept = request.headers.accept ?? '';
|
|
57
|
+
return accept.includes('text/html') || accept.includes('application/xhtml+xml');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Answers a request no handler could serve.
|
|
61
|
+
function notFound(response) {
|
|
62
|
+
response.statusCode = 404;
|
|
63
|
+
response.end('Not found');
|
|
64
|
+
}
|
|
65
|
+
|
|
40
66
|
/**
|
|
41
67
|
* Removes the configured base prefix from a request URL, keeping any query string
|
|
42
68
|
* intact. Returns null when the request falls outside the base path.
|