mnfst-render 0.5.37 → 0.5.39

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 +260 -123
  2. package/package.json +1 -1
@@ -2,7 +2,7 @@
2
2
 
3
3
  /* Manifest Render */
4
4
 
5
- import { readFileSync, readSync, mkdirSync, writeFileSync, existsSync, rmSync, statSync, readdirSync, cpSync, unlinkSync, renameSync } from 'node:fs';
5
+ import { readFileSync, readSync, mkdirSync, writeFileSync, appendFileSync, existsSync, rmSync, statSync, readdirSync, cpSync, unlinkSync, renameSync } from 'node:fs';
6
6
  import { spawnSync } from 'node:child_process';
7
7
  import { join, resolve, dirname, relative, basename, sep } from 'node:path';
8
8
  import { createServer } from 'node:http';
@@ -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() {
@@ -282,6 +372,10 @@ function resolveConfig() {
282
372
  : [],
283
373
  dryRun: !!cli.dryRun,
284
374
  debugPrerender: !!cli.debugPrerender,
375
+ // render.icons: "lazy" strips prerender-inlined x-icon SVGs from the
376
+ // output — the icons plugin re-renders them at boot (decorative content;
377
+ // an icon-heavy nav duplicates kilobytes of identical SVG per link).
378
+ lazyIcons: pre.icons === 'lazy',
285
379
  // Cap on the manifest:render-ready wait. When the data plugin dispatches
286
380
  // the event, we resolve immediately; when it doesn't (most projects), we
287
381
  // fall back to the timeout. 10s gives slow data plugin pipelines a
@@ -1006,15 +1100,16 @@ function buildRootAssetPath(routerBasePath, filename) {
1006
1100
  return '/' + path.replace(/\/{2,}/g, '/');
1007
1101
  }
1008
1102
 
1009
- /** Inject stylesheet link with root-absolute href (avoids ../ resolving under locale segments like /en/page/). */
1010
- function postProcessInjectStylesheetLink(outputDir, filename, routerBasePath) {
1103
+ /** Inject stylesheet link with root-absolute href (avoids ../ resolving under locale segments like /en/page/).
1104
+ * `attrs` (e.g. `data-mnfst-utilities`) lets the runtime utilities plugin find and skip classes this sheet covers. */
1105
+ function postProcessInjectStylesheetLink(outputDir, filename, routerBasePath, attrs = '') {
1011
1106
  const cssPath = join(outputDir, filename);
1012
1107
  if (!existsSync(cssPath)) return;
1013
1108
  const stat = statSync(cssPath);
1014
1109
  if (stat.size === 0) return;
1015
1110
 
1016
1111
  const href = buildRootAssetPath(routerBasePath, filename);
1017
- const tag = `<link rel="stylesheet" href="${href}">`;
1112
+ const tag = `<link rel="stylesheet" href="${href}"${attrs ? ` ${attrs}` : ''}>`;
1018
1113
  const files = walkHtmlFiles(outputDir);
1019
1114
  for (const file of files) {
1020
1115
  let html = readFileSync(file, 'utf8');
@@ -1127,6 +1222,18 @@ function stripRedundantImgSrcBindings(html) {
1127
1222
  */
1128
1223
  // Prerender inlined Iconify SVG under <i x-icon="iterator.icon">; clear x-icon value so Alpine does not evaluate
1129
1224
  // loop/item expressions while the attribute remains for CSS (e.g. inline layout that keys off [x-icon]).
1225
+ // render.icons: "lazy" — drop prerender-inlined icon SVGs from the output.
1226
+ // Each svg carries its resolved name (data-icon), which is written back into
1227
+ // the host's x-icon attribute as a static, scope-free value — so the icons
1228
+ // plugin re-renders it at boot even on baked clones whose expression attrs
1229
+ // were emptied. Only svg-as-first-child hosts match; anything else stays inline.
1230
+ function stripLazyIconSvgs(html) {
1231
+ return html.replace(
1232
+ /(x-icon\s*=\s*")[^"]*("[^>]*>\s*)<svg\b[^>]*\bdata-icon="([^"]+)"[^>]*>[\s\S]*?<\/svg>/gi,
1233
+ (m, pre, post, name) => `${pre}${name}${post}`
1234
+ );
1235
+ }
1236
+
1130
1237
  function stripResolvedXIconDirectives(html) {
1131
1238
  return html.replace(/<i\b([^>]*)>([\s\S]*?)<\/i>/gi, (full, attrs, inner) => {
1132
1239
  if (isHydrateMarkedAttrs(attrs)) return full;
@@ -1593,6 +1700,7 @@ function generateLocaleVariantHtml({
1593
1700
  html = stripRedundantImgSrcBindings(html);
1594
1701
  html = stripEmptyInlineMaskStyles(html);
1595
1702
  html = stripResolvedXIconDirectives(html);
1703
+ if (config.lazyIcons) html = stripLazyIconSvgs(html);
1596
1704
  // markPrerenderedManifestComponents must run BEFORE stripPrerenderHydrateMarkers so it can
1597
1705
  // detect data-hydrate markers and skip components inside hydrate islands.
1598
1706
  html = markPrerenderedManifestComponents(html);
@@ -1874,7 +1982,8 @@ async function waitForVisualSettle(page, { settleMs = 300, mdWaitMs = 5000, capM
1874
1982
  const bootPending = !!window.__manifestReadyCoordinator && !window.__manifestReady;
1875
1983
  const inflight = (window.__manifestMarkdownPending || 0) > 0;
1876
1984
  const mdPending = cur.mdEmpty > 0 && Date.now() - start < mdWaitMs;
1877
- const utilPending = elapsed < mdWaitMs + 4000 && unresolvedVariants() > 0;
1985
+ const utilPending = (window.__manifestUtilitiesPending || 0) > 0
1986
+ || (elapsed < mdWaitMs + 4000 && unresolvedVariants() > 0);
1878
1987
  // A throttled compiler can DROP the request that would resolve them —
1879
1988
  // a class mutation re-triggers its observer's schedule.
1880
1989
  if (utilPending && elapsed % 500 < 100) document.body.classList.toggle('mnfst-og-nudge');
@@ -1991,13 +2100,26 @@ async function takeOgSnapshot(page, outputDir, pathSeg, globalAssetSignature, ca
1991
2100
  // surface (headless pages can sit frames behind after heavy updates).
1992
2101
  await page.screenshot({ type: 'png', clip: { x: 0, y: 0, width: 8, height: 8 }, captureBeyondViewport: false }).catch(() => { });
1993
2102
  await new Promise((r) => setTimeout(r, 120));
1994
- await page.screenshot({
2103
+ const shoot = () => page.screenshot({
1995
2104
  path: filePath,
1996
2105
  type: 'png',
1997
2106
  clip: { x: 0, y: 0, width: 1200, height: 630 },
1998
2107
  omitBackground: false,
1999
2108
  captureBeyondViewport: false,
2000
2109
  });
2110
+ await shoot();
2111
+ // Residual-race retry: a text-heavy page yielding a shell-weight card
2112
+ // means the main content missed the frame — settle again and reshoot.
2113
+ for (let attempt = 0; attempt < 2; attempt++) {
2114
+ let size = 0;
2115
+ try { size = statSync(filePath).size; } catch { break; }
2116
+ if (size >= 34 * 1024) break;
2117
+ const textLen = await page.evaluate(() => document.body?.innerText.length || 0).catch(() => 0);
2118
+ if (textLen < 2500) break;
2119
+ await new Promise((r) => setTimeout(r, 1200));
2120
+ await waitForVisualSettle(page, { settleMs: 400, capMs: 5000 });
2121
+ await shoot();
2122
+ }
2001
2123
  // Sanity check: a blank 1200×630 PNG (header only, white body) is ~8–10KB;
2002
2124
  // a content-rich page is 50KB+. When the resulting file is suspiciously
2003
2125
  // small the snapshot is treated as failed and the renderer falls through
@@ -2313,8 +2435,10 @@ async function injectMetaInDom(page, ctx) {
2313
2435
  if (existingTypes.has(type)) continue;
2314
2436
  if (def === false) continue;
2315
2437
  if (type === 'BreadcrumbList' && def === true) {
2316
- // Auto-derive from URL path segments.
2438
+ // Auto-derive from URL path segments; skip crumbs whose URL has no
2439
+ // rendered page (avoids linking soft-404 intermediate paths).
2317
2440
  const parts = location.pathname.split('/').filter(Boolean);
2441
+ const rendered = Array.isArray(ctx.renderedPaths) ? new Set(ctx.renderedPaths) : null;
2318
2442
  const items = [{
2319
2443
  '@type': 'ListItem',
2320
2444
  position: 1,
@@ -2322,11 +2446,13 @@ async function injectMetaInDom(page, ctx) {
2322
2446
  item: liveBase ? liveBase + '/' : '/',
2323
2447
  }];
2324
2448
  parts.forEach((seg, i) => {
2449
+ const crumbPath = parts.slice(0, i + 1).join('/');
2450
+ if (rendered && !rendered.has(crumbPath)) return;
2325
2451
  items.push({
2326
2452
  '@type': 'ListItem',
2327
- position: i + 2,
2453
+ position: items.length + 1,
2328
2454
  name: seg.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
2329
- item: liveBase ? `${liveBase}/${parts.slice(0, i + 1).join('/')}` : '/' + parts.slice(0, i + 1).join('/'),
2455
+ item: liveBase ? `${liveBase}/${crumbPath}` : '/' + crumbPath,
2330
2456
  });
2331
2457
  });
2332
2458
  blocks.push({ '@context': 'https://schema.org', '@type': 'BreadcrumbList', itemListElement: items });
@@ -3228,13 +3354,49 @@ async function runPrerender(config) {
3228
3354
  // Recycle the browser every N processed pages to bound resource growth.
3229
3355
  // Configurable via manifest.prerender.browserRecycleEvery.
3230
3356
  const browserRecycleEvery = Math.max(0, pre.browserRecycleEvery ?? 40);
3231
- let pagesSinceRecycle = 0;
3232
- const recycleLock = { busy: false };
3233
- // Workers block on this promise before touching `browser`. While a recycle
3234
- // is in progress it's a pending promise; once the new browser is up it
3235
- // resolves and workers can proceed. This prevents "browser not ready"
3236
- // errors from racing retries during recycle.
3237
- let browserReadyPromise = Promise.resolve();
3357
+ // Bounds on the recycle handshake and on a single page render. A stuck
3358
+ // page must never pin the whole run: each is capped, logged and stepped over.
3359
+ const drainTimeoutMs = Math.max(0, pre.recycleDrainTimeout ?? 60000);
3360
+ const recycleTimeoutMs = Math.max(0, pre.recycleTimeout ?? 180000);
3361
+ const pageTimeoutMs = Math.max(0, pre.pageTimeout ?? Math.max(120000, (config.wait ?? 30000) * 4));
3362
+
3363
+ // Close the browser without hanging: SIGKILL the process if close stalls.
3364
+ async function closeBrowser(target) {
3365
+ if (!target) return;
3366
+ const proc = typeof target.process === 'function' ? target.process() : null;
3367
+ const closed = await withDeadline(Promise.resolve(target.close()).catch(() => {}), 15000);
3368
+ if (!closed) {
3369
+ process.stderr.write('prerender: browser close timed out — killing the process\n');
3370
+ try { proc?.kill('SIGKILL'); } catch (_) {}
3371
+ }
3372
+ }
3373
+
3374
+ // Launch with a deadline and one retry; a hung launch would otherwise stall
3375
+ // every worker behind the recycle gate.
3376
+ async function launchBrowserBounded() {
3377
+ for (let attempt = 0; attempt < 2; attempt++) {
3378
+ let launched = null;
3379
+ const ok = await withDeadline(
3380
+ launchBrowser().then((b) => { launched = b; }),
3381
+ 60000
3382
+ );
3383
+ if (ok && launched) return launched;
3384
+ process.stderr.write('prerender: browser launch timed out — retrying\n');
3385
+ }
3386
+ throw new Error('browser launch timed out');
3387
+ }
3388
+
3389
+ const recycleGate = createRecycleGate({
3390
+ every: browserRecycleEvery,
3391
+ drainTimeoutMs,
3392
+ recycleTimeoutMs,
3393
+ onLog: (message) => process.stderr.write(`prerender: ${message}\n`),
3394
+ recycle: async (processed) => {
3395
+ process.stdout.write(`prerender: recycling browser (processed ${processed} pages)\n`);
3396
+ await closeBrowser(browser);
3397
+ browser = await launchBrowserBounded();
3398
+ },
3399
+ });
3238
3400
  const pathTotal = pathList.length;
3239
3401
  const failedPaths = [];
3240
3402
  const debugRows = [];
@@ -3323,7 +3485,7 @@ async function runPrerender(config) {
3323
3485
  debugRows.push(row);
3324
3486
  }
3325
3487
 
3326
- async function processPath(pathSeg, pathIndex, { onRawHtml } = {}) {
3488
+ async function processPath(pathSeg, pathIndex, { onRawHtml, attempt } = {}) {
3327
3489
  const is404 = pathSeg === NOT_FOUND_PATH;
3328
3490
  const pathname = is404 ? `/${NOT_FOUND_PATH}` : (pathSeg ? `/${pathSeg}` : '/');
3329
3491
  const displayPath = pathSeg === '' ? '/' : pathname;
@@ -3339,11 +3501,8 @@ async function runPrerender(config) {
3339
3501
  : defaultLocale || 'en'
3340
3502
  : defaultLocale || 'en';
3341
3503
 
3342
- // Wait for any in-progress browser recycle to complete before touching
3343
- // `browser`. This transparently handles the window between the old
3344
- // browser being closed and the new one being launched — workers block
3345
- // here instead of throwing "browser not ready".
3346
- await browserReadyPromise;
3504
+ // No recycle can run while the caller holds a gate slot, so `browser` is
3505
+ // live for the whole call.
3347
3506
  const page = await browser.newPage();
3348
3507
  // Render at a typical desktop viewport so layouts dependent on viewport
3349
3508
  // width (responsive flex/grid, container queries, media queries) settle
@@ -3357,6 +3516,9 @@ async function runPrerender(config) {
3357
3516
  // initializeDataSourcesPlugin picks locale from document.documentElement.lang first; a mismatch
3358
3517
  // (e.g. headless default vs /en/...) leaves $x.* empty while x-route sections still render.
3359
3518
  await page.evaluateOnNewDocument((locale) => {
3519
+ // Render pass: the defer plugin keeps closed containers eager so the
3520
+ // snapshot carries their markup (hydration stashes it again).
3521
+ window.__manifestRender = true;
3360
3522
  const apply = () => {
3361
3523
  try {
3362
3524
  if (locale && typeof locale === 'string') document.documentElement.lang = locale;
@@ -4447,6 +4609,16 @@ async function runPrerender(config) {
4447
4609
  });
4448
4610
  });
4449
4611
 
4612
+ // (render.icons: "lazy" is applied on the serialized HTML string — see
4613
+ // stripLazyIconSvgs. Doing it in the live DOM re-triggers the icons
4614
+ // plugin's observer, which re-injects every svg before serialization.)
4615
+
4616
+ // x-virtual recomputes spacer geometry at boot; measured heights vary
4617
+ // run to run — zero them so identical content serializes identically.
4618
+ await page.evaluate(() => {
4619
+ document.querySelectorAll('[data-virtual-spacer]').forEach((el) => { el.style.height = '0px'; });
4620
+ });
4621
+
4450
4622
  // Deterministic output: randomized CSS anchor-positioning names
4451
4623
  // (--dropdown-x7f2, --popover-…, --anchor-…) are regenerated per run and
4452
4624
  // made every render a phantom diff. Renumber them sequentially in DOM
@@ -4491,6 +4663,7 @@ async function runPrerender(config) {
4491
4663
  liveUrl: (config.liveUrl || '').replace(/\/$/, ''),
4492
4664
  pathSeg: is404 ? '__404__' : pathSeg,
4493
4665
  snapshotUrl: earlySnapshotUrl,
4666
+ renderedPaths: pathList.filter((p) => p !== NOT_FOUND_PATH),
4494
4667
  });
4495
4668
 
4496
4669
  let html = await page.evaluate(() => document.documentElement.outerHTML);
@@ -4547,6 +4720,7 @@ async function runPrerender(config) {
4547
4720
  html = stripRedundantImgSrcBindings(html);
4548
4721
  html = stripEmptyInlineMaskStyles(html);
4549
4722
  html = stripResolvedXIconDirectives(html);
4723
+ if (config.lazyIcons) html = stripLazyIconSvgs(html);
4550
4724
  html = markPrerenderedManifestComponents(html);
4551
4725
 
4552
4726
  // Prefix internal <a> links with the locale for non-default locales so
@@ -4581,6 +4755,7 @@ async function runPrerender(config) {
4581
4755
  // (Hydration contract was already injected into the raw HTML before
4582
4756
  // the Node.js post-processing pipeline ran, so it's already present.)
4583
4757
  html = ensureDoctype(html);
4758
+ if (attempt?.abandoned) return; // a retry owns the output now
4584
4759
  mkdirSync(outDir, { recursive: true });
4585
4760
  writeFileSync(outFile, html, 'utf8');
4586
4761
  pushDebug({
@@ -4591,13 +4766,13 @@ async function runPrerender(config) {
4591
4766
  hasXForTemplate: html.includes('template x-for') || html.includes('template[x-for]'),
4592
4767
  });
4593
4768
  } catch (err) {
4594
- failedPaths.push({
4595
- path: displayPath,
4596
- message: err && err.message ? err.message : String(err)
4597
- });
4598
- if (failedPaths.length <= 10) {
4599
- process.stderr.write(`prerender: failed ${displayPath}: ${failedPaths[failedPaths.length - 1].message}\n`);
4600
- }
4769
+ if (attempt?.abandoned) return;
4770
+ const message = err && err.message ? err.message : String(err);
4771
+ // The worker owns retries and reporting; a shared array can't be popped
4772
+ // safely while other workers push to it.
4773
+ if (attempt) { attempt.failure = { path: displayPath, message }; return; }
4774
+ failedPaths.push({ path: displayPath, message });
4775
+ process.stderr.write(`prerender: failed ${displayPath}: ${message}\n`);
4601
4776
  } finally {
4602
4777
  try { await page.close(); } catch (_) { /* page may be gone if browser died */ }
4603
4778
  }
@@ -4607,131 +4782,84 @@ async function runPrerender(config) {
4607
4782
  // Any failures (e.g. transient navigation timeouts) are retried up to
4608
4783
  // `maxRetries` times with a short backoff before being reported as fatal.
4609
4784
  //
4610
- // Browser recycling: after every `browserRecycleEvery` successful pages,
4611
- // all workers pause, one worker closes the browser and launches a fresh
4612
- // one, then all resume. This bounds Chromium's memory + handle growth.
4785
+ // Browser recycling: after every `browserRecycleEvery` successful pages the
4786
+ // gate pauses new work, drains the in-flight pages, swaps the browser and
4787
+ // resumes. Workers hold a gate slot only while they touch the browser.
4613
4788
  try {
4614
4789
  let index = 0;
4615
- let activeWorkers = 0;
4616
- const recycleGate = { resume: null, waitForZero: null };
4617
-
4618
- const waitUntilZero = () => new Promise((resolve) => {
4619
- if (activeWorkers === 0) return resolve();
4620
- recycleGate.waitForZero = resolve;
4621
- });
4622
- const waitForResume = () => new Promise((resolve) => {
4623
- if (!recycleLock.busy) return resolve();
4624
- const prev = recycleGate.resume;
4625
- recycleGate.resume = () => { if (prev) prev(); resolve(); };
4626
- });
4627
-
4628
- const maybeRecycleBrowser = async () => {
4629
- if (browserRecycleEvery <= 0) return;
4630
- if (pagesSinceRecycle < browserRecycleEvery) return;
4631
- if (recycleLock.busy) return;
4632
- recycleLock.busy = true;
4633
- // Wait for all in-flight workers to finish their current page BEFORE
4634
- // we gate `browserReadyPromise`, so workers already mid-processPath
4635
- // don't deadlock awaiting a promise we haven't yet started.
4636
- await waitUntilZero();
4637
- // Now gate newPage() calls from any worker that enters processPath
4638
- // after this point.
4639
- let resolveReady;
4640
- browserReadyPromise = new Promise((r) => { resolveReady = r; });
4641
- try {
4642
- process.stdout.write(`prerender: recycling browser (processed ${pagesSinceRecycle} pages)\n`);
4643
- try { await browser.close(); } catch (_) {}
4644
- browser = await launchBrowser();
4645
- pagesSinceRecycle = 0;
4646
- } finally {
4647
- // Release the gate first so any waiting workers can proceed, then
4648
- // clear the recycle lock so the outer while loop stops pausing.
4649
- try { resolveReady(); } catch (_) {}
4650
- recycleLock.busy = false;
4651
- const r = recycleGate.resume;
4652
- recycleGate.resume = null;
4653
- if (r) r();
4654
- }
4655
- };
4656
4790
 
4657
4791
  async function worker() {
4658
4792
  while (true) {
4659
- // Pause if a recycle is underway.
4660
- if (recycleLock.busy) await waitForResume();
4661
- // Also wait for any pending browser readiness (e.g. another worker
4662
- // started a recycle while we were processing).
4663
- await browserReadyPromise;
4664
-
4665
4793
  const i = index++;
4666
4794
  if (i >= puppeteerPaths.length) return;
4667
4795
  const pathSeg = puppeteerPaths[i];
4796
+ const displayPath = pathSeg === '' ? '/' : (pathSeg === NOT_FOUND_PATH ? '/__prerender_404__' : '/' + pathSeg);
4668
4797
  let attempt = 0;
4669
4798
  while (true) {
4670
- // Re-check recycle state at the start of every retry iteration.
4671
- if (recycleLock.busy) await waitForResume();
4672
- await browserReadyPromise;
4673
-
4674
- const failureCountBefore = failedPaths.length;
4675
- activeWorkers++;
4799
+ // Per-attempt result: `failedPaths` is shared, so a retry must never
4800
+ // read or pop entries another worker pushed.
4801
+ const attemptToken = { abandoned: false, failure: null };
4802
+ await recycleGate.acquire();
4676
4803
  try {
4677
- await processPath(pathSeg, i, {
4678
- onRawHtml: (seg, html) => {
4679
- if (seg !== NOT_FOUND_PATH) baseHtmlCache.set(seg || '', html);
4680
- },
4681
- });
4804
+ // Watchdog: a wedged renderer never returns, so cap the attempt.
4805
+ const finished = await withDeadline(
4806
+ processPath(pathSeg, i, {
4807
+ attempt: attemptToken,
4808
+ onRawHtml: (seg, html) => {
4809
+ if (seg !== NOT_FOUND_PATH) baseHtmlCache.set(seg || '', html);
4810
+ },
4811
+ }),
4812
+ pageTimeoutMs
4813
+ );
4814
+ if (!finished) {
4815
+ attemptToken.abandoned = true;
4816
+ attemptToken.failure = { path: displayPath, message: `page render exceeded ${pageTimeoutMs}ms` };
4817
+ }
4682
4818
  } catch (err) {
4683
- // Unexpected exception escaped processPath (e.g. browser died
4684
- // mid-call). Record as a failure so the retry logic can handle
4685
- // it gracefully instead of tearing down the whole worker.
4686
- failedPaths.push({
4687
- path: pathSeg === '' ? '/' : '/' + pathSeg,
4819
+ // Unexpected exception escaped processPath (e.g. browser died mid-call).
4820
+ attemptToken.failure = {
4821
+ path: displayPath,
4688
4822
  message: err && err.message ? err.message : String(err),
4689
- });
4690
- if (failedPaths.length <= 10) {
4691
- process.stderr.write(`prerender: worker exception on ${pathSeg || '/'}: ${failedPaths[failedPaths.length - 1].message}\n`);
4692
- }
4823
+ };
4693
4824
  } finally {
4694
- activeWorkers--;
4695
- if (activeWorkers === 0 && recycleGate.waitForZero) {
4696
- const z = recycleGate.waitForZero;
4697
- recycleGate.waitForZero = null;
4698
- z();
4699
- }
4825
+ recycleGate.release();
4700
4826
  }
4701
- if (failedPaths.length === failureCountBefore) {
4702
- pagesSinceRecycle++;
4827
+ if (!attemptToken.failure) {
4828
+ recycleGate.countPage();
4703
4829
  break; // success
4704
4830
  }
4705
4831
  if (attempt >= maxRetries) {
4706
4832
  // Exhausted retries — likely an unstable browser (e.g. cascading
4707
- // "detached Frame" errors). Force a recycle counter past the
4708
- // threshold so the next path triggers a fresh browser.
4709
- pagesSinceRecycle = Math.max(pagesSinceRecycle + 1, browserRecycleEvery);
4833
+ // "detached Frame" errors). Force the next path onto a fresh one.
4834
+ failedPaths.push(attemptToken.failure);
4835
+ if (failedPaths.length <= 10) {
4836
+ process.stderr.write(`prerender: failed ${displayPath}: ${attemptToken.failure.message}\n`);
4837
+ }
4838
+ recycleGate.countPage();
4839
+ recycleGate.requestRecycle();
4710
4840
  break;
4711
4841
  }
4712
4842
  // Halfway through retries with no success → preemptively recycle the
4713
4843
  // browser before the next attempt. This unblocks cascading frame
4714
4844
  // failures where the browser process needs a fresh start.
4715
- if (attempt + 1 >= Math.ceil(maxRetries / 2) && pagesSinceRecycle > 0) {
4716
- pagesSinceRecycle = Math.max(pagesSinceRecycle, browserRecycleEvery);
4717
- await maybeRecycleBrowser();
4845
+ if (attempt + 1 >= Math.ceil(maxRetries / 2) && recycleGate.pages > 0) {
4846
+ recycleGate.requestRecycle();
4847
+ await recycleGate.maybeRecycle();
4718
4848
  }
4719
- failedPaths.pop();
4720
4849
  attempt++;
4721
- const displayPath = pathSeg === '' ? '/' : (pathSeg === NOT_FOUND_PATH ? '/__prerender_404__' : '/' + pathSeg);
4722
- process.stderr.write(`prerender: retrying ${displayPath} (attempt ${attempt + 1}/${maxRetries + 1})\n`);
4850
+ process.stderr.write(`prerender: retrying ${displayPath} (${attemptToken.failure.message}) (attempt ${attempt + 1}/${maxRetries + 1})\n`);
4723
4851
  await new Promise((r) => setTimeout(r, 500 * attempt));
4724
4852
  }
4725
- // Attempt recycle after each completed path (only one worker will
4726
- // actually perform the recycle; others will be gated by recycleLock).
4727
- await maybeRecycleBrowser();
4853
+ // Attempt recycle after each completed path (only one worker performs
4854
+ // it; the rest block in acquire()).
4855
+ await recycleGate.maybeRecycle();
4728
4856
  }
4729
4857
  }
4730
4858
  await Promise.all(
4731
4859
  Array.from({ length: Math.min(concurrency, puppeteerPaths.length || 1) }, () => worker())
4732
4860
  );
4733
4861
  } finally {
4734
- try { await browser.close(); } catch (_) {}
4862
+ await closeBrowser(browser);
4735
4863
  }
4736
4864
 
4737
4865
  // Phase 2: Node.js — generate locale variants via text substitution
@@ -4790,9 +4918,18 @@ async function runPrerender(config) {
4790
4918
  if (bundleUtilities) {
4791
4919
  const utilMerged = mergeUtilityCssBlocks(utilityBlocks);
4792
4920
  if (utilMerged.trim()) {
4793
- writeFileSync(join(outputResolved, 'prerender.utilities.css'), `${utilMerged}\n`, 'utf8');
4794
- process.stdout.write('prerender: wrote prerender.utilities.css (Manifest custom utilities)\n');
4795
- postProcessInjectStylesheetLink(outputResolved, 'prerender.utilities.css', routerBasePath || '');
4921
+ // Append to prerender.tailwind.css when it exists — one render-blocking
4922
+ // stylesheet instead of two, same cascade order (utilities last). The
4923
+ // separate file remains only for projects without a Tailwind build.
4924
+ const twPath = join(outputResolved, 'prerender.tailwind.css');
4925
+ if (existsSync(twPath)) {
4926
+ appendFileSync(twPath, `\n/* Manifest custom utilities */\n${utilMerged}\n`, 'utf8');
4927
+ process.stdout.write('prerender: merged custom utilities into prerender.tailwind.css\n');
4928
+ } else {
4929
+ writeFileSync(join(outputResolved, 'prerender.utilities.css'), `${utilMerged}\n`, 'utf8');
4930
+ process.stdout.write('prerender: wrote prerender.utilities.css (Manifest custom utilities)\n');
4931
+ postProcessInjectStylesheetLink(outputResolved, 'prerender.utilities.css', routerBasePath || '', 'data-mnfst-utilities');
4932
+ }
4796
4933
  }
4797
4934
  }
4798
4935
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mnfst-render",
3
- "version": "0.5.37",
3
+ "version": "0.5.39",
4
4
  "description": "Render Manifest sites to static HTML for SEO",
5
5
  "type": "module",
6
6
  "bin": {