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.
- package/LICENSE +21 -0
- package/README.md +502 -0
- package/package.json +89 -0
- package/src/cli.js +476 -0
- package/src/core/blocking.js +28 -0
- package/src/core/browser.js +122 -0
- package/src/core/config.js +320 -0
- package/src/core/destination.js +45 -0
- package/src/core/form-state.js +23 -0
- package/src/core/index.js +2 -0
- package/src/core/init.js +136 -0
- package/src/core/inline-css.js +67 -0
- package/src/core/log.js +45 -0
- package/src/core/metadata-origin.js +39 -0
- package/src/core/minify.js +42 -0
- package/src/core/normalize.js +197 -0
- package/src/core/output.js +81 -0
- package/src/core/preload-manifest.js +28 -0
- package/src/core/render.js +228 -0
- package/src/core/resources.js +113 -0
- package/src/core/routes.js +150 -0
- package/src/core/run.js +523 -0
- package/src/core/runtime-styles.js +42 -0
- package/src/core/scheduler.js +83 -0
- package/src/core/server.js +216 -0
- package/src/core/settle.js +141 -0
- package/src/core/text-separators.js +30 -0
- package/src/core/verify.js +148 -0
- package/src/head/index.js +56 -0
- package/src/head/manager.js +230 -0
- package/src/head/tags.js +211 -0
- package/src/index.js +2 -0
- package/src/vite-plugin.js +49 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import CleanCSS from 'clean-css';
|
|
2
|
+
import { minify as minifyMarkup } from 'html-minifier-terser';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Options matching react-snap's defaults, kept as the baseline so behaviour is
|
|
6
|
+
* familiar. Whitespace collapsing can change text nodes, which React notices during
|
|
7
|
+
* hydration, so minification stays opt-in and the hydration verifier is the check.
|
|
8
|
+
*/
|
|
9
|
+
const HTML_MINIFY_DEFAULTS = {
|
|
10
|
+
collapseBooleanAttributes: true,
|
|
11
|
+
collapseWhitespace: true,
|
|
12
|
+
decodeEntities: true,
|
|
13
|
+
keepClosingSlash: true,
|
|
14
|
+
sortAttributes: true,
|
|
15
|
+
sortClassName: false,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Minifies serialised HTML. `minifyCss` options are forwarded so styles inlined into
|
|
20
|
+
* the document are minified in the same pass. html-minifier-terser is async, so this
|
|
21
|
+
* resolves to the minified markup.
|
|
22
|
+
*/
|
|
23
|
+
export async function minifyHtml(html, options = {}, minifyCssOptions = false) {
|
|
24
|
+
const merged = {
|
|
25
|
+
...HTML_MINIFY_DEFAULTS,
|
|
26
|
+
...(options === true ? {} : options),
|
|
27
|
+
};
|
|
28
|
+
if (minifyCssOptions) merged.minifyCSS = minifyCssOptions === true ? {} : minifyCssOptions;
|
|
29
|
+
return minifyMarkup(html, merged);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Minifies a CSS string with clean-css, used for stylesheets inlined by the
|
|
34
|
+
* inlineCss feature.
|
|
35
|
+
*/
|
|
36
|
+
export function minifyCss(css, options = {}) {
|
|
37
|
+
const result = new CleanCSS(options === true ? {} : options).minify(css);
|
|
38
|
+
if (result.errors.length > 0) {
|
|
39
|
+
throw new Error(`CSS minification failed: ${result.errors.join('; ')}`);
|
|
40
|
+
}
|
|
41
|
+
return result.styles;
|
|
42
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { parse, serialize } from 'parse5';
|
|
2
|
+
import { collectBuildOrigins, isMetadataUrlNode, metadataUrlValue } from './metadata-origin.js';
|
|
3
|
+
|
|
4
|
+
const FREEZE_STYLE_ID = 'snappy-freeze';
|
|
5
|
+
const REMOVE_ATTRIBUTE = 'data-prerender-remove';
|
|
6
|
+
const MAX_METADATA_ORIGINS = 3;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Cleans and post-processes the browser-serialised DOM before it is written to disk.
|
|
10
|
+
* Always: strips the injected animation-freezing style, removes elements marked with
|
|
11
|
+
* data-prerender-remove, and drops duplicate head styles emitted by CSS-in-JS.
|
|
12
|
+
* Optionally: removes style or script tags, marks scripts async, drops dead blob
|
|
13
|
+
* stylesheets, and adds preconnect and image preload hints.
|
|
14
|
+
*/
|
|
15
|
+
export function normaliseHtml(html, options = {}) {
|
|
16
|
+
const document = parse(html);
|
|
17
|
+
const stats = {
|
|
18
|
+
removedElements: 0,
|
|
19
|
+
dedupedStyles: 0,
|
|
20
|
+
hints: 0,
|
|
21
|
+
textSeparators: 0,
|
|
22
|
+
titleElements: 0,
|
|
23
|
+
metadataOrigins: [],
|
|
24
|
+
};
|
|
25
|
+
const seenStyles = new Set();
|
|
26
|
+
cleanChildren(document, stats, seenStyles, false, options);
|
|
27
|
+
|
|
28
|
+
const preconnectOrigins = options.preconnectOrigins ?? [];
|
|
29
|
+
const preloadImages = options.preloadImages ?? [];
|
|
30
|
+
if (preconnectOrigins.length > 0 || preloadImages.length > 0) {
|
|
31
|
+
stats.hints = appendHints(document, { preconnectOrigins, preloadImages });
|
|
32
|
+
}
|
|
33
|
+
return { html: serialize(document), stats };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Rebuilds a node's child list, dropping removed nodes, separating adjacent text nodes
|
|
37
|
+
// and de-duplicating head styles.
|
|
38
|
+
function cleanChildren(parent, stats, seenStyles, inHead, options) {
|
|
39
|
+
const children = parent.childNodes ?? [];
|
|
40
|
+
const kept = [];
|
|
41
|
+
for (const child of children) {
|
|
42
|
+
const childInHead = inHead || child.tagName === 'head';
|
|
43
|
+
if (shouldRemove(child, stats, options)) continue;
|
|
44
|
+
if (childInHead && child.tagName === 'style' && dedupeStyle(child, stats, seenStyles)) continue;
|
|
45
|
+
if (childInHead && child.tagName === 'title') stats.titleElements += 1;
|
|
46
|
+
if (childInHead) collectMetadataOrigins(child, stats);
|
|
47
|
+
if (options.asyncScriptTags && child.tagName === 'script') markScriptAsync(child);
|
|
48
|
+
cleanChildren(child, stats, seenStyles, childInHead, options);
|
|
49
|
+
if (child.content) cleanChildren(child.content, stats, seenStyles, childInHead, options);
|
|
50
|
+
if (isText(kept[kept.length - 1]) && isText(child)) {
|
|
51
|
+
kept.push(createComment());
|
|
52
|
+
stats.textSeparators += 1;
|
|
53
|
+
}
|
|
54
|
+
kept.push(child);
|
|
55
|
+
}
|
|
56
|
+
parent.childNodes = kept;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Reports whether a node is a text node.
|
|
60
|
+
function isText(node) {
|
|
61
|
+
return node?.nodeName === '#text';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Builds the comment node that keeps two text siblings apart, matching what React's
|
|
65
|
+
// own server rendering emits so hydration finds the same node boundaries.
|
|
66
|
+
function createComment() {
|
|
67
|
+
return { nodeName: '#comment', data: ' ', parentNode: null };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Reports whether a node is removed for any of the configured reasons. The metadata script is
|
|
71
|
+
// exempt: the head layer reads it on the client, so stripping it would lose the site defaults.
|
|
72
|
+
function shouldRemove(node, stats, options) {
|
|
73
|
+
if (node.tagName === 'script' && hasAttribute(node, 'data-snappy-meta', null)) return false;
|
|
74
|
+
const reason =
|
|
75
|
+
(node.tagName === 'style' && hasAttribute(node, 'id', FREEZE_STYLE_ID)) ||
|
|
76
|
+
hasAttribute(node, REMOVE_ATTRIBUTE, null) ||
|
|
77
|
+
(options.removeStyleTags && node.tagName === 'style') ||
|
|
78
|
+
(options.removeScriptTags && node.tagName === 'script') ||
|
|
79
|
+
(options.removeBlobs && isBlobStylesheet(node));
|
|
80
|
+
|
|
81
|
+
if (!reason) return false;
|
|
82
|
+
stats.removedElements += 1;
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Records the build server origins that metadata URLs point at, so the run can warn once
|
|
87
|
+
// about metadata composed without a public site URL.
|
|
88
|
+
function collectMetadataOrigins(node, stats) {
|
|
89
|
+
if (stats.metadataOrigins.length >= MAX_METADATA_ORIGINS) return;
|
|
90
|
+
if (!isMetadataUrlNode(node)) return;
|
|
91
|
+
for (const origin of collectBuildOrigins(metadataUrlValue(node))) {
|
|
92
|
+
if (!stats.metadataOrigins.includes(origin)) stats.metadataOrigins.push(origin);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Drops a style tag whose text was already seen, reporting it through stats.
|
|
97
|
+
function dedupeStyle(node, stats, seenStyles) {
|
|
98
|
+
const text = textContent(node);
|
|
99
|
+
if (seenStyles.has(text)) {
|
|
100
|
+
stats.dedupedStyles += 1;
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
seenStyles.add(text);
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Adds async to external scripts that are neither async nor defer yet.
|
|
108
|
+
function markScriptAsync(node) {
|
|
109
|
+
const attrs = node.attrs ?? [];
|
|
110
|
+
if (!attrs.some((attribute) => attribute.name === 'src')) return;
|
|
111
|
+
if (attrs.some((attribute) => attribute.name === 'async' || attribute.name === 'defer')) return;
|
|
112
|
+
attrs.push({ name: 'async', value: '' });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Reports whether a node is a stylesheet link pointing at a dead blob URL.
|
|
116
|
+
function isBlobStylesheet(node) {
|
|
117
|
+
if (node.tagName !== 'link') return false;
|
|
118
|
+
if (attributeValue(node, 'rel').toLowerCase() !== 'stylesheet') return false;
|
|
119
|
+
return attributeValue(node, 'href').startsWith('blob:');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Appends preconnect and image preload links to the head, skipping duplicates.
|
|
123
|
+
function appendHints(document, { preconnectOrigins, preloadImages }) {
|
|
124
|
+
const head = findHead(document);
|
|
125
|
+
if (!head) return 0;
|
|
126
|
+
const existing = new Set(
|
|
127
|
+
(head.childNodes ?? [])
|
|
128
|
+
.filter((node) => node.tagName === 'link')
|
|
129
|
+
.map((node) => `${attributeValue(node, 'rel')}|${attributeValue(node, 'href')}`),
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
let added = 0;
|
|
133
|
+
for (const origin of preconnectOrigins) {
|
|
134
|
+
if (existing.has(`preconnect|${origin}`)) continue;
|
|
135
|
+
existing.add(`preconnect|${origin}`);
|
|
136
|
+
head.childNodes.push(
|
|
137
|
+
createElement('link', [
|
|
138
|
+
{ name: 'rel', value: 'preconnect' },
|
|
139
|
+
{ name: 'href', value: origin },
|
|
140
|
+
]),
|
|
141
|
+
);
|
|
142
|
+
added += 1;
|
|
143
|
+
}
|
|
144
|
+
for (const image of preloadImages) {
|
|
145
|
+
if (existing.has(`preload|${image}`)) continue;
|
|
146
|
+
existing.add(`preload|${image}`);
|
|
147
|
+
head.childNodes.push(
|
|
148
|
+
createElement('link', [
|
|
149
|
+
{ name: 'rel', value: 'preload' },
|
|
150
|
+
{ name: 'as', value: 'image' },
|
|
151
|
+
{ name: 'href', value: image },
|
|
152
|
+
]),
|
|
153
|
+
);
|
|
154
|
+
added += 1;
|
|
155
|
+
}
|
|
156
|
+
return added;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Locates the head element of a parsed document.
|
|
160
|
+
function findHead(document) {
|
|
161
|
+
const html = (document.childNodes ?? []).find((node) => node.tagName === 'html');
|
|
162
|
+
return (html?.childNodes ?? []).find((node) => node.tagName === 'head') ?? null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Builds a minimal parse5 element node for injection into the tree.
|
|
166
|
+
function createElement(tagName, attrs = []) {
|
|
167
|
+
return {
|
|
168
|
+
nodeName: tagName,
|
|
169
|
+
tagName,
|
|
170
|
+
attrs,
|
|
171
|
+
namespaceURI: 'http://www.w3.org/1999/xhtml',
|
|
172
|
+
childNodes: [],
|
|
173
|
+
parentNode: null,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Reads an attribute, optionally requiring an exact value.
|
|
178
|
+
function hasAttribute(node, name, value) {
|
|
179
|
+
const attribute = node.attrs?.find((candidate) => candidate.name === name);
|
|
180
|
+
if (!attribute) return false;
|
|
181
|
+
return value === null || attribute.value === value;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Reads an attribute value, defaulting to an empty string.
|
|
185
|
+
function attributeValue(node, name) {
|
|
186
|
+
return node.attrs?.find((attribute) => attribute.name === name)?.value ?? '';
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Concatenates all text nodes beneath a node, used to compare style contents.
|
|
190
|
+
function textContent(node) {
|
|
191
|
+
let text = '';
|
|
192
|
+
for (const child of node.childNodes ?? []) {
|
|
193
|
+
if (child.nodeName === '#text') text += child.value;
|
|
194
|
+
else text += textContent(child);
|
|
195
|
+
}
|
|
196
|
+
return text;
|
|
197
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { normaliseRoute } from './routes.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Maps an app-relative route to its output file. The configured not-found route is
|
|
7
|
+
* always emitted as 404.html so hosts with static error pages pick it up.
|
|
8
|
+
*/
|
|
9
|
+
export function routeToFile(route, { flatOutput = false, notFoundRoute = null } = {}) {
|
|
10
|
+
const normalised = normaliseRoute(route);
|
|
11
|
+
if (notFoundRoute && normalised === normaliseRoute(notFoundRoute)) return '404.html';
|
|
12
|
+
if (normalised === '/') return 'index.html';
|
|
13
|
+
const relative = normalised.slice(1);
|
|
14
|
+
return flatOutput ? `${relative}.html` : path.posix.join(relative, 'index.html');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Maps a route to its screenshot file. The root route becomes index.png, other
|
|
19
|
+
* routes keep their path with the extension swapped.
|
|
20
|
+
*/
|
|
21
|
+
export function routeToScreenshotFile(route, { saveAs = 'png' } = {}) {
|
|
22
|
+
const normalised = normaliseRoute(route);
|
|
23
|
+
const extension = saveAs === 'jpeg' ? 'jpeg' : 'png';
|
|
24
|
+
if (normalised === '/') return `index.${extension}`;
|
|
25
|
+
return `${normalised.slice(1)}.${extension}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Finds routes that would write the same output file. Two routes can collide, most
|
|
30
|
+
* obviously with flatOutput where both "/" and "/index" map to index.html. Callers
|
|
31
|
+
* use this to refuse the ambiguous writes instead of letting one overwrite the other.
|
|
32
|
+
*/
|
|
33
|
+
export function findOutputCollisions(routes, config) {
|
|
34
|
+
const byFile = new Map();
|
|
35
|
+
const saveAs = config.saveAs ?? 'html';
|
|
36
|
+
for (const route of routes) {
|
|
37
|
+
const file =
|
|
38
|
+
saveAs === 'html' ? routeToFile(route, config) : routeToScreenshotFile(route, config);
|
|
39
|
+
const existing = byFile.get(file);
|
|
40
|
+
if (existing) existing.push(route);
|
|
41
|
+
else byFile.set(file, [route]);
|
|
42
|
+
}
|
|
43
|
+
return new Map([...byFile].filter(([, list]) => list.length > 1));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Writes one rendered route into the output directory. Identical existing files are
|
|
48
|
+
* left untouched, which keeps rebuilds and CI caches stable.
|
|
49
|
+
*/
|
|
50
|
+
export async function writeRouteHtml({ dir, route, html, config }) {
|
|
51
|
+
const file = routeToFile(route, config);
|
|
52
|
+
if (config.dryRun) {
|
|
53
|
+
return { route, file, status: 'dry-run', bytes: Buffer.byteLength(html) };
|
|
54
|
+
}
|
|
55
|
+
const written = await writeFileIfChanged({ dir, file, contents: html });
|
|
56
|
+
return { route, file, status: written.status, bytes: written.bytes };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Writes a generated file, leaving an identical one untouched. Page files and the state
|
|
61
|
+
* files routes reference both go through here so a rebuild only touches what changed.
|
|
62
|
+
*/
|
|
63
|
+
export async function writeFileIfChanged({ dir, file, contents }) {
|
|
64
|
+
const bytes = Buffer.byteLength(contents);
|
|
65
|
+
const target = path.join(dir, file);
|
|
66
|
+
const existing = await fs.readFile(target, 'utf8').catch(() => null);
|
|
67
|
+
if (existing === contents) return { file, status: 'unchanged', bytes };
|
|
68
|
+
|
|
69
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
70
|
+
// Written through a temporary file and renamed, so an interrupted run cannot leave a
|
|
71
|
+
// half-written file behind for a host to serve.
|
|
72
|
+
const temporary = `${target}.snappy-tmp`;
|
|
73
|
+
try {
|
|
74
|
+
await fs.writeFile(temporary, contents, 'utf8');
|
|
75
|
+
await fs.rename(temporary, target);
|
|
76
|
+
} catch (error) {
|
|
77
|
+
await fs.rm(temporary, { force: true }).catch(() => {});
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
80
|
+
return { file, status: 'written', bytes };
|
|
81
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { toPublicPath } from './routes.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Builds the preload manifest: for every route, the same-origin scripts and
|
|
6
|
+
* stylesheets it loaded, rendered as a Link header value. This replaces react-snap's
|
|
7
|
+
* http2PushManifest because browsers removed HTTP/2 push (Chrome in v106); hosts can
|
|
8
|
+
* still use these values for Early Hints or Link response headers. Hints and route keys
|
|
9
|
+
* both carry the base path so they match the URLs a host actually sees.
|
|
10
|
+
*/
|
|
11
|
+
export function buildPreloadManifest(entries, { ignoreForPreload = [], base = '/' } = {}) {
|
|
12
|
+
const ignored = new Set(ignoreForPreload);
|
|
13
|
+
const manifest = [];
|
|
14
|
+
for (const entry of entries) {
|
|
15
|
+
const hints = [];
|
|
16
|
+
for (const asset of [...entry.scripts, ...entry.styles]) {
|
|
17
|
+
if (ignored.has(path.posix.basename(asset))) continue;
|
|
18
|
+
const as = entry.scripts.includes(asset) ? 'script' : 'style';
|
|
19
|
+
hints.push(`<${toPublicPath(asset, base)}>;rel=preload;as=${as}`);
|
|
20
|
+
}
|
|
21
|
+
if (hints.length === 0) continue;
|
|
22
|
+
manifest.push({
|
|
23
|
+
source: toPublicPath(entry.route, base),
|
|
24
|
+
headers: [{ key: 'Link', value: hints.join(',') }],
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
return manifest;
|
|
28
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { captureFormState } from './form-state.js';
|
|
4
|
+
import { inlineStylesheets } from './inline-css.js';
|
|
5
|
+
import { createResourceCollector } from './resources.js';
|
|
6
|
+
import { toAppRoute, toServerUrl } from './routes.js';
|
|
7
|
+
import { captureRuntimeStyles } from './runtime-styles.js';
|
|
8
|
+
import { freezeAnimations, scrollThroughPage, settlePage, trackNetwork } from './settle.js';
|
|
9
|
+
import { separateTextNodes } from './text-separators.js';
|
|
10
|
+
|
|
11
|
+
const SCRIPT_ESCAPES = {
|
|
12
|
+
'<': '\\u003C',
|
|
13
|
+
'>': '\\u003E',
|
|
14
|
+
'/': '\\u002F',
|
|
15
|
+
'\u2028': '\\u2028',
|
|
16
|
+
'\u2029': '\\u2029',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// Serialises a value for embedding in a script element without allowing tag breakout.
|
|
20
|
+
function escapeForScript(value) {
|
|
21
|
+
return JSON.stringify(value).replace(/[<>/\u2028\u2029]/g, (char) => SCRIPT_ESCAPES[char]);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Renders one route in a fresh browser context and returns the serialised DOM plus
|
|
26
|
+
* the same-origin links discovered on it. Context isolation keeps cookies and
|
|
27
|
+
* storage from leaking between routes. When a screenshot path is supplied the route
|
|
28
|
+
* is captured as an image instead of HTML.
|
|
29
|
+
*/
|
|
30
|
+
export async function renderRoute({ browser, origin, route, config, screenshotPath }) {
|
|
31
|
+
const context = await browser.newContext({
|
|
32
|
+
viewport: config.viewport,
|
|
33
|
+
storageState: config.storageState ?? undefined,
|
|
34
|
+
userAgent: config.userAgent ?? undefined,
|
|
35
|
+
ignoreHTTPSErrors: config.ignoreHTTPSErrors,
|
|
36
|
+
reducedMotion: config.freezeAnimations ? 'reduce' : 'no-preference',
|
|
37
|
+
});
|
|
38
|
+
const page = await context.newPage();
|
|
39
|
+
const pageErrors = [];
|
|
40
|
+
let inlineCssSkipped = [];
|
|
41
|
+
page.on('pageerror', (error) => pageErrors.push(error.message));
|
|
42
|
+
|
|
43
|
+
const metadataDefaults = config.metadata ? { base: config.base, ...config.metadata } : null;
|
|
44
|
+
if (metadataDefaults) {
|
|
45
|
+
await page.addInitScript((defaults) => {
|
|
46
|
+
window.__SNAPPY_META__ = defaults;
|
|
47
|
+
}, metadataDefaults);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const collector = createResourceCollector({
|
|
51
|
+
origin,
|
|
52
|
+
base: config.base,
|
|
53
|
+
allowedHosts: config.allowedHosts,
|
|
54
|
+
blockThirdParty: config.blockThirdParty,
|
|
55
|
+
collectJson: config.cacheAjaxRequests,
|
|
56
|
+
maxCachedBytes: config.maxCachedBytes,
|
|
57
|
+
});
|
|
58
|
+
await collector.install(page);
|
|
59
|
+
const tracker = trackNetwork(page);
|
|
60
|
+
try {
|
|
61
|
+
await page.goto(toServerUrl(route, config.base, origin), {
|
|
62
|
+
waitUntil: 'domcontentloaded',
|
|
63
|
+
timeout: config.timeout,
|
|
64
|
+
});
|
|
65
|
+
await settlePage(page, config, tracker);
|
|
66
|
+
|
|
67
|
+
if (config.captureRuntimeStyles) await captureRuntimeStyles(page);
|
|
68
|
+
if (config.captureFormState) await captureFormState(page);
|
|
69
|
+
if (config.inlineCss === true || config.inlineCss === 'inline') {
|
|
70
|
+
const inlined = await inlineStylesheets({ page, minifyCssOptions: config.minifyCss });
|
|
71
|
+
inlineCssSkipped = inlined.skipped;
|
|
72
|
+
}
|
|
73
|
+
await collector.settled();
|
|
74
|
+
const stateScript = await injectCapturedState(page, collector.json, {
|
|
75
|
+
externalScripts: config.externalScripts,
|
|
76
|
+
base: config.base,
|
|
77
|
+
});
|
|
78
|
+
if (metadataDefaults) await injectMetadataDefaults(page, metadataDefaults);
|
|
79
|
+
if (config.freezeAnimations) await freezeAnimations(page);
|
|
80
|
+
if (config.scrollToBottom) await scrollThroughPage(page, config);
|
|
81
|
+
await separateTextNodes(page);
|
|
82
|
+
|
|
83
|
+
const links = await collectLinks(page, config.base, origin);
|
|
84
|
+
|
|
85
|
+
if (screenshotPath) {
|
|
86
|
+
await fs.mkdir(path.dirname(screenshotPath), { recursive: true });
|
|
87
|
+
await page.screenshot({ path: screenshotPath, fullPage: true });
|
|
88
|
+
return {
|
|
89
|
+
route,
|
|
90
|
+
links,
|
|
91
|
+
pageErrors,
|
|
92
|
+
collector,
|
|
93
|
+
inlineCssSkipped,
|
|
94
|
+
stateScript,
|
|
95
|
+
screenshot: true,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const html = await page.content();
|
|
100
|
+
return { route, html, links, pageErrors, collector, inlineCssSkipped, stateScript };
|
|
101
|
+
} finally {
|
|
102
|
+
tracker.stop();
|
|
103
|
+
await context.close();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Injects captured JSON responses as window.snapStore and anything the app exposes
|
|
109
|
+
* through window.snapSaveState, before the first script runs. The client can then
|
|
110
|
+
* replay the same data during hydration instead of re-fetching and mismatching.
|
|
111
|
+
*
|
|
112
|
+
* Inline by default. With externalScripts the same payload is written as a content-hashed
|
|
113
|
+
* same-origin file and referenced with src, so a strict script-src can allow it without
|
|
114
|
+
* unsafe-inline. Returns that file for the caller to write, or null when nothing was needed.
|
|
115
|
+
*/
|
|
116
|
+
async function injectCapturedState(page, cache, { externalScripts = false, base = '/' } = {}) {
|
|
117
|
+
const store = Object.fromEntries(cache);
|
|
118
|
+
return page.evaluate(
|
|
119
|
+
async ({ captured, external, basePath }) => {
|
|
120
|
+
const UNSAFE_CHARS = /[<>/\u2028\u2029]/g;
|
|
121
|
+
const ESCAPED_CHARS = {
|
|
122
|
+
'<': '\\u003C',
|
|
123
|
+
'>': '\\u003E',
|
|
124
|
+
'/': '\\u002F',
|
|
125
|
+
'\u2028': '\\u2028',
|
|
126
|
+
'\u2029': '\\u2029',
|
|
127
|
+
};
|
|
128
|
+
const escapeJson = (value) =>
|
|
129
|
+
JSON.stringify(value).replace(UNSAFE_CHARS, (char) => ESCAPED_CHARS[char]);
|
|
130
|
+
|
|
131
|
+
const parts = [];
|
|
132
|
+
if (Object.keys(captured).length > 0) {
|
|
133
|
+
parts.push(`window.snapStore=${escapeJson(captured)};`);
|
|
134
|
+
}
|
|
135
|
+
const state = typeof window.snapSaveState === 'function' ? window.snapSaveState() : null;
|
|
136
|
+
if (state && typeof state === 'object') {
|
|
137
|
+
for (const [key, value] of Object.entries(state)) {
|
|
138
|
+
// Both the key and the value are escaped: JSON.stringify alone leaves angle
|
|
139
|
+
// brackets and slashes intact, which would let a dynamic key close the tag.
|
|
140
|
+
parts.push(`window[${escapeJson(key)}]=${escapeJson(value)};`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
// Replacing an earlier injection keeps a rerun against already prerendered output
|
|
144
|
+
// byte-identical instead of stacking a second copy of the state script.
|
|
145
|
+
document.querySelectorAll('script[data-snappy-state]').forEach((script) => script.remove());
|
|
146
|
+
if (parts.length === 0) return null;
|
|
147
|
+
|
|
148
|
+
// Inserted before the first script so the state exists before the bundle runs.
|
|
149
|
+
const insert = (element) => {
|
|
150
|
+
const first = document.scripts[0];
|
|
151
|
+
if (first?.parentNode) first.parentNode.insertBefore(element, first);
|
|
152
|
+
else document.head.appendChild(element);
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const script = document.createElement('script');
|
|
156
|
+
script.setAttribute('data-snappy-state', '');
|
|
157
|
+
const text = parts.join('');
|
|
158
|
+
|
|
159
|
+
if (!external) {
|
|
160
|
+
script.textContent = text;
|
|
161
|
+
insert(script);
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
|
|
166
|
+
const hash = [...new Uint8Array(digest)]
|
|
167
|
+
.map((byte) => byte.toString(16).padStart(2, '0'))
|
|
168
|
+
.join('')
|
|
169
|
+
.slice(0, 12);
|
|
170
|
+
const file = `snappy/state-${hash}.js`;
|
|
171
|
+
const prefix = basePath.endsWith('/') ? basePath.slice(0, -1) : basePath;
|
|
172
|
+
script.setAttribute('src', `${prefix}/${file}`);
|
|
173
|
+
insert(script);
|
|
174
|
+
return { file, contents: text };
|
|
175
|
+
},
|
|
176
|
+
{ captured: store, external: externalScripts === true, basePath: base },
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Persists the metadata defaults into the document itself, so the head component reads the
|
|
182
|
+
* public site URL on the client too, not only while prerendering. Anything the head layer
|
|
183
|
+
* recorded about the tags it overwrote is merged in, and an earlier injection from a previous
|
|
184
|
+
* run is replaced so repeated runs stay identical. Written as inert JSON rather than an
|
|
185
|
+
* executable script, so a strict script-src applies to nothing here.
|
|
186
|
+
*/
|
|
187
|
+
async function injectMetadataDefaults(page, defaults) {
|
|
188
|
+
const payload = await page.evaluate((fallback) => {
|
|
189
|
+
document.querySelectorAll('script[data-snappy-meta]').forEach((script) => script.remove());
|
|
190
|
+
const current = typeof window === 'undefined' ? null : window.__SNAPPY_META__;
|
|
191
|
+
const recorded = typeof window === 'undefined' ? null : window.__snappyHeadOriginals;
|
|
192
|
+
const merged = { ...(current ?? fallback) };
|
|
193
|
+
if (recorded) merged.originals = recorded;
|
|
194
|
+
return Object.keys(merged).length > 0 ? merged : fallback;
|
|
195
|
+
}, defaults);
|
|
196
|
+
const element = escapeForScript(payload);
|
|
197
|
+
await page.evaluate((text) => {
|
|
198
|
+
const script = document.createElement('script');
|
|
199
|
+
script.setAttribute('type', 'application/json');
|
|
200
|
+
script.setAttribute('data-snappy-meta', '');
|
|
201
|
+
script.textContent = text;
|
|
202
|
+
const first = document.scripts[0];
|
|
203
|
+
if (first?.parentNode) first.parentNode.insertBefore(script, first);
|
|
204
|
+
else document.head.appendChild(script);
|
|
205
|
+
}, element);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Collects same-origin link targets from the rendered page and converts them to
|
|
210
|
+
* app-relative routes for the crawler. Off-origin links are ignored so external
|
|
211
|
+
* URLs can never be mistaken for routes and written into the output directory.
|
|
212
|
+
*/
|
|
213
|
+
async function collectLinks(page, base, origin) {
|
|
214
|
+
const hrefs = await page.$$eval('a[href]', (anchors) => anchors.map((anchor) => anchor.href));
|
|
215
|
+
const routes = [];
|
|
216
|
+
for (const href of hrefs) {
|
|
217
|
+
let parsed;
|
|
218
|
+
try {
|
|
219
|
+
parsed = new URL(href);
|
|
220
|
+
} catch {
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if (parsed.origin !== origin) continue;
|
|
224
|
+
const route = toAppRoute(href, base);
|
|
225
|
+
if (route) routes.push(route);
|
|
226
|
+
}
|
|
227
|
+
return routes;
|
|
228
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { blockThirdPartyRequests } from './blocking.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Watches one page's network traffic. Records every third-party origin for the
|
|
5
|
+
* preconnect hints, records same-origin asset paths for the preload features,
|
|
6
|
+
* captures JSON bodies when the AJAX cache is enabled, and optionally aborts
|
|
7
|
+
* third-party requests when blocking is switched on.
|
|
8
|
+
*/
|
|
9
|
+
export function createResourceCollector({
|
|
10
|
+
origin,
|
|
11
|
+
base,
|
|
12
|
+
allowedHosts = [],
|
|
13
|
+
blockThirdParty = false,
|
|
14
|
+
collectJson = false,
|
|
15
|
+
maxCachedBytes = Number.POSITIVE_INFINITY,
|
|
16
|
+
}) {
|
|
17
|
+
const thirdPartyOrigins = new Set();
|
|
18
|
+
const images = new Set();
|
|
19
|
+
const scripts = new Set();
|
|
20
|
+
const styles = new Set();
|
|
21
|
+
const json = new Map();
|
|
22
|
+
const pending = [];
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
thirdPartyOrigins,
|
|
26
|
+
images,
|
|
27
|
+
scripts,
|
|
28
|
+
styles,
|
|
29
|
+
json,
|
|
30
|
+
// Waits for in-flight JSON body reads so the cache is complete before injection.
|
|
31
|
+
async settled() {
|
|
32
|
+
await Promise.allSettled(pending);
|
|
33
|
+
},
|
|
34
|
+
async install(page) {
|
|
35
|
+
page.on('request', (request) => {
|
|
36
|
+
const parsed = parseUrl(request.url());
|
|
37
|
+
if (!parsed) return;
|
|
38
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return;
|
|
39
|
+
if (parsed.origin === origin) return;
|
|
40
|
+
thirdPartyOrigins.add(parsed.origin);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
if (blockThirdParty) {
|
|
44
|
+
await blockThirdPartyRequests(page, origin, allowedHosts);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
page.on('response', (response) => {
|
|
48
|
+
const request = response.request();
|
|
49
|
+
const url = request.url();
|
|
50
|
+
const parsed = parseUrl(url);
|
|
51
|
+
if (!parsed || parsed.origin !== origin) return;
|
|
52
|
+
const path = toAppPath(url, base);
|
|
53
|
+
if (!path) return;
|
|
54
|
+
|
|
55
|
+
const resourceType = request.resourceType();
|
|
56
|
+
if (resourceType === 'image') images.add(path);
|
|
57
|
+
else if (resourceType === 'script') scripts.add(path);
|
|
58
|
+
else if (resourceType === 'stylesheet') styles.add(path);
|
|
59
|
+
else if (collectJson && isJson(response)) {
|
|
60
|
+
const length = Number(response.headers()['content-length'] ?? Number.NaN);
|
|
61
|
+
if (Number.isFinite(length) && length > maxCachedBytes) return;
|
|
62
|
+
const capture = response
|
|
63
|
+
.json()
|
|
64
|
+
.then((body) => {
|
|
65
|
+
if (withinCacheLimit(body, maxCachedBytes)) json.set(path, body);
|
|
66
|
+
})
|
|
67
|
+
.catch(() => {
|
|
68
|
+
// A truncated or aborted response is simply not cached.
|
|
69
|
+
});
|
|
70
|
+
pending.push(capture);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Converts an absolute URL to the app-relative path, keeping the query string so
|
|
79
|
+
* cached responses stay addressable by the same key the app used.
|
|
80
|
+
*/
|
|
81
|
+
export function toAppPath(url, base) {
|
|
82
|
+
const parsed = parseUrl(url);
|
|
83
|
+
if (!parsed) return null;
|
|
84
|
+
let pathname = parsed.pathname;
|
|
85
|
+
if (base !== '/') {
|
|
86
|
+
const prefix = base.slice(0, -1);
|
|
87
|
+
if (pathname === prefix) pathname = '/';
|
|
88
|
+
else if (pathname.startsWith(`${prefix}/`)) pathname = pathname.slice(prefix.length);
|
|
89
|
+
else return null;
|
|
90
|
+
}
|
|
91
|
+
return `${pathname}${parsed.search}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Reports whether a captured JSON body fits the cache budget, measured on the serialised
|
|
96
|
+
* text so the check matches how much is injected into the page.
|
|
97
|
+
*/
|
|
98
|
+
export function withinCacheLimit(body, maxBytes) {
|
|
99
|
+
return JSON.stringify(body).length <= maxBytes;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function parseUrl(url) {
|
|
103
|
+
try {
|
|
104
|
+
return new URL(url);
|
|
105
|
+
} catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function isJson(response) {
|
|
111
|
+
const contentType = response.headers()['content-type'] ?? '';
|
|
112
|
+
return contentType.includes('json');
|
|
113
|
+
}
|