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.
Files changed (49) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/README.md +281 -101
  3. package/assets/snappy-logo.png +0 -0
  4. package/docs/limitations.md +94 -0
  5. package/docs/react-snap-migration.md +45 -0
  6. package/docs/report.md +135 -0
  7. package/package.json +4 -1
  8. package/src/cli-init.js +205 -0
  9. package/src/cli-options.js +258 -0
  10. package/src/cli.js +128 -309
  11. package/src/core/artifacts.js +2 -0
  12. package/src/core/audit/index.js +114 -0
  13. package/src/core/audit/links.js +74 -0
  14. package/src/core/audit/metadata.js +197 -0
  15. package/src/core/audit/sitemap.js +39 -0
  16. package/src/core/clock.js +60 -0
  17. package/src/core/config-validation.js +557 -0
  18. package/src/core/config-warnings.js +150 -0
  19. package/src/core/config.js +189 -136
  20. package/src/core/crawl.js +362 -0
  21. package/src/core/csp.js +138 -0
  22. package/src/core/destination.js +23 -7
  23. package/src/core/frameworks.js +94 -0
  24. package/src/core/hydration-diff.js +93 -0
  25. package/src/core/hydration-policy.js +38 -0
  26. package/src/core/init.js +143 -30
  27. package/src/core/inline-css.js +52 -9
  28. package/src/core/locale-context.js +63 -0
  29. package/src/core/locales.js +385 -0
  30. package/src/core/normalize.js +37 -6
  31. package/src/core/page-content.js +57 -7
  32. package/src/core/page-meta.js +122 -0
  33. package/src/core/preload-links.js +133 -0
  34. package/src/core/preload-manifest.js +6 -2
  35. package/src/core/render.js +126 -35
  36. package/src/core/report.js +116 -0
  37. package/src/core/resources.js +5 -1
  38. package/src/core/route-config.js +17 -0
  39. package/src/core/route-sources.js +220 -0
  40. package/src/core/run.js +156 -131
  41. package/src/core/seo-locale.js +234 -0
  42. package/src/core/settle.js +2 -4
  43. package/src/core/sitemap.js +90 -14
  44. package/src/core/text-separators.js +11 -7
  45. package/src/core/verify.js +164 -71
  46. package/src/core/write.js +314 -52
  47. package/src/head/index.js +104 -6
  48. package/src/head/manager.js +304 -48
  49. package/src/head/tags.js +349 -30
@@ -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 { isPageEmpty } from './page-content.js';
4
- import { toServerUrl } from './routes.js';
5
- import { mapLimit } from './scheduler.js';
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 React root was
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({ browser, origin, routes, config }) {
78
- const { results, errors } = await mapLimit(routes, config.concurrency, (route) =>
79
- verifyRoute({ browser, origin, route, config }),
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
- for (const entry of verified) modes[entry.mode] += 1;
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
- * Verifies one route against the same request rules as the render pass, so what is
94
- * verified matches what was rendered.
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.storageState ?? undefined,
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
- const page = await context.newPage();
107
- const hydrationErrors = [];
108
- const consoleErrors = [];
109
- const pageErrors = [];
110
- // Hydration messages arrive through either channel, so both are classified the same way.
111
- const collect = (message, target) => {
112
- if (isHydrationError(message)) hydrationErrors.push(message);
113
- else target.push(message);
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') collect(message.text(), consoleErrors);
167
+ if (message.type() === 'error') consoleMessages.push(message.text());
117
168
  });
118
- page.on('pageerror', (error) => collect(error.message, pageErrors));
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 isPageEmpty(page);
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 boot = await page.evaluate((selector) => {
135
- const probe = window.__snappyBoot ?? {};
136
- const container =
137
- document.querySelector(selector) ?? document.body?.firstElementChild ?? null;
138
- const frameworkMounted = container
139
- ? Object.keys(container).some(
140
- (key) => key.startsWith('__reactContainer') || key === '_reactRootContainer',
141
- )
142
- : false;
143
- const child = probe.prerenderedChild ?? null;
144
- return { frameworkMounted, markupPreserved: child ? child.isConnected : null };
145
- }, CONTAINER_SELECTOR);
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: hydrationErrors.length === 0 && (status === null || status < 400) && !empty,
155
- mode: classifyBoot(boot),
156
- markupPreserved: boot.markupPreserved,
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 context.close();
252
+ await page.close();
253
+ if (ownsContext) await activeContext.close();
161
254
  }
162
255
  }