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
package/src/core/verify.js
CHANGED
|
@@ -1,27 +1,19 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
1
3
|
import { blockThirdPartyRequests } from './blocking.js';
|
|
4
|
+
import { installClock, installClockScripts } from './clock.js';
|
|
2
5
|
import { DEFAULTS } from './config.js';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
+
import { frameworkFromMarkers, isHydrationError } from './frameworks.js';
|
|
7
|
+
import { compareContainers } from './hydration-diff.js';
|
|
8
|
+
import { isHydrationAllowed, resolveHydrationPolicy } from './hydration-policy.js';
|
|
9
|
+
import { localeContextOptions, localeStorageState, seedLocaleContext } from './locale-context.js';
|
|
10
|
+
import { activeLocale } from './locales.js';
|
|
11
|
+
import { routeToFile } from './output.js';
|
|
12
|
+
import { CONTAINER_SELECTOR, readBootState } from './page-content.js';
|
|
13
|
+
import { resolveRouteConfig } from './route-config.js';
|
|
14
|
+
import { normaliseRoute, toServerUrl } from './routes.js';
|
|
6
15
|
import { settlePage, trackNetwork } from './settle.js';
|
|
7
16
|
|
|
8
|
-
const HYDRATION_PATTERNS = [
|
|
9
|
-
/hydrat/i,
|
|
10
|
-
/text content did not match/i,
|
|
11
|
-
/prop [`"].+?[`"] did not match/i,
|
|
12
|
-
/server-rendered HTML/i,
|
|
13
|
-
// React 16 and 17 phrase their mismatch warnings differently and never use the
|
|
14
|
-
// minified codes, which only exist from React 18 on.
|
|
15
|
-
/did not expect server HTML/i,
|
|
16
|
-
/expected server HTML to contain/i,
|
|
17
|
-
/react\.dev\/errors\/(418|419|421|422|423|424|425)/i,
|
|
18
|
-
/Minified React error #(418|419|421|422|423|424|425)\b/i,
|
|
19
|
-
];
|
|
20
|
-
|
|
21
|
-
// Containers used by the common React setups, so the probe can find the prerendered
|
|
22
|
-
// subtree without configuration.
|
|
23
|
-
const CONTAINER_SELECTOR = '#root, #app, #__next';
|
|
24
|
-
|
|
25
17
|
/**
|
|
26
18
|
* Page-side probe installed before navigation. It remembers the first prerendered
|
|
27
19
|
* child of the app container, which is what lets the verifier tell hydration from a
|
|
@@ -48,19 +40,9 @@ function bootProbe() {
|
|
|
48
40
|
});
|
|
49
41
|
}
|
|
50
42
|
|
|
51
|
-
/**
|
|
52
|
-
* Classifies a console or page error as a React hydration failure. Exported so the
|
|
53
|
-
* pattern set is testable without a browser. Patterns stay narrow on purpose: a
|
|
54
|
-
* false positive fails a user's build, so unrelated 4xx React codes and generic
|
|
55
|
-
* "does not match" messages are deliberately not treated as hydration errors.
|
|
56
|
-
*/
|
|
57
|
-
export function isHydrationError(message) {
|
|
58
|
-
return HYDRATION_PATTERNS.some((pattern) => pattern.test(message));
|
|
59
|
-
}
|
|
60
|
-
|
|
61
43
|
/**
|
|
62
44
|
* Classifies how a prerendered page booted. `hydrated` means the app adopted the
|
|
63
|
-
* markup, `re-rendered` means it discarded it, `undetected` means no
|
|
45
|
+
* markup, `re-rendered` means it discarded it, `undetected` means no framework root was
|
|
64
46
|
* found on the container, and `unknown` means the prerendered child was never seen.
|
|
65
47
|
*/
|
|
66
48
|
export function classifyBoot({ markupPreserved, frameworkMounted }) {
|
|
@@ -74,54 +56,132 @@ export function classifyBoot({ markupPreserved, frameworkMounted }) {
|
|
|
74
56
|
* hydration errors plus how each route booted. This is the pass that catches markup
|
|
75
57
|
* that only breaks once the app re-attaches, and apps that never attach to it at all.
|
|
76
58
|
*/
|
|
77
|
-
export async function verifyRoutes({
|
|
78
|
-
|
|
79
|
-
|
|
59
|
+
export async function verifyRoutes({
|
|
60
|
+
browser,
|
|
61
|
+
origin,
|
|
62
|
+
routes,
|
|
63
|
+
config,
|
|
64
|
+
outputDir = null,
|
|
65
|
+
routeLocales = new Map(),
|
|
66
|
+
}) {
|
|
67
|
+
const policySkipped = routes.filter((route) => resolveHydrationPolicy(config, route) === 'skip');
|
|
68
|
+
const policySkippedSet = new Set(policySkipped);
|
|
69
|
+
const toVerify = routes.filter((route) => !policySkippedSet.has(route));
|
|
70
|
+
const results = new Array(toVerify.length).fill(undefined);
|
|
71
|
+
const errors = [];
|
|
72
|
+
let cursor = 0;
|
|
73
|
+
const canReuse = config.reuseVerificationContext && !config.locales && !config.seoLocale;
|
|
74
|
+
const workerCount = Math.max(1, Math.min(config.concurrency, toVerify.length || 1));
|
|
75
|
+
await Promise.all(
|
|
76
|
+
Array.from({ length: workerCount }, async () => {
|
|
77
|
+
let sharedContext = null;
|
|
78
|
+
try {
|
|
79
|
+
if (canReuse && toVerify.length > 0) {
|
|
80
|
+
sharedContext = await createContext(browser, config, activeLocale(config.locales));
|
|
81
|
+
}
|
|
82
|
+
while (cursor < toVerify.length) {
|
|
83
|
+
const index = cursor;
|
|
84
|
+
cursor += 1;
|
|
85
|
+
const route = toVerify[index];
|
|
86
|
+
try {
|
|
87
|
+
results[index] = await verifyRoute({
|
|
88
|
+
browser,
|
|
89
|
+
origin,
|
|
90
|
+
route,
|
|
91
|
+
config: resolveRouteConfig(config, route),
|
|
92
|
+
outputDir,
|
|
93
|
+
context: sharedContext,
|
|
94
|
+
locale:
|
|
95
|
+
routeLocales.get(route)?.locale ??
|
|
96
|
+
(config.locales?.mode === 'single' ? activeLocale(config.locales) : null),
|
|
97
|
+
});
|
|
98
|
+
} catch (error) {
|
|
99
|
+
errors.push({ item: route, error });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
} finally {
|
|
103
|
+
if (sharedContext) await sharedContext.close();
|
|
104
|
+
}
|
|
105
|
+
}),
|
|
80
106
|
);
|
|
81
107
|
const verified = results.filter(Boolean);
|
|
82
108
|
const modes = { hydrated: 0, 're-rendered': 0, undetected: 0, unknown: 0 };
|
|
83
|
-
|
|
109
|
+
const frameworks = {};
|
|
110
|
+
for (const entry of verified) {
|
|
111
|
+
modes[entry.mode] += 1;
|
|
112
|
+
const name = entry.framework ?? 'unknown';
|
|
113
|
+
frameworks[name] = (frameworks[name] ?? 0) + 1;
|
|
114
|
+
}
|
|
84
115
|
return {
|
|
85
116
|
routes: verified,
|
|
86
117
|
modes,
|
|
118
|
+
frameworks,
|
|
119
|
+
allowed: verified.filter((entry) => entry.allowed).length,
|
|
120
|
+
allowedRerender: verified.filter((entry) => entry.allowed && entry.mode === 're-rendered')
|
|
121
|
+
.length,
|
|
122
|
+
skippedRoutes: policySkipped,
|
|
87
123
|
ok: verified.every((entry) => entry.ok) && errors.length === 0,
|
|
88
124
|
errors: errors.map(({ item, error }) => ({ route: item, message: error.message })),
|
|
89
125
|
};
|
|
90
126
|
}
|
|
91
127
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
*/
|
|
96
|
-
async function verifyRoute({ browser, origin, route, config }) {
|
|
97
|
-
const context = await browser.newContext({
|
|
128
|
+
// Creates the browser context a verification worker reuses across routes.
|
|
129
|
+
function createContext(browser, config, locale = null) {
|
|
130
|
+
return browser.newContext({
|
|
98
131
|
viewport: config.viewport,
|
|
99
|
-
storageState: config
|
|
132
|
+
storageState: localeStorageState(config, locale),
|
|
100
133
|
userAgent:
|
|
101
134
|
config.userAgent === DEFAULTS.userAgent ? undefined : (config.userAgent ?? undefined),
|
|
102
135
|
ignoreHTTPSErrors: config.ignoreHTTPSErrors,
|
|
103
136
|
reducedMotion: config.freezeAnimations ? 'reduce' : 'no-preference',
|
|
104
137
|
serviceWorkers: 'block',
|
|
138
|
+
...localeContextOptions(config, locale),
|
|
105
139
|
});
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Verifies one route against the same request rules as the render pass, so what is
|
|
144
|
+
* verified matches what was rendered. A supplied context is reused by the worker; the
|
|
145
|
+
* page is always fresh.
|
|
146
|
+
*/
|
|
147
|
+
async function verifyRoute({
|
|
148
|
+
browser,
|
|
149
|
+
origin,
|
|
150
|
+
route,
|
|
151
|
+
config,
|
|
152
|
+
outputDir,
|
|
153
|
+
context = null,
|
|
154
|
+
locale = null,
|
|
155
|
+
}) {
|
|
156
|
+
const ownsContext = context === null;
|
|
157
|
+
const activeContext = context ?? (await createContext(browser, config, locale));
|
|
158
|
+
const page = await activeContext.newPage();
|
|
159
|
+
await installClock(page, config.fixedTime ?? null);
|
|
160
|
+
await installClockScripts(page, {
|
|
161
|
+
fixedTime: config.fixedTime ?? null,
|
|
162
|
+
randomSeed: config.randomSeed ?? null,
|
|
163
|
+
});
|
|
164
|
+
const consoleMessages = [];
|
|
165
|
+
const pageMessages = [];
|
|
115
166
|
page.on('console', (message) => {
|
|
116
|
-
if (message.type() === 'error')
|
|
167
|
+
if (message.type() === 'error') consoleMessages.push(message.text());
|
|
117
168
|
});
|
|
118
|
-
page.on('pageerror', (error) =>
|
|
169
|
+
page.on('pageerror', (error) => pageMessages.push(error.message));
|
|
119
170
|
|
|
120
171
|
const tracker = trackNetwork(page);
|
|
121
172
|
try {
|
|
122
173
|
if (config.blockThirdParty) {
|
|
123
174
|
await blockThirdPartyRequests(page, origin, config.allowedHosts);
|
|
124
175
|
}
|
|
176
|
+
await seedLocaleContext({
|
|
177
|
+
page,
|
|
178
|
+
context: activeContext,
|
|
179
|
+
origin,
|
|
180
|
+
config,
|
|
181
|
+
route,
|
|
182
|
+
locale,
|
|
183
|
+
phase: 'verify',
|
|
184
|
+
});
|
|
125
185
|
await page.addInitScript(bootProbe);
|
|
126
186
|
const response = await page.goto(toServerUrl(route, config.base, origin), {
|
|
127
187
|
waitUntil: 'domcontentloaded',
|
|
@@ -129,34 +189,67 @@ async function verifyRoute({ browser, origin, route, config }) {
|
|
|
129
189
|
});
|
|
130
190
|
const status = response?.status() ?? null;
|
|
131
191
|
await settlePage(page, config, tracker);
|
|
132
|
-
const empty = await
|
|
192
|
+
const { empty, statusCode, markers, markupPreserved } = await readBootState(page);
|
|
193
|
+
const notFoundRoute = config.notFoundRoute ? normaliseRoute(config.notFoundRoute) : null;
|
|
194
|
+
const isNotFoundRoute = notFoundRoute !== null && normaliseRoute(route) === notFoundRoute;
|
|
195
|
+
const notFound = statusCode === 404 || isNotFoundRoute;
|
|
133
196
|
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
197
|
+
const detected = frameworkFromMarkers(markers, config.framework);
|
|
198
|
+
// Classify only after the framework is known, so an explicit `framework` choice and
|
|
199
|
+
// the detected framework select the matching message patterns.
|
|
200
|
+
const hydrationErrors = [];
|
|
201
|
+
const consoleErrors = [];
|
|
202
|
+
const pageErrors = [];
|
|
203
|
+
for (const message of consoleMessages) {
|
|
204
|
+
if (isHydrationError(message, detected.name ?? 'auto')) hydrationErrors.push(message);
|
|
205
|
+
else consoleErrors.push(message);
|
|
206
|
+
}
|
|
207
|
+
for (const message of pageMessages) {
|
|
208
|
+
if (isHydrationError(message, detected.name ?? 'auto')) hydrationErrors.push(message);
|
|
209
|
+
else pageErrors.push(message);
|
|
210
|
+
}
|
|
211
|
+
const mode = classifyBoot({
|
|
212
|
+
markupPreserved,
|
|
213
|
+
frameworkMounted: detected.mounted,
|
|
214
|
+
});
|
|
215
|
+
let firstMismatch = null;
|
|
216
|
+
if (outputDir && (hydrationErrors.length > 0 || mode === 're-rendered')) {
|
|
217
|
+
const liveHtml = await page.evaluate((selector) => {
|
|
218
|
+
const container = document.querySelector(selector) ?? document.body;
|
|
219
|
+
return container?.outerHTML ?? null;
|
|
220
|
+
}, CONTAINER_SELECTOR);
|
|
221
|
+
const file = path.join(outputDir, routeToFile(route, config));
|
|
222
|
+
const expectedHtml = await fs.readFile(file, 'utf8').catch(() => null);
|
|
223
|
+
if (liveHtml && expectedHtml) firstMismatch = compareContainers(expectedHtml, liveHtml);
|
|
224
|
+
}
|
|
225
|
+
const hydrationPolicy = resolveHydrationPolicy(config, route);
|
|
226
|
+
const allowed = isHydrationAllowed(hydrationPolicy, { mode, hydrationErrors });
|
|
227
|
+
const effectiveHydrationErrors = allowed ? [] : hydrationErrors;
|
|
146
228
|
|
|
147
229
|
return {
|
|
148
230
|
route,
|
|
149
231
|
status,
|
|
232
|
+
statusCode,
|
|
233
|
+
notFound,
|
|
234
|
+
framework: detected.name,
|
|
150
235
|
empty,
|
|
151
|
-
hydrationErrors,
|
|
236
|
+
hydrationErrors: effectiveHydrationErrors,
|
|
237
|
+
ignoredHydrationErrors: allowed ? hydrationErrors : [],
|
|
238
|
+
hydrationPolicy,
|
|
239
|
+
allowed,
|
|
152
240
|
consoleErrors,
|
|
153
241
|
pageErrors,
|
|
154
|
-
ok:
|
|
155
|
-
|
|
156
|
-
|
|
242
|
+
ok:
|
|
243
|
+
effectiveHydrationErrors.length === 0 &&
|
|
244
|
+
(status === null || status < 400 || (isNotFoundRoute && status === 404)) &&
|
|
245
|
+
!empty,
|
|
246
|
+
mode,
|
|
247
|
+
firstMismatch,
|
|
248
|
+
markupPreserved,
|
|
157
249
|
};
|
|
158
250
|
} finally {
|
|
159
251
|
tracker.stop();
|
|
160
|
-
await
|
|
252
|
+
await page.close();
|
|
253
|
+
if (ownsContext) await activeContext.close();
|
|
161
254
|
}
|
|
162
255
|
}
|