snappy-prerender 2.7.0 → 2.8.1

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.
@@ -0,0 +1,234 @@
1
+ import { matchesPattern } from './routes.js';
2
+
3
+ /**
4
+ * Resolves the SEO locale recipe for one route. The code comes from the route locale in
5
+ * path mode, from the active locale in single mode, or from seoLocale.code standalone.
6
+ * @param {object} config
7
+ * @param {object | null} locale
8
+ * @param {string} route
9
+ * @returns {object | null}
10
+ */
11
+ export function resolveSeoLocale(config, locale, route) {
12
+ const seo = config.seoLocale;
13
+ if (!seo) return null;
14
+ if (!(seo.routes ?? []).some((pattern) => matchesPattern(route, pattern))) return null;
15
+ const code =
16
+ config.locales?.mode === 'path'
17
+ ? locale?.code
18
+ : (config.locales?.active ?? seo.code ?? locale?.code);
19
+ if (!code) return null;
20
+ return { ...seo, code, value: seo.value ?? code };
21
+ }
22
+
23
+ /**
24
+ * Seeds local storage before the app bundle runs. The init script runs on the page's
25
+ * origin, which is what makes it work with the ephemeral prerender server.
26
+ * @param {import('playwright-core').Page} page
27
+ * @param {object} seo
28
+ */
29
+ export async function seedPageStorage(page, seo) {
30
+ if (seo.source !== 'localStorage' && seo.source !== 'both') return;
31
+ await seedLocalStorage(page, { [seo.localStorageKey]: seo.value }, seo.overwrite);
32
+ }
33
+
34
+ /**
35
+ * Seeds a fixed set of local storage entries before the app boots.
36
+ * @param {import('playwright-core').Page} page
37
+ * @param {Record<string, string>} entries
38
+ * @param {boolean} overwrite
39
+ */
40
+ export async function seedLocalStorage(page, entries, overwrite = true) {
41
+ if (!entries || Object.keys(entries).length === 0) return;
42
+ await page.addInitScript(
43
+ ({ values, replace }) => {
44
+ try {
45
+ for (const [key, value] of Object.entries(values)) {
46
+ if (replace || window.localStorage.getItem(key) === null) {
47
+ window.localStorage.setItem(key, value);
48
+ }
49
+ }
50
+ } catch {
51
+ // Local storage can be unavailable in private contexts; the app still renders its default.
52
+ }
53
+ },
54
+ { values: entries, replace: overwrite },
55
+ );
56
+ }
57
+
58
+ /**
59
+ * Seeds the locale cookie in the browser context, using the current origin when no
60
+ * explicit domain is configured.
61
+ * @param {import('playwright-core').BrowserContext} context
62
+ * @param {string} origin
63
+ * @param {object} seo
64
+ */
65
+ export async function seedContextCookie(context, origin, seo) {
66
+ if (seo.source !== 'cookie' && seo.source !== 'both') return;
67
+ if (!seo.overwrite) {
68
+ const existing = await context.cookies();
69
+ if (existing.some((cookie) => cookie.name === seo.cookie.name)) return;
70
+ }
71
+ await seedCookies(context, origin, [
72
+ {
73
+ ...seo.cookie,
74
+ value: seo.value,
75
+ httpOnly: false,
76
+ },
77
+ ]);
78
+ }
79
+
80
+ /**
81
+ * Adds cookies with the same attributes to the render and verification contexts.
82
+ * @param {import('playwright-core').BrowserContext} context
83
+ * @param {string} origin
84
+ * @param {Array<object>} cookies
85
+ */
86
+ export async function seedCookies(context, origin, cookies = []) {
87
+ if (!Array.isArray(cookies) || cookies.length === 0) return;
88
+ const entries = cookies.map((cookie) => {
89
+ const secure = cookie.secure === true && !origin.startsWith('http://');
90
+ const sameSite = normaliseSameSite(cookie.sameSite);
91
+ const entry = {
92
+ name: cookie.name,
93
+ value: String(cookie.value),
94
+ httpOnly: cookie.httpOnly === true,
95
+ secure,
96
+ // Browsers reject SameSite=None without Secure; keep local HTTP previews usable.
97
+ sameSite: sameSite === 'None' && !secure ? 'Lax' : sameSite,
98
+ expires:
99
+ cookie.maxAge !== null && cookie.maxAge !== undefined
100
+ ? Math.floor(Date.now() / 1000) + cookie.maxAge
101
+ : -1,
102
+ };
103
+ if (cookie.domain) {
104
+ entry.domain = cookie.domain;
105
+ entry.path = cookie.path ?? '/';
106
+ } else {
107
+ entry.url = origin;
108
+ }
109
+ return entry;
110
+ });
111
+ await context.addCookies(entries);
112
+ }
113
+
114
+ /**
115
+ * Builds the small bootstrap script written into localized pages. Values are serialised as
116
+ * strings and cookie values are percent-encoded; no configuration value is interpolated
117
+ * as raw JavaScript.
118
+ * @param {object} seo
119
+ * @returns {string}
120
+ */
121
+ export function buildSeoLocaleScript(seo) {
122
+ const key = JSON.stringify(seo.localStorageKey);
123
+ const value = JSON.stringify(seo.value);
124
+ const cookieName = JSON.stringify(seo.cookie.name);
125
+ const cookieAttributes =
126
+ `; path=${seo.cookie.path ?? '/'}` +
127
+ (seo.cookie.domain ? `; domain=${seo.cookie.domain}` : '') +
128
+ (seo.cookie.maxAge !== null && seo.cookie.maxAge !== undefined
129
+ ? `; max-age=${seo.cookie.maxAge}`
130
+ : '') +
131
+ `; SameSite=${normaliseSameSite(seo.cookie.sameSite)}` +
132
+ (seo.cookie.secure ? '; Secure' : '');
133
+ const cookieExpression = `${cookieName} + "=" + encodeURIComponent(${value}) + ${JSON.stringify(cookieAttributes)}`;
134
+ const localCondition = seo.overwrite ? 'true' : `window.localStorage.getItem(${key}) === null`;
135
+ const parts = ['(function () {'];
136
+ if (seo.source === 'localStorage' || seo.source === 'both') {
137
+ parts.push(
138
+ ' try {',
139
+ ` if (${localCondition}) {`,
140
+ ` window.localStorage.setItem(${key}, ${value});`,
141
+ ' }',
142
+ ' } catch (error) {}',
143
+ );
144
+ }
145
+ if (seo.source === 'cookie' || seo.source === 'both') {
146
+ parts.push(
147
+ ' try {',
148
+ ` var name = ${cookieName};`,
149
+ ' var has = document.cookie.split("; ").some(function (part) {',
150
+ ' return part.split("=")[0] === name;',
151
+ ' });',
152
+ ` if (${seo.overwrite ? 'true' : '!has'}) {`,
153
+ ` document.cookie = ${cookieExpression};`,
154
+ ' }',
155
+ ' } catch (error) {}',
156
+ );
157
+ }
158
+ parts.push('})();');
159
+ return parts.join('\n');
160
+ }
161
+
162
+ /**
163
+ * Inserts the bootstrap before the first head script, or at the end of the head when the
164
+ * document has no script. A generated marker makes reruns replace the same script.
165
+ * @param {object} document parse5 document
166
+ * @param {string} script
167
+ * @param {string | null} externalHref
168
+ */
169
+ export function injectSeoLocaleScript(document, script, externalHref = null) {
170
+ const head = findHead(document);
171
+ if (!head) return;
172
+ head.childNodes = (head.childNodes ?? []).filter(
173
+ (node) => !node.attrs?.some((entry) => entry.name === 'data-snappy-seo-locale'),
174
+ );
175
+ const node = externalHref
176
+ ? {
177
+ nodeName: 'script',
178
+ tagName: 'script',
179
+ attrs: [
180
+ { name: 'src', value: externalHref },
181
+ { name: 'data-snappy-seo-locale', value: '' },
182
+ ],
183
+ namespaceURI: 'http://www.w3.org/1999/xhtml',
184
+ childNodes: [],
185
+ parentNode: null,
186
+ }
187
+ : {
188
+ nodeName: 'script',
189
+ tagName: 'script',
190
+ attrs: [{ name: 'data-snappy-seo-locale', value: '' }],
191
+ namespaceURI: 'http://www.w3.org/1999/xhtml',
192
+ childNodes: [{ nodeName: '#text', value: script, parentNode: null }],
193
+ parentNode: null,
194
+ };
195
+ const scriptIndex = (head.childNodes ?? []).findIndex((child) => child.tagName === 'script');
196
+ if (scriptIndex === -1) head.childNodes.push(node);
197
+ else head.childNodes.splice(scriptIndex, 0, node);
198
+ }
199
+
200
+ /**
201
+ * Returns the current head language and direction controls for metadata injection.
202
+ * @param {object | null} seo
203
+ * @returns {{ htmlLang: string | boolean, dir: string | null, forceHtmlLang: boolean }}
204
+ */
205
+ export function seoLocaleMetadataOptions(seo) {
206
+ if (!seo) return { htmlLang: true, dir: null, forceHtmlLang: false };
207
+ return {
208
+ htmlLang: seo.htmlLang,
209
+ dir: seo.dir,
210
+ forceHtmlLang: seo.forceHtmlLang === true,
211
+ };
212
+ }
213
+
214
+ /**
215
+ * Normalises a cookie SameSite value to the form Playwright and document.cookie expect.
216
+ * @param {string | undefined} value
217
+ * @returns {'Strict' | 'Lax' | 'None'}
218
+ */
219
+ function normaliseSameSite(value) {
220
+ const text = String(value ?? 'Lax').toLowerCase();
221
+ if (text === 'strict') return 'Strict';
222
+ if (text === 'none') return 'None';
223
+ return 'Lax';
224
+ }
225
+
226
+ /**
227
+ * Finds the document head in a parsed document.
228
+ * @param {object} document
229
+ * @returns {object | null}
230
+ */
231
+ function findHead(document) {
232
+ const html = (document.childNodes ?? []).find((node) => node.tagName === 'html');
233
+ return (html?.childNodes ?? []).find((node) => node.tagName === 'head') ?? null;
234
+ }
@@ -112,15 +112,13 @@ export async function settlePage(page, config, tracker) {
112
112
  * normalisation.
113
113
  */
114
114
  export async function freezeAnimations(page) {
115
- await page.evaluate((css) => {
115
+ await page.evaluate(async (css) => {
116
116
  const style = document.createElement('style');
117
117
  style.id = 'snappy-freeze';
118
118
  style.textContent = css;
119
119
  document.head.appendChild(style);
120
+ await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
120
121
  }, FREEZE_CSS);
121
- await page.evaluate(
122
- () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))),
123
- );
124
122
  }
125
123
 
126
124
  /**
@@ -26,7 +26,7 @@ export function buildSitemap({ files, config, maxUrls = SITEMAP_MAX_URLS }) {
26
26
  })
27
27
  : publicPath(file.file);
28
28
  entries.push({
29
- loc: `${siteUrl}${toPublicPath(path, config.base)}`,
29
+ loc: file.localeUrl ?? `${siteUrl}${toPublicPath(path, config.base)}`,
30
30
  alternates: normaliseAlternates(file.alternates ?? [], siteUrl, config.base),
31
31
  images: config.imageSitemap ? normaliseImages(file.images ?? [], siteUrl, config.base) : [],
32
32
  });
@@ -1,12 +1,12 @@
1
1
  /**
2
- * Inserts comment separators between adjacent text nodes before the DOM is
3
- * serialised. HTML cannot express two neighbouring text nodes, so serialising a
4
- * client-rendered DOM merges them, and hydration then finds one node where the
5
- * client expects two — which React reports as a failed hydration. React's own server
6
- * rendering emits the same separators, so this makes captured markup equivalent.
2
+ * Final page snapshot: inserts comment separators between adjacent text nodes and
3
+ * returns the LCP URL and anchor hrefs in the same browser round trip. HTML cannot
4
+ * express two neighbouring text nodes, so serialising a client-rendered DOM merges
5
+ * them and hydration then finds one node where the client expects two. React's own
6
+ * server rendering emits the same separators.
7
7
  */
8
- export async function separateTextNodes(page) {
9
- await page.evaluate(() => {
8
+ export async function readRenderSnapshot(page) {
9
+ return page.evaluate(() => {
10
10
  // Elements whose children are raw text, where a comment would corrupt the content.
11
11
  const RAW_TEXT = new Set(['SCRIPT', 'STYLE', 'TEXTAREA', 'TITLE']);
12
12
 
@@ -26,5 +26,9 @@ export async function separateTextNodes(page) {
26
26
  };
27
27
 
28
28
  separate(document.documentElement);
29
+ return {
30
+ lcpUrl: window.__snappyLcp?.at(-1)?.url ?? null,
31
+ hrefs: Array.from(document.querySelectorAll('a[href]'), (anchor) => anchor.href),
32
+ };
29
33
  });
30
34
  }
@@ -1,20 +1,19 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { blockThirdPartyRequests } from './blocking.js';
4
+ import { installClock, installClockScripts } from './clock.js';
4
5
  import { DEFAULTS } from './config.js';
5
6
  import { frameworkFromMarkers, isHydrationError } from './frameworks.js';
6
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';
7
11
  import { routeToFile } from './output.js';
8
- import { isPageEmpty } from './page-content.js';
12
+ import { CONTAINER_SELECTOR, readBootState } from './page-content.js';
9
13
  import { resolveRouteConfig } from './route-config.js';
10
14
  import { normaliseRoute, toServerUrl } from './routes.js';
11
- import { mapLimit } from './scheduler.js';
12
15
  import { settlePage, trackNetwork } from './settle.js';
13
16
 
14
- // Containers used by the common setups, so the probe can find the prerendered
15
- // subtree without configuration.
16
- const CONTAINER_SELECTOR = '#root, #app, #__next';
17
-
18
17
  /**
19
18
  * Page-side probe installed before navigation. It remembers the first prerendered
20
19
  * child of the app container, which is what lets the verifier tell hydration from a
@@ -57,9 +56,53 @@ export function classifyBoot({ markupPreserved, frameworkMounted }) {
57
56
  * hydration errors plus how each route booted. This is the pass that catches markup
58
57
  * that only breaks once the app re-attaches, and apps that never attach to it at all.
59
58
  */
60
- export async function verifyRoutes({ browser, origin, routes, config, outputDir = null }) {
61
- const { results, errors } = await mapLimit(routes, config.concurrency, (route) =>
62
- verifyRoute({ browser, origin, route, config: resolveRouteConfig(config, route), outputDir }),
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
+ }),
63
106
  );
64
107
  const verified = results.filter(Boolean);
65
108
  const modes = { hydrated: 0, 're-rendered': 0, undetected: 0, unknown: 0 };
@@ -73,26 +116,51 @@ export async function verifyRoutes({ browser, origin, routes, config, outputDir
73
116
  routes: verified,
74
117
  modes,
75
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,
76
123
  ok: verified.every((entry) => entry.ok) && errors.length === 0,
77
124
  errors: errors.map(({ item, error }) => ({ route: item, message: error.message })),
78
125
  };
79
126
  }
80
127
 
81
- /**
82
- * Verifies one route against the same request rules as the render pass, so what is
83
- * verified matches what was rendered.
84
- */
85
- async function verifyRoute({ browser, origin, route, config, outputDir }) {
86
- 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({
87
131
  viewport: config.viewport,
88
- storageState: config.storageState ?? undefined,
132
+ storageState: localeStorageState(config, locale),
89
133
  userAgent:
90
134
  config.userAgent === DEFAULTS.userAgent ? undefined : (config.userAgent ?? undefined),
91
135
  ignoreHTTPSErrors: config.ignoreHTTPSErrors,
92
136
  reducedMotion: config.freezeAnimations ? 'reduce' : 'no-preference',
93
137
  serviceWorkers: 'block',
138
+ ...localeContextOptions(config, locale),
139
+ });
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,
94
163
  });
95
- const page = await context.newPage();
96
164
  const consoleMessages = [];
97
165
  const pageMessages = [];
98
166
  page.on('console', (message) => {
@@ -105,6 +173,15 @@ async function verifyRoute({ browser, origin, route, config, outputDir }) {
105
173
  if (config.blockThirdParty) {
106
174
  await blockThirdPartyRequests(page, origin, config.allowedHosts);
107
175
  }
176
+ await seedLocaleContext({
177
+ page,
178
+ context: activeContext,
179
+ origin,
180
+ config,
181
+ route,
182
+ locale,
183
+ phase: 'verify',
184
+ });
108
185
  await page.addInitScript(bootProbe);
109
186
  const response = await page.goto(toServerUrl(route, config.base, origin), {
110
187
  waitUntil: 'domcontentloaded',
@@ -112,47 +189,12 @@ async function verifyRoute({ browser, origin, route, config, outputDir }) {
112
189
  });
113
190
  const status = response?.status() ?? null;
114
191
  await settlePage(page, config, tracker);
115
- const empty = await isPageEmpty(page);
116
- const statusCode = await page.evaluate(() => {
117
- const marker = document.querySelector('meta[name="prerender-status-code"]');
118
- if (!marker) return null;
119
- const value = Number.parseInt(marker.getAttribute('content') ?? '', 10);
120
- return Number.isFinite(value) ? value : null;
121
- });
192
+ const { empty, statusCode, markers, markupPreserved } = await readBootState(page);
122
193
  const notFoundRoute = config.notFoundRoute ? normaliseRoute(config.notFoundRoute) : null;
123
194
  const isNotFoundRoute = notFoundRoute !== null && normaliseRoute(route) === notFoundRoute;
124
195
  const notFound = statusCode === 404 || isNotFoundRoute;
125
196
 
126
- const boot = await page.evaluate((selector) => {
127
- const probe = window.__snappyBoot ?? {};
128
- const container =
129
- document.querySelector(selector) ?? document.body?.firstElementChild ?? null;
130
- const markers = container
131
- ? {
132
- react: Object.keys(container).some(
133
- (key) => key.startsWith('__reactContainer') || key === '_reactRootContainer',
134
- ),
135
- vue: Boolean(
136
- container.__vue_app__ ||
137
- container.__vueParentComponent ||
138
- container._vnode ||
139
- container.__vue__,
140
- ),
141
- preact: Boolean(
142
- container.__k ||
143
- container._children ||
144
- container.firstElementChild?.__k ||
145
- container.firstElementChild?._children,
146
- ),
147
- svelte: Boolean(
148
- container.__svelte_meta || container.querySelector?.('[data-svelte-h]'),
149
- ),
150
- }
151
- : {};
152
- const child = probe.prerenderedChild ?? null;
153
- return { markers, markupPreserved: child ? child.isConnected : null };
154
- }, CONTAINER_SELECTOR);
155
- const detected = frameworkFromMarkers(boot.markers, config.framework);
197
+ const detected = frameworkFromMarkers(markers, config.framework);
156
198
  // Classify only after the framework is known, so an explicit `framework` choice and
157
199
  // the detected framework select the matching message patterns.
158
200
  const hydrationErrors = [];
@@ -167,7 +209,7 @@ async function verifyRoute({ browser, origin, route, config, outputDir }) {
167
209
  else pageErrors.push(message);
168
210
  }
169
211
  const mode = classifyBoot({
170
- markupPreserved: boot.markupPreserved,
212
+ markupPreserved,
171
213
  frameworkMounted: detected.mounted,
172
214
  });
173
215
  let firstMismatch = null;
@@ -180,6 +222,9 @@ async function verifyRoute({ browser, origin, route, config, outputDir }) {
180
222
  const expectedHtml = await fs.readFile(file, 'utf8').catch(() => null);
181
223
  if (liveHtml && expectedHtml) firstMismatch = compareContainers(expectedHtml, liveHtml);
182
224
  }
225
+ const hydrationPolicy = resolveHydrationPolicy(config, route);
226
+ const allowed = isHydrationAllowed(hydrationPolicy, { mode, hydrationErrors });
227
+ const effectiveHydrationErrors = allowed ? [] : hydrationErrors;
183
228
 
184
229
  return {
185
230
  route,
@@ -188,19 +233,23 @@ async function verifyRoute({ browser, origin, route, config, outputDir }) {
188
233
  notFound,
189
234
  framework: detected.name,
190
235
  empty,
191
- hydrationErrors,
236
+ hydrationErrors: effectiveHydrationErrors,
237
+ ignoredHydrationErrors: allowed ? hydrationErrors : [],
238
+ hydrationPolicy,
239
+ allowed,
192
240
  consoleErrors,
193
241
  pageErrors,
194
242
  ok:
195
- hydrationErrors.length === 0 &&
243
+ effectiveHydrationErrors.length === 0 &&
196
244
  (status === null || status < 400 || (isNotFoundRoute && status === 404)) &&
197
245
  !empty,
198
246
  mode,
199
247
  firstMismatch,
200
- markupPreserved: boot.markupPreserved,
248
+ markupPreserved,
201
249
  };
202
250
  } finally {
203
251
  tracker.stop();
204
- await context.close();
252
+ await page.close();
253
+ if (ownsContext) await activeContext.close();
205
254
  }
206
255
  }