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,133 @@
|
|
|
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(html, options = {}) {
|
|
11
|
+
if (options.scripts?.length === 0 && options.fonts?.size === 0 && !options.lcpImage) {
|
|
12
|
+
return html;
|
|
13
|
+
}
|
|
14
|
+
const document = parse(html);
|
|
15
|
+
const added = injectPreloadLinksInto(document, options);
|
|
16
|
+
return added > 0 ? serialize(document) : html;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Injects preload hints into a parsed document and reports how many were added.
|
|
21
|
+
* Keeping this separate lets the write pipeline parse the page once for all final
|
|
22
|
+
* post-processing steps.
|
|
23
|
+
*/
|
|
24
|
+
export function injectPreloadLinksInto(
|
|
25
|
+
document,
|
|
26
|
+
{
|
|
27
|
+
scripts = [],
|
|
28
|
+
fonts = new Map(),
|
|
29
|
+
lcpImage = null,
|
|
30
|
+
base = '/',
|
|
31
|
+
ignoreForPreload = [],
|
|
32
|
+
skipScripts = new Set(),
|
|
33
|
+
} = {},
|
|
34
|
+
) {
|
|
35
|
+
const head = findHead(document);
|
|
36
|
+
if (!head) return 0;
|
|
37
|
+
const existingScripts = new Set();
|
|
38
|
+
const hints = new Set();
|
|
39
|
+
|
|
40
|
+
const walk = (node) => {
|
|
41
|
+
if (node.tagName === 'script') {
|
|
42
|
+
const src = attribute(node, 'src');
|
|
43
|
+
if (src) existingScripts.add(src);
|
|
44
|
+
}
|
|
45
|
+
if (node.tagName === 'link') {
|
|
46
|
+
const href = attribute(node, 'href');
|
|
47
|
+
if (href && attribute(node, 'rel').split(/\s+/).includes('preload')) hints.add(href);
|
|
48
|
+
}
|
|
49
|
+
for (const child of node.childNodes ?? []) walk(child);
|
|
50
|
+
};
|
|
51
|
+
walk(document);
|
|
52
|
+
|
|
53
|
+
const ignored = new Set(ignoreForPreload);
|
|
54
|
+
let added = 0;
|
|
55
|
+
const add = (element, href) => {
|
|
56
|
+
if (hints.has(href) || ignored.has(path.posix.basename(href))) return;
|
|
57
|
+
hints.add(href);
|
|
58
|
+
head.childNodes.push(element);
|
|
59
|
+
added += 1;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
for (const script of [...scripts].sort()) {
|
|
63
|
+
const href = toPublicPath(script, base);
|
|
64
|
+
if (existingScripts.has(href) || skipScripts.has(href)) continue;
|
|
65
|
+
add(
|
|
66
|
+
linkNode([
|
|
67
|
+
{ name: 'rel', value: 'modulepreload' },
|
|
68
|
+
{ name: 'href', value: href },
|
|
69
|
+
]),
|
|
70
|
+
href,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
for (const [font, contentType] of [...fonts.entries()].sort(([left], [right]) =>
|
|
74
|
+
left.localeCompare(right),
|
|
75
|
+
)) {
|
|
76
|
+
const href = toPublicPath(font, base);
|
|
77
|
+
const attrs = [
|
|
78
|
+
{ name: 'rel', value: 'preload' },
|
|
79
|
+
{ name: 'as', value: 'font' },
|
|
80
|
+
{ name: 'href', value: href },
|
|
81
|
+
{ name: 'crossorigin', value: '' },
|
|
82
|
+
];
|
|
83
|
+
if (contentType) attrs.push({ name: 'type', value: contentType });
|
|
84
|
+
add(linkNode(attrs), href);
|
|
85
|
+
}
|
|
86
|
+
if (lcpImage) {
|
|
87
|
+
const href = toPublicPath(lcpImage, base);
|
|
88
|
+
add(
|
|
89
|
+
linkNode([
|
|
90
|
+
{ name: 'rel', value: 'preload' },
|
|
91
|
+
{ name: 'as', value: 'image' },
|
|
92
|
+
{ name: 'href', value: href },
|
|
93
|
+
{ name: 'fetchpriority', value: 'high' },
|
|
94
|
+
]),
|
|
95
|
+
href,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
return added;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Collects script src values from an already parsed document.
|
|
102
|
+
export function collectScriptSourcesFromDocument(document) {
|
|
103
|
+
const sources = new Set();
|
|
104
|
+
const walk = (node) => {
|
|
105
|
+
if (node.tagName === 'script') {
|
|
106
|
+
const src = attribute(node, 'src');
|
|
107
|
+
if (src) sources.add(src);
|
|
108
|
+
}
|
|
109
|
+
for (const child of node.childNodes ?? []) walk(child);
|
|
110
|
+
};
|
|
111
|
+
walk(document);
|
|
112
|
+
return sources;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function findHead(document) {
|
|
116
|
+
const html = (document.childNodes ?? []).find((node) => node.tagName === 'html');
|
|
117
|
+
return (html?.childNodes ?? []).find((node) => node.tagName === 'head') ?? null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function linkNode(attrs) {
|
|
121
|
+
return {
|
|
122
|
+
nodeName: 'link',
|
|
123
|
+
tagName: 'link',
|
|
124
|
+
attrs,
|
|
125
|
+
namespaceURI: 'http://www.w3.org/1999/xhtml',
|
|
126
|
+
childNodes: [],
|
|
127
|
+
parentNode: null,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function attribute(node, name) {
|
|
132
|
+
return node.attrs?.find((entry) => entry.name === name)?.value ?? '';
|
|
133
|
+
}
|
|
@@ -12,8 +12,12 @@ const PAGE_ORIGIN = 'http://snappy.local';
|
|
|
12
12
|
* output rather than the render-time resource list keeps the manifest correct after
|
|
13
13
|
* inline CSS, tag surgery, or a failed write.
|
|
14
14
|
*/
|
|
15
|
-
export function collectPageResources(html,
|
|
16
|
-
|
|
15
|
+
export function collectPageResources(html, options = {}) {
|
|
16
|
+
return collectPageResourcesFromDocument(parse(html), options);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Reads referenced resources from an already parsed document.
|
|
20
|
+
export function collectPageResourcesFromDocument(document, { base = '/', route = '/' } = {}) {
|
|
17
21
|
const scripts = [];
|
|
18
22
|
const styles = [];
|
|
19
23
|
const pageUrl = new URL(toPublicPath(route, base), PAGE_ORIGIN);
|
package/src/core/render.js
CHANGED
|
@@ -1,24 +1,13 @@
|
|
|
1
|
+
import { installClock, installClockScripts } from './clock.js';
|
|
1
2
|
import { captureFormState } from './form-state.js';
|
|
2
3
|
import { inlineStylesheets } from './inline-css.js';
|
|
3
|
-
import {
|
|
4
|
+
import { localeContextOptions, localeStorageState, seedLocaleContext } from './locale-context.js';
|
|
5
|
+
import { readPageState } from './page-content.js';
|
|
4
6
|
import { createResourceCollector } from './resources.js';
|
|
5
|
-
import { toAppRoute, toServerUrl } from './routes.js';
|
|
7
|
+
import { normaliseRoute, toAppRoute, toServerUrl } from './routes.js';
|
|
6
8
|
import { captureRuntimeStyles } from './runtime-styles.js';
|
|
7
9
|
import { freezeAnimations, scrollThroughPage, settlePage, trackNetwork } from './settle.js';
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
const SCRIPT_ESCAPES = {
|
|
11
|
-
'<': '\\u003C',
|
|
12
|
-
'>': '\\u003E',
|
|
13
|
-
'/': '\\u002F',
|
|
14
|
-
'\u2028': '\\u2028',
|
|
15
|
-
'\u2029': '\\u2029',
|
|
16
|
-
};
|
|
17
|
-
|
|
18
|
-
// Serialises a value for embedding in a script element without allowing tag breakout.
|
|
19
|
-
function escapeForScript(value) {
|
|
20
|
-
return JSON.stringify(value).replace(/[<>/\u2028\u2029]/g, (char) => SCRIPT_ESCAPES[char]);
|
|
21
|
-
}
|
|
10
|
+
import { readRenderSnapshot } from './text-separators.js';
|
|
22
11
|
|
|
23
12
|
/**
|
|
24
13
|
* Renders one route in a fresh browser context and returns the serialised DOM plus
|
|
@@ -26,16 +15,36 @@ function escapeForScript(value) {
|
|
|
26
15
|
* storage from leaking between routes. When a screenshot path is supplied the route
|
|
27
16
|
* is captured as an image instead of HTML.
|
|
28
17
|
*/
|
|
29
|
-
export async function renderRoute({
|
|
18
|
+
export async function renderRoute({
|
|
19
|
+
browser,
|
|
20
|
+
origin,
|
|
21
|
+
route,
|
|
22
|
+
locale = null,
|
|
23
|
+
config,
|
|
24
|
+
screenshot = false,
|
|
25
|
+
traceFile = null,
|
|
26
|
+
caches = null,
|
|
27
|
+
}) {
|
|
30
28
|
const context = await browser.newContext({
|
|
31
29
|
viewport: config.viewport,
|
|
32
|
-
storageState: config
|
|
30
|
+
storageState: localeStorageState(config, locale),
|
|
33
31
|
userAgent: config.userAgent ?? undefined,
|
|
34
32
|
ignoreHTTPSErrors: config.ignoreHTTPSErrors,
|
|
35
33
|
reducedMotion: config.freezeAnimations ? 'reduce' : 'no-preference',
|
|
36
34
|
serviceWorkers: 'block',
|
|
35
|
+
...localeContextOptions(config, locale),
|
|
37
36
|
});
|
|
37
|
+
let tracing = false;
|
|
38
|
+
if (traceFile) {
|
|
39
|
+
await context.tracing.start({ screenshots: false, snapshots: true, sources: false });
|
|
40
|
+
tracing = true;
|
|
41
|
+
}
|
|
38
42
|
const page = await context.newPage();
|
|
43
|
+
await installClock(page, config.fixedTime ?? null);
|
|
44
|
+
await installClockScripts(page, {
|
|
45
|
+
fixedTime: config.fixedTime ?? null,
|
|
46
|
+
randomSeed: config.randomSeed ?? null,
|
|
47
|
+
});
|
|
39
48
|
const pageErrors = [];
|
|
40
49
|
const consoleErrors = [];
|
|
41
50
|
const failedRequests = [];
|
|
@@ -63,6 +72,15 @@ export async function renderRoute({ browser, origin, route, config, screenshot =
|
|
|
63
72
|
const metadataDefaults = config.metadata
|
|
64
73
|
? { base: config.base, flatOutput: config.flatOutput, ...config.metadata }
|
|
65
74
|
: null;
|
|
75
|
+
await seedLocaleContext({
|
|
76
|
+
page,
|
|
77
|
+
context,
|
|
78
|
+
origin,
|
|
79
|
+
config,
|
|
80
|
+
route,
|
|
81
|
+
locale,
|
|
82
|
+
phase: 'render',
|
|
83
|
+
});
|
|
66
84
|
if (metadataDefaults) {
|
|
67
85
|
await page.addInitScript((defaults) => {
|
|
68
86
|
window.__SNAPPY_META__ = defaults;
|
|
@@ -78,25 +96,58 @@ export async function renderRoute({ browser, origin, route, config, screenshot =
|
|
|
78
96
|
maxCachedBytes: config.maxCachedBytes,
|
|
79
97
|
});
|
|
80
98
|
await collector.install(page);
|
|
99
|
+
await page.addInitScript(() => {
|
|
100
|
+
window.__snappyLcp = [];
|
|
101
|
+
try {
|
|
102
|
+
const observer = new PerformanceObserver((list) => {
|
|
103
|
+
for (const entry of list.getEntries()) {
|
|
104
|
+
const element = entry.element;
|
|
105
|
+
window.__snappyLcp.push({
|
|
106
|
+
url: entry.url || element?.currentSrc || element?.src || null,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
observer.observe({ type: 'largest-contentful-paint', buffered: true });
|
|
111
|
+
} catch {
|
|
112
|
+
// Older engines simply do not report LCP; preload injection falls back to no hint.
|
|
113
|
+
}
|
|
114
|
+
});
|
|
81
115
|
const tracker = trackNetwork(page);
|
|
82
116
|
try {
|
|
83
117
|
const response = await page.goto(toServerUrl(route, config.base, origin), {
|
|
84
118
|
waitUntil: 'domcontentloaded',
|
|
85
119
|
timeout: config.timeout,
|
|
86
120
|
});
|
|
121
|
+
const notFoundRoute = config.notFoundRoute ? normaliseRoute(config.notFoundRoute) : null;
|
|
122
|
+
const isNotFoundRoute = notFoundRoute !== null && normaliseRoute(route) === notFoundRoute;
|
|
87
123
|
if (response && response.status() >= 400) {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
)
|
|
124
|
+
// A configured 404 route may legitimately answer with HTTP 404; every other status
|
|
125
|
+
// is a failure, and the marker cannot be read before the navigation completes.
|
|
126
|
+
if (!(response.status() === 404 && isNotFoundRoute)) {
|
|
127
|
+
const error = new Error(
|
|
128
|
+
`route responded with HTTP ${response.status()}${response.statusText() ? ` ${response.statusText()}` : ''}`,
|
|
129
|
+
);
|
|
130
|
+
// A server error can be transient and is retried; a client error is the app's own.
|
|
131
|
+
error.retryable = response.status() >= 500;
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
91
134
|
}
|
|
92
135
|
await settlePage(page, config, tracker);
|
|
93
|
-
const empty = await
|
|
136
|
+
const { empty, statusCode } = await readPageState(page);
|
|
137
|
+
const finalUrl = page.url();
|
|
94
138
|
|
|
95
139
|
if (config.captureRuntimeStyles) await captureRuntimeStyles(page);
|
|
96
140
|
if (config.captureFormState) await captureFormState(page);
|
|
97
141
|
if (config.inlineCss === true || config.inlineCss === 'inline') {
|
|
98
|
-
const inlined = await inlineStylesheets({
|
|
142
|
+
const inlined = await inlineStylesheets({
|
|
143
|
+
page,
|
|
144
|
+
minifyCssOptions: config.minifyCss,
|
|
145
|
+
cache: caches?.inlineCss ?? null,
|
|
146
|
+
});
|
|
99
147
|
inlineCssSkipped = inlined.skipped;
|
|
148
|
+
if (caches?.inlineCss) {
|
|
149
|
+
for (const sheet of inlined.fetched ?? []) caches.inlineCss.set(sheet.href, sheet.css);
|
|
150
|
+
}
|
|
100
151
|
}
|
|
101
152
|
await collector.settled();
|
|
102
153
|
const stateScript = await injectCapturedState(page, collector.json, {
|
|
@@ -106,9 +157,17 @@ export async function renderRoute({ browser, origin, route, config, screenshot =
|
|
|
106
157
|
if (metadataDefaults) await injectMetadataDefaults(page, metadataDefaults);
|
|
107
158
|
if (config.freezeAnimations) await freezeAnimations(page);
|
|
108
159
|
if (config.scrollToBottom) await scrollThroughPage(page, config);
|
|
109
|
-
await
|
|
110
|
-
|
|
111
|
-
|
|
160
|
+
const { lcpUrl, hrefs } = await readRenderSnapshot(page);
|
|
161
|
+
let lcpImage = null;
|
|
162
|
+
if (lcpUrl) {
|
|
163
|
+
try {
|
|
164
|
+
const parsed = new URL(lcpUrl);
|
|
165
|
+
if (parsed.origin === origin) lcpImage = `${parsed.pathname}${parsed.search}`;
|
|
166
|
+
} catch {
|
|
167
|
+
// Ignore malformed performance entries.
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const links = collectLinks(hrefs, config.base, origin);
|
|
112
171
|
|
|
113
172
|
if (screenshot) {
|
|
114
173
|
const buffer = await page.screenshot({
|
|
@@ -126,6 +185,10 @@ export async function renderRoute({ browser, origin, route, config, screenshot =
|
|
|
126
185
|
stateScript,
|
|
127
186
|
screenshot: buffer,
|
|
128
187
|
empty,
|
|
188
|
+
statusCode,
|
|
189
|
+
httpStatus: response?.status() ?? null,
|
|
190
|
+
finalUrl,
|
|
191
|
+
lcpImage,
|
|
129
192
|
};
|
|
130
193
|
}
|
|
131
194
|
|
|
@@ -141,13 +204,37 @@ export async function renderRoute({ browser, origin, route, config, screenshot =
|
|
|
141
204
|
inlineCssSkipped,
|
|
142
205
|
stateScript,
|
|
143
206
|
empty,
|
|
207
|
+
statusCode,
|
|
208
|
+
httpStatus: response?.status() ?? null,
|
|
209
|
+
finalUrl,
|
|
210
|
+
lcpImage,
|
|
144
211
|
};
|
|
212
|
+
} catch (error) {
|
|
213
|
+
error.retryable ??= isRetryableRenderError(error);
|
|
214
|
+
if (tracing) {
|
|
215
|
+
tracing = false;
|
|
216
|
+
await context.tracing.stop({ path: traceFile }).then(
|
|
217
|
+
() => {
|
|
218
|
+
error.tracePath = traceFile;
|
|
219
|
+
},
|
|
220
|
+
// A failed trace must never replace the render error that explains the build failure.
|
|
221
|
+
() => {},
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
throw error;
|
|
145
225
|
} finally {
|
|
146
226
|
tracker.stop();
|
|
227
|
+
if (tracing) await context.tracing.stop().catch(() => {});
|
|
147
228
|
await context.close();
|
|
148
229
|
}
|
|
149
230
|
}
|
|
150
231
|
|
|
232
|
+
// Navigation and network failures are transient; a bad selector or an HTTP status is not.
|
|
233
|
+
function isRetryableRenderError(error) {
|
|
234
|
+
if (error?.name === 'TimeoutError') return true;
|
|
235
|
+
return /net::ERR_|timed? ?out|ECONNRESET|ECONNREFUSED|socket hang up/i.test(error?.message ?? '');
|
|
236
|
+
}
|
|
237
|
+
|
|
151
238
|
/**
|
|
152
239
|
* Injects captured JSON responses as window.snapStore and anything the app exposes
|
|
153
240
|
* through window.snapSaveState, before the first script runs. The client can then
|
|
@@ -229,16 +316,21 @@ async function injectCapturedState(page, cache, { externalScripts = false, base
|
|
|
229
316
|
* executable script, so a strict script-src applies to nothing here.
|
|
230
317
|
*/
|
|
231
318
|
async function injectMetadataDefaults(page, defaults) {
|
|
232
|
-
|
|
319
|
+
await page.evaluate((fallback) => {
|
|
233
320
|
document.querySelectorAll('script[data-snappy-meta]').forEach((script) => script.remove());
|
|
234
321
|
const current = typeof window === 'undefined' ? null : window.__SNAPPY_META__;
|
|
235
322
|
const recorded = typeof window === 'undefined' ? null : window.__snappyHeadOriginals;
|
|
236
323
|
const merged = { ...(current ?? fallback) };
|
|
237
324
|
if (recorded) merged.originals = recorded;
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
325
|
+
const payload = Object.keys(merged).length > 0 ? merged : fallback;
|
|
326
|
+
const escapes = {
|
|
327
|
+
'<': '\\u003C',
|
|
328
|
+
'>': '\\u003E',
|
|
329
|
+
'/': '\\u002F',
|
|
330
|
+
'\u2028': '\\u2028',
|
|
331
|
+
'\u2029': '\\u2029',
|
|
332
|
+
};
|
|
333
|
+
const text = JSON.stringify(payload).replace(/[<>/\u2028\u2029]/g, (char) => escapes[char]);
|
|
242
334
|
const script = document.createElement('script');
|
|
243
335
|
script.setAttribute('type', 'application/json');
|
|
244
336
|
script.setAttribute('data-snappy-meta', '');
|
|
@@ -246,7 +338,7 @@ async function injectMetadataDefaults(page, defaults) {
|
|
|
246
338
|
const first = document.scripts[0];
|
|
247
339
|
if (first?.parentNode) first.parentNode.insertBefore(script, first);
|
|
248
340
|
else document.head.appendChild(script);
|
|
249
|
-
},
|
|
341
|
+
}, defaults);
|
|
250
342
|
}
|
|
251
343
|
|
|
252
344
|
/**
|
|
@@ -254,8 +346,7 @@ async function injectMetadataDefaults(page, defaults) {
|
|
|
254
346
|
* app-relative routes for the crawler. Off-origin links are ignored so external
|
|
255
347
|
* URLs can never be mistaken for routes and written into the output directory.
|
|
256
348
|
*/
|
|
257
|
-
|
|
258
|
-
const hrefs = await page.$$eval('a[href]', (anchors) => anchors.map((anchor) => anchor.href));
|
|
349
|
+
function collectLinks(hrefs, base, origin) {
|
|
259
350
|
const routes = [];
|
|
260
351
|
for (const href of hrefs) {
|
|
261
352
|
let parsed;
|
|
@@ -268,7 +359,7 @@ async function collectLinks(page, base, origin) {
|
|
|
268
359
|
const route = toAppRoute(href, base);
|
|
269
360
|
if (route) routes.push(route);
|
|
270
361
|
}
|
|
271
|
-
return routes;
|
|
362
|
+
return [...new Set(routes)];
|
|
272
363
|
}
|
|
273
364
|
|
|
274
365
|
// Parses an HTTP URL, returning null for data, blob and other non-network URLs.
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { writeFileIfChanged } from './output.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Writes the machine-readable run report. The JSON is deliberately limited to route,
|
|
6
|
+
* audit and artifact data; configuration and captured state are never serialised.
|
|
7
|
+
*/
|
|
8
|
+
export async function writeReport({ report, config, log }) {
|
|
9
|
+
if (!config.reportPath) return null;
|
|
10
|
+
const contents = `${JSON.stringify(buildReport(report, config), null, 2)}\n`;
|
|
11
|
+
if (config.dryRun) {
|
|
12
|
+
log.info(`Dry run: would write report to ${config.reportPath}`);
|
|
13
|
+
return { file: config.reportPath, status: 'dry-run' };
|
|
14
|
+
}
|
|
15
|
+
const target = path.resolve(config.reportPath);
|
|
16
|
+
const written = await writeFileIfChanged({
|
|
17
|
+
dir: path.dirname(target),
|
|
18
|
+
file: path.basename(target),
|
|
19
|
+
contents,
|
|
20
|
+
});
|
|
21
|
+
log.info(`Wrote report to ${config.reportPath}`);
|
|
22
|
+
return { file: config.reportPath, status: written.status };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Shapes the public report document. `routes` here is an array of per-route records;
|
|
27
|
+
* the programmatic `report.routes` stays a list of route strings. URLs are reduced to
|
|
28
|
+
* origin and path, and the configured storage-state path is redacted everywhere, so a
|
|
29
|
+
* report can be attached to a build log without leaking credentials or session data.
|
|
30
|
+
*/
|
|
31
|
+
export function buildReport(report, config = null) {
|
|
32
|
+
const audit = report.audit ?? { errors: [], warnings: [], linksChecked: 0 };
|
|
33
|
+
const document = {
|
|
34
|
+
version: 1,
|
|
35
|
+
generatedAt: new Date().toISOString(),
|
|
36
|
+
durationMs: report.durationMs,
|
|
37
|
+
timings: report.timings,
|
|
38
|
+
ok: report.ok,
|
|
39
|
+
clock: report.clock ?? null,
|
|
40
|
+
locales: report.locales ?? null,
|
|
41
|
+
seoLocale: report.seoLocale ?? null,
|
|
42
|
+
summary: {
|
|
43
|
+
routes: report.routeDetails.length,
|
|
44
|
+
files: report.files.filter((file) => file.status !== 'dry-run').length,
|
|
45
|
+
errors: uniqueErrorCount(report, audit),
|
|
46
|
+
warnings: audit.warnings.length,
|
|
47
|
+
},
|
|
48
|
+
routes: report.routeDetails,
|
|
49
|
+
verification: report.verification
|
|
50
|
+
? {
|
|
51
|
+
ok: report.verification.ok,
|
|
52
|
+
modes: report.verification.modes,
|
|
53
|
+
frameworks: report.verification.frameworks,
|
|
54
|
+
skipped: report.verification.skipped ?? 0,
|
|
55
|
+
policySkipped: report.verification.policySkipped ?? 0,
|
|
56
|
+
allowed: report.verification.allowed ?? 0,
|
|
57
|
+
allowedRerender: report.verification.allowedRerender ?? 0,
|
|
58
|
+
errors: report.verification.errors,
|
|
59
|
+
}
|
|
60
|
+
: null,
|
|
61
|
+
audit,
|
|
62
|
+
sitemap: report.sitemap,
|
|
63
|
+
preloadManifest: report.preloadManifest,
|
|
64
|
+
truncated: report.truncated,
|
|
65
|
+
notFoundRoutes: report.notFoundRoutes,
|
|
66
|
+
duplicateOutputs: report.duplicatePages,
|
|
67
|
+
routeSources: report.routeSources ?? [],
|
|
68
|
+
cspSkipped: report.cspSkipped ?? [],
|
|
69
|
+
traces: report.traces ?? [],
|
|
70
|
+
};
|
|
71
|
+
return sanitiseValue(document, config);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Counts route errors once even when the audit mirrors them as findings.
|
|
75
|
+
function uniqueErrorCount(report, audit) {
|
|
76
|
+
const seen = new Set();
|
|
77
|
+
for (const error of [...(report.errors ?? []), ...(audit.errors ?? [])]) {
|
|
78
|
+
seen.add(`${error.route ?? ''}|${error.message ?? ''}`);
|
|
79
|
+
}
|
|
80
|
+
return seen.size;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Recursively redacts URLs and the configured storage-state path from report strings.
|
|
84
|
+
function sanitiseValue(value, config) {
|
|
85
|
+
if (Array.isArray(value)) return value.map((entry) => sanitiseValue(entry, config));
|
|
86
|
+
if (value !== null && typeof value === 'object') {
|
|
87
|
+
return Object.fromEntries(
|
|
88
|
+
Object.entries(value).map(([key, entry]) => [key, sanitiseValue(entry, config)]),
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
if (typeof value !== 'string') return value;
|
|
92
|
+
let text = value;
|
|
93
|
+
if (config?.storageState) {
|
|
94
|
+
for (const secret of [config.storageState, path.resolve(config.storageState)]) {
|
|
95
|
+
if (text.includes(secret)) text = text.split(secret).join('[redacted]');
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (!/https?:\/\//i.test(text)) return text;
|
|
99
|
+
return text.replace(/https?:\/\/[^\s"'<>]+/gi, redactUrl);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Removes credentials, query strings and fragments from an absolute URL.
|
|
103
|
+
function redactUrl(raw) {
|
|
104
|
+
const trailing = /[.,;)\]]+$/.exec(raw)?.[0] ?? '';
|
|
105
|
+
const urlText = trailing ? raw.slice(0, -trailing.length) : raw;
|
|
106
|
+
try {
|
|
107
|
+
const url = new URL(urlText);
|
|
108
|
+
url.username = '';
|
|
109
|
+
url.password = '';
|
|
110
|
+
url.search = '';
|
|
111
|
+
url.hash = '';
|
|
112
|
+
return `${url.href}${trailing}`;
|
|
113
|
+
} catch {
|
|
114
|
+
return raw;
|
|
115
|
+
}
|
|
116
|
+
}
|
package/src/core/resources.js
CHANGED
|
@@ -18,6 +18,7 @@ export function createResourceCollector({
|
|
|
18
18
|
const images = new Set();
|
|
19
19
|
const scripts = new Set();
|
|
20
20
|
const styles = new Set();
|
|
21
|
+
const fonts = new Map();
|
|
21
22
|
const json = new Map();
|
|
22
23
|
const pending = [];
|
|
23
24
|
|
|
@@ -26,6 +27,7 @@ export function createResourceCollector({
|
|
|
26
27
|
images,
|
|
27
28
|
scripts,
|
|
28
29
|
styles,
|
|
30
|
+
fonts,
|
|
29
31
|
json,
|
|
30
32
|
// Waits for in-flight JSON body reads so the cache is complete before injection.
|
|
31
33
|
async settled() {
|
|
@@ -57,7 +59,9 @@ export function createResourceCollector({
|
|
|
57
59
|
if (resourceType === 'image') images.add(path);
|
|
58
60
|
else if (resourceType === 'script') scripts.add(path);
|
|
59
61
|
else if (resourceType === 'stylesheet') styles.add(path);
|
|
60
|
-
else if (
|
|
62
|
+
else if (resourceType === 'font') {
|
|
63
|
+
fonts.set(path, response.headers()['content-type'] ?? null);
|
|
64
|
+
} else if (collectJson && isJson(response)) {
|
|
61
65
|
const length = Number(response.headers()['content-length'] ?? Number.NaN);
|
|
62
66
|
if (Number.isFinite(length) && length > maxCachedBytes) return;
|
|
63
67
|
const capture = response
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { matchesPattern } from './routes.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resolves one route's effective config by applying matching routeOverrides in order.
|
|
5
|
+
* Later entries win per key; the base config is never mutated.
|
|
6
|
+
*/
|
|
7
|
+
export function resolveRouteConfig(config, route) {
|
|
8
|
+
let resolved = config;
|
|
9
|
+
for (const override of config.routeOverrides ?? []) {
|
|
10
|
+
if (!matchesPattern(route, override.match)) continue;
|
|
11
|
+
const values = Object.fromEntries(
|
|
12
|
+
Object.entries(override).filter(([key, value]) => key !== 'match' && value !== undefined),
|
|
13
|
+
);
|
|
14
|
+
resolved = { ...resolved, ...values };
|
|
15
|
+
}
|
|
16
|
+
return resolved;
|
|
17
|
+
}
|