mnfst-render 0.5.51 → 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 +449 -74
  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; }
@@ -524,6 +526,25 @@ function expandLocaleRoutePaths({ routeSegments, locales, defaultLocale, localeR
524
526
  return paths;
525
527
  }
526
528
 
529
+ /**
530
+ * Incremental participation policy. Recording is on whenever the project
531
+ * PARTICIPATES: config `render.incremental: true`, the --incremental flag, or
532
+ * a valid ledger already on disk (so one opted-in render upgrades every later
533
+ * plain run). --no-incremental disables skipping only — a participating run
534
+ * still records and refreshes the ledger. Explicit config `incremental:
535
+ * false` is fully off, ledger or not. Never-opted-in stays fully inert.
536
+ */
537
+ function resolveIncrementalPolicy({ configValue, flagIncremental, flagNoIncremental, ledgerExists }) {
538
+ const participates = flagIncremental === true
539
+ ? true
540
+ : configValue === false ? false : (configValue === true || !!ledgerExists);
541
+ return {
542
+ record: participates,
543
+ skip: participates && flagNoIncremental !== true,
544
+ explicit: flagIncremental === true || configValue === true,
545
+ };
546
+ }
547
+
527
548
  function resolveConfig() {
528
549
  const cli = parseArgs();
529
550
  const cwd = process.cwd();
@@ -580,9 +601,23 @@ function resolveConfig() {
580
601
  paths: Array.isArray(pre.paths)
581
602
  ? pre.paths.map((p) => String(p).replace(/^\/+|\/+$/g, '')).filter(Boolean)
582
603
  : [],
583
- // Incremental renders (opt-in): skip Puppeteer routes whose fingerprinted
584
- // inputs are unchanged since the last successful run. --no-incremental wins.
585
- incremental: cli.noIncremental ? false : !!(cli.incremental ?? pre.incremental ?? false),
604
+ // Incremental renders: see resolveIncrementalPolicy. `incremental` gates
605
+ // skipping; `incrementalRecord` gates capture + ledger writes.
606
+ ...(() => {
607
+ const p = resolveIncrementalPolicy({
608
+ configValue: pre.incremental,
609
+ flagIncremental: cli.incremental,
610
+ flagNoIncremental: cli.noIncremental,
611
+ ledgerExists: loadRouteLedger(root) !== null,
612
+ });
613
+ return { incremental: p.skip, incrementalRecord: p.record, incrementalExplicit: p.explicit };
614
+ })(),
615
+ // Requests ABORTED during prerender (substring match, `*` glob within a
616
+ // pattern). blockTelemetry (default on) adds TELEMETRY_BLOCK_PATTERNS.
617
+ blockRequests: Array.isArray(pre.blockRequests) ? pre.blockRequests.map((p) => String(p)).filter(Boolean) : [],
618
+ blockTelemetry: pre.blockTelemetry !== false,
619
+ // Run-scoped cross-origin response cache (render.runCache / --no-run-cache).
620
+ runCache: cli.runCache ?? pre.runCache !== false,
586
621
  dryRun: !!cli.dryRun,
587
622
  forceLock: !!cli.forceLock,
588
623
  debugPrerender: !!cli.debugPrerender,
@@ -3492,6 +3527,249 @@ const PINNED_VERSION_RE = /@\d+\.\d+\.\d+/;
3492
3527
  // Bodies above this are never fingerprinted — the route renders instead.
3493
3528
  const EXTERNAL_BODY_HASH_LIMIT = 8 * 1024 * 1024;
3494
3529
 
3530
+ // A prerender is not a visitor (Playcom measured 465 fake GA page_views per
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.
3534
+ const TELEMETRY_BLOCK_PATTERNS = [
3535
+ 'google-analytics.com/g/collect',
3536
+ '*.google-analytics.com/mp/collect',
3537
+ 'stats.g.doubleclick.net',
3538
+ '*.analytics.google.com/g/collect',
3539
+ 'plausible.io/api/event',
3540
+ 'api.segment.io/v1',
3541
+ 'api.mixpanel.com/track',
3542
+ '.facebook.com/tr?',
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',
3550
+ ];
3551
+
3552
+ /** URL predicate from block patterns: substring match, `*` globs within a
3553
+ * pattern. null when there is nothing to block. */
3554
+ function compileBlockMatcher(patterns) {
3555
+ const regs = [];
3556
+ for (const p of patterns || []) {
3557
+ const src = String(p).split('*').map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('.*');
3558
+ if (src) regs.push(new RegExp(src));
3559
+ }
3560
+ if (!regs.length) return null;
3561
+ return (url) => regs.some((r) => r.test(String(url)));
3562
+ }
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
+
3664
+ /** Cross-origin response → { validator } | { flag } | {} (2xx without
3665
+ * validator: content-address the body). A 304 is the page's OWN conditional
3666
+ * revalidation answered "unchanged" — a hit, not a failure: the validator
3667
+ * comes from the 304's headers, else the request's conditional values.
3668
+ * A 304 has no body, so it never falls through to buffer(). */
3669
+ function classifyExternalResponse(status, responseHeaders = {}, requestHeaders = {}) {
3670
+ if (status === 304) {
3671
+ const etag = responseHeaders['etag'] || requestHeaders['if-none-match'];
3672
+ if (etag) return { validator: { etag } };
3673
+ const lastModified = responseHeaders['last-modified'] || requestHeaders['if-modified-since'];
3674
+ if (lastModified) return { validator: { lastModified } };
3675
+ return { flag: 'external 304 without validator' };
3676
+ }
3677
+ if (status < 200 || status >= 300) return { flag: `external ${status}` };
3678
+ const etag = responseHeaders['etag'];
3679
+ const lastModified = responseHeaders['last-modified'];
3680
+ if (etag || lastModified) return { validator: etag ? { etag } : { lastModified } };
3681
+ return {};
3682
+ }
3683
+
3684
+ /**
3685
+ * Incremental input-capture hooks over plain request/response shapes
3686
+ * (Puppeteer-independent for unit tests). Same-origin GETs map to disk files
3687
+ * via the static server's own resolution; anything un-fingerprintable marks
3688
+ * the route non-cacheable. A blocked request never happened: not an input,
3689
+ * not a failure, never awaited.
3690
+ */
3691
+ function createCaptureHooks({ capture, localOrigin, resolveRequest, isBlocked }) {
3692
+ const flag = (reason) => flagNonCacheable(capture, reason);
3693
+ return {
3694
+ request({ url, method }) {
3695
+ try {
3696
+ const u = new URL(url);
3697
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return;
3698
+ if (isBlocked && isBlocked(u.href)) return;
3699
+ if (method !== 'GET') { flag(`non-GET ${method} ${u.href}`); return; }
3700
+ if (u.origin !== localOrigin) {
3701
+ // Pinned-version URL: the URL is its own identity. Anything else
3702
+ // must get a PROCESSED response (validator or content hash) by the
3703
+ // end of the route or it stays in `awaiting` → route renders.
3704
+ if (PINNED_VERSION_RE.test(u.pathname)) capture.externalPinned.add(u.href);
3705
+ else capture.awaiting.add(u.href);
3706
+ return;
3707
+ }
3708
+ const rel = resolveRequest(u.pathname);
3709
+ if (rel) { capture.requested.set(u.pathname, rel); capture.files.add(rel); }
3710
+ else flag(`unresolvable request ${u.pathname}`);
3711
+ } catch { flag('unparseable request URL'); }
3712
+ },
3713
+ requestFailed({ url }) {
3714
+ if (isBlocked && isBlocked(url)) return;
3715
+ try { capture.awaiting.delete(new URL(url).href); } catch { /* flagged below */ }
3716
+ flag(`request failed: ${url}`);
3717
+ },
3718
+ response({ url, status, headers, requestHeaders, buffer }) {
3719
+ try {
3720
+ const u = new URL(url);
3721
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return;
3722
+ if (isBlocked && isBlocked(u.href)) return;
3723
+ if (u.origin === localOrigin || PINNED_VERSION_RE.test(u.pathname)) {
3724
+ if (status >= 400) flag(`${status} ${u.href}`);
3725
+ return;
3726
+ }
3727
+ const verdict = classifyExternalResponse(status, headers || {}, requestHeaders || {});
3728
+ if (verdict.flag) { capture.awaiting.delete(u.href); flag(`${verdict.flag} ${u.href}`); return; }
3729
+ if (verdict.validator) {
3730
+ capture.externalValidated.set(u.href, verdict.validator);
3731
+ capture.awaiting.delete(u.href);
3732
+ return;
3733
+ }
3734
+ // No validator (e.g. cdn.manifestx.dev): content-address the bytes;
3735
+ // revalidation re-downloads and compares. Unreadable or oversized
3736
+ // body → render. `awaiting` clears only once the hash is recorded.
3737
+ capture.pending.push(
3738
+ Promise.resolve().then(buffer)
3739
+ .then((body) => {
3740
+ if (body.length > EXTERNAL_BODY_HASH_LIMIT) { flag(`external response too large to fingerprint: ${u.href}`); return; }
3741
+ capture.externalValidated.set(u.href, { contentHash: sha256Full(body) });
3742
+ capture.awaiting.delete(u.href);
3743
+ })
3744
+ .catch(() => flag(`external response unreadable: ${u.href}`))
3745
+ );
3746
+ } catch { flag('unparseable response URL'); }
3747
+ },
3748
+ };
3749
+ }
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
+
3495
3773
  const externalValidatorToken = (url, v) =>
3496
3774
  `${url}#etag:${v?.etag || ''}|lm:${v?.lastModified || ''}|ch:${v?.contentHash || ''}`;
3497
3775
 
@@ -3500,8 +3778,7 @@ const externalValidatorToken = (url, v) =>
3500
3778
  * UNVERIFIED external dependency — the route must render. */
3501
3779
  function settleCaptureAwaiting(capture) {
3502
3780
  if (capture.awaiting && capture.awaiting.size > 0 && !capture.nonCacheable) {
3503
- capture.nonCacheable = true;
3504
- capture.reason = `unverified external: ${[...capture.awaiting][0]}`;
3781
+ flagNonCacheable(capture, `unverified external: ${[...capture.awaiting][0]}`);
3505
3782
  }
3506
3783
  return capture;
3507
3784
  }
@@ -3656,7 +3933,7 @@ async function evaluateRouteSkip({ entry, hashInput, resolveRequest, revalidateE
3656
3933
  * Run-scoped incremental state: ledger, memoized hashing/resolution, the skip
3657
3934
  * pass, per-route capture records, and the end-of-run ledger write.
3658
3935
  */
3659
- function createIncrementalRun({ rootResolved, previousOutput, stagingOutput, globalKey, deps }) {
3936
+ function createIncrementalRun({ rootResolved, previousOutput, stagingOutput, globalKey, deps, skipEnabled = true }) {
3660
3937
  const ledger = loadRouteLedger(rootResolved);
3661
3938
  const revalidate = createExternalRevalidator();
3662
3939
  const hashFileAt = (base, relPath) => {
@@ -3671,7 +3948,7 @@ function createIncrementalRun({ rootResolved, previousOutput, stagingOutput, glo
3671
3948
  };
3672
3949
  const captures = new Map(); // routeKey → capture record of a route rendered this run
3673
3950
  const carried = new Map(); // routeKey → previous entry revalidated and reused this run
3674
- const state = { disabled: false, skipped: 0, reasons: {} };
3951
+ const state = { disabled: false, skipped: 0, reasons: {}, skipEnabled };
3675
3952
  const disable = (reason) => {
3676
3953
  if (state.disabled) return;
3677
3954
  state.disabled = true;
@@ -3702,6 +3979,7 @@ function createIncrementalRun({ rootResolved, previousOutput, stagingOutput, glo
3702
3979
  /** Skip pass: returns the subset of `paths` that must render; copies the
3703
3980
  * previous outputs of the rest into staging and carries their entries. */
3704
3981
  async partition(paths) {
3982
+ if (!skipEnabled) return paths; // recording-only run: render all, refresh the ledger
3705
3983
  if (!ledger) {
3706
3984
  for (let i = 0; i < paths.length; i++) noteRender('no ledger entry');
3707
3985
  return paths;
@@ -3768,7 +4046,13 @@ function createIncrementalRun({ rootResolved, previousOutput, stagingOutput, glo
3768
4046
  const routes = {};
3769
4047
  for (const [key, entry] of carried) routes[key] = entry;
3770
4048
  for (const [key, cap] of captures) {
3771
- 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
+ }
3772
4056
  const requested = {};
3773
4057
  const requestedPairs = [];
3774
4058
  for (const pathname of [...cap.requested.keys()].sort()) {
@@ -4275,6 +4559,9 @@ async function runPrerender(config) {
4275
4559
  // a wedged page.evaluate() may have poisoned the shared browser process.
4276
4560
  // pageTimeout is configurable via manifest.prerender.pageTimeout; 0 disables.
4277
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);
4278
4565
  // The recycle gate must never swap the browser out from under a live page,
4279
4566
  // so its drain wait has to outlast the per-page timeout above (every page
4280
4567
  // is guaranteed to release its slot by then). Configurable via
@@ -4437,15 +4724,22 @@ async function runPrerender(config) {
4437
4724
  // baseHtmlCache: base path segment → raw DOM HTML captured before any Node.js transforms
4438
4725
  const baseHtmlCache = new Map();
4439
4726
 
4440
- // --- Incremental skip pass (opt-in; see the incremental-rendering section) --
4727
+ // --- Incremental skip pass (see the incremental-rendering section) ----------
4441
4728
  // Skips reuse files from the PREVIOUS final output, which still exists while
4442
- // this run builds into staging. Off → no capture, no ledger, no new work.
4729
+ // this run builds into staging. Recording runs whenever the project
4730
+ // participates (incrementalRecord); skipping only when `incremental` is on.
4731
+ // Never-opted-in → no capture, no ledger, no new work.
4443
4732
  let inc = null;
4444
- if (config.incremental && !config.serve) {
4733
+ if (config.incrementalRecord && !config.serve) {
4445
4734
  // Input capture maps request pathnames to disk via the built-in server's
4446
4735
  // resolution; an external --local server voids that mapping.
4447
- process.stdout.write('prerender: incremental needs the built-in static server — ignoring it for this run\n');
4448
- } else if (config.incremental) {
4736
+ if (config.incrementalExplicit) {
4737
+ process.stdout.write('prerender: incremental needs the built-in static server — ignoring it for this run\n');
4738
+ }
4739
+ } else if (config.incrementalRecord) {
4740
+ if (!config.incrementalExplicit) {
4741
+ process.stdout.write('prerender: incremental active from existing ledger (.mnfst-cache/routes.json; --no-incremental to disable)\n');
4742
+ }
4449
4743
  let manifestContentHash = 'missing';
4450
4744
  try { manifestContentHash = sha256Full(readFileSync(join(config.root, 'manifest.json'))); } catch { /* missing */ }
4451
4745
  // Output-relevant resolved options (CLI included). pathList is in here
@@ -4466,6 +4760,9 @@ async function runPrerender(config) {
4466
4760
  liveUrl: config.liveUrl,
4467
4761
  utilitiesBundle: bundleUtilities,
4468
4762
  tailwindBuilt,
4763
+ // Blocking changes what a page loads; entries from another regime must not skip.
4764
+ blockRequests: [...(config.blockRequests || [])].sort(),
4765
+ blockTelemetry: config.blockTelemetry,
4469
4766
  pathList: [...pathList].sort(),
4470
4767
  }));
4471
4768
  inc = createIncrementalRun({
@@ -4481,8 +4778,29 @@ async function runPrerender(config) {
4481
4778
  getRawHtml: (seg) => baseHtmlCache.get(seg),
4482
4779
  pushUtilityBlock: (b) => utilityBlocks.push(b),
4483
4780
  },
4781
+ skipEnabled: config.incremental,
4484
4782
  });
4485
4783
  }
4784
+
4785
+ // --- Request blocking during prerender ---------------------------------------
4786
+ // Telemetry collection endpoints are blocked by default (blockTelemetry:
4787
+ // false opts out); render.blockRequests adds user patterns. Blocked
4788
+ // requests are aborted via CDP and are invisible to incremental capture.
4789
+ const blockPatterns = [
4790
+ ...(config.blockTelemetry ? TELEMETRY_BLOCK_PATTERNS : []),
4791
+ ...(config.blockRequests || []),
4792
+ ];
4793
+ const blockMatcher = compileBlockMatcher(blockPatterns);
4794
+ const telemetryMatcher = config.blockTelemetry ? compileBlockMatcher(TELEMETRY_BLOCK_PATTERNS) : null;
4795
+ const cdpBlockedUrls = blockPatterns.map((p) => `*${p}*`);
4796
+ let telemetryBlockedCount = 0;
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
+
4486
4804
  const puppeteerRenderPaths = inc ? await inc.partition(puppeteerPaths) : puppeteerPaths;
4487
4805
  let incRendered = puppeteerRenderPaths.length;
4488
4806
  const puppeteerTotal = puppeteerRenderPaths.length;
@@ -4535,66 +4853,24 @@ async function runPrerender(config) {
4535
4853
  capture = {
4536
4854
  requested: new Map(), files: new Set(),
4537
4855
  externalPinned: new Set(), externalValidated: new Map(),
4538
- awaiting: new Set(), pending: [], nonCacheable: false, reason: null,
4539
- };
4540
- const localOrigin = new URL(config.localUrl).origin;
4541
- const flag = (reason) => {
4542
- if (!capture.nonCacheable) { capture.nonCacheable = true; capture.reason = reason; }
4856
+ awaiting: new Set(), pending: [], nonCacheable: false, reason: null, reasons: [],
4543
4857
  };
4544
- page.on('request', (req) => {
4545
- try {
4546
- const u = new URL(req.url());
4547
- if (u.protocol !== 'http:' && u.protocol !== 'https:') return;
4548
- if (req.method() !== 'GET') { flag(`non-GET ${req.method()} ${u.href}`); return; }
4549
- if (u.origin !== localOrigin) {
4550
- // Pinned-version URL: the URL is its own identity. Anything else
4551
- // must get a PROCESSED response (validator or content hash) by the
4552
- // end of the route or it stays in `awaiting` → route renders.
4553
- if (PINNED_VERSION_RE.test(u.pathname)) capture.externalPinned.add(u.href);
4554
- else capture.awaiting.add(u.href);
4555
- return;
4556
- }
4557
- const rel = resolveStaticRequestFile(rootResolved, u.pathname);
4558
- if (rel) { capture.requested.set(u.pathname, rel); capture.files.add(rel); }
4559
- else flag(`unresolvable request ${u.pathname}`);
4560
- } catch { flag('unparseable request URL'); }
4561
- });
4562
- page.on('requestfailed', (req) => {
4563
- try { capture.awaiting.delete(new URL(req.url()).href); } catch { /* flagged below */ }
4564
- flag(`request failed: ${req.url()}`);
4565
- });
4566
- page.on('response', (res) => {
4567
- try {
4568
- const u = new URL(res.url());
4569
- if (u.protocol !== 'http:' && u.protocol !== 'https:') return;
4570
- const status = res.status();
4571
- if (u.origin === localOrigin || PINNED_VERSION_RE.test(u.pathname)) {
4572
- if (status >= 400) flag(`${status} ${u.href}`);
4573
- return;
4574
- }
4575
- if (status < 200 || status >= 300) { flag(`external ${status} ${u.href}`); capture.awaiting.delete(u.href); return; }
4576
- const headers = res.headers();
4577
- const etag = headers['etag'];
4578
- const lastModified = headers['last-modified'];
4579
- if (etag || lastModified) {
4580
- capture.externalValidated.set(u.href, etag ? { etag } : { lastModified });
4581
- capture.awaiting.delete(u.href);
4582
- return;
4583
- }
4584
- // No validator (e.g. cdn.manifestx.dev): content-address the bytes;
4585
- // revalidation re-downloads and compares. Unreadable or oversized
4586
- // body → render. `awaiting` clears only once the hash is recorded.
4587
- capture.pending.push(
4588
- res.buffer()
4589
- .then((body) => {
4590
- if (body.length > EXTERNAL_BODY_HASH_LIMIT) { flag(`external response too large to fingerprint: ${u.href}`); return; }
4591
- capture.externalValidated.set(u.href, { contentHash: sha256Full(body) });
4592
- capture.awaiting.delete(u.href);
4593
- })
4594
- .catch(() => flag(`external response unreadable: ${u.href}`))
4595
- );
4596
- } catch { flag('unparseable response URL'); }
4858
+ const hooks = createCaptureHooks({
4859
+ capture,
4860
+ localOrigin: new URL(config.localUrl).origin,
4861
+ resolveRequest: (pathname) => resolveStaticRequestFile(rootResolved, pathname),
4862
+ isBlocked: blockMatcher,
4597
4863
  });
4864
+ page.on('request', (req) => hooks.request({ url: req.url(), method: req.method() }));
4865
+ page.on('requestfailed', (req) => hooks.requestFailed({ url: req.url() }));
4866
+ page.on('response', (res) => hooks.response({
4867
+ url: res.url(), status: res.status(), headers: res.headers(),
4868
+ requestHeaders: res.request().headers(),
4869
+ buffer: () => res.buffer(),
4870
+ }));
4871
+ }
4872
+ if (telemetryMatcher) {
4873
+ page.on('requestfailed', (req) => { if (telemetryMatcher(req.url())) telemetryBlockedCount++; });
4598
4874
  }
4599
4875
  // Render at a typical desktop viewport so layouts dependent on viewport
4600
4876
  // width (responsive flex/grid, container queries, media queries) settle
@@ -4604,6 +4880,64 @@ async function runPrerender(config) {
4604
4880
  // viewport-driven flex distribution.
4605
4881
  await page.setViewport({ width: 1200, height: 800, deviceScaleFactor: 1 });
4606
4882
  try {
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.
4937
+ const cdp = await page.createCDPSession();
4938
+ await cdp.send('Network.enable');
4939
+ await cdp.send('Network.setBlockedURLs', { urls: cdpBlockedUrls });
4940
+ }
4607
4941
  // Align <html lang> with the URL being prerendered before any app script runs.
4608
4942
  // initializeDataSourcesPlugin picks locale from document.documentElement.lang first; a mismatch
4609
4943
  // (e.g. headless default vs /en/...) leaves $x.* empty while x-route sections still render.
@@ -4781,7 +5115,7 @@ async function runPrerender(config) {
4781
5115
  pushDebug({ path: displayPath, stage: 'start' });
4782
5116
  await page.goto(url, {
4783
5117
  waitUntil: 'domcontentloaded',
4784
- timeout: Math.min(timeout, 30000),
5118
+ timeout: navTimeoutMs,
4785
5119
  });
4786
5120
 
4787
5121
  // Settle waits. These give Manifest plugins (especially the components
@@ -5947,6 +6281,7 @@ async function runPrerender(config) {
5947
6281
  externalValidated: capture.externalValidated,
5948
6282
  nonCacheable: capture.nonCacheable,
5949
6283
  reason: capture.reason,
6284
+ reasons: capture.reasons,
5950
6285
  outputs,
5951
6286
  utility: routeUtilityBlocks,
5952
6287
  });
@@ -6059,8 +6394,36 @@ async function runPrerender(config) {
6059
6394
  }
6060
6395
 
6061
6396
  if (inc) {
6062
- const why = Object.entries(inc.state.reasons).sort((a, b) => b[1] - a[1]).map(([r, c]) => `${c} ${r}`);
6063
- process.stdout.write(`prerender: skipped ${inc.state.skipped} unchanged, rendered ${incRendered}${why.length ? ` (${why.join(', ')})` : ''}\n`);
6397
+ if (!inc.state.skipEnabled) {
6398
+ process.stdout.write(`prerender: rendered ${incRendered} (ledger refreshed, skipping off)\n`);
6399
+ } else {
6400
+ const why = Object.entries(inc.state.reasons).sort((a, b) => b[1] - a[1]).map(([r, c]) => `${c} ${r}`);
6401
+ process.stdout.write(`prerender: skipped ${inc.state.skipped} unchanged, rendered ${incRendered}${why.length ? ` (${why.join(', ')})` : ''}\n`);
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`);
6424
+ }
6425
+ if (telemetryBlockedCount > 0) {
6426
+ process.stdout.write(`prerender: blocked ${telemetryBlockedCount} telemetry requests (render.blockTelemetry: false to allow)\n`);
6064
6427
  }
6065
6428
 
6066
6429
  if (config.debugPrerender) {
@@ -6187,4 +6550,16 @@ export {
6187
6550
  isSafeRelPath,
6188
6551
  settleCaptureAwaiting,
6189
6552
  EXTERNAL_BODY_HASH_LIMIT,
6553
+ resolveIncrementalPolicy,
6554
+ classifyExternalResponse,
6555
+ createCaptureHooks,
6556
+ compileBlockMatcher,
6557
+ TELEMETRY_BLOCK_PATTERNS,
6558
+ createRunCache,
6559
+ classifyRunCacheRequest,
6560
+ runCacheReplayHeaders,
6561
+ deriveNavTimeoutMs,
6562
+ flagNonCacheable,
6563
+ normalizeNonCacheableCause,
6564
+ NONCACHEABLE_REASONS_CAP,
6190
6565
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mnfst-render",
3
- "version": "0.5.51",
3
+ "version": "0.5.53",
4
4
  "description": "Render Manifest sites to static HTML for SEO",
5
5
  "type": "module",
6
6
  "bin": {