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.
package/src/core/crawl.js CHANGED
@@ -1,5 +1,12 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
+ import {
4
+ activeLocale,
5
+ isLocalePrefixedRoute,
6
+ localeByCode,
7
+ localeRoute,
8
+ stripLocalePrefix,
9
+ } from './locales.js';
3
10
  import { renderRoute } from './render.js';
4
11
  import { resolveRouteConfig } from './route-config.js';
5
12
  import {
@@ -24,12 +31,37 @@ export async function renderAllRoutes({
24
31
  report,
25
32
  outputDir,
26
33
  sourceSeeds = [],
34
+ caches = null,
35
+ localePlan = null,
27
36
  }) {
28
37
  const visited = new Set();
29
38
  const scheduled = new Set();
30
39
  const rendered = new Map();
40
+ const routeLocales = new Map();
31
41
  const cap = config.maxRoutes ?? Number.POSITIVE_INFINITY;
32
42
  let nextOrder = 0;
43
+ const defaultLocale = localePlan ? localeByCode(localePlan, localePlan.default) : null;
44
+ const notFoundRoute = config.notFoundRoute ? normaliseRoute(config.notFoundRoute) : null;
45
+
46
+ // Expands a base route into every locale variant it publishes. The not-found route is
47
+ // deliberately not localized: a host needs one 404 document.
48
+ const variantsFor = (route) => {
49
+ if (!localePlan) return [{ route, locale: null, baseRoute: route }];
50
+ if (localePlan.mode === 'single') {
51
+ return [{ route, locale: activeLocale(localePlan), baseRoute: route }];
52
+ }
53
+ const baseRoute = defaultLocale ? stripLocalePrefix(route, defaultLocale) : route;
54
+ const isNotFound = notFoundRoute !== null && normaliseRoute(baseRoute) === notFoundRoute;
55
+ const variants = [];
56
+ for (const locale of localePlan.list) {
57
+ if (isNotFound && locale !== defaultLocale) continue;
58
+ const localized = isNotFound
59
+ ? normaliseRoute(config.notFoundRoute)
60
+ : localeRoute(locale, baseRoute);
61
+ variants.push({ route: localized, locale, baseRoute });
62
+ }
63
+ return variants;
64
+ };
33
65
 
34
66
  // Queues routes up to maxRoutes, recording exactly which candidates did not fit.
35
67
  // A seed used only to reach wildcard routes is given output: false and is rendered
@@ -38,14 +70,24 @@ export async function renderAllRoutes({
38
70
  const accepted = [];
39
71
  const dropped = [];
40
72
  for (const route of routes) {
41
- if (scheduled.has(route)) continue;
42
- if (scheduled.size >= cap) {
43
- dropped.push(route);
44
- continue;
73
+ if (isLocalePrefixedRoute(route, localePlan)) continue;
74
+ for (const variant of variantsFor(route)) {
75
+ if (scheduled.has(variant.route)) continue;
76
+ if (scheduled.size >= cap) {
77
+ dropped.push(variant.route);
78
+ continue;
79
+ }
80
+ scheduled.add(variant.route);
81
+ routeLocales.set(variant.route, variant);
82
+ accepted.push({
83
+ route: variant.route,
84
+ output: outputFor(variant.baseRoute),
85
+ order: nextOrder,
86
+ locale: variant.locale,
87
+ baseRoute: variant.baseRoute,
88
+ });
89
+ nextOrder += 1;
45
90
  }
46
- scheduled.add(route);
47
- accepted.push({ route, output: outputFor(route), order: nextOrder });
48
- nextOrder += 1;
49
91
  }
50
92
  if (dropped.length > 0) {
51
93
  const previous = report.truncated?.droppedRoutes ?? [];
@@ -58,7 +100,14 @@ export async function renderAllRoutes({
58
100
  return accepted;
59
101
  };
60
102
 
61
- const renderOne = async ({ route, output, depth = 0, order = null }) => {
103
+ const renderOne = async ({
104
+ route,
105
+ output,
106
+ depth = 0,
107
+ order = null,
108
+ locale = null,
109
+ baseRoute = route,
110
+ }) => {
62
111
  if (visited.has(route)) return null;
63
112
  visited.add(route);
64
113
  log.debug(`rendering ${route}${output ? '' : ' as a crawl seed'}`);
@@ -73,13 +122,15 @@ export async function renderAllRoutes({
73
122
  report,
74
123
  outputDir,
75
124
  screenshot: config.saveAs !== 'html',
125
+ caches,
126
+ locale,
76
127
  });
77
128
  if (!result) return null;
78
129
  result.depth = depth;
79
130
  result.order = order;
80
131
  try {
81
132
  const notFoundRoute = config.notFoundRoute ? normaliseRoute(config.notFoundRoute) : null;
82
- const isNotFoundRoute = notFoundRoute !== null && route === notFoundRoute;
133
+ const isNotFoundRoute = notFoundRoute !== null && normaliseRoute(baseRoute) === notFoundRoute;
83
134
  if (result.statusCode === 404 && !isNotFoundRoute) {
84
135
  report.errors.push({ route, message: 'returned 404' });
85
136
  log.error(` route ${route} returned 404`);
@@ -147,7 +198,12 @@ export async function renderAllRoutes({
147
198
  if (sourceSeeds.length === 0 || config.includeProvided) {
148
199
  candidates.push(...crawlRoots(config));
149
200
  }
150
- const roots = accept([...new Set(sortRoutes(candidates, config))], outputFor);
201
+ const roots = accept(
202
+ [...new Set(sortRoutes(candidates, config))].filter(
203
+ (route) => !isLocalePrefixedRoute(route, localePlan),
204
+ ),
205
+ outputFor,
206
+ );
151
207
 
152
208
  const deterministic = config.maxRoutes !== null || config.routePriority.length > 0;
153
209
  if (deterministic) {
@@ -155,15 +211,17 @@ export async function renderAllRoutes({
155
211
  } else {
156
212
  const pool = createPool({
157
213
  concurrency: config.concurrency,
158
- handler: async ({ route, depth, output, order }) => {
159
- const renderedRoute = await renderOne({ route, output, depth, order });
214
+ handler: async ({ route, depth, output, order, locale, baseRoute }) => {
215
+ const renderedRoute = await renderOne({ route, output, depth, order, locale, baseRoute });
160
216
  if (!renderedRoute) return;
161
217
  const { result, routeConfig } = renderedRoute;
162
218
  if (routeConfig.crawl && withinDepth(depth + 1, routeConfig.maxDepth)) {
163
219
  const discovered = filterRoutes(result.links, {
164
220
  include: config.includeProvided ? config.include : [],
165
221
  exclude: config.exclude,
166
- }).filter((candidate) => !scheduled.has(candidate));
222
+ })
223
+ .filter((candidate) => !isLocalePrefixedRoute(candidate, localePlan))
224
+ .filter((candidate) => !scheduled.has(candidate));
167
225
  const accepted = accept(sortRoutes(discovered, config));
168
226
  pool.push(accepted.map((item) => ({ ...item, depth: depth + 1 })));
169
227
  }
@@ -176,7 +234,7 @@ export async function renderAllRoutes({
176
234
  log.error(` pool error on ${item.route}: ${error.message}`);
177
235
  }
178
236
  }
179
- return rendered;
237
+ return { rendered, routeLocales };
180
238
  }
181
239
 
182
240
  /**
@@ -194,6 +252,8 @@ async function renderWithRetries({
194
252
  report,
195
253
  outputDir,
196
254
  screenshot,
255
+ caches,
256
+ locale,
197
257
  }) {
198
258
  const attempts = config.retries + 1;
199
259
  for (let attempt = 1; attempt <= attempts; attempt += 1) {
@@ -206,9 +266,11 @@ async function renderWithRetries({
206
266
  browser,
207
267
  origin: server.origin,
208
268
  route,
269
+ locale,
209
270
  config: routeConfig,
210
271
  screenshot,
211
272
  traceFile,
273
+ caches,
212
274
  });
213
275
  if (attempt > 1) log.info(` ${route}: succeeded on attempt ${attempt}`);
214
276
  result.attempts = attempt;
package/src/core/csp.js CHANGED
@@ -20,11 +20,23 @@ const CSP_MARKER = 'data-snappy-csp';
20
20
  */
21
21
  export function applyCspHashes(html) {
22
22
  const document = parse(html);
23
+ const result = applyCspHashesToDocument(document);
24
+ return {
25
+ html: result.existingPolicy ? html : serialize(document),
26
+ ...result,
27
+ };
28
+ }
29
+
30
+ /**
31
+ * Applies the hash policy to an already parsed document. Returns counts and whether a
32
+ * user-owned policy was left untouched.
33
+ */
34
+ export function applyCspHashesToDocument(document) {
23
35
  const head = findHead(document);
24
- if (!head) return { html, hashedScripts: 0, hashedStyles: 0, existingPolicy: false };
36
+ if (!head) return { hashedScripts: 0, hashedStyles: 0, existingPolicy: false };
25
37
  removeGeneratedPolicy(head);
26
38
  const existing = findPolicyMeta(head);
27
- if (existing) return { html, hashedScripts: 0, hashedStyles: 0, existingPolicy: true };
39
+ if (existing) return { hashedScripts: 0, hashedStyles: 0, existingPolicy: true };
28
40
 
29
41
  const scriptHashes = [];
30
42
  const styleHashes = [];
@@ -43,7 +55,6 @@ export function applyCspHashes(html) {
43
55
  ];
44
56
  head.childNodes.unshift(metaNode(directives.join('; ')));
45
57
  return {
46
- html: serialize(document),
47
58
  hashedScripts: scriptHashes.length,
48
59
  hashedStyles: styleHashes.length,
49
60
  existingPolicy: false,
@@ -1,5 +1,8 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
+ import { mapLimit } from './scheduler.js';
4
+
5
+ const COPY_CONCURRENCY = 8;
3
6
 
4
7
  /**
5
8
  * Prepares the directory the run writes into. When a destination is configured the
@@ -58,13 +61,26 @@ export async function prepareDestination({
58
61
  async function copyTree(source, target) {
59
62
  await fs.mkdir(target, { recursive: true });
60
63
  const entries = await fs.readdir(source, { withFileTypes: true });
61
- let skipped = 0;
62
- for (const entry of entries) {
64
+ const { results, errors } = await mapLimit(entries, COPY_CONCURRENCY, async (entry) => {
63
65
  const from = path.join(source, entry.name);
64
66
  const to = path.join(target, entry.name);
65
- if (entry.isDirectory()) skipped += await copyTree(from, to);
66
- else if (entry.isFile()) await fs.copyFile(from, to);
67
- else skipped += 1;
68
- }
69
- return skipped;
67
+ if (entry.isDirectory()) return copyTree(from, to);
68
+ if (!entry.isFile()) return 1;
69
+ const [sourceStats, targetStats] = await Promise.all([
70
+ fs.stat(from),
71
+ fs.stat(to).catch(() => null),
72
+ ]);
73
+ if (
74
+ targetStats?.isFile() &&
75
+ targetStats.size === sourceStats.size &&
76
+ targetStats.mtimeMs === sourceStats.mtimeMs
77
+ ) {
78
+ return 0;
79
+ }
80
+ await fs.copyFile(from, to);
81
+ await fs.utimes(to, sourceStats.atime, sourceStats.mtime);
82
+ return 0;
83
+ });
84
+ if (errors.length > 0) throw errors[0].error;
85
+ return results.reduce((total, skipped) => total + (skipped ?? 0), 0);
70
86
  }
@@ -0,0 +1,38 @@
1
+ import { matchesPattern } from './routes.js';
2
+
3
+ /**
4
+ * Resolves the hydration policy for one route. Global lists provide the default and a
5
+ * matching route override wins, so a broad allow-list can be narrowed for one route.
6
+ * @param {object} config
7
+ * @param {string} route
8
+ * @returns {'strict' | 'allow-rerender' | 'skip'}
9
+ */
10
+ export function resolveHydrationPolicy(config, route) {
11
+ let policy = 'strict';
12
+ if ((config.hydration?.skip ?? []).some((pattern) => matchesPattern(route, pattern))) {
13
+ policy = 'skip';
14
+ } else if (
15
+ (config.hydration?.allowRerender ?? []).some((pattern) => matchesPattern(route, pattern))
16
+ ) {
17
+ policy = 'allow-rerender';
18
+ }
19
+ for (const override of config.routeOverrides ?? []) {
20
+ if (!matchesPattern(route, override.match)) continue;
21
+ if (override.hydration !== undefined) policy = override.hydration;
22
+ }
23
+ return policy;
24
+ }
25
+
26
+ /**
27
+ * Reports whether a verification result is allowed to differ from the prerendered markup.
28
+ * A route only becomes allowed after it actually re-rendered or logged a hydration error.
29
+ * @param {'strict' | 'allow-rerender' | 'skip'} policy
30
+ * @param {{ mode: string, hydrationErrors: string[] }} result
31
+ * @returns {boolean}
32
+ */
33
+ export function isHydrationAllowed(policy, result) {
34
+ return (
35
+ policy === 'allow-rerender' &&
36
+ (result.mode === 're-rendered' || (result.hydrationErrors?.length ?? 0) > 0)
37
+ );
38
+ }
@@ -1,3 +1,4 @@
1
+ import path from 'node:path';
1
2
  import { hasImportRule, rewriteCssUrls } from './css-url.js';
2
3
  import { minifyCss } from './minify.js';
3
4
 
@@ -8,8 +9,9 @@ import { minifyCss } from './minify.js';
8
9
  * place. Relative url() values are rebased against each sheet before joining so fonts
9
10
  * and images keep working on nested routes.
10
11
  */
11
- export async function inlineStylesheets({ page, minifyCssOptions }) {
12
- const collected = await page.evaluate(async () => {
12
+ export async function inlineStylesheets({ page, minifyCssOptions, cache = null }) {
13
+ const cachedCss = cache ? Object.fromEntries(cache) : {};
14
+ const collected = await page.evaluate(async (knownCss) => {
13
15
  // Only media that applies on screen can be inlined without changing how the sheet
14
16
  // applies, so a print stylesheet is left as a link instead of becoming active.
15
17
  const INLINEABLE_MEDIA = new Set(['', 'all', 'screen']);
@@ -22,24 +24,30 @@ export async function inlineStylesheets({ page, minifyCssOptions }) {
22
24
  skipped.push(link.href);
23
25
  continue;
24
26
  }
27
+ if (Object.hasOwn(knownCss, link.href)) {
28
+ sheets.push({ css: knownCss[link.href], href: link.href, fetched: false });
29
+ continue;
30
+ }
25
31
  try {
26
32
  const response = await fetch(link.href);
27
33
  if (!response.ok) throw new Error(String(response.status));
28
- sheets.push({ css: await response.text(), href: link.href });
34
+ sheets.push({ css: await response.text(), href: link.href, fetched: true });
29
35
  } catch {
30
36
  skipped.push(link.href);
31
37
  }
32
38
  }
33
39
  return { sheets, skipped };
34
- });
40
+ }, cachedCss);
35
41
 
36
42
  const inlineable = [];
37
43
  const skipped = [...collected.skipped];
44
+ const fetched = [];
38
45
  for (const sheet of collected.sheets) {
46
+ if (sheet.fetched) fetched.push({ href: sheet.href, css: sheet.css });
39
47
  if (hasImportRule(sheet.css)) skipped.push(sheet.href);
40
48
  else inlineable.push(sheet);
41
49
  }
42
- if (inlineable.length === 0) return { skipped };
50
+ if (inlineable.length === 0) return { skipped, fetched };
43
51
 
44
52
  const pageOrigin = new URL(page.url()).origin;
45
53
  const joined = inlineable
@@ -59,7 +67,7 @@ export async function inlineStylesheets({ page, minifyCssOptions }) {
59
67
  },
60
68
  { text: css, hrefs: inlineable.map((sheet) => sheet.href) },
61
69
  );
62
- return { skipped };
70
+ return { skipped, fetched };
63
71
  }
64
72
 
65
73
  /**
@@ -68,7 +76,43 @@ export async function inlineStylesheets({ page, minifyCssOptions }) {
68
76
  * beasties is an optional peer dependency because most projects never need it.
69
77
  * `preload` is passed through so a strict script-src can turn the swap handlers off.
70
78
  */
71
- export async function inlineCriticalCss({ html, outputDir, base, preload = 'media' }) {
79
+ export async function inlineCriticalCss({
80
+ html,
81
+ outputDir,
82
+ base,
83
+ preload = 'media',
84
+ cache = null,
85
+ }) {
86
+ const key = `${path.resolve(outputDir)}|${base}|${preload}`;
87
+ let state = cache?.get(key);
88
+ if (!state) {
89
+ state = {
90
+ processor: createCriticalCssProcessor({ outputDir, base, preload }),
91
+ tail: Promise.resolve(),
92
+ };
93
+ cache?.set(key, state);
94
+ }
95
+ // Beasties keeps per-instance selector state and is not safe for concurrent process
96
+ // calls, so this queue serialises routes while sharing the instance.
97
+ const run = state.tail.then(async () => {
98
+ const beasties = await state.processor;
99
+ return beasties.process(html);
100
+ });
101
+ state.tail = run.then(
102
+ () => undefined,
103
+ () => undefined,
104
+ );
105
+ try {
106
+ return await run;
107
+ } catch (error) {
108
+ if (cache?.get(key) === state) cache.delete(key);
109
+ throw error;
110
+ }
111
+ }
112
+
113
+ // Creates the Beasties processor once per output configuration. The selector cache is
114
+ // reused across routes; stylesheet text is still read per page by the library.
115
+ async function createCriticalCssProcessor({ outputDir, base, preload }) {
72
116
  let Beasties;
73
117
  try {
74
118
  ({ default: Beasties } = await import('beasties'));
@@ -77,12 +121,11 @@ export async function inlineCriticalCss({ html, outputDir, base, preload = 'medi
77
121
  "inlineCss: 'critical' needs the optional beasties dependency. Install it with: npm install beasties",
78
122
  );
79
123
  }
80
- const beasties = new Beasties({
124
+ return new Beasties({
81
125
  path: outputDir,
82
126
  publicPath: base,
83
127
  logLevel: 'silent',
84
128
  preload,
85
129
  noscriptFallback: true,
86
130
  });
87
- return beasties.process(html);
88
131
  }
@@ -0,0 +1,63 @@
1
+ import {
2
+ resolveSeoLocale,
3
+ seedContextCookie,
4
+ seedCookies,
5
+ seedLocalStorage,
6
+ seedPageStorage,
7
+ } from './seo-locale.js';
8
+
9
+ /**
10
+ * Builds the Playwright context options that make one locale deterministic: browser
11
+ * locale, timezone and the base storage state. The caller keeps auth storage separate from
12
+ * locale cookies and local storage.
13
+ * @param {object} config
14
+ * @param {object | null} locale
15
+ * @returns {{ locale?: string, timezoneId?: string }}
16
+ */
17
+ export function localeContextOptions(config, locale) {
18
+ const options = {};
19
+ const browserLocale = locale?.locale ?? null;
20
+ if (browserLocale) options.locale = browserLocale;
21
+ const timezone = locale?.timezone ?? config.timezone ?? null;
22
+ if (timezone) options.timezoneId = timezone;
23
+ return options;
24
+ }
25
+
26
+ /**
27
+ * Returns the storage state a locale should use, preferring its own file over the global
28
+ * authenticated state.
29
+ * @param {object} config
30
+ * @param {object | null} locale
31
+ * @returns {string | undefined}
32
+ */
33
+ export function localeStorageState(config, locale) {
34
+ return locale?.storageState ?? config.storageState ?? undefined;
35
+ }
36
+
37
+ /**
38
+ * Seeds the extra cookies and local storage declared on a locale record. These are
39
+ * independent of the SEO locale recipe and let a locale carry app-specific keys.
40
+ * @param {{ page: import('playwright-core').Page, context: import('playwright-core').BrowserContext, origin: string, locale: object | null }} input
41
+ */
42
+ export async function seedLocaleExtras({ page, context, origin, locale }) {
43
+ if (!locale) return;
44
+ await seedCookies(context, origin, locale.cookies ?? []);
45
+ await seedLocalStorage(page, locale.localStorage ?? {}, true);
46
+ }
47
+
48
+ /**
49
+ * Seeds the cookie and local storage the app reads before it boots. Render and verification
50
+ * use the same recipe, so the selected locale is identical in both passes.
51
+ * @param {{ page: import('playwright-core').Page, context: import('playwright-core').BrowserContext, origin: string, config: object, route: string, locale: object | null, phase: 'render' | 'verify' }} input
52
+ * @returns {Promise<object | null>} the resolved SEO locale, when one applied
53
+ */
54
+ export async function seedLocaleContext({ page, context, origin, config, route, locale, phase }) {
55
+ await seedLocaleExtras({ page, context, origin, locale });
56
+ const seo = resolveSeoLocale(config, locale, route);
57
+ if (!seo) return null;
58
+ if (phase === 'render' && !seo.seedRender) return null;
59
+ if (phase === 'verify' && !seo.seedVerification) return null;
60
+ await seedPageStorage(page, seo);
61
+ await seedContextCookie(context, origin, seo);
62
+ return seo;
63
+ }