snappy-prerender 2.6.0 → 2.8.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 +100 -0
- package/README.md +281 -101
- package/assets/snappy-logo.png +0 -0
- package/docs/limitations.md +94 -0
- package/docs/react-snap-migration.md +45 -0
- package/docs/report.md +135 -0
- package/package.json +4 -1
- package/src/cli-init.js +205 -0
- package/src/cli-options.js +258 -0
- package/src/cli.js +128 -309
- package/src/core/artifacts.js +2 -0
- package/src/core/audit/index.js +114 -0
- package/src/core/audit/links.js +74 -0
- package/src/core/audit/metadata.js +197 -0
- package/src/core/audit/sitemap.js +39 -0
- package/src/core/clock.js +60 -0
- package/src/core/config-validation.js +557 -0
- package/src/core/config-warnings.js +150 -0
- package/src/core/config.js +189 -136
- package/src/core/crawl.js +362 -0
- package/src/core/csp.js +138 -0
- package/src/core/destination.js +23 -7
- package/src/core/frameworks.js +94 -0
- package/src/core/hydration-diff.js +93 -0
- package/src/core/hydration-policy.js +38 -0
- package/src/core/init.js +143 -30
- package/src/core/inline-css.js +52 -9
- package/src/core/locale-context.js +63 -0
- package/src/core/locales.js +385 -0
- package/src/core/normalize.js +37 -6
- package/src/core/page-content.js +57 -7
- package/src/core/page-meta.js +122 -0
- package/src/core/preload-links.js +133 -0
- package/src/core/preload-manifest.js +6 -2
- package/src/core/render.js +126 -35
- package/src/core/report.js +116 -0
- package/src/core/resources.js +5 -1
- package/src/core/route-config.js +17 -0
- package/src/core/route-sources.js +220 -0
- package/src/core/run.js +156 -131
- package/src/core/seo-locale.js +234 -0
- package/src/core/settle.js +2 -4
- package/src/core/sitemap.js +90 -14
- package/src/core/text-separators.js +11 -7
- package/src/core/verify.js +164 -71
- package/src/core/write.js +314 -52
- package/src/head/index.js +104 -6
- package/src/head/manager.js +304 -48
- package/src/head/tags.js +349 -30
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import {
|
|
4
|
+
activeLocale,
|
|
5
|
+
isLocalePrefixedRoute,
|
|
6
|
+
localeByCode,
|
|
7
|
+
localeRoute,
|
|
8
|
+
stripLocalePrefix,
|
|
9
|
+
} from './locales.js';
|
|
10
|
+
import { renderRoute } from './render.js';
|
|
11
|
+
import { resolveRouteConfig } from './route-config.js';
|
|
12
|
+
import {
|
|
13
|
+
crawlRoots,
|
|
14
|
+
filterRoutes,
|
|
15
|
+
isIncludedRoute,
|
|
16
|
+
matchesPattern,
|
|
17
|
+
normaliseRoute,
|
|
18
|
+
withinDepth,
|
|
19
|
+
} from './routes.js';
|
|
20
|
+
import { createPool, mapLimit } from './scheduler.js';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Runs the crawl and render pool. Each rendered page contributes its output, any new
|
|
24
|
+
* same-origin routes, and the resources recorded for the link-hint features.
|
|
25
|
+
*/
|
|
26
|
+
export async function renderAllRoutes({
|
|
27
|
+
browser,
|
|
28
|
+
server,
|
|
29
|
+
config,
|
|
30
|
+
log,
|
|
31
|
+
report,
|
|
32
|
+
outputDir,
|
|
33
|
+
sourceSeeds = [],
|
|
34
|
+
caches = null,
|
|
35
|
+
localePlan = null,
|
|
36
|
+
}) {
|
|
37
|
+
const visited = new Set();
|
|
38
|
+
const scheduled = new Set();
|
|
39
|
+
const rendered = new Map();
|
|
40
|
+
const routeLocales = new Map();
|
|
41
|
+
const cap = config.maxRoutes ?? Number.POSITIVE_INFINITY;
|
|
42
|
+
let nextOrder = 0;
|
|
43
|
+
const defaultLocale = localePlan ? localeByCode(localePlan, localePlan.default) : null;
|
|
44
|
+
const notFoundRoute = config.notFoundRoute ? normaliseRoute(config.notFoundRoute) : null;
|
|
45
|
+
|
|
46
|
+
// Expands a base route into every locale variant it publishes. The not-found route is
|
|
47
|
+
// deliberately not localized: a host needs one 404 document.
|
|
48
|
+
const variantsFor = (route) => {
|
|
49
|
+
if (!localePlan) return [{ route, locale: null, baseRoute: route }];
|
|
50
|
+
if (localePlan.mode === 'single') {
|
|
51
|
+
return [{ route, locale: activeLocale(localePlan), baseRoute: route }];
|
|
52
|
+
}
|
|
53
|
+
const baseRoute = defaultLocale ? stripLocalePrefix(route, defaultLocale) : route;
|
|
54
|
+
const isNotFound = notFoundRoute !== null && normaliseRoute(baseRoute) === notFoundRoute;
|
|
55
|
+
const variants = [];
|
|
56
|
+
for (const locale of localePlan.list) {
|
|
57
|
+
if (isNotFound && locale !== defaultLocale) continue;
|
|
58
|
+
const localized = isNotFound
|
|
59
|
+
? normaliseRoute(config.notFoundRoute)
|
|
60
|
+
: localeRoute(locale, baseRoute);
|
|
61
|
+
variants.push({ route: localized, locale, baseRoute });
|
|
62
|
+
}
|
|
63
|
+
return variants;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// Queues routes up to maxRoutes, recording exactly which candidates did not fit.
|
|
67
|
+
// A seed used only to reach wildcard routes is given output: false and is rendered
|
|
68
|
+
// for its links without being written.
|
|
69
|
+
const accept = (routes, outputFor = () => true) => {
|
|
70
|
+
const accepted = [];
|
|
71
|
+
const dropped = [];
|
|
72
|
+
for (const route of routes) {
|
|
73
|
+
if (isLocalePrefixedRoute(route, localePlan)) continue;
|
|
74
|
+
for (const variant of variantsFor(route)) {
|
|
75
|
+
if (scheduled.has(variant.route)) continue;
|
|
76
|
+
if (scheduled.size >= cap) {
|
|
77
|
+
dropped.push(variant.route);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
scheduled.add(variant.route);
|
|
81
|
+
routeLocales.set(variant.route, variant);
|
|
82
|
+
accepted.push({
|
|
83
|
+
route: variant.route,
|
|
84
|
+
output: outputFor(variant.baseRoute),
|
|
85
|
+
order: nextOrder,
|
|
86
|
+
locale: variant.locale,
|
|
87
|
+
baseRoute: variant.baseRoute,
|
|
88
|
+
});
|
|
89
|
+
nextOrder += 1;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (dropped.length > 0) {
|
|
93
|
+
const previous = report.truncated?.droppedRoutes ?? [];
|
|
94
|
+
report.truncated = {
|
|
95
|
+
limit: config.maxRoutes,
|
|
96
|
+
routes: scheduled.size,
|
|
97
|
+
droppedRoutes: [...new Set([...previous, ...dropped])].sort(),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
return accepted;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const renderOne = async ({
|
|
104
|
+
route,
|
|
105
|
+
output,
|
|
106
|
+
depth = 0,
|
|
107
|
+
order = null,
|
|
108
|
+
locale = null,
|
|
109
|
+
baseRoute = route,
|
|
110
|
+
}) => {
|
|
111
|
+
if (visited.has(route)) return null;
|
|
112
|
+
visited.add(route);
|
|
113
|
+
log.debug(`rendering ${route}${output ? '' : ' as a crawl seed'}`);
|
|
114
|
+
const routeConfig = resolveRouteConfig(config, route);
|
|
115
|
+
const result = await renderWithRetries({
|
|
116
|
+
browser,
|
|
117
|
+
server,
|
|
118
|
+
route,
|
|
119
|
+
routeConfig,
|
|
120
|
+
config,
|
|
121
|
+
log,
|
|
122
|
+
report,
|
|
123
|
+
outputDir,
|
|
124
|
+
screenshot: config.saveAs !== 'html',
|
|
125
|
+
caches,
|
|
126
|
+
locale,
|
|
127
|
+
});
|
|
128
|
+
if (!result) return null;
|
|
129
|
+
result.depth = depth;
|
|
130
|
+
result.order = order;
|
|
131
|
+
try {
|
|
132
|
+
const notFoundRoute = config.notFoundRoute ? normaliseRoute(config.notFoundRoute) : null;
|
|
133
|
+
const isNotFoundRoute = notFoundRoute !== null && normaliseRoute(baseRoute) === notFoundRoute;
|
|
134
|
+
if (result.statusCode === 404 && !isNotFoundRoute) {
|
|
135
|
+
report.errors.push({ route, message: 'returned 404' });
|
|
136
|
+
log.error(` route ${route} returned 404`);
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
if (isNotFoundRoute) {
|
|
140
|
+
result.notFound = true;
|
|
141
|
+
if (!report.notFoundRoutes.includes(route)) report.notFoundRoutes.push(route);
|
|
142
|
+
if (result.statusCode !== null && result.statusCode !== 404) {
|
|
143
|
+
log.warn(` ignored unknown prerender-status-code ${result.statusCode} on ${route}`);
|
|
144
|
+
}
|
|
145
|
+
} else if (result.statusCode !== null) {
|
|
146
|
+
log.warn(` ignored unknown prerender-status-code ${result.statusCode} on ${route}`);
|
|
147
|
+
}
|
|
148
|
+
if (output && result.empty) {
|
|
149
|
+
report.emptyPages.push(route);
|
|
150
|
+
if (config.failOnEmptyPage) {
|
|
151
|
+
report.errors.push({ route, message: 'rendered an empty page' });
|
|
152
|
+
log.error(` empty page ${route}: no app content was found`);
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
log.warn(` empty page ${route}: no app content was found`);
|
|
156
|
+
}
|
|
157
|
+
if (output) {
|
|
158
|
+
rendered.set(route, result);
|
|
159
|
+
report.routes.push(route);
|
|
160
|
+
for (const to of result.links) report.links.push({ from: route, to });
|
|
161
|
+
}
|
|
162
|
+
for (const message of result.pageErrors) {
|
|
163
|
+
report.pageErrors.push({ route, message });
|
|
164
|
+
log.warn(` page error on ${route}: ${message}`);
|
|
165
|
+
}
|
|
166
|
+
for (const message of result.consoleErrors) {
|
|
167
|
+
report.consoleErrors.push({ route, message });
|
|
168
|
+
}
|
|
169
|
+
if (result.consoleErrors.length > 0) {
|
|
170
|
+
log.debug(` console error on ${route}: ${result.consoleErrors[0]}`);
|
|
171
|
+
if (config.failOnConsoleError) {
|
|
172
|
+
report.errors.push({ route, message: `console error: ${result.consoleErrors[0]}` });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
for (const failure of result.failedRequests) {
|
|
176
|
+
report.failedRequests.push({ route, ...failure });
|
|
177
|
+
const detail = failure.status ? `HTTP ${failure.status}` : failure.error;
|
|
178
|
+
log.warn(` failed request on ${route}: ${failure.url} (${detail})`);
|
|
179
|
+
}
|
|
180
|
+
if (result.failedRequests.length > 0 && config.failOnFailedRequest) {
|
|
181
|
+
const first = result.failedRequests[0];
|
|
182
|
+
report.errors.push({
|
|
183
|
+
route,
|
|
184
|
+
message: `failed request: ${first.url}${first.status ? ` (HTTP ${first.status})` : ''}`,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
return { result, routeConfig };
|
|
188
|
+
} catch (error) {
|
|
189
|
+
report.errors.push({ route, message: error.message });
|
|
190
|
+
log.error(` failed ${route}: ${error.message}`);
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const outputFor = (route) => !config.includeProvided || isIncludedRoute(route, config.include);
|
|
196
|
+
const candidates = [];
|
|
197
|
+
if (sourceSeeds.length > 0) candidates.push(...sourceSeeds);
|
|
198
|
+
if (sourceSeeds.length === 0 || config.includeProvided) {
|
|
199
|
+
candidates.push(...crawlRoots(config));
|
|
200
|
+
}
|
|
201
|
+
const roots = accept(
|
|
202
|
+
[...new Set(sortRoutes(candidates, config))].filter(
|
|
203
|
+
(route) => !isLocalePrefixedRoute(route, localePlan),
|
|
204
|
+
),
|
|
205
|
+
outputFor,
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
const deterministic = config.maxRoutes !== null || config.routePriority.length > 0;
|
|
209
|
+
if (deterministic) {
|
|
210
|
+
await renderWaves({ config, accept, renderOne, roots, report, log });
|
|
211
|
+
} else {
|
|
212
|
+
const pool = createPool({
|
|
213
|
+
concurrency: config.concurrency,
|
|
214
|
+
handler: async ({ route, depth, output, order, locale, baseRoute }) => {
|
|
215
|
+
const renderedRoute = await renderOne({ route, output, depth, order, locale, baseRoute });
|
|
216
|
+
if (!renderedRoute) return;
|
|
217
|
+
const { result, routeConfig } = renderedRoute;
|
|
218
|
+
if (routeConfig.crawl && withinDepth(depth + 1, routeConfig.maxDepth)) {
|
|
219
|
+
const discovered = filterRoutes(result.links, {
|
|
220
|
+
include: config.includeProvided ? config.include : [],
|
|
221
|
+
exclude: config.exclude,
|
|
222
|
+
})
|
|
223
|
+
.filter((candidate) => !isLocalePrefixedRoute(candidate, localePlan))
|
|
224
|
+
.filter((candidate) => !scheduled.has(candidate));
|
|
225
|
+
const accepted = accept(sortRoutes(discovered, config));
|
|
226
|
+
pool.push(accepted.map((item) => ({ ...item, depth: depth + 1 })));
|
|
227
|
+
}
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
pool.push(roots.map((item) => ({ ...item, depth: 0 })));
|
|
231
|
+
const { errors } = await pool.run();
|
|
232
|
+
for (const { item, error } of errors) {
|
|
233
|
+
report.errors.push({ route: item.route, message: error.message });
|
|
234
|
+
log.error(` pool error on ${item.route}: ${error.message}`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return { rendered, routeLocales };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Renders one route with the configured retry policy. A retryable failure is retried
|
|
242
|
+
* after retryDelay; the final failure records the attempt count and, when traceOnError
|
|
243
|
+
* is on, a Playwright trace written under the output directory.
|
|
244
|
+
*/
|
|
245
|
+
async function renderWithRetries({
|
|
246
|
+
browser,
|
|
247
|
+
server,
|
|
248
|
+
route,
|
|
249
|
+
routeConfig,
|
|
250
|
+
config,
|
|
251
|
+
log,
|
|
252
|
+
report,
|
|
253
|
+
outputDir,
|
|
254
|
+
screenshot,
|
|
255
|
+
caches,
|
|
256
|
+
locale,
|
|
257
|
+
}) {
|
|
258
|
+
const attempts = config.retries + 1;
|
|
259
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
260
|
+
const traceFile =
|
|
261
|
+
config.traceOnError && !config.dryRun && attempt === attempts
|
|
262
|
+
? await prepareTraceFile(outputDir, route, attempt)
|
|
263
|
+
: null;
|
|
264
|
+
try {
|
|
265
|
+
const result = await renderRoute({
|
|
266
|
+
browser,
|
|
267
|
+
origin: server.origin,
|
|
268
|
+
route,
|
|
269
|
+
locale,
|
|
270
|
+
config: routeConfig,
|
|
271
|
+
screenshot,
|
|
272
|
+
traceFile,
|
|
273
|
+
caches,
|
|
274
|
+
});
|
|
275
|
+
if (attempt > 1) log.info(` ${route}: succeeded on attempt ${attempt}`);
|
|
276
|
+
result.attempts = attempt;
|
|
277
|
+
return result;
|
|
278
|
+
} catch (error) {
|
|
279
|
+
if (error.tracePath) {
|
|
280
|
+
report.traces.push({ route, attempt, file: error.tracePath });
|
|
281
|
+
log.warn(` trace for ${route}: ${error.tracePath}`);
|
|
282
|
+
}
|
|
283
|
+
if (error.retryable !== false && attempt < attempts) {
|
|
284
|
+
log.warn(
|
|
285
|
+
` attempt ${attempt}/${attempts} failed for ${route}: ${error.message}; retrying`,
|
|
286
|
+
);
|
|
287
|
+
await delay(config.retryDelay);
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
report.errors.push({ route, message: error.message, attempts: attempt });
|
|
291
|
+
log.error(` failed ${route} after ${attempt} attempt(s): ${error.message}`);
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Creates the trace directory and returns the archive path for one failed attempt.
|
|
299
|
+
async function prepareTraceFile(outputDir, route, attempt) {
|
|
300
|
+
const slug = route
|
|
301
|
+
.replace(/^\/+|\/+$/g, '')
|
|
302
|
+
.replace(/[^a-z0-9._-]+/gi, '-')
|
|
303
|
+
.toLowerCase();
|
|
304
|
+
const dir = path.join(outputDir, 'snappy-traces');
|
|
305
|
+
await fs.mkdir(dir, { recursive: true });
|
|
306
|
+
return path.join(dir, `${slug || 'root'}-${attempt}.zip`);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Waits between retry attempts.
|
|
310
|
+
function delay(ms) {
|
|
311
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Deterministic BFS crawl used when maxRoutes or routePriority is set. Each depth level
|
|
316
|
+
* is sorted, capped and rendered as a wave, so the accepted set never depends on which
|
|
317
|
+
* concurrent page happened to finish first.
|
|
318
|
+
*/
|
|
319
|
+
async function renderWaves({ config, accept, renderOne, roots, report, log }) {
|
|
320
|
+
let frontier = roots.map((item) => ({ ...item, depth: 0 }));
|
|
321
|
+
let depth = 0;
|
|
322
|
+
while (frontier.length > 0) {
|
|
323
|
+
const { results, errors } = await mapLimit(frontier, config.concurrency, async (item) => ({
|
|
324
|
+
item,
|
|
325
|
+
rendered: await renderOne(item),
|
|
326
|
+
}));
|
|
327
|
+
for (const { item, error } of errors) {
|
|
328
|
+
report.errors.push({ route: item.route, message: error.message });
|
|
329
|
+
log.error(` pool error on ${item.route}: ${error.message}`);
|
|
330
|
+
}
|
|
331
|
+
const discovered = [];
|
|
332
|
+
for (const { rendered: renderedRoute } of results) {
|
|
333
|
+
if (!renderedRoute) continue;
|
|
334
|
+
if (!renderedRoute.routeConfig.crawl) continue;
|
|
335
|
+
if (!withinDepth(depth + 1, config.maxDepth)) continue;
|
|
336
|
+
discovered.push(
|
|
337
|
+
...filterRoutes(renderedRoute.result.links, {
|
|
338
|
+
include: config.includeProvided ? config.include : [],
|
|
339
|
+
exclude: config.exclude,
|
|
340
|
+
}),
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
if (!withinDepth(depth + 1, config.maxDepth)) break;
|
|
344
|
+
const next = accept(sortRoutes(discovered, config));
|
|
345
|
+
frontier = next.map((item) => ({ ...item, depth: depth + 1 }));
|
|
346
|
+
depth += 1;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Orders routes by routePriority first, then by path for a stable frontier.
|
|
351
|
+
function sortRoutes(routes, config) {
|
|
352
|
+
return [...routes].sort((a, b) => sortRoute(a, b, config));
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function sortRoute(a, b, config) {
|
|
356
|
+
const rank = (route) => {
|
|
357
|
+
const index = config.routePriority.findIndex((pattern) => matchesPattern(route, pattern));
|
|
358
|
+
return index === -1 ? Number.MAX_SAFE_INTEGER : index;
|
|
359
|
+
};
|
|
360
|
+
const difference = rank(a) - rank(b);
|
|
361
|
+
return difference === 0 ? a.localeCompare(b) : difference;
|
|
362
|
+
}
|
package/src/core/csp.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { parse, serialize } from 'parse5';
|
|
3
|
+
|
|
4
|
+
// Types that execute as JavaScript. A script with any other type is data, not code.
|
|
5
|
+
const EXECUTABLE_TYPES = new Set([
|
|
6
|
+
'',
|
|
7
|
+
'text/javascript',
|
|
8
|
+
'application/javascript',
|
|
9
|
+
'application/ecmascript',
|
|
10
|
+
'text/ecmascript',
|
|
11
|
+
'module',
|
|
12
|
+
]);
|
|
13
|
+
const CSP_MARKER = 'data-snappy-csp';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Adds a hash-based Content-Security-Policy meta tag covering every inline script and
|
|
17
|
+
* style in the final document. A policy this function wrote on an earlier run is
|
|
18
|
+
* replaced; a policy the app wrote is kept untouched, so the prerenderer never merges
|
|
19
|
+
* two policies silently.
|
|
20
|
+
*/
|
|
21
|
+
export function applyCspHashes(html) {
|
|
22
|
+
const document = parse(html);
|
|
23
|
+
const result = applyCspHashesToDocument(document);
|
|
24
|
+
return {
|
|
25
|
+
html: result.existingPolicy ? html : serialize(document),
|
|
26
|
+
...result,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Applies the hash policy to an already parsed document. Returns counts and whether a
|
|
32
|
+
* user-owned policy was left untouched.
|
|
33
|
+
*/
|
|
34
|
+
export function applyCspHashesToDocument(document) {
|
|
35
|
+
const head = findHead(document);
|
|
36
|
+
if (!head) return { hashedScripts: 0, hashedStyles: 0, existingPolicy: false };
|
|
37
|
+
removeGeneratedPolicy(head);
|
|
38
|
+
const existing = findPolicyMeta(head);
|
|
39
|
+
if (existing) return { hashedScripts: 0, hashedStyles: 0, existingPolicy: true };
|
|
40
|
+
|
|
41
|
+
const scriptHashes = [];
|
|
42
|
+
const styleHashes = [];
|
|
43
|
+
walk(document, (node) => {
|
|
44
|
+
if (node.tagName === 'script' && isExecutableInline(node)) {
|
|
45
|
+
scriptHashes.push(hashOf(textContent(node)));
|
|
46
|
+
}
|
|
47
|
+
if (node.tagName === 'style') {
|
|
48
|
+
styleHashes.push(hashOf(textContent(node)));
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const directives = [
|
|
53
|
+
`script-src 'self'${hashList(scriptHashes)}`,
|
|
54
|
+
`style-src 'self'${hashList(styleHashes)}`,
|
|
55
|
+
];
|
|
56
|
+
head.childNodes.unshift(metaNode(directives.join('; ')));
|
|
57
|
+
return {
|
|
58
|
+
hashedScripts: scriptHashes.length,
|
|
59
|
+
hashedStyles: styleHashes.length,
|
|
60
|
+
existingPolicy: false,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// A script is executable inline code when it has no src and no data type.
|
|
65
|
+
function isExecutableInline(node) {
|
|
66
|
+
if (attribute(node, 'src').length > 0) return false;
|
|
67
|
+
const type = attribute(node, 'type').trim().toLowerCase();
|
|
68
|
+
return EXECUTABLE_TYPES.has(type);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Renders the hash list, deduplicated and in document order.
|
|
72
|
+
function hashList(hashes) {
|
|
73
|
+
return [...new Set(hashes)].map((hash) => ` '${hash}'`).join('');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Base64 SHA-256, the form a CSP hash source uses.
|
|
77
|
+
function hashOf(text) {
|
|
78
|
+
return `sha256-${createHash('sha256').update(text).digest('base64')}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function findHead(document) {
|
|
82
|
+
const html = (document.childNodes ?? []).find((node) => node.tagName === 'html');
|
|
83
|
+
return (html?.childNodes ?? []).find((node) => node.tagName === 'head') ?? null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function findPolicyMeta(head) {
|
|
87
|
+
return (head.childNodes ?? []).some(
|
|
88
|
+
(node) =>
|
|
89
|
+
node.tagName === 'meta' &&
|
|
90
|
+
attribute(node, 'http-equiv').toLowerCase() === 'content-security-policy' &&
|
|
91
|
+
!hasAttribute(node, CSP_MARKER),
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Removes the policy this prerenderer wrote on an earlier run, so it is recomputed against
|
|
96
|
+
// the current document instead of being mistaken for an app-owned policy.
|
|
97
|
+
function removeGeneratedPolicy(head) {
|
|
98
|
+
head.childNodes = (head.childNodes ?? []).filter(
|
|
99
|
+
(node) => !(node.tagName === 'meta' && hasAttribute(node, CSP_MARKER)),
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function metaNode(content) {
|
|
104
|
+
return {
|
|
105
|
+
nodeName: 'meta',
|
|
106
|
+
tagName: 'meta',
|
|
107
|
+
attrs: [
|
|
108
|
+
{ name: 'http-equiv', value: 'Content-Security-Policy' },
|
|
109
|
+
{ name: 'content', value: content },
|
|
110
|
+
{ name: CSP_MARKER, value: '' },
|
|
111
|
+
],
|
|
112
|
+
namespaceURI: 'http://www.w3.org/1999/xhtml',
|
|
113
|
+
childNodes: [],
|
|
114
|
+
parentNode: null,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function walk(node, visit) {
|
|
119
|
+
visit(node);
|
|
120
|
+
for (const child of node.childNodes ?? []) walk(child, visit);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function textContent(node) {
|
|
124
|
+
let text = '';
|
|
125
|
+
for (const child of node.childNodes ?? []) {
|
|
126
|
+
if (child.nodeName === '#text') text += child.value;
|
|
127
|
+
else text += textContent(child);
|
|
128
|
+
}
|
|
129
|
+
return text;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function attribute(node, name) {
|
|
133
|
+
return node.attrs?.find((entry) => entry.name === name)?.value ?? '';
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function hasAttribute(node, name) {
|
|
137
|
+
return Boolean(node.attrs?.some((entry) => entry.name === name));
|
|
138
|
+
}
|
package/src/core/destination.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { mapLimit } from './scheduler.js';
|
|
4
|
+
|
|
5
|
+
const COPY_CONCURRENCY = 8;
|
|
3
6
|
|
|
4
7
|
/**
|
|
5
8
|
* Prepares the directory the run writes into. When a destination is configured the
|
|
@@ -58,13 +61,26 @@ export async function prepareDestination({
|
|
|
58
61
|
async function copyTree(source, target) {
|
|
59
62
|
await fs.mkdir(target, { recursive: true });
|
|
60
63
|
const entries = await fs.readdir(source, { withFileTypes: true });
|
|
61
|
-
|
|
62
|
-
for (const entry of entries) {
|
|
64
|
+
const { results, errors } = await mapLimit(entries, COPY_CONCURRENCY, async (entry) => {
|
|
63
65
|
const from = path.join(source, entry.name);
|
|
64
66
|
const to = path.join(target, entry.name);
|
|
65
|
-
if (entry.isDirectory())
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
67
|
+
if (entry.isDirectory()) return copyTree(from, to);
|
|
68
|
+
if (!entry.isFile()) return 1;
|
|
69
|
+
const [sourceStats, targetStats] = await Promise.all([
|
|
70
|
+
fs.stat(from),
|
|
71
|
+
fs.stat(to).catch(() => null),
|
|
72
|
+
]);
|
|
73
|
+
if (
|
|
74
|
+
targetStats?.isFile() &&
|
|
75
|
+
targetStats.size === sourceStats.size &&
|
|
76
|
+
targetStats.mtimeMs === sourceStats.mtimeMs
|
|
77
|
+
) {
|
|
78
|
+
return 0;
|
|
79
|
+
}
|
|
80
|
+
await fs.copyFile(from, to);
|
|
81
|
+
await fs.utimes(to, sourceStats.atime, sourceStats.mtime);
|
|
82
|
+
return 0;
|
|
83
|
+
});
|
|
84
|
+
if (errors.length > 0) throw errors[0].error;
|
|
85
|
+
return results.reduce((total, skipped) => total + (skipped ?? 0), 0);
|
|
70
86
|
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
const REACT = (container) =>
|
|
2
|
+
Object.keys(container).some(
|
|
3
|
+
(key) => key.startsWith('__reactContainer') || key === '_reactRootContainer',
|
|
4
|
+
);
|
|
5
|
+
const VUE = (container) =>
|
|
6
|
+
Boolean(
|
|
7
|
+
container.__vue_app__ ||
|
|
8
|
+
container.__vueParentComponent ||
|
|
9
|
+
container._vnode ||
|
|
10
|
+
container.__vue__,
|
|
11
|
+
);
|
|
12
|
+
const PREACT = (container) =>
|
|
13
|
+
Boolean(
|
|
14
|
+
container.__k ||
|
|
15
|
+
container._children ||
|
|
16
|
+
container.firstElementChild?.__k ||
|
|
17
|
+
container.firstElementChild?._children,
|
|
18
|
+
);
|
|
19
|
+
const SVELTE = (container) =>
|
|
20
|
+
Boolean(container.__svelte_meta || container.querySelector?.('[data-svelte-h]'));
|
|
21
|
+
|
|
22
|
+
const DETECTORS = { react: REACT, vue: VUE, preact: PREACT, svelte: SVELTE };
|
|
23
|
+
const AUTO_ORDER = ['react', 'vue', 'preact', 'svelte'];
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Detects the framework that mounted into a container from its DOM markers. This is
|
|
27
|
+
* observation only: no framework is imported or executed by the prerenderer.
|
|
28
|
+
*/
|
|
29
|
+
export function detectFramework(container, requested = 'auto') {
|
|
30
|
+
if (!container) return { name: null, mounted: false };
|
|
31
|
+
if (requested !== 'auto') {
|
|
32
|
+
return { name: requested, mounted: Boolean(DETECTORS[requested]?.(container)) };
|
|
33
|
+
}
|
|
34
|
+
const markers = {
|
|
35
|
+
react: REACT(container),
|
|
36
|
+
vue: VUE(container),
|
|
37
|
+
preact: PREACT(container),
|
|
38
|
+
svelte: SVELTE(container),
|
|
39
|
+
};
|
|
40
|
+
return frameworkFromMarkers(markers, requested);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Selects a framework from a plain marker map produced inside the page, where DOM
|
|
45
|
+
* object identity is not available to Node.
|
|
46
|
+
*/
|
|
47
|
+
export function frameworkFromMarkers(markers, requested = 'auto') {
|
|
48
|
+
if (requested !== 'auto') {
|
|
49
|
+
return { name: requested, mounted: markers?.[requested] === true };
|
|
50
|
+
}
|
|
51
|
+
for (const name of AUTO_ORDER) {
|
|
52
|
+
if (markers?.[name] === true) return { name, mounted: true };
|
|
53
|
+
}
|
|
54
|
+
return { name: null, mounted: false };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const COMMON_PATTERNS = [
|
|
58
|
+
/react\.dev\/errors\/(418|419|421|422|423|424|425)/i,
|
|
59
|
+
/Minified React error #(418|419|421|422|423|424|425)\b/i,
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
const FRAMEWORK_PATTERNS = {
|
|
63
|
+
react: [
|
|
64
|
+
...COMMON_PATTERNS,
|
|
65
|
+
/hydrat/i,
|
|
66
|
+
/text content did not match/i,
|
|
67
|
+
/prop [`"].+?[`"] did not match/i,
|
|
68
|
+
/server-rendered HTML/i,
|
|
69
|
+
/did not expect server HTML/i,
|
|
70
|
+
/expected server HTML to contain/i,
|
|
71
|
+
],
|
|
72
|
+
preact: [
|
|
73
|
+
...COMMON_PATTERNS,
|
|
74
|
+
/hydrat/i,
|
|
75
|
+
/text content did not match/i,
|
|
76
|
+
/did not expect server HTML/i,
|
|
77
|
+
],
|
|
78
|
+
vue: [/hydration (?:node|text|children) mismatch/i, /hydration-mismatch/i, /hydrat/i],
|
|
79
|
+
svelte: [/hydration_mismatch/i, /hydration failed/i],
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Classifies a console or page error as a hydration failure for the detected framework.
|
|
84
|
+
* Patterns stay narrow so an unrelated application error is not treated as a build gate.
|
|
85
|
+
* With `auto`, every framework set is tried because the framework was not established.
|
|
86
|
+
*/
|
|
87
|
+
export function isHydrationError(message, framework = 'auto') {
|
|
88
|
+
const text = String(message);
|
|
89
|
+
const patterns =
|
|
90
|
+
framework === 'auto'
|
|
91
|
+
? Object.values(FRAMEWORK_PATTERNS).flat()
|
|
92
|
+
: [...(FRAMEWORK_PATTERNS[framework] ?? [])];
|
|
93
|
+
return patterns.some((pattern) => pattern.test(text));
|
|
94
|
+
}
|