mnfst-render 0.5.38 → 0.5.40

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 (2) hide show
  1. package/manifest.render.mjs +285 -152
  2. package/package.json +1 -1
@@ -189,6 +189,96 @@ async function waitForManifestRenderReady(page, { allLocales, currentLocale, tim
189
189
  // intentional and benign — the DOM is still captured.
190
190
  }
191
191
 
192
+ // --- Browser recycle gate ----------------------------------------------------
193
+
194
+ /** Bound a promise: resolves true if it settles first, false on timeout. */
195
+ function withDeadline(promise, ms) {
196
+ if (!(ms > 0)) return Promise.resolve(promise).then(() => true);
197
+ let timer;
198
+ const settled = Promise.resolve(promise).then(
199
+ () => { clearTimeout(timer); return true; },
200
+ (err) => { clearTimeout(timer); throw err; }
201
+ );
202
+ return Promise.race([
203
+ settled,
204
+ new Promise((resolve) => {
205
+ timer = setTimeout(() => resolve(false), ms);
206
+ if (typeof timer.unref === 'function') timer.unref();
207
+ }),
208
+ ]);
209
+ }
210
+
211
+ /**
212
+ * Coordinates N render workers around a shared browser that is swapped out
213
+ * every `every` pages. Workers hold a slot (acquire/release) for exactly as
214
+ * long as they touch the browser; a recycle pauses new slots first, then
215
+ * drains the in-flight ones, so no worker can ever be blocked on the gate
216
+ * while it is still counted as active. Every wait is bounded: a stuck page
217
+ * or a stuck browser swap logs and continues instead of hanging the run.
218
+ * Exported for unit tests.
219
+ */
220
+ export function createRecycleGate({
221
+ every = 40,
222
+ recycle = async () => {},
223
+ drainTimeoutMs = 60000,
224
+ recycleTimeoutMs = 180000,
225
+ onLog = () => {},
226
+ } = {}) {
227
+ let active = 0;
228
+ let pages = 0;
229
+ let paused = false;
230
+ const zeroWaiters = [];
231
+ const resumeWaiters = [];
232
+ const wake = (list) => { for (const resolve of list.splice(0)) resolve(); };
233
+
234
+ return {
235
+ get active() { return active; },
236
+ get pages() { return pages; },
237
+ get paused() { return paused; },
238
+ /** Claim a browser slot; blocks while a recycle is pending or running. */
239
+ async acquire() {
240
+ while (paused) await new Promise((resolve) => { resumeWaiters.push(resolve); });
241
+ active++;
242
+ },
243
+ release() {
244
+ active = Math.max(0, active - 1);
245
+ if (active === 0) wake(zeroWaiters);
246
+ },
247
+ /** Count a page against the recycle threshold. */
248
+ countPage() { pages++; },
249
+ /** Make the next maybeRecycle() fire (unstable browser after retries). */
250
+ requestRecycle() { if (every > 0) pages = Math.max(pages, every); },
251
+ /** Swap the browser if the threshold is reached. Never throws. */
252
+ async maybeRecycle() {
253
+ if (every <= 0 || paused || pages < every) return false;
254
+ paused = true; // no new slots from here on — active can only fall
255
+ const processed = pages;
256
+ try {
257
+ if (active > 0) {
258
+ const drained = await withDeadline(
259
+ new Promise((resolve) => { zeroWaiters.push(resolve); }),
260
+ drainTimeoutMs
261
+ );
262
+ if (!drained) {
263
+ onLog(`recycle: ${active} page(s) still in flight after ${drainTimeoutMs}ms — recycling anyway`);
264
+ }
265
+ }
266
+ const swapped = await withDeadline(recycle(processed), recycleTimeoutMs);
267
+ if (!swapped) onLog(`recycle: browser swap exceeded ${recycleTimeoutMs}ms — continuing`);
268
+ pages = 0;
269
+ return swapped;
270
+ } catch (err) {
271
+ onLog(`recycle: failed (${err && err.message ? err.message : err}) — continuing`);
272
+ pages = 0;
273
+ return false;
274
+ } finally {
275
+ paused = false;
276
+ wake(resumeWaiters);
277
+ }
278
+ },
279
+ };
280
+ }
281
+
192
282
  // --- Config ------------------------------------------------------------------
193
283
 
194
284
  function parseArgs() {
@@ -227,12 +317,69 @@ function loadConfig(rootDir) {
227
317
  }
228
318
 
229
319
  function normalizeLocaleRouteExclude(val) {
320
+ const clean = (s) => String(s).trim().replace(/^\/+|\/+$/g, '');
230
321
  if (val == null) return [];
231
- if (Array.isArray(val)) return val.map((s) => String(s).trim()).filter(Boolean);
232
- if (typeof val === 'string') return val.split(',').map((s) => s.trim()).filter(Boolean);
322
+ if (Array.isArray(val)) return val.map(clean).filter(Boolean);
323
+ if (typeof val === 'string') return val.split(',').map(clean).filter(Boolean);
233
324
  return [];
234
325
  }
235
326
 
327
+ /**
328
+ * Shared predicate: true when route segment `seg` (no leading slash) falls
329
+ * under one of the excluded prefixes — prefix-matched on whole segments, so
330
+ * "docs" excludes "docs" itself and "docs/group/article" but not "docsite".
331
+ * Used by locale route expansion, link prefixing, and (mirrored in
332
+ * mnfst-publish) the publisher's expected-file computation.
333
+ */
334
+ function isLocaleRouteExcluded(seg, localeRouteExclude) {
335
+ if (!seg || !localeRouteExclude || !localeRouteExclude.length) return false;
336
+ const clean = String(seg).replace(/^\/+|\/+$/g, '');
337
+ for (const prefix of localeRouteExclude) {
338
+ if (clean === prefix || clean.startsWith(prefix + '/')) return true;
339
+ }
340
+ return false;
341
+ }
342
+
343
+ /**
344
+ * Expand discovered route segments into the full path list to render:
345
+ * every locale-neutral route as-is, plus a `<locale>/<route>` variant for
346
+ * every non-default locale (and for the default locale itself, under its
347
+ * own slug, when there's more than one locale) — except routes under an
348
+ * excluded prefix (localeRouteExclude), which are never locale-prefixed.
349
+ * Pure/side-effect-free so it can be unit tested without Puppeteer.
350
+ */
351
+ function expandLocaleRoutePaths({ routeSegments, locales, defaultLocale, localeRouteExclude }) {
352
+ const localeList = locales || [];
353
+ const localeSet = new Set(localeList.map((l) => String(l).toLowerCase()));
354
+ const localeNeutralSegments = routeSegments.filter((seg) => {
355
+ if (!seg) return true;
356
+ const first = seg.split('/')[0].toLowerCase();
357
+ return !localeSet.has(first);
358
+ });
359
+ const paths = new Set();
360
+ paths.add('');
361
+ for (const seg of routeSegments) paths.add(seg);
362
+ for (const locale of localeList.slice(1)) {
363
+ paths.add(locale);
364
+ for (const seg of localeNeutralSegments) {
365
+ if (!seg) continue;
366
+ if (isLocaleRouteExcluded(seg, localeRouteExclude)) continue;
367
+ paths.add(`${locale}/${seg}`);
368
+ }
369
+ }
370
+ // Default locale also under its slug (e.g. /en/, /en/page-1) so linking is
371
+ // symmetric with other locales; canonical points to root. Skip this when
372
+ // there's only one locale — the duplicates serve no purpose and bloat the
373
+ // output (every page would be written twice: at root AND under /en/).
374
+ if (defaultLocale && localeList.length > 1) {
375
+ paths.add(defaultLocale);
376
+ for (const seg of localeNeutralSegments) {
377
+ if (seg !== '' && !isLocaleRouteExcluded(seg, localeRouteExclude)) paths.add(`${defaultLocale}/${seg}`);
378
+ }
379
+ }
380
+ return paths;
381
+ }
382
+
236
383
  function resolveConfig() {
237
384
  const cli = parseArgs();
238
385
  const cwd = process.cwd();
@@ -1010,15 +1157,16 @@ function buildRootAssetPath(routerBasePath, filename) {
1010
1157
  return '/' + path.replace(/\/{2,}/g, '/');
1011
1158
  }
1012
1159
 
1013
- /** Inject stylesheet link with root-absolute href (avoids ../ resolving under locale segments like /en/page/). */
1014
- function postProcessInjectStylesheetLink(outputDir, filename, routerBasePath) {
1160
+ /** Inject stylesheet link with root-absolute href (avoids ../ resolving under locale segments like /en/page/).
1161
+ * `attrs` (e.g. `data-mnfst-utilities`) lets the runtime utilities plugin find and skip classes this sheet covers. */
1162
+ function postProcessInjectStylesheetLink(outputDir, filename, routerBasePath, attrs = '') {
1015
1163
  const cssPath = join(outputDir, filename);
1016
1164
  if (!existsSync(cssPath)) return;
1017
1165
  const stat = statSync(cssPath);
1018
1166
  if (stat.size === 0) return;
1019
1167
 
1020
1168
  const href = buildRootAssetPath(routerBasePath, filename);
1021
- const tag = `<link rel="stylesheet" href="${href}">`;
1169
+ const tag = `<link rel="stylesheet" href="${href}"${attrs ? ` ${attrs}` : ''}>`;
1022
1170
  const files = walkHtmlFiles(outputDir);
1023
1171
  for (const file of files) {
1024
1172
  let html = readFileSync(file, 'utf8');
@@ -1476,7 +1624,6 @@ function buildSubstitutionPairs(defaultLocaleData, targetLocaleData) {
1476
1624
  function prefixLocaleInternalLinks(html, locale, locales, localeRouteExclude) {
1477
1625
  if (!locale || !locales || !locales.length) return html;
1478
1626
  const localeSet = new Set(locales);
1479
- const excludeSet = new Set(localeRouteExclude || []);
1480
1627
 
1481
1628
  // Match <a ... href="..." ...> — capture the href value
1482
1629
  return html.replace(
@@ -1497,7 +1644,7 @@ function prefixLocaleInternalLinks(html, locale, locales, localeRouteExclude) {
1497
1644
  if (/\.(css|js|json|svg|png|jpg|jpeg|gif|ico|woff2?|ttf|eot|pdf|xml|txt)$/i.test(href)) return full;
1498
1645
 
1499
1646
  // Respect localeRouteExclude — these routes stay locale-neutral
1500
- if (excludeSet.has(firstSeg)) return full;
1647
+ if (isLocaleRouteExcluded(withoutSlash, localeRouteExclude)) return full;
1501
1648
 
1502
1649
  // Prefix with locale
1503
1650
  return `${prefix}/${locale}${href}${suffix}`;
@@ -2344,8 +2491,10 @@ async function injectMetaInDom(page, ctx) {
2344
2491
  if (existingTypes.has(type)) continue;
2345
2492
  if (def === false) continue;
2346
2493
  if (type === 'BreadcrumbList' && def === true) {
2347
- // Auto-derive from URL path segments.
2494
+ // Auto-derive from URL path segments; skip crumbs whose URL has no
2495
+ // rendered page (avoids linking soft-404 intermediate paths).
2348
2496
  const parts = location.pathname.split('/').filter(Boolean);
2497
+ const rendered = Array.isArray(ctx.renderedPaths) ? new Set(ctx.renderedPaths) : null;
2349
2498
  const items = [{
2350
2499
  '@type': 'ListItem',
2351
2500
  position: 1,
@@ -2353,11 +2502,13 @@ async function injectMetaInDom(page, ctx) {
2353
2502
  item: liveBase ? liveBase + '/' : '/',
2354
2503
  }];
2355
2504
  parts.forEach((seg, i) => {
2505
+ const crumbPath = parts.slice(0, i + 1).join('/');
2506
+ if (rendered && !rendered.has(crumbPath)) return;
2356
2507
  items.push({
2357
2508
  '@type': 'ListItem',
2358
- position: i + 2,
2509
+ position: items.length + 1,
2359
2510
  name: seg.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
2360
- item: liveBase ? `${liveBase}/${parts.slice(0, i + 1).join('/')}` : '/' + parts.slice(0, i + 1).join('/'),
2511
+ item: liveBase ? `${liveBase}/${crumbPath}` : '/' + crumbPath,
2361
2512
  });
2362
2513
  });
2363
2514
  blocks.push({ '@context': 'https://schema.org', '@type': 'BreadcrumbList', itemListElement: items });
@@ -3119,35 +3270,11 @@ async function runPrerender(config) {
3119
3270
  if (!segSet.has(p)) { routeSegments.push(p); segSet.add(p); }
3120
3271
  }
3121
3272
  }
3122
- const localeSet = new Set(locales.map((l) => String(l).toLowerCase()));
3123
- const localeNeutralSegments = routeSegments.filter((seg) => {
3124
- if (!seg) return true;
3125
- const first = seg.split('/')[0].toLowerCase();
3126
- return !localeSet.has(first);
3127
- });
3128
- const paths = new Set();
3129
- paths.add('');
3130
-
3131
- for (const seg of routeSegments) {
3132
- paths.add(seg);
3133
- }
3134
- for (const locale of locales.slice(1)) {
3135
- paths.add(locale);
3136
- for (const seg of localeNeutralSegments) {
3137
- if (!seg) continue;
3138
- paths.add(`${locale}/${seg}`);
3139
- }
3140
- }
3141
- // Default locale also under its slug (e.g. /en/, /en/page-1) so linking is
3142
- // symmetric with other locales; canonical points to root. Skip this when
3143
- // there's only one locale — the duplicates serve no purpose and bloat the
3144
- // output (every page would be written twice: at root AND under /en/).
3145
- if (defaultLocale && locales.length > 1) {
3146
- paths.add(defaultLocale);
3147
- for (const seg of localeNeutralSegments) {
3148
- if (seg !== '') paths.add(`${defaultLocale}/${seg}`);
3149
- }
3150
- }
3273
+ // Expand routeSegments into the full path list: locale-neutral routes as-is,
3274
+ // plus a `<locale>/<route>` variant per locale (skipping localeRouteExclude
3275
+ // prefixes). Factored into a pure function so it's unit-testable without
3276
+ // Puppeteer — see expandLocaleRoutePaths.
3277
+ const paths = expandLocaleRoutePaths({ routeSegments, locales, defaultLocale, localeRouteExclude: config.localeRouteExclude });
3151
3278
 
3152
3279
  const NOT_FOUND_PATH = '__prerender_404__'; // URL path that matches no route so router shows x-route="!*" (404)
3153
3280
  const pathList = [...paths, NOT_FOUND_PATH];
@@ -3259,13 +3386,52 @@ async function runPrerender(config) {
3259
3386
  // Recycle the browser every N processed pages to bound resource growth.
3260
3387
  // Configurable via manifest.prerender.browserRecycleEvery.
3261
3388
  const browserRecycleEvery = Math.max(0, pre.browserRecycleEvery ?? 40);
3262
- let pagesSinceRecycle = 0;
3263
- const recycleLock = { busy: false };
3264
- // Workers block on this promise before touching `browser`. While a recycle
3265
- // is in progress it's a pending promise; once the new browser is up it
3266
- // resolves and workers can proceed. This prevents "browser not ready"
3267
- // errors from racing retries during recycle.
3268
- let browserReadyPromise = Promise.resolve();
3389
+ // Bounds on the recycle handshake and on a single page render. A stuck
3390
+ // page must never pin the whole run: each is capped, logged and stepped
3391
+ // over — and a page that hits the ceiling forces a browser recycle, since
3392
+ // a wedged page.evaluate() may have poisoned the shared browser process.
3393
+ // pageTimeout is configurable via manifest.prerender.pageTimeout; 0 disables.
3394
+ const drainTimeoutMs = Math.max(0, pre.recycleDrainTimeout ?? 60000);
3395
+ const recycleTimeoutMs = Math.max(0, pre.recycleTimeout ?? 180000);
3396
+ const pageTimeoutMs = Math.max(0, pre.pageTimeout ?? Math.max(120000, (config.wait ?? 30000) * 4));
3397
+
3398
+ // Close the browser without hanging: SIGKILL the process if close stalls.
3399
+ async function closeBrowser(target) {
3400
+ if (!target) return;
3401
+ const proc = typeof target.process === 'function' ? target.process() : null;
3402
+ const closed = await withDeadline(Promise.resolve(target.close()).catch(() => {}), 15000);
3403
+ if (!closed) {
3404
+ process.stderr.write('prerender: browser close timed out — killing the process\n');
3405
+ try { proc?.kill('SIGKILL'); } catch (_) {}
3406
+ }
3407
+ }
3408
+
3409
+ // Launch with a deadline and one retry; a hung launch would otherwise stall
3410
+ // every worker behind the recycle gate.
3411
+ async function launchBrowserBounded() {
3412
+ for (let attempt = 0; attempt < 2; attempt++) {
3413
+ let launched = null;
3414
+ const ok = await withDeadline(
3415
+ launchBrowser().then((b) => { launched = b; }),
3416
+ 60000
3417
+ );
3418
+ if (ok && launched) return launched;
3419
+ process.stderr.write('prerender: browser launch timed out — retrying\n');
3420
+ }
3421
+ throw new Error('browser launch timed out');
3422
+ }
3423
+
3424
+ const recycleGate = createRecycleGate({
3425
+ every: browserRecycleEvery,
3426
+ drainTimeoutMs,
3427
+ recycleTimeoutMs,
3428
+ onLog: (message) => process.stderr.write(`prerender: ${message}\n`),
3429
+ recycle: async (processed) => {
3430
+ process.stdout.write(`prerender: recycling browser (processed ${processed} pages)\n`);
3431
+ await closeBrowser(browser);
3432
+ browser = await launchBrowserBounded();
3433
+ },
3434
+ });
3269
3435
  const pathTotal = pathList.length;
3270
3436
  const failedPaths = [];
3271
3437
  const debugRows = [];
@@ -3354,7 +3520,7 @@ async function runPrerender(config) {
3354
3520
  debugRows.push(row);
3355
3521
  }
3356
3522
 
3357
- async function processPath(pathSeg, pathIndex, { onRawHtml } = {}) {
3523
+ async function processPath(pathSeg, pathIndex, { onRawHtml, attempt } = {}) {
3358
3524
  const is404 = pathSeg === NOT_FOUND_PATH;
3359
3525
  const pathname = is404 ? `/${NOT_FOUND_PATH}` : (pathSeg ? `/${pathSeg}` : '/');
3360
3526
  const displayPath = pathSeg === '' ? '/' : pathname;
@@ -3370,11 +3536,8 @@ async function runPrerender(config) {
3370
3536
  : defaultLocale || 'en'
3371
3537
  : defaultLocale || 'en';
3372
3538
 
3373
- // Wait for any in-progress browser recycle to complete before touching
3374
- // `browser`. This transparently handles the window between the old
3375
- // browser being closed and the new one being launched — workers block
3376
- // here instead of throwing "browser not ready".
3377
- await browserReadyPromise;
3539
+ // No recycle can run while the caller holds a gate slot, so `browser` is
3540
+ // live for the whole call.
3378
3541
  const page = await browser.newPage();
3379
3542
  // Render at a typical desktop viewport so layouts dependent on viewport
3380
3543
  // width (responsive flex/grid, container queries, media queries) settle
@@ -3388,6 +3551,9 @@ async function runPrerender(config) {
3388
3551
  // initializeDataSourcesPlugin picks locale from document.documentElement.lang first; a mismatch
3389
3552
  // (e.g. headless default vs /en/...) leaves $x.* empty while x-route sections still render.
3390
3553
  await page.evaluateOnNewDocument((locale) => {
3554
+ // Render pass: the defer plugin keeps closed containers eager so the
3555
+ // snapshot carries their markup (hydration stashes it again).
3556
+ window.__manifestRender = true;
3391
3557
  const apply = () => {
3392
3558
  try {
3393
3559
  if (locale && typeof locale === 'string') document.documentElement.lang = locale;
@@ -4532,6 +4698,7 @@ async function runPrerender(config) {
4532
4698
  liveUrl: (config.liveUrl || '').replace(/\/$/, ''),
4533
4699
  pathSeg: is404 ? '__404__' : pathSeg,
4534
4700
  snapshotUrl: earlySnapshotUrl,
4701
+ renderedPaths: pathList.filter((p) => p !== NOT_FOUND_PATH),
4535
4702
  });
4536
4703
 
4537
4704
  let html = await page.evaluate(() => document.documentElement.outerHTML);
@@ -4623,6 +4790,7 @@ async function runPrerender(config) {
4623
4790
  // (Hydration contract was already injected into the raw HTML before
4624
4791
  // the Node.js post-processing pipeline ran, so it's already present.)
4625
4792
  html = ensureDoctype(html);
4793
+ if (attempt?.abandoned) return; // a retry owns the output now
4626
4794
  mkdirSync(outDir, { recursive: true });
4627
4795
  writeFileSync(outFile, html, 'utf8');
4628
4796
  pushDebug({
@@ -4633,13 +4801,13 @@ async function runPrerender(config) {
4633
4801
  hasXForTemplate: html.includes('template x-for') || html.includes('template[x-for]'),
4634
4802
  });
4635
4803
  } catch (err) {
4636
- failedPaths.push({
4637
- path: displayPath,
4638
- message: err && err.message ? err.message : String(err)
4639
- });
4640
- if (failedPaths.length <= 10) {
4641
- process.stderr.write(`prerender: failed ${displayPath}: ${failedPaths[failedPaths.length - 1].message}\n`);
4642
- }
4804
+ if (attempt?.abandoned) return;
4805
+ const message = err && err.message ? err.message : String(err);
4806
+ // The worker owns retries and reporting; a shared array can't be popped
4807
+ // safely while other workers push to it.
4808
+ if (attempt) { attempt.failure = { path: displayPath, message }; return; }
4809
+ failedPaths.push({ path: displayPath, message });
4810
+ process.stderr.write(`prerender: failed ${displayPath}: ${message}\n`);
4643
4811
  } finally {
4644
4812
  try { await page.close(); } catch (_) { /* page may be gone if browser died */ }
4645
4813
  }
@@ -4649,131 +4817,89 @@ async function runPrerender(config) {
4649
4817
  // Any failures (e.g. transient navigation timeouts) are retried up to
4650
4818
  // `maxRetries` times with a short backoff before being reported as fatal.
4651
4819
  //
4652
- // Browser recycling: after every `browserRecycleEvery` successful pages,
4653
- // all workers pause, one worker closes the browser and launches a fresh
4654
- // one, then all resume. This bounds Chromium's memory + handle growth.
4820
+ // Browser recycling: after every `browserRecycleEvery` successful pages the
4821
+ // gate pauses new work, drains the in-flight pages, swaps the browser and
4822
+ // resumes. Workers hold a gate slot only while they touch the browser.
4655
4823
  try {
4656
4824
  let index = 0;
4657
- let activeWorkers = 0;
4658
- const recycleGate = { resume: null, waitForZero: null };
4659
-
4660
- const waitUntilZero = () => new Promise((resolve) => {
4661
- if (activeWorkers === 0) return resolve();
4662
- recycleGate.waitForZero = resolve;
4663
- });
4664
- const waitForResume = () => new Promise((resolve) => {
4665
- if (!recycleLock.busy) return resolve();
4666
- const prev = recycleGate.resume;
4667
- recycleGate.resume = () => { if (prev) prev(); resolve(); };
4668
- });
4669
-
4670
- const maybeRecycleBrowser = async () => {
4671
- if (browserRecycleEvery <= 0) return;
4672
- if (pagesSinceRecycle < browserRecycleEvery) return;
4673
- if (recycleLock.busy) return;
4674
- recycleLock.busy = true;
4675
- // Wait for all in-flight workers to finish their current page BEFORE
4676
- // we gate `browserReadyPromise`, so workers already mid-processPath
4677
- // don't deadlock awaiting a promise we haven't yet started.
4678
- await waitUntilZero();
4679
- // Now gate newPage() calls from any worker that enters processPath
4680
- // after this point.
4681
- let resolveReady;
4682
- browserReadyPromise = new Promise((r) => { resolveReady = r; });
4683
- try {
4684
- process.stdout.write(`prerender: recycling browser (processed ${pagesSinceRecycle} pages)\n`);
4685
- try { await browser.close(); } catch (_) {}
4686
- browser = await launchBrowser();
4687
- pagesSinceRecycle = 0;
4688
- } finally {
4689
- // Release the gate first so any waiting workers can proceed, then
4690
- // clear the recycle lock so the outer while loop stops pausing.
4691
- try { resolveReady(); } catch (_) {}
4692
- recycleLock.busy = false;
4693
- const r = recycleGate.resume;
4694
- recycleGate.resume = null;
4695
- if (r) r();
4696
- }
4697
- };
4698
4825
 
4699
4826
  async function worker() {
4700
4827
  while (true) {
4701
- // Pause if a recycle is underway.
4702
- if (recycleLock.busy) await waitForResume();
4703
- // Also wait for any pending browser readiness (e.g. another worker
4704
- // started a recycle while we were processing).
4705
- await browserReadyPromise;
4706
-
4707
4828
  const i = index++;
4708
4829
  if (i >= puppeteerPaths.length) return;
4709
4830
  const pathSeg = puppeteerPaths[i];
4831
+ const displayPath = pathSeg === '' ? '/' : (pathSeg === NOT_FOUND_PATH ? '/__prerender_404__' : '/' + pathSeg);
4710
4832
  let attempt = 0;
4711
4833
  while (true) {
4712
- // Re-check recycle state at the start of every retry iteration.
4713
- if (recycleLock.busy) await waitForResume();
4714
- await browserReadyPromise;
4715
-
4716
- const failureCountBefore = failedPaths.length;
4717
- activeWorkers++;
4834
+ // Per-attempt result: `failedPaths` is shared, so a retry must never
4835
+ // read or pop entries another worker pushed.
4836
+ const attemptToken = { abandoned: false, failure: null };
4837
+ await recycleGate.acquire();
4718
4838
  try {
4719
- await processPath(pathSeg, i, {
4720
- onRawHtml: (seg, html) => {
4721
- if (seg !== NOT_FOUND_PATH) baseHtmlCache.set(seg || '', html);
4722
- },
4723
- });
4839
+ // Watchdog: a wedged renderer never returns, so cap the attempt.
4840
+ const finished = await withDeadline(
4841
+ processPath(pathSeg, i, {
4842
+ attempt: attemptToken,
4843
+ onRawHtml: (seg, html) => {
4844
+ if (seg !== NOT_FOUND_PATH) baseHtmlCache.set(seg || '', html);
4845
+ },
4846
+ }),
4847
+ pageTimeoutMs
4848
+ );
4849
+ if (!finished) {
4850
+ attemptToken.abandoned = true;
4851
+ attemptToken.failure = { path: displayPath, message: `page render exceeded ${pageTimeoutMs}ms` };
4852
+ // A timed-out page is presumed to have wedged the browser (e.g.
4853
+ // a hung page.evaluate on a dead Chrome process) — force a
4854
+ // recycle once this path is done, even if a later retry
4855
+ // "succeeds" by reusing the same possibly-poisoned browser.
4856
+ recycleGate.requestRecycle();
4857
+ }
4724
4858
  } catch (err) {
4725
- // Unexpected exception escaped processPath (e.g. browser died
4726
- // mid-call). Record as a failure so the retry logic can handle
4727
- // it gracefully instead of tearing down the whole worker.
4728
- failedPaths.push({
4729
- path: pathSeg === '' ? '/' : '/' + pathSeg,
4859
+ // Unexpected exception escaped processPath (e.g. browser died mid-call).
4860
+ attemptToken.failure = {
4861
+ path: displayPath,
4730
4862
  message: err && err.message ? err.message : String(err),
4731
- });
4732
- if (failedPaths.length <= 10) {
4733
- process.stderr.write(`prerender: worker exception on ${pathSeg || '/'}: ${failedPaths[failedPaths.length - 1].message}\n`);
4734
- }
4863
+ };
4735
4864
  } finally {
4736
- activeWorkers--;
4737
- if (activeWorkers === 0 && recycleGate.waitForZero) {
4738
- const z = recycleGate.waitForZero;
4739
- recycleGate.waitForZero = null;
4740
- z();
4741
- }
4865
+ recycleGate.release();
4742
4866
  }
4743
- if (failedPaths.length === failureCountBefore) {
4744
- pagesSinceRecycle++;
4867
+ if (!attemptToken.failure) {
4868
+ recycleGate.countPage();
4745
4869
  break; // success
4746
4870
  }
4747
4871
  if (attempt >= maxRetries) {
4748
4872
  // Exhausted retries — likely an unstable browser (e.g. cascading
4749
- // "detached Frame" errors). Force a recycle counter past the
4750
- // threshold so the next path triggers a fresh browser.
4751
- pagesSinceRecycle = Math.max(pagesSinceRecycle + 1, browserRecycleEvery);
4873
+ // "detached Frame" errors). Force the next path onto a fresh one.
4874
+ failedPaths.push(attemptToken.failure);
4875
+ if (failedPaths.length <= 10) {
4876
+ process.stderr.write(`prerender: failed ${displayPath}: ${attemptToken.failure.message}\n`);
4877
+ }
4878
+ recycleGate.countPage();
4879
+ recycleGate.requestRecycle();
4752
4880
  break;
4753
4881
  }
4754
4882
  // Halfway through retries with no success → preemptively recycle the
4755
4883
  // browser before the next attempt. This unblocks cascading frame
4756
4884
  // failures where the browser process needs a fresh start.
4757
- if (attempt + 1 >= Math.ceil(maxRetries / 2) && pagesSinceRecycle > 0) {
4758
- pagesSinceRecycle = Math.max(pagesSinceRecycle, browserRecycleEvery);
4759
- await maybeRecycleBrowser();
4885
+ if (attempt + 1 >= Math.ceil(maxRetries / 2) && recycleGate.pages > 0) {
4886
+ recycleGate.requestRecycle();
4887
+ await recycleGate.maybeRecycle();
4760
4888
  }
4761
- failedPaths.pop();
4762
4889
  attempt++;
4763
- const displayPath = pathSeg === '' ? '/' : (pathSeg === NOT_FOUND_PATH ? '/__prerender_404__' : '/' + pathSeg);
4764
- process.stderr.write(`prerender: retrying ${displayPath} (attempt ${attempt + 1}/${maxRetries + 1})\n`);
4890
+ process.stderr.write(`prerender: retrying ${displayPath} (${attemptToken.failure.message}) (attempt ${attempt + 1}/${maxRetries + 1})\n`);
4765
4891
  await new Promise((r) => setTimeout(r, 500 * attempt));
4766
4892
  }
4767
- // Attempt recycle after each completed path (only one worker will
4768
- // actually perform the recycle; others will be gated by recycleLock).
4769
- await maybeRecycleBrowser();
4893
+ // Attempt recycle after each completed path (only one worker performs
4894
+ // it; the rest block in acquire()).
4895
+ await recycleGate.maybeRecycle();
4770
4896
  }
4771
4897
  }
4772
4898
  await Promise.all(
4773
4899
  Array.from({ length: Math.min(concurrency, puppeteerPaths.length || 1) }, () => worker())
4774
4900
  );
4775
4901
  } finally {
4776
- try { await browser.close(); } catch (_) {}
4902
+ await closeBrowser(browser);
4777
4903
  }
4778
4904
 
4779
4905
  // Phase 2: Node.js — generate locale variants via text substitution
@@ -4842,7 +4968,7 @@ async function runPrerender(config) {
4842
4968
  } else {
4843
4969
  writeFileSync(join(outputResolved, 'prerender.utilities.css'), `${utilMerged}\n`, 'utf8');
4844
4970
  process.stdout.write('prerender: wrote prerender.utilities.css (Manifest custom utilities)\n');
4845
- postProcessInjectStylesheetLink(outputResolved, 'prerender.utilities.css', routerBasePath || '');
4971
+ postProcessInjectStylesheetLink(outputResolved, 'prerender.utilities.css', routerBasePath || '', 'data-mnfst-utilities');
4846
4972
  }
4847
4973
  }
4848
4974
  }
@@ -4902,4 +5028,11 @@ if (_isDirectEntry) {
4902
5028
  }
4903
5029
 
4904
5030
  // Exports for the CLI bin script and for unit testing.
4905
- export { main, markPrerenderedManifestComponents };
5031
+ export {
5032
+ main,
5033
+ markPrerenderedManifestComponents,
5034
+ normalizeLocaleRouteExclude,
5035
+ isLocaleRouteExcluded,
5036
+ expandLocaleRoutePaths,
5037
+ prefixLocaleInternalLinks,
5038
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mnfst-render",
3
- "version": "0.5.38",
3
+ "version": "0.5.40",
4
4
  "description": "Render Manifest sites to static HTML for SEO",
5
5
  "type": "module",
6
6
  "bin": {