mnfst-render 0.5.52 → 0.5.53

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 +238 -12
  2. package/package.json +1 -1
@@ -411,6 +411,8 @@ function parseArgs() {
411
411
  if (args[i] === '--retries' && args[i + 1]) { out.retries = parseInt(args[++i], 10); continue; }
412
412
  if (args[i] === '--incremental') { out.incremental = true; continue; }
413
413
  if (args[i] === '--no-incremental') { out.noIncremental = true; continue; }
414
+ if (args[i] === '--run-cache') { out.runCache = true; continue; }
415
+ if (args[i] === '--no-run-cache') { out.runCache = false; continue; }
414
416
  if (args[i] === '--dry-run') { out.dryRun = true; continue; }
415
417
  if (args[i] === '--force-lock') { out.forceLock = true; continue; }
416
418
  if (args[i] === '--debug-prerender') { out.debugPrerender = true; continue; }
@@ -614,6 +616,8 @@ function resolveConfig() {
614
616
  // pattern). blockTelemetry (default on) adds TELEMETRY_BLOCK_PATTERNS.
615
617
  blockRequests: Array.isArray(pre.blockRequests) ? pre.blockRequests.map((p) => String(p)).filter(Boolean) : [],
616
618
  blockTelemetry: pre.blockTelemetry !== false,
619
+ // Run-scoped cross-origin response cache (render.runCache / --no-run-cache).
620
+ runCache: cli.runCache ?? pre.runCache !== false,
617
621
  dryRun: !!cli.dryRun,
618
622
  forceLock: !!cli.forceLock,
619
623
  debugPrerender: !!cli.debugPrerender,
@@ -3524,7 +3528,9 @@ const PINNED_VERSION_RE = /@\d+\.\d+\.\d+/;
3524
3528
  const EXTERNAL_BODY_HASH_LIMIT = 8 * 1024 * 1024;
3525
3529
 
3526
3530
  // A prerender is not a visitor (Playcom measured 465 fake GA page_views per
3527
- // render). Collection endpoints only — never script loads.
3531
+ // render), and error/RUM reporters phone home from headless too (a widget
3532
+ // crash in headless spams the site's production error tracker every render).
3533
+ // Analytics + error/RUM collection endpoints only — never script loads.
3528
3534
  const TELEMETRY_BLOCK_PATTERNS = [
3529
3535
  'google-analytics.com/g/collect',
3530
3536
  '*.google-analytics.com/mp/collect',
@@ -3535,6 +3541,12 @@ const TELEMETRY_BLOCK_PATTERNS = [
3535
3541
  'api.mixpanel.com/track',
3536
3542
  '.facebook.com/tr?',
3537
3543
  '.facebook.com/tr/',
3544
+ 'ingest.sentry.io',
3545
+ 'notify.bugsnag.com',
3546
+ 'api.rollbar.com',
3547
+ 'cloudflareinsights.com/cdn-cgi/rum',
3548
+ 'bam.nr-data.net',
3549
+ 'browser-intake-datadoghq.com',
3538
3550
  ];
3539
3551
 
3540
3552
  /** URL predicate from block patterns: substring match, `*` globs within a
@@ -3549,6 +3561,106 @@ function compileBlockMatcher(patterns) {
3549
3561
  return (url) => regs.some((r) => r.test(String(url)));
3550
3562
  }
3551
3563
 
3564
+ // --- Run-scoped cross-origin response cache ------------------------------------
3565
+ // Within one run every cross-origin GET is fetched AT MOST ONCE: the first 2xx
3566
+ // body+headers are replayed to every later page. A prerender is a point-in-
3567
+ // time snapshot, so run-level consistency is a feature — and N pages
3568
+ // re-fetching identical framework assets manufactures the burst that fails.
3569
+ const RUN_CACHE_MAX_ENTRY_BYTES = EXTERNAL_BODY_HASH_LIMIT;
3570
+ const RUN_CACHE_MAX_TOTAL_BYTES = 64 * 1024 * 1024;
3571
+ const RUN_CACHE_MAX_ENTRIES = 500;
3572
+
3573
+ /** Interception verdict: blocked URLs abort FIRST (never cached/served); only
3574
+ * cross-origin http(s) GETs are cache-eligible; everything else passes. */
3575
+ function classifyRunCacheRequest({ url, method, localOrigin, isBlocked }) {
3576
+ if (isBlocked && isBlocked(url)) return 'block';
3577
+ let u;
3578
+ try { u = new URL(url); } catch { return 'bypass'; }
3579
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return 'bypass';
3580
+ if (method !== 'GET' || u.origin === localOrigin) return 'bypass';
3581
+ return 'cache';
3582
+ }
3583
+
3584
+ /** Headers safe to replay via request.respond: the stored body is the decoded
3585
+ * bytes, so the original transfer's encoding/length no longer apply. ETag,
3586
+ * Last-Modified and Content-Type pass through verbatim — capture must observe
3587
+ * identical metadata on served and fetched responses. */
3588
+ function runCacheReplayHeaders(headers) {
3589
+ const out = {};
3590
+ for (const [k, v] of Object.entries(headers || {})) {
3591
+ const key = k.toLowerCase();
3592
+ if (key === 'content-encoding' || key === 'content-length'
3593
+ || key === 'transfer-encoding' || key === 'set-cookie') continue;
3594
+ out[k] = v;
3595
+ }
3596
+ return out;
3597
+ }
3598
+
3599
+ function createRunCache({
3600
+ maxEntryBytes = RUN_CACHE_MAX_ENTRY_BYTES,
3601
+ maxTotalBytes = RUN_CACHE_MAX_TOTAL_BYTES,
3602
+ maxEntries = RUN_CACHE_MAX_ENTRIES,
3603
+ } = {}) {
3604
+ const entries = new Map(); // url → { status, headers, body }; insertion = recency
3605
+ const inflight = new Map(); // url → { promise, settle }
3606
+ let totalBytes = 0;
3607
+ const stats = { served: 0, servedUrls: new Set(), stored: 0 };
3608
+ return {
3609
+ stats,
3610
+ get size() { return entries.size; },
3611
+ get totalBytes() { return totalBytes; },
3612
+ /** Hit refreshes LRU recency. */
3613
+ get(url) {
3614
+ const e = entries.get(url);
3615
+ if (!e) return null;
3616
+ entries.delete(url); entries.set(url, e);
3617
+ return e;
3618
+ },
3619
+ peek: (url) => entries.get(url) || null,
3620
+ /** First 2xx wins; failures and over-cap bodies are never cached. */
3621
+ set(url, entry) {
3622
+ const { status, body } = entry || {};
3623
+ if (!(status >= 200 && status < 300)) return false;
3624
+ if (!body || typeof body.length !== 'number' || body.length > maxEntryBytes) return false;
3625
+ if (entries.has(url)) return true;
3626
+ entries.set(url, entry);
3627
+ totalBytes += body.length;
3628
+ stats.stored++;
3629
+ while (entries.size > maxEntries || totalBytes > maxTotalBytes) {
3630
+ const oldest = entries.keys().next().value;
3631
+ totalBytes -= entries.get(oldest).body.length;
3632
+ entries.delete(oldest);
3633
+ }
3634
+ return true;
3635
+ },
3636
+ noteServed(url) { stats.served++; stats.servedUrls.add(url); },
3637
+ flight: (url) => inflight.get(url) || null,
3638
+ /** One network fetch per URL at a time: concurrent pages await the first.
3639
+ * The timer settles null if nothing ever answers (page died mid-flight). */
3640
+ beginFlight(url, timeoutMs = 30000) {
3641
+ let resolve;
3642
+ const promise = new Promise((r) => { resolve = r; });
3643
+ const f = {
3644
+ promise,
3645
+ settle(entry) {
3646
+ if (inflight.get(url) === f) inflight.delete(url);
3647
+ clearTimeout(timer);
3648
+ resolve(entry || null);
3649
+ },
3650
+ };
3651
+ const timer = setTimeout(() => f.settle(null), timeoutMs);
3652
+ timer.unref?.();
3653
+ inflight.set(url, f);
3654
+ return f;
3655
+ },
3656
+ settleFlight(url, entry) { inflight.get(url)?.settle(entry); },
3657
+ };
3658
+ }
3659
+
3660
+ /** Navigation budget: min(pageTimeout, 60s), keeping pageTimeout's 0=disabled. */
3661
+ const deriveNavTimeoutMs = (pageTimeoutMs) =>
3662
+ (pageTimeoutMs > 0 ? Math.min(pageTimeoutMs, 60000) : 0);
3663
+
3552
3664
  /** Cross-origin response → { validator } | { flag } | {} (2xx without
3553
3665
  * validator: content-address the body). A 304 is the page's OWN conditional
3554
3666
  * revalidation answered "unchanged" — a hit, not a failure: the validator
@@ -3577,9 +3689,7 @@ function classifyExternalResponse(status, responseHeaders = {}, requestHeaders =
3577
3689
  * not a failure, never awaited.
3578
3690
  */
3579
3691
  function createCaptureHooks({ capture, localOrigin, resolveRequest, isBlocked }) {
3580
- const flag = (reason) => {
3581
- if (!capture.nonCacheable) { capture.nonCacheable = true; capture.reason = reason; }
3582
- };
3692
+ const flag = (reason) => flagNonCacheable(capture, reason);
3583
3693
  return {
3584
3694
  request({ url, method }) {
3585
3695
  try {
@@ -3638,6 +3748,28 @@ function createCaptureHooks({ capture, localOrigin, resolveRequest, isBlocked })
3638
3748
  };
3639
3749
  }
3640
3750
 
3751
+ // ALL distinct non-cacheable causes, not just the first: one render surfaces
3752
+ // every third party to block/fix instead of one per 65-minute run.
3753
+ const NONCACHEABLE_REASONS_CAP = 20;
3754
+
3755
+ /** URLs in a cause reduced to host+path (no scheme/query) so per-page query
3756
+ * strings collapse into one actionable line. */
3757
+ function normalizeNonCacheableCause(reason) {
3758
+ return String(reason).replace(/https?:\/\/[^\s]+/g, (m) => {
3759
+ try { const u = new URL(m); return u.host + u.pathname; } catch { return m; }
3760
+ });
3761
+ }
3762
+
3763
+ /** `.reason` stays the first cause (ledger back-compat); `.reasons` collects
3764
+ * every distinct cause, ordered, deduped on the normalized form, capped. */
3765
+ function flagNonCacheable(capture, reason) {
3766
+ if (!capture.nonCacheable) { capture.nonCacheable = true; capture.reason = reason; }
3767
+ const list = capture.reasons || (capture.reasons = []);
3768
+ if (list.length >= NONCACHEABLE_REASONS_CAP) return;
3769
+ const key = normalizeNonCacheableCause(reason);
3770
+ if (!list.some((r) => normalizeNonCacheableCause(r) === key)) list.push(reason);
3771
+ }
3772
+
3641
3773
  const externalValidatorToken = (url, v) =>
3642
3774
  `${url}#etag:${v?.etag || ''}|lm:${v?.lastModified || ''}|ch:${v?.contentHash || ''}`;
3643
3775
 
@@ -3646,8 +3778,7 @@ const externalValidatorToken = (url, v) =>
3646
3778
  * UNVERIFIED external dependency — the route must render. */
3647
3779
  function settleCaptureAwaiting(capture) {
3648
3780
  if (capture.awaiting && capture.awaiting.size > 0 && !capture.nonCacheable) {
3649
- capture.nonCacheable = true;
3650
- capture.reason = `unverified external: ${[...capture.awaiting][0]}`;
3781
+ flagNonCacheable(capture, `unverified external: ${[...capture.awaiting][0]}`);
3651
3782
  }
3652
3783
  return capture;
3653
3784
  }
@@ -3915,7 +4046,13 @@ function createIncrementalRun({ rootResolved, previousOutput, stagingOutput, glo
3915
4046
  const routes = {};
3916
4047
  for (const [key, entry] of carried) routes[key] = entry;
3917
4048
  for (const [key, cap] of captures) {
3918
- if (cap.nonCacheable) { routes[key] = { nonCacheable: true, reason: cap.reason }; continue; }
4049
+ if (cap.nonCacheable) {
4050
+ routes[key] = { nonCacheable: true, reason: cap.reason };
4051
+ // Additive: tolerated by the malformed-entry check (non-cacheable
4052
+ // entries short-circuit before the shape test), so no schema bump.
4053
+ if (Array.isArray(cap.reasons) && cap.reasons.length) routes[key].reasons = cap.reasons;
4054
+ continue;
4055
+ }
3919
4056
  const requested = {};
3920
4057
  const requestedPairs = [];
3921
4058
  for (const pathname of [...cap.requested.keys()].sort()) {
@@ -4422,6 +4559,9 @@ async function runPrerender(config) {
4422
4559
  // a wedged page.evaluate() may have poisoned the shared browser process.
4423
4560
  // pageTimeout is configurable via manifest.prerender.pageTimeout; 0 disables.
4424
4561
  const pageTimeoutMs = Math.max(0, pre.pageTimeout ?? Math.max(120000, (config.wait ?? 30000) * 4));
4562
+ // Navigation shares the page budget up to 60s (was a hard 30s cap that
4563
+ // failed runs while a 120s page budget sat unused); 0 stays disabled.
4564
+ const navTimeoutMs = deriveNavTimeoutMs(pageTimeoutMs);
4425
4565
  // The recycle gate must never swap the browser out from under a live page,
4426
4566
  // so its drain wait has to outlast the per-page timeout above (every page
4427
4567
  // is guaranteed to release its slot by then). Configurable via
@@ -4655,6 +4795,12 @@ async function runPrerender(config) {
4655
4795
  const cdpBlockedUrls = blockPatterns.map((p) => `*${p}*`);
4656
4796
  let telemetryBlockedCount = 0;
4657
4797
 
4798
+ // Run cache (render.runCache, default on): one cross-origin fetch per URL per
4799
+ // run, replayed to every later page. With it active, blocking folds into the
4800
+ // same interception path (Network.setBlockedURLs does not layer under
4801
+ // request interception).
4802
+ const runCache = config.runCache ? createRunCache() : null;
4803
+
4658
4804
  const puppeteerRenderPaths = inc ? await inc.partition(puppeteerPaths) : puppeteerPaths;
4659
4805
  let incRendered = puppeteerRenderPaths.length;
4660
4806
  const puppeteerTotal = puppeteerRenderPaths.length;
@@ -4707,7 +4853,7 @@ async function runPrerender(config) {
4707
4853
  capture = {
4708
4854
  requested: new Map(), files: new Set(),
4709
4855
  externalPinned: new Set(), externalValidated: new Map(),
4710
- awaiting: new Set(), pending: [], nonCacheable: false, reason: null,
4856
+ awaiting: new Set(), pending: [], nonCacheable: false, reason: null, reasons: [],
4711
4857
  };
4712
4858
  const hooks = createCaptureHooks({
4713
4859
  capture,
@@ -4734,9 +4880,60 @@ async function runPrerender(config) {
4734
4880
  // viewport-driven flex distribution.
4735
4881
  await page.setViewport({ width: 1200, height: 800, deviceScaleFactor: 1 });
4736
4882
  try {
4737
- // CDP URL blocking (telemetry + render.blockRequests): requests abort at
4738
- // the network layer; coexists with the page.on capture listeners above.
4739
- if (cdpBlockedUrls.length > 0) {
4883
+ if (runCache) {
4884
+ // Run cache via request interception. Blocked URLs abort FIRST and are
4885
+ // never cached or served; same-origin and non-GET traffic passes
4886
+ // through untouched. A served response is a real fulfilled response:
4887
+ // the page.on capture listeners above observe it like a network one.
4888
+ const pageLocalOrigin = new URL(config.localUrl).origin;
4889
+ const done = (p) => p.catch(() => { /* page/request gone */ });
4890
+ await page.setRequestInterception(true);
4891
+ page.on('request', (req) => {
4892
+ try {
4893
+ const verdict = classifyRunCacheRequest({
4894
+ url: req.url(), method: req.method(),
4895
+ localOrigin: pageLocalOrigin, isBlocked: blockMatcher,
4896
+ });
4897
+ if (verdict === 'block') return done(req.abort('blockedbyclient'));
4898
+ if (verdict === 'bypass') return done(req.continue());
4899
+ const href = new URL(req.url()).href;
4900
+ const hit = runCache.get(href);
4901
+ if (hit) { runCache.noteServed(href); return done(req.respond(hit)); }
4902
+ const flight = runCache.flight(href);
4903
+ if (flight) {
4904
+ return flight.promise.then((entry) => {
4905
+ if (entry) { runCache.noteServed(href); return done(req.respond(entry)); }
4906
+ return done(req.continue()); // first fetch failed: retry the network
4907
+ });
4908
+ }
4909
+ runCache.beginFlight(href);
4910
+ return done(req.continue());
4911
+ } catch { return done(req.continue()); }
4912
+ });
4913
+ page.on('response', (res) => {
4914
+ try {
4915
+ if (res.request().method() !== 'GET') return;
4916
+ const u = new URL(res.url());
4917
+ if ((u.protocol !== 'http:' && u.protocol !== 'https:') || u.origin === pageLocalOrigin) return;
4918
+ const prior = runCache.peek(u.href);
4919
+ if (prior) return runCache.settleFlight(u.href, prior);
4920
+ const status = res.status();
4921
+ if (status < 200 || status >= 300) return runCache.settleFlight(u.href, null);
4922
+ const headers = runCacheReplayHeaders(res.headers());
4923
+ res.buffer()
4924
+ .then((body) => {
4925
+ const entry = { status, headers, body };
4926
+ runCache.settleFlight(u.href, runCache.set(u.href, entry) ? entry : null);
4927
+ })
4928
+ .catch(() => runCache.settleFlight(u.href, null));
4929
+ } catch { /* not a cacheable response */ }
4930
+ });
4931
+ page.on('requestfailed', (req) => {
4932
+ try { runCache.settleFlight(new URL(req.url()).href, null); } catch { /* unparseable */ }
4933
+ });
4934
+ } else if (cdpBlockedUrls.length > 0) {
4935
+ // CDP URL blocking (telemetry + render.blockRequests): requests abort at
4936
+ // the network layer; coexists with the page.on capture listeners above.
4740
4937
  const cdp = await page.createCDPSession();
4741
4938
  await cdp.send('Network.enable');
4742
4939
  await cdp.send('Network.setBlockedURLs', { urls: cdpBlockedUrls });
@@ -4918,7 +5115,7 @@ async function runPrerender(config) {
4918
5115
  pushDebug({ path: displayPath, stage: 'start' });
4919
5116
  await page.goto(url, {
4920
5117
  waitUntil: 'domcontentloaded',
4921
- timeout: Math.min(timeout, 30000),
5118
+ timeout: navTimeoutMs,
4922
5119
  });
4923
5120
 
4924
5121
  // Settle waits. These give Manifest plugins (especially the components
@@ -6084,6 +6281,7 @@ async function runPrerender(config) {
6084
6281
  externalValidated: capture.externalValidated,
6085
6282
  nonCacheable: capture.nonCacheable,
6086
6283
  reason: capture.reason,
6284
+ reasons: capture.reasons,
6087
6285
  outputs,
6088
6286
  utility: routeUtilityBlocks,
6089
6287
  });
@@ -6202,6 +6400,27 @@ async function runPrerender(config) {
6202
6400
  const why = Object.entries(inc.state.reasons).sort((a, b) => b[1] - a[1]).map(([r, c]) => `${c} ${r}`);
6203
6401
  process.stdout.write(`prerender: skipped ${inc.state.skipped} unchanged, rendered ${incRendered}${why.length ? ` (${why.join(', ')})` : ''}\n`);
6204
6402
  }
6403
+ // Every distinct non-cacheable cause across the run, host+path visible:
6404
+ // what to block or fix, found in ONE render instead of serially.
6405
+ const causes = new Map();
6406
+ for (const cap of inc.captures.values()) {
6407
+ if (!cap.nonCacheable) continue;
6408
+ const list = Array.isArray(cap.reasons) && cap.reasons.length ? cap.reasons : [cap.reason];
6409
+ for (const r of list) {
6410
+ if (!r) continue;
6411
+ const key = normalizeNonCacheableCause(r);
6412
+ causes.set(key, (causes.get(key) || 0) + 1);
6413
+ }
6414
+ }
6415
+ if (causes.size > 0) {
6416
+ const rows = [...causes.entries()].sort((a, b) => b[1] - a[1]);
6417
+ process.stdout.write('prerender: non-cacheable causes —\n');
6418
+ for (const [cause, n] of rows.slice(0, 10)) process.stdout.write(` ${n}× ${cause}\n`);
6419
+ if (rows.length > 10) process.stdout.write(` … +${rows.length - 10} more cause(s)\n`);
6420
+ }
6421
+ }
6422
+ if (runCache && runCache.stats.served > 0) {
6423
+ process.stdout.write(`prerender: run cache served ${runCache.stats.served} cross-origin requests (${runCache.stats.servedUrls.size} unique)\n`);
6205
6424
  }
6206
6425
  if (telemetryBlockedCount > 0) {
6207
6426
  process.stdout.write(`prerender: blocked ${telemetryBlockedCount} telemetry requests (render.blockTelemetry: false to allow)\n`);
@@ -6336,4 +6555,11 @@ export {
6336
6555
  createCaptureHooks,
6337
6556
  compileBlockMatcher,
6338
6557
  TELEMETRY_BLOCK_PATTERNS,
6558
+ createRunCache,
6559
+ classifyRunCacheRequest,
6560
+ runCacheReplayHeaders,
6561
+ deriveNavTimeoutMs,
6562
+ flagNonCacheable,
6563
+ normalizeNonCacheableCause,
6564
+ NONCACHEABLE_REASONS_CAP,
6339
6565
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mnfst-render",
3
- "version": "0.5.52",
3
+ "version": "0.5.53",
4
4
  "description": "Render Manifest sites to static HTML for SEO",
5
5
  "type": "module",
6
6
  "bin": {