snappy-prerender 2.6.0 → 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 +51 -0
- package/README.md +122 -101
- package/docs/limitations.md +58 -0
- package/docs/react-snap-migration.md +45 -0
- package/docs/report.md +102 -0
- package/package.json +2 -1
- package/src/cli-init.js +205 -0
- package/src/cli.js +73 -168
- package/src/core/artifacts.js +2 -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/config.js +129 -5
- package/src/core/crawl.js +300 -0
- package/src/core/csp.js +127 -0
- package/src/core/frameworks.js +94 -0
- package/src/core/hydration-diff.js +93 -0
- package/src/core/init.js +143 -30
- package/src/core/normalize.js +7 -0
- package/src/core/page-meta.js +108 -0
- package/src/core/preload-links.js +120 -0
- package/src/core/render.js +87 -5
- package/src/core/report.js +108 -0
- package/src/core/resources.js +5 -1
- package/src/core/route-config.js +17 -0
- package/src/core/route-sources.js +209 -0
- package/src/core/run.js +68 -113
- package/src/core/sitemap.js +90 -14
- package/src/core/verify.js +92 -48
- package/src/core/write.js +92 -7
- package/src/head/index.js +2 -2
- package/src/head/manager.js +54 -13
- package/src/head/tags.js +52 -6
package/src/core/init.js
CHANGED
|
@@ -2,6 +2,7 @@ import fs from 'node:fs/promises';
|
|
|
2
2
|
import { createRequire } from 'node:module';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { pathToFileURL } from 'node:url';
|
|
5
|
+
import { DEFAULTS } from './config.js';
|
|
5
6
|
import { normaliseRoute } from './routes.js';
|
|
6
7
|
|
|
7
8
|
export const CONFIG_FILE = 'snappy.config.js';
|
|
@@ -15,6 +16,47 @@ const RUNNERS = [
|
|
|
15
16
|
['package-lock.json', 'npm'],
|
|
16
17
|
];
|
|
17
18
|
|
|
19
|
+
// react-snap options whose name differs here, mapped to this package's option.
|
|
20
|
+
const REACT_SNAP_RENAMED = {
|
|
21
|
+
source: 'sourceDir',
|
|
22
|
+
puppeteerArgs: 'browserArgs',
|
|
23
|
+
puppeteerIgnoreHTTPSErrors: 'ignoreHTTPSErrors',
|
|
24
|
+
skipThirdPartyRequests: 'blockThirdParty',
|
|
25
|
+
fixInsertRule: 'captureRuntimeStyles',
|
|
26
|
+
fixFormFields: 'captureFormState',
|
|
27
|
+
http2PushManifest: 'preloadManifest',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// react-snap options that keep their name and meaning.
|
|
31
|
+
const REACT_SNAP_PASSTHROUGH = new Set([
|
|
32
|
+
'include',
|
|
33
|
+
'exclude',
|
|
34
|
+
'crawl',
|
|
35
|
+
'concurrency',
|
|
36
|
+
'timeout',
|
|
37
|
+
'waitFor',
|
|
38
|
+
'inlineCss',
|
|
39
|
+
'minifyHtml',
|
|
40
|
+
'minifyCss',
|
|
41
|
+
'cacheAjaxRequests',
|
|
42
|
+
'preconnectThirdParty',
|
|
43
|
+
'preloadImages',
|
|
44
|
+
'removeBlobs',
|
|
45
|
+
'removeStyleTags',
|
|
46
|
+
'removeScriptTags',
|
|
47
|
+
'asyncScriptTags',
|
|
48
|
+
'userAgent',
|
|
49
|
+
'viewport',
|
|
50
|
+
'saveAs',
|
|
51
|
+
'destination',
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
// react-snap options that cannot be carried over, with the reason shown to the user.
|
|
55
|
+
const REACT_SNAP_IGNORED = {
|
|
56
|
+
port: 'the prerenderer serves the build itself on an ephemeral port',
|
|
57
|
+
sourceMaps: 'react-snap declares it but never uses it',
|
|
58
|
+
};
|
|
59
|
+
|
|
18
60
|
/**
|
|
19
61
|
* Reads what the project already says about itself: the framework that decides the build
|
|
20
62
|
* directory, whether Vite is in use, which package runner it uses, and whether this package
|
|
@@ -26,13 +68,61 @@ export async function detectProjectDefaults(dir = process.cwd()) {
|
|
|
26
68
|
const usesVite = Boolean(dependencies.vite) || (await anyExists(dir, VITE_CONFIGS));
|
|
27
69
|
const usesCreateReactApp = Boolean(dependencies['react-scripts']) && !usesVite;
|
|
28
70
|
const vite = usesVite ? await detectViteConfig(dir) : null;
|
|
29
|
-
|
|
71
|
+
const detected = {
|
|
30
72
|
sourceDir: vite?.sourceDir ?? (usesCreateReactApp ? 'build' : 'dist'),
|
|
31
73
|
base: vite?.base ?? homepageBase(manifest?.homepage) ?? '/',
|
|
32
74
|
usesVite,
|
|
33
75
|
runner: await detectRunner(dir),
|
|
34
76
|
installed: PACKAGE_NAME in dependencies,
|
|
35
77
|
};
|
|
78
|
+
const reactSnap = mapReactSnapOptions(manifest?.reactSnap);
|
|
79
|
+
if (reactSnap) detected.reactSnap = reactSnap;
|
|
80
|
+
return detected;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Maps a legacy react-snap block onto this package's options. The result lists what
|
|
85
|
+
* carried over, what was deliberately dropped and what was not recognised, so the
|
|
86
|
+
* wizard can explain the migration instead of guessing.
|
|
87
|
+
*/
|
|
88
|
+
export function mapReactSnapOptions(block) {
|
|
89
|
+
if (typeof block !== 'object' || block === null || Array.isArray(block)) return null;
|
|
90
|
+
const values = {};
|
|
91
|
+
const mapping = [];
|
|
92
|
+
const ignored = [];
|
|
93
|
+
const unknown = [];
|
|
94
|
+
for (const [key, value] of Object.entries(block)) {
|
|
95
|
+
if (Object.hasOwn(REACT_SNAP_IGNORED, key)) {
|
|
96
|
+
ignored.push({ key, reason: REACT_SNAP_IGNORED[key] });
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const target = REACT_SNAP_RENAMED[key] ?? (REACT_SNAP_PASSTHROUGH.has(key) ? key : null);
|
|
100
|
+
if (target === null) {
|
|
101
|
+
unknown.push(key);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
// JSON cannot carry functions or RegExp, but a hand-written manifest may.
|
|
105
|
+
if (!isSerialisable(value)) {
|
|
106
|
+
unknown.push(`${key} (unsupported value)`);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
const mappedValue =
|
|
110
|
+
target === 'include' && typeof value === 'string' ? parseRouteList(value) : value;
|
|
111
|
+
values[target] = mappedValue;
|
|
112
|
+
mapping.push({ from: key, to: target, value: mappedValue });
|
|
113
|
+
}
|
|
114
|
+
return { values, mapping, ignored, unknown };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Reports whether a value can be written into a JavaScript config file.
|
|
118
|
+
function isSerialisable(value) {
|
|
119
|
+
if (value === null) return true;
|
|
120
|
+
if (['string', 'number', 'boolean'].includes(typeof value)) return true;
|
|
121
|
+
if (Array.isArray(value)) return value.every(isSerialisable);
|
|
122
|
+
if (typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype) {
|
|
123
|
+
return Object.values(value).every(isSerialisable);
|
|
124
|
+
}
|
|
125
|
+
return false;
|
|
36
126
|
}
|
|
37
127
|
|
|
38
128
|
/**
|
|
@@ -87,46 +177,74 @@ export function parseRouteList(input) {
|
|
|
87
177
|
return routes.length > 0 ? routes : ['/'];
|
|
88
178
|
}
|
|
89
179
|
|
|
180
|
+
/**
|
|
181
|
+
* Turns the wizard's answers into the options object the generated config will export.
|
|
182
|
+
* Only values that change a default are included, and only options the resolver knows.
|
|
183
|
+
*/
|
|
184
|
+
export function configValuesFromAnswers(answers = {}) {
|
|
185
|
+
const values = { sourceDir: answers.sourceDir ?? 'dist' };
|
|
186
|
+
if (answers.base && answers.base !== '/') values.base = answers.base;
|
|
187
|
+
// A supplied include list acts as an allowlist, so writing the default would stop discovery.
|
|
188
|
+
const includeValue = answers.include ?? ['/'];
|
|
189
|
+
const include = parseRouteList(
|
|
190
|
+
Array.isArray(includeValue) ? includeValue.join(',') : includeValue,
|
|
191
|
+
);
|
|
192
|
+
const includeIsDefault = include.length === 1 && include[0] === '/';
|
|
193
|
+
if (!includeIsDefault) values.include = include;
|
|
194
|
+
if (answers.crawl === false) values.crawl = false;
|
|
195
|
+
if (answers.csp === 'strict' || answers.csp === 'hash') values.csp = answers.csp;
|
|
196
|
+
if (answers.externalScripts === true) values.externalScripts = true;
|
|
197
|
+
if (answers.criticalCssPreload !== undefined && answers.criticalCssPreload !== 'media') {
|
|
198
|
+
values.criticalCssPreload = answers.criticalCssPreload;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const metadata = METADATA_KEYS.filter((key) => answers[key]).map((key) => [key, answers[key]]);
|
|
202
|
+
if (metadata.length > 0) values.metadata = Object.fromEntries(metadata);
|
|
203
|
+
|
|
204
|
+
for (const [key, value] of Object.entries(answers.extraOptions ?? {})) {
|
|
205
|
+
if (!Object.hasOwn(DEFAULTS, key) || key === 'metadata') continue;
|
|
206
|
+
if (Object.hasOwn(values, key)) continue;
|
|
207
|
+
if (!isSerialisable(value)) continue;
|
|
208
|
+
values[key] = value;
|
|
209
|
+
}
|
|
210
|
+
return values;
|
|
211
|
+
}
|
|
212
|
+
|
|
90
213
|
/**
|
|
91
214
|
* Renders the config file for the wizard's answers, writing only the options the answers
|
|
92
215
|
* actually set so every line in the file does something.
|
|
93
216
|
*/
|
|
94
217
|
export function renderConfigFile(answers = {}) {
|
|
218
|
+
const values = configValuesFromAnswers(answers);
|
|
95
219
|
const lines = [
|
|
96
220
|
'// Created by `snappy-prerender init`. Every option is documented in the snappy-prerender README.',
|
|
97
221
|
'export default {',
|
|
98
|
-
` sourceDir: ${quote(answers.sourceDir ?? 'dist')},`,
|
|
99
222
|
];
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
if (!includeIsDefault) lines.push(` include: [${include.map(quote).join(', ')}],`);
|
|
106
|
-
if (answers.crawl === false) lines.push(' crawl: false,');
|
|
107
|
-
// The CSP dial and the parts it was tuned with, so a strict-CSP project keeps the choice.
|
|
108
|
-
if (answers.csp === 'strict') lines.push(' csp: "strict",');
|
|
109
|
-
if (answers.externalScripts === true) lines.push(' externalScripts: true,');
|
|
110
|
-
if (answers.criticalCssPreload !== undefined && answers.criticalCssPreload !== 'media') {
|
|
111
|
-
// A boolean stays a literal; a mode name is quoted.
|
|
112
|
-
const value =
|
|
113
|
-
typeof answers.criticalCssPreload === 'boolean'
|
|
114
|
-
? String(answers.criticalCssPreload)
|
|
115
|
-
: quote(answers.criticalCssPreload);
|
|
116
|
-
lines.push(` criticalCssPreload: ${value},`);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
const metadata = METADATA_KEYS.filter((key) => answers[key]).map((key) => [key, answers[key]]);
|
|
120
|
-
if (metadata.length > 0) {
|
|
223
|
+
for (const [key, value] of Object.entries(values)) {
|
|
224
|
+
if (key !== 'metadata') {
|
|
225
|
+
lines.push(` ${key}: ${serializeValue(value)},`);
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
121
228
|
lines.push(' metadata: {');
|
|
122
|
-
for (const [
|
|
229
|
+
for (const [metadataKey, metadataValue] of Object.entries(value)) {
|
|
230
|
+
lines.push(` ${metadataKey}: ${serializeValue(metadataValue)},`);
|
|
231
|
+
}
|
|
123
232
|
lines.push(' },');
|
|
124
233
|
}
|
|
125
|
-
|
|
126
234
|
lines.push('};', '');
|
|
127
235
|
return lines.join('\n');
|
|
128
236
|
}
|
|
129
237
|
|
|
238
|
+
// Writes a JSON-compatible value as JavaScript source with a stable, readable shape.
|
|
239
|
+
function serializeValue(value) {
|
|
240
|
+
if (Array.isArray(value)) return `[${value.map(serializeValue).join(', ')}]`;
|
|
241
|
+
if (value !== null && typeof value === 'object') {
|
|
242
|
+
const entries = Object.entries(value).map(([key, entry]) => `${key}: ${serializeValue(entry)}`);
|
|
243
|
+
return `{ ${entries.join(', ')} }`;
|
|
244
|
+
}
|
|
245
|
+
return JSON.stringify(value);
|
|
246
|
+
}
|
|
247
|
+
|
|
130
248
|
/**
|
|
131
249
|
* Writes the config file into the project, refusing to replace an existing one unless the
|
|
132
250
|
* caller asked for it, so a wizard run can never silently destroy hand-written settings.
|
|
@@ -140,11 +258,6 @@ export async function writeConfigFile({ dir, contents, force = false }) {
|
|
|
140
258
|
return target;
|
|
141
259
|
}
|
|
142
260
|
|
|
143
|
-
// Quotes a string for the generated module.
|
|
144
|
-
function quote(value) {
|
|
145
|
-
return JSON.stringify(String(value));
|
|
146
|
-
}
|
|
147
|
-
|
|
148
261
|
// Reads a JSON file, treating a missing or malformed file as absent.
|
|
149
262
|
async function readJson(file) {
|
|
150
263
|
try {
|
package/src/core/normalize.js
CHANGED
|
@@ -117,6 +117,13 @@ function shouldStripMetadata(node, options, stats) {
|
|
|
117
117
|
function shouldRemove(node, stats, options) {
|
|
118
118
|
if (node.tagName === 'script' && hasAttribute(node, 'data-snappy-meta', null)) return false;
|
|
119
119
|
if (node.tagName === 'style' && hasAttribute(node, 'data-snappy-inline', null)) return false;
|
|
120
|
+
if (
|
|
121
|
+
node.tagName === 'meta' &&
|
|
122
|
+
attributeValue(node, 'name').toLowerCase() === 'prerender-status-code'
|
|
123
|
+
) {
|
|
124
|
+
stats.removedElements += 1;
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
120
127
|
const reason =
|
|
121
128
|
(node.tagName === 'style' && hasAttribute(node, 'id', FREEZE_STYLE_ID)) ||
|
|
122
129
|
hasAttribute(node, REMOVE_ATTRIBUTE, null) ||
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { parse } from 'parse5';
|
|
2
|
+
|
|
3
|
+
const DATA_URL = /^(?:[a-z][a-z0-9+.-]*:|\/\/|#)/i;
|
|
4
|
+
const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Reads the metadata and structural markers from a final HTML document. This runs
|
|
8
|
+
* after post-processing so a transform that changes the head is reflected in the
|
|
9
|
+
* sitemap, the audit and the report.
|
|
10
|
+
*/
|
|
11
|
+
export function collectPageMeta(html) {
|
|
12
|
+
const document = parse(html);
|
|
13
|
+
const meta = {
|
|
14
|
+
title: null,
|
|
15
|
+
titleCount: 0,
|
|
16
|
+
description: null,
|
|
17
|
+
canonical: null,
|
|
18
|
+
robots: null,
|
|
19
|
+
noindex: false,
|
|
20
|
+
h1Count: 0,
|
|
21
|
+
alternates: [],
|
|
22
|
+
images: [],
|
|
23
|
+
inlineStyleAttributes: 0,
|
|
24
|
+
};
|
|
25
|
+
const visitedLinks = new Set();
|
|
26
|
+
|
|
27
|
+
const walk = (node) => {
|
|
28
|
+
// An SVG <title> names a drawing, not the document, so it must not count as a page title.
|
|
29
|
+
if (node.tagName === 'title' && node.namespaceURI !== SVG_NAMESPACE) {
|
|
30
|
+
meta.titleCount += 1;
|
|
31
|
+
if (meta.title === null) meta.title = textContent(node);
|
|
32
|
+
}
|
|
33
|
+
if (node.tagName === 'meta') {
|
|
34
|
+
const name = attribute(node, 'name').toLowerCase();
|
|
35
|
+
const content = attribute(node, 'content');
|
|
36
|
+
if (name === 'description' && meta.description === null) meta.description = content;
|
|
37
|
+
if (name === 'robots') {
|
|
38
|
+
meta.robots ??= content;
|
|
39
|
+
if (/\b(noindex|none)\b/i.test(content)) meta.noindex = true;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (node.tagName === 'link') {
|
|
43
|
+
const rel = attribute(node, 'rel').toLowerCase();
|
|
44
|
+
if (rel === 'canonical' && meta.canonical === null) meta.canonical = attribute(node, 'href');
|
|
45
|
+
if (rel.split(/\s+/).includes('alternate')) {
|
|
46
|
+
const hreflang = attribute(node, 'hreflang');
|
|
47
|
+
const href = attribute(node, 'href');
|
|
48
|
+
if (hreflang && href) {
|
|
49
|
+
const key = `${hreflang}|${href}`;
|
|
50
|
+
if (!visitedLinks.has(key)) {
|
|
51
|
+
visitedLinks.add(key);
|
|
52
|
+
meta.alternates.push({ hreflang, href });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (node.tagName === 'h1') meta.h1Count += 1;
|
|
58
|
+
if (node.tagName === 'img') {
|
|
59
|
+
addImage(meta, attribute(node, 'src'));
|
|
60
|
+
for (const candidate of srcsetCandidates(attribute(node, 'srcset')))
|
|
61
|
+
addImage(meta, candidate);
|
|
62
|
+
}
|
|
63
|
+
if (node.tagName === 'source' && node.parentNode?.tagName === 'picture') {
|
|
64
|
+
addImage(meta, attribute(node, 'src'));
|
|
65
|
+
for (const candidate of srcsetCandidates(attribute(node, 'srcset')))
|
|
66
|
+
addImage(meta, candidate);
|
|
67
|
+
}
|
|
68
|
+
for (const attr of node.attrs ?? []) {
|
|
69
|
+
if (attr.name === 'style' && attr.value.trim().length > 0) meta.inlineStyleAttributes += 1;
|
|
70
|
+
}
|
|
71
|
+
for (const child of node.childNodes ?? []) walk(child);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
walk(document);
|
|
75
|
+
return meta;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Adds a usable image URL once. Data URLs have no host to list in a sitemap.
|
|
79
|
+
function addImage(meta, value) {
|
|
80
|
+
if (!value || DATA_URL.test(value) || meta.images.includes(value)) return;
|
|
81
|
+
meta.images.push(value);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Extracts URL candidates from a srcset, ignoring their density or width descriptors.
|
|
85
|
+
function srcsetCandidates(value) {
|
|
86
|
+
if (!value) return [];
|
|
87
|
+
return value
|
|
88
|
+
.split(',')
|
|
89
|
+
.map((candidate) => candidate.trim().split(/\s+/)[0])
|
|
90
|
+
.filter(
|
|
91
|
+
(candidate) => candidate.length > 0 && !DATA_URL.test(candidate) && !/[<>]/.test(candidate),
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Reads an attribute value, defaulting to an empty string.
|
|
96
|
+
function attribute(node, name) {
|
|
97
|
+
return node.attrs?.find((entry) => entry.name === name)?.value ?? '';
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Concatenates the text beneath a node.
|
|
101
|
+
function textContent(node) {
|
|
102
|
+
let text = '';
|
|
103
|
+
for (const child of node.childNodes ?? []) {
|
|
104
|
+
if (child.nodeName === '#text') text += child.value;
|
|
105
|
+
else text += textContent(child);
|
|
106
|
+
}
|
|
107
|
+
return text.trim();
|
|
108
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { parse, serialize } from 'parse5';
|
|
3
|
+
import { toPublicPath } from './routes.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Injects static preload hints into the final HTML. Static hosts cannot send the
|
|
7
|
+
* preload-manifest headers, so this is the page-level equivalent for runtime chunks,
|
|
8
|
+
* fonts and the LCP image.
|
|
9
|
+
*/
|
|
10
|
+
export function injectPreloadLinks(
|
|
11
|
+
html,
|
|
12
|
+
{
|
|
13
|
+
scripts = [],
|
|
14
|
+
fonts = new Map(),
|
|
15
|
+
lcpImage = null,
|
|
16
|
+
base = '/',
|
|
17
|
+
ignoreForPreload = [],
|
|
18
|
+
skipScripts = new Set(),
|
|
19
|
+
} = {},
|
|
20
|
+
) {
|
|
21
|
+
const document = parse(html);
|
|
22
|
+
const head = findHead(document);
|
|
23
|
+
if (!head) return html;
|
|
24
|
+
const existingScripts = new Set();
|
|
25
|
+
const hints = new Set();
|
|
26
|
+
|
|
27
|
+
const walk = (node) => {
|
|
28
|
+
if (node.tagName === 'script') {
|
|
29
|
+
const src = attribute(node, 'src');
|
|
30
|
+
if (src) existingScripts.add(src);
|
|
31
|
+
}
|
|
32
|
+
if (node.tagName === 'link') {
|
|
33
|
+
const href = attribute(node, 'href');
|
|
34
|
+
if (href && attribute(node, 'rel').split(/\s+/).includes('preload')) hints.add(href);
|
|
35
|
+
}
|
|
36
|
+
for (const child of node.childNodes ?? []) walk(child);
|
|
37
|
+
};
|
|
38
|
+
walk(document);
|
|
39
|
+
|
|
40
|
+
const ignored = new Set(ignoreForPreload);
|
|
41
|
+
let added = 0;
|
|
42
|
+
const add = (element, href) => {
|
|
43
|
+
if (hints.has(href) || ignored.has(path.posix.basename(href))) return;
|
|
44
|
+
hints.add(href);
|
|
45
|
+
head.childNodes.push(element);
|
|
46
|
+
added += 1;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
for (const script of scripts) {
|
|
50
|
+
const href = toPublicPath(script, base);
|
|
51
|
+
if (existingScripts.has(href) || skipScripts.has(href)) continue;
|
|
52
|
+
add(
|
|
53
|
+
linkNode([
|
|
54
|
+
{ name: 'rel', value: 'modulepreload' },
|
|
55
|
+
{ name: 'href', value: href },
|
|
56
|
+
]),
|
|
57
|
+
href,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
for (const [font, contentType] of fonts) {
|
|
61
|
+
const href = toPublicPath(font, base);
|
|
62
|
+
const attrs = [
|
|
63
|
+
{ name: 'rel', value: 'preload' },
|
|
64
|
+
{ name: 'as', value: 'font' },
|
|
65
|
+
{ name: 'href', value: href },
|
|
66
|
+
{ name: 'crossorigin', value: '' },
|
|
67
|
+
];
|
|
68
|
+
if (contentType) attrs.push({ name: 'type', value: contentType });
|
|
69
|
+
add(linkNode(attrs), href);
|
|
70
|
+
}
|
|
71
|
+
if (lcpImage) {
|
|
72
|
+
const href = toPublicPath(lcpImage, base);
|
|
73
|
+
add(
|
|
74
|
+
linkNode([
|
|
75
|
+
{ name: 'rel', value: 'preload' },
|
|
76
|
+
{ name: 'as', value: 'image' },
|
|
77
|
+
{ name: 'href', value: href },
|
|
78
|
+
{ name: 'fetchpriority', value: 'high' },
|
|
79
|
+
]),
|
|
80
|
+
href,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
return added > 0 ? serialize(document) : html;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Collects script src values from a document, used to tell a script a transform
|
|
87
|
+
// removed from a chunk the app loads at runtime.
|
|
88
|
+
export function collectScriptSources(html) {
|
|
89
|
+
const document = parse(html);
|
|
90
|
+
const sources = new Set();
|
|
91
|
+
const walk = (node) => {
|
|
92
|
+
if (node.tagName === 'script') {
|
|
93
|
+
const src = attribute(node, 'src');
|
|
94
|
+
if (src) sources.add(src);
|
|
95
|
+
}
|
|
96
|
+
for (const child of node.childNodes ?? []) walk(child);
|
|
97
|
+
};
|
|
98
|
+
walk(document);
|
|
99
|
+
return sources;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function findHead(document) {
|
|
103
|
+
const html = (document.childNodes ?? []).find((node) => node.tagName === 'html');
|
|
104
|
+
return (html?.childNodes ?? []).find((node) => node.tagName === 'head') ?? null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function linkNode(attrs) {
|
|
108
|
+
return {
|
|
109
|
+
nodeName: 'link',
|
|
110
|
+
tagName: 'link',
|
|
111
|
+
attrs,
|
|
112
|
+
namespaceURI: 'http://www.w3.org/1999/xhtml',
|
|
113
|
+
childNodes: [],
|
|
114
|
+
parentNode: null,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function attribute(node, name) {
|
|
119
|
+
return node.attrs?.find((entry) => entry.name === name)?.value ?? '';
|
|
120
|
+
}
|
package/src/core/render.js
CHANGED
|
@@ -2,7 +2,7 @@ import { captureFormState } from './form-state.js';
|
|
|
2
2
|
import { inlineStylesheets } from './inline-css.js';
|
|
3
3
|
import { isPageEmpty } from './page-content.js';
|
|
4
4
|
import { createResourceCollector } from './resources.js';
|
|
5
|
-
import { toAppRoute, toServerUrl } from './routes.js';
|
|
5
|
+
import { normaliseRoute, toAppRoute, toServerUrl } from './routes.js';
|
|
6
6
|
import { captureRuntimeStyles } from './runtime-styles.js';
|
|
7
7
|
import { freezeAnimations, scrollThroughPage, settlePage, trackNetwork } from './settle.js';
|
|
8
8
|
import { separateTextNodes } from './text-separators.js';
|
|
@@ -26,7 +26,14 @@ function escapeForScript(value) {
|
|
|
26
26
|
* storage from leaking between routes. When a screenshot path is supplied the route
|
|
27
27
|
* is captured as an image instead of HTML.
|
|
28
28
|
*/
|
|
29
|
-
export async function renderRoute({
|
|
29
|
+
export async function renderRoute({
|
|
30
|
+
browser,
|
|
31
|
+
origin,
|
|
32
|
+
route,
|
|
33
|
+
config,
|
|
34
|
+
screenshot = false,
|
|
35
|
+
traceFile = null,
|
|
36
|
+
}) {
|
|
30
37
|
const context = await browser.newContext({
|
|
31
38
|
viewport: config.viewport,
|
|
32
39
|
storageState: config.storageState ?? undefined,
|
|
@@ -35,6 +42,11 @@ export async function renderRoute({ browser, origin, route, config, screenshot =
|
|
|
35
42
|
reducedMotion: config.freezeAnimations ? 'reduce' : 'no-preference',
|
|
36
43
|
serviceWorkers: 'block',
|
|
37
44
|
});
|
|
45
|
+
let tracing = false;
|
|
46
|
+
if (traceFile) {
|
|
47
|
+
await context.tracing.start({ screenshots: false, snapshots: true, sources: false });
|
|
48
|
+
tracing = true;
|
|
49
|
+
}
|
|
38
50
|
const page = await context.newPage();
|
|
39
51
|
const pageErrors = [];
|
|
40
52
|
const consoleErrors = [];
|
|
@@ -78,19 +90,51 @@ export async function renderRoute({ browser, origin, route, config, screenshot =
|
|
|
78
90
|
maxCachedBytes: config.maxCachedBytes,
|
|
79
91
|
});
|
|
80
92
|
await collector.install(page);
|
|
93
|
+
await page.addInitScript(() => {
|
|
94
|
+
window.__snappyLcp = [];
|
|
95
|
+
try {
|
|
96
|
+
const observer = new PerformanceObserver((list) => {
|
|
97
|
+
for (const entry of list.getEntries()) {
|
|
98
|
+
const element = entry.element;
|
|
99
|
+
window.__snappyLcp.push({
|
|
100
|
+
url: entry.url || element?.currentSrc || element?.src || null,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
observer.observe({ type: 'largest-contentful-paint', buffered: true });
|
|
105
|
+
} catch {
|
|
106
|
+
// Older engines simply do not report LCP; preload injection falls back to no hint.
|
|
107
|
+
}
|
|
108
|
+
});
|
|
81
109
|
const tracker = trackNetwork(page);
|
|
82
110
|
try {
|
|
83
111
|
const response = await page.goto(toServerUrl(route, config.base, origin), {
|
|
84
112
|
waitUntil: 'domcontentloaded',
|
|
85
113
|
timeout: config.timeout,
|
|
86
114
|
});
|
|
115
|
+
const notFoundRoute = config.notFoundRoute ? normaliseRoute(config.notFoundRoute) : null;
|
|
116
|
+
const isNotFoundRoute = notFoundRoute !== null && normaliseRoute(route) === notFoundRoute;
|
|
87
117
|
if (response && response.status() >= 400) {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
)
|
|
118
|
+
// A configured 404 route may legitimately answer with HTTP 404; every other status
|
|
119
|
+
// is a failure, and the marker cannot be read before the navigation completes.
|
|
120
|
+
if (!(response.status() === 404 && isNotFoundRoute)) {
|
|
121
|
+
const error = new Error(
|
|
122
|
+
`route responded with HTTP ${response.status()}${response.statusText() ? ` ${response.statusText()}` : ''}`,
|
|
123
|
+
);
|
|
124
|
+
// A server error can be transient and is retried; a client error is the app's own.
|
|
125
|
+
error.retryable = response.status() >= 500;
|
|
126
|
+
throw error;
|
|
127
|
+
}
|
|
91
128
|
}
|
|
92
129
|
await settlePage(page, config, tracker);
|
|
93
130
|
const empty = await isPageEmpty(page);
|
|
131
|
+
const statusCode = await page.evaluate(() => {
|
|
132
|
+
const marker = document.querySelector('meta[name="prerender-status-code"]');
|
|
133
|
+
if (!marker) return null;
|
|
134
|
+
const value = Number.parseInt(marker.getAttribute('content') ?? '', 10);
|
|
135
|
+
return Number.isFinite(value) ? value : null;
|
|
136
|
+
});
|
|
137
|
+
const finalUrl = page.url();
|
|
94
138
|
|
|
95
139
|
if (config.captureRuntimeStyles) await captureRuntimeStyles(page);
|
|
96
140
|
if (config.captureFormState) await captureFormState(page);
|
|
@@ -106,6 +150,16 @@ export async function renderRoute({ browser, origin, route, config, screenshot =
|
|
|
106
150
|
if (metadataDefaults) await injectMetadataDefaults(page, metadataDefaults);
|
|
107
151
|
if (config.freezeAnimations) await freezeAnimations(page);
|
|
108
152
|
if (config.scrollToBottom) await scrollThroughPage(page, config);
|
|
153
|
+
const lcpUrl = await page.evaluate(() => window.__snappyLcp?.at(-1)?.url ?? null);
|
|
154
|
+
let lcpImage = null;
|
|
155
|
+
if (lcpUrl) {
|
|
156
|
+
try {
|
|
157
|
+
const parsed = new URL(lcpUrl);
|
|
158
|
+
if (parsed.origin === origin) lcpImage = `${parsed.pathname}${parsed.search}`;
|
|
159
|
+
} catch {
|
|
160
|
+
// Ignore malformed performance entries.
|
|
161
|
+
}
|
|
162
|
+
}
|
|
109
163
|
await separateTextNodes(page);
|
|
110
164
|
|
|
111
165
|
const links = await collectLinks(page, config.base, origin);
|
|
@@ -126,6 +180,10 @@ export async function renderRoute({ browser, origin, route, config, screenshot =
|
|
|
126
180
|
stateScript,
|
|
127
181
|
screenshot: buffer,
|
|
128
182
|
empty,
|
|
183
|
+
statusCode,
|
|
184
|
+
httpStatus: response?.status() ?? null,
|
|
185
|
+
finalUrl,
|
|
186
|
+
lcpImage,
|
|
129
187
|
};
|
|
130
188
|
}
|
|
131
189
|
|
|
@@ -141,13 +199,37 @@ export async function renderRoute({ browser, origin, route, config, screenshot =
|
|
|
141
199
|
inlineCssSkipped,
|
|
142
200
|
stateScript,
|
|
143
201
|
empty,
|
|
202
|
+
statusCode,
|
|
203
|
+
httpStatus: response?.status() ?? null,
|
|
204
|
+
finalUrl,
|
|
205
|
+
lcpImage,
|
|
144
206
|
};
|
|
207
|
+
} catch (error) {
|
|
208
|
+
error.retryable ??= isRetryableRenderError(error);
|
|
209
|
+
if (tracing) {
|
|
210
|
+
tracing = false;
|
|
211
|
+
await context.tracing.stop({ path: traceFile }).then(
|
|
212
|
+
() => {
|
|
213
|
+
error.tracePath = traceFile;
|
|
214
|
+
},
|
|
215
|
+
// A failed trace must never replace the render error that explains the build failure.
|
|
216
|
+
() => {},
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
throw error;
|
|
145
220
|
} finally {
|
|
146
221
|
tracker.stop();
|
|
222
|
+
if (tracing) await context.tracing.stop().catch(() => {});
|
|
147
223
|
await context.close();
|
|
148
224
|
}
|
|
149
225
|
}
|
|
150
226
|
|
|
227
|
+
// Navigation and network failures are transient; a bad selector or an HTTP status is not.
|
|
228
|
+
function isRetryableRenderError(error) {
|
|
229
|
+
if (error?.name === 'TimeoutError') return true;
|
|
230
|
+
return /net::ERR_|timed? ?out|ECONNRESET|ECONNREFUSED|socket hang up/i.test(error?.message ?? '');
|
|
231
|
+
}
|
|
232
|
+
|
|
151
233
|
/**
|
|
152
234
|
* Injects captured JSON responses as window.snapStore and anything the app exposes
|
|
153
235
|
* through window.snapSaveState, before the first script runs. The client can then
|