mnfst-render 0.5.51 → 0.5.52

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 +218 -69
  2. package/package.json +1 -1
@@ -524,6 +524,25 @@ function expandLocaleRoutePaths({ routeSegments, locales, defaultLocale, localeR
524
524
  return paths;
525
525
  }
526
526
 
527
+ /**
528
+ * Incremental participation policy. Recording is on whenever the project
529
+ * PARTICIPATES: config `render.incremental: true`, the --incremental flag, or
530
+ * a valid ledger already on disk (so one opted-in render upgrades every later
531
+ * plain run). --no-incremental disables skipping only — a participating run
532
+ * still records and refreshes the ledger. Explicit config `incremental:
533
+ * false` is fully off, ledger or not. Never-opted-in stays fully inert.
534
+ */
535
+ function resolveIncrementalPolicy({ configValue, flagIncremental, flagNoIncremental, ledgerExists }) {
536
+ const participates = flagIncremental === true
537
+ ? true
538
+ : configValue === false ? false : (configValue === true || !!ledgerExists);
539
+ return {
540
+ record: participates,
541
+ skip: participates && flagNoIncremental !== true,
542
+ explicit: flagIncremental === true || configValue === true,
543
+ };
544
+ }
545
+
527
546
  function resolveConfig() {
528
547
  const cli = parseArgs();
529
548
  const cwd = process.cwd();
@@ -580,9 +599,21 @@ function resolveConfig() {
580
599
  paths: Array.isArray(pre.paths)
581
600
  ? pre.paths.map((p) => String(p).replace(/^\/+|\/+$/g, '')).filter(Boolean)
582
601
  : [],
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),
602
+ // Incremental renders: see resolveIncrementalPolicy. `incremental` gates
603
+ // skipping; `incrementalRecord` gates capture + ledger writes.
604
+ ...(() => {
605
+ const p = resolveIncrementalPolicy({
606
+ configValue: pre.incremental,
607
+ flagIncremental: cli.incremental,
608
+ flagNoIncremental: cli.noIncremental,
609
+ ledgerExists: loadRouteLedger(root) !== null,
610
+ });
611
+ return { incremental: p.skip, incrementalRecord: p.record, incrementalExplicit: p.explicit };
612
+ })(),
613
+ // Requests ABORTED during prerender (substring match, `*` glob within a
614
+ // pattern). blockTelemetry (default on) adds TELEMETRY_BLOCK_PATTERNS.
615
+ blockRequests: Array.isArray(pre.blockRequests) ? pre.blockRequests.map((p) => String(p)).filter(Boolean) : [],
616
+ blockTelemetry: pre.blockTelemetry !== false,
586
617
  dryRun: !!cli.dryRun,
587
618
  forceLock: !!cli.forceLock,
588
619
  debugPrerender: !!cli.debugPrerender,
@@ -3492,6 +3523,121 @@ const PINNED_VERSION_RE = /@\d+\.\d+\.\d+/;
3492
3523
  // Bodies above this are never fingerprinted — the route renders instead.
3493
3524
  const EXTERNAL_BODY_HASH_LIMIT = 8 * 1024 * 1024;
3494
3525
 
3526
+ // A prerender is not a visitor (Playcom measured 465 fake GA page_views per
3527
+ // render). Collection endpoints only — never script loads.
3528
+ const TELEMETRY_BLOCK_PATTERNS = [
3529
+ 'google-analytics.com/g/collect',
3530
+ '*.google-analytics.com/mp/collect',
3531
+ 'stats.g.doubleclick.net',
3532
+ '*.analytics.google.com/g/collect',
3533
+ 'plausible.io/api/event',
3534
+ 'api.segment.io/v1',
3535
+ 'api.mixpanel.com/track',
3536
+ '.facebook.com/tr?',
3537
+ '.facebook.com/tr/',
3538
+ ];
3539
+
3540
+ /** URL predicate from block patterns: substring match, `*` globs within a
3541
+ * pattern. null when there is nothing to block. */
3542
+ function compileBlockMatcher(patterns) {
3543
+ const regs = [];
3544
+ for (const p of patterns || []) {
3545
+ const src = String(p).split('*').map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('.*');
3546
+ if (src) regs.push(new RegExp(src));
3547
+ }
3548
+ if (!regs.length) return null;
3549
+ return (url) => regs.some((r) => r.test(String(url)));
3550
+ }
3551
+
3552
+ /** Cross-origin response → { validator } | { flag } | {} (2xx without
3553
+ * validator: content-address the body). A 304 is the page's OWN conditional
3554
+ * revalidation answered "unchanged" — a hit, not a failure: the validator
3555
+ * comes from the 304's headers, else the request's conditional values.
3556
+ * A 304 has no body, so it never falls through to buffer(). */
3557
+ function classifyExternalResponse(status, responseHeaders = {}, requestHeaders = {}) {
3558
+ if (status === 304) {
3559
+ const etag = responseHeaders['etag'] || requestHeaders['if-none-match'];
3560
+ if (etag) return { validator: { etag } };
3561
+ const lastModified = responseHeaders['last-modified'] || requestHeaders['if-modified-since'];
3562
+ if (lastModified) return { validator: { lastModified } };
3563
+ return { flag: 'external 304 without validator' };
3564
+ }
3565
+ if (status < 200 || status >= 300) return { flag: `external ${status}` };
3566
+ const etag = responseHeaders['etag'];
3567
+ const lastModified = responseHeaders['last-modified'];
3568
+ if (etag || lastModified) return { validator: etag ? { etag } : { lastModified } };
3569
+ return {};
3570
+ }
3571
+
3572
+ /**
3573
+ * Incremental input-capture hooks over plain request/response shapes
3574
+ * (Puppeteer-independent for unit tests). Same-origin GETs map to disk files
3575
+ * via the static server's own resolution; anything un-fingerprintable marks
3576
+ * the route non-cacheable. A blocked request never happened: not an input,
3577
+ * not a failure, never awaited.
3578
+ */
3579
+ function createCaptureHooks({ capture, localOrigin, resolveRequest, isBlocked }) {
3580
+ const flag = (reason) => {
3581
+ if (!capture.nonCacheable) { capture.nonCacheable = true; capture.reason = reason; }
3582
+ };
3583
+ return {
3584
+ request({ url, method }) {
3585
+ try {
3586
+ const u = new URL(url);
3587
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return;
3588
+ if (isBlocked && isBlocked(u.href)) return;
3589
+ if (method !== 'GET') { flag(`non-GET ${method} ${u.href}`); return; }
3590
+ if (u.origin !== localOrigin) {
3591
+ // Pinned-version URL: the URL is its own identity. Anything else
3592
+ // must get a PROCESSED response (validator or content hash) by the
3593
+ // end of the route or it stays in `awaiting` → route renders.
3594
+ if (PINNED_VERSION_RE.test(u.pathname)) capture.externalPinned.add(u.href);
3595
+ else capture.awaiting.add(u.href);
3596
+ return;
3597
+ }
3598
+ const rel = resolveRequest(u.pathname);
3599
+ if (rel) { capture.requested.set(u.pathname, rel); capture.files.add(rel); }
3600
+ else flag(`unresolvable request ${u.pathname}`);
3601
+ } catch { flag('unparseable request URL'); }
3602
+ },
3603
+ requestFailed({ url }) {
3604
+ if (isBlocked && isBlocked(url)) return;
3605
+ try { capture.awaiting.delete(new URL(url).href); } catch { /* flagged below */ }
3606
+ flag(`request failed: ${url}`);
3607
+ },
3608
+ response({ url, status, headers, requestHeaders, buffer }) {
3609
+ try {
3610
+ const u = new URL(url);
3611
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return;
3612
+ if (isBlocked && isBlocked(u.href)) return;
3613
+ if (u.origin === localOrigin || PINNED_VERSION_RE.test(u.pathname)) {
3614
+ if (status >= 400) flag(`${status} ${u.href}`);
3615
+ return;
3616
+ }
3617
+ const verdict = classifyExternalResponse(status, headers || {}, requestHeaders || {});
3618
+ if (verdict.flag) { capture.awaiting.delete(u.href); flag(`${verdict.flag} ${u.href}`); return; }
3619
+ if (verdict.validator) {
3620
+ capture.externalValidated.set(u.href, verdict.validator);
3621
+ capture.awaiting.delete(u.href);
3622
+ return;
3623
+ }
3624
+ // No validator (e.g. cdn.manifestx.dev): content-address the bytes;
3625
+ // revalidation re-downloads and compares. Unreadable or oversized
3626
+ // body → render. `awaiting` clears only once the hash is recorded.
3627
+ capture.pending.push(
3628
+ Promise.resolve().then(buffer)
3629
+ .then((body) => {
3630
+ if (body.length > EXTERNAL_BODY_HASH_LIMIT) { flag(`external response too large to fingerprint: ${u.href}`); return; }
3631
+ capture.externalValidated.set(u.href, { contentHash: sha256Full(body) });
3632
+ capture.awaiting.delete(u.href);
3633
+ })
3634
+ .catch(() => flag(`external response unreadable: ${u.href}`))
3635
+ );
3636
+ } catch { flag('unparseable response URL'); }
3637
+ },
3638
+ };
3639
+ }
3640
+
3495
3641
  const externalValidatorToken = (url, v) =>
3496
3642
  `${url}#etag:${v?.etag || ''}|lm:${v?.lastModified || ''}|ch:${v?.contentHash || ''}`;
3497
3643
 
@@ -3656,7 +3802,7 @@ async function evaluateRouteSkip({ entry, hashInput, resolveRequest, revalidateE
3656
3802
  * Run-scoped incremental state: ledger, memoized hashing/resolution, the skip
3657
3803
  * pass, per-route capture records, and the end-of-run ledger write.
3658
3804
  */
3659
- function createIncrementalRun({ rootResolved, previousOutput, stagingOutput, globalKey, deps }) {
3805
+ function createIncrementalRun({ rootResolved, previousOutput, stagingOutput, globalKey, deps, skipEnabled = true }) {
3660
3806
  const ledger = loadRouteLedger(rootResolved);
3661
3807
  const revalidate = createExternalRevalidator();
3662
3808
  const hashFileAt = (base, relPath) => {
@@ -3671,7 +3817,7 @@ function createIncrementalRun({ rootResolved, previousOutput, stagingOutput, glo
3671
3817
  };
3672
3818
  const captures = new Map(); // routeKey → capture record of a route rendered this run
3673
3819
  const carried = new Map(); // routeKey → previous entry revalidated and reused this run
3674
- const state = { disabled: false, skipped: 0, reasons: {} };
3820
+ const state = { disabled: false, skipped: 0, reasons: {}, skipEnabled };
3675
3821
  const disable = (reason) => {
3676
3822
  if (state.disabled) return;
3677
3823
  state.disabled = true;
@@ -3702,6 +3848,7 @@ function createIncrementalRun({ rootResolved, previousOutput, stagingOutput, glo
3702
3848
  /** Skip pass: returns the subset of `paths` that must render; copies the
3703
3849
  * previous outputs of the rest into staging and carries their entries. */
3704
3850
  async partition(paths) {
3851
+ if (!skipEnabled) return paths; // recording-only run: render all, refresh the ledger
3705
3852
  if (!ledger) {
3706
3853
  for (let i = 0; i < paths.length; i++) noteRender('no ledger entry');
3707
3854
  return paths;
@@ -4437,15 +4584,22 @@ async function runPrerender(config) {
4437
4584
  // baseHtmlCache: base path segment → raw DOM HTML captured before any Node.js transforms
4438
4585
  const baseHtmlCache = new Map();
4439
4586
 
4440
- // --- Incremental skip pass (opt-in; see the incremental-rendering section) --
4587
+ // --- Incremental skip pass (see the incremental-rendering section) ----------
4441
4588
  // 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.
4589
+ // this run builds into staging. Recording runs whenever the project
4590
+ // participates (incrementalRecord); skipping only when `incremental` is on.
4591
+ // Never-opted-in → no capture, no ledger, no new work.
4443
4592
  let inc = null;
4444
- if (config.incremental && !config.serve) {
4593
+ if (config.incrementalRecord && !config.serve) {
4445
4594
  // Input capture maps request pathnames to disk via the built-in server's
4446
4595
  // 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) {
4596
+ if (config.incrementalExplicit) {
4597
+ process.stdout.write('prerender: incremental needs the built-in static server — ignoring it for this run\n');
4598
+ }
4599
+ } else if (config.incrementalRecord) {
4600
+ if (!config.incrementalExplicit) {
4601
+ process.stdout.write('prerender: incremental active from existing ledger (.mnfst-cache/routes.json; --no-incremental to disable)\n');
4602
+ }
4449
4603
  let manifestContentHash = 'missing';
4450
4604
  try { manifestContentHash = sha256Full(readFileSync(join(config.root, 'manifest.json'))); } catch { /* missing */ }
4451
4605
  // Output-relevant resolved options (CLI included). pathList is in here
@@ -4466,6 +4620,9 @@ async function runPrerender(config) {
4466
4620
  liveUrl: config.liveUrl,
4467
4621
  utilitiesBundle: bundleUtilities,
4468
4622
  tailwindBuilt,
4623
+ // Blocking changes what a page loads; entries from another regime must not skip.
4624
+ blockRequests: [...(config.blockRequests || [])].sort(),
4625
+ blockTelemetry: config.blockTelemetry,
4469
4626
  pathList: [...pathList].sort(),
4470
4627
  }));
4471
4628
  inc = createIncrementalRun({
@@ -4481,8 +4638,23 @@ async function runPrerender(config) {
4481
4638
  getRawHtml: (seg) => baseHtmlCache.get(seg),
4482
4639
  pushUtilityBlock: (b) => utilityBlocks.push(b),
4483
4640
  },
4641
+ skipEnabled: config.incremental,
4484
4642
  });
4485
4643
  }
4644
+
4645
+ // --- Request blocking during prerender ---------------------------------------
4646
+ // Telemetry collection endpoints are blocked by default (blockTelemetry:
4647
+ // false opts out); render.blockRequests adds user patterns. Blocked
4648
+ // requests are aborted via CDP and are invisible to incremental capture.
4649
+ const blockPatterns = [
4650
+ ...(config.blockTelemetry ? TELEMETRY_BLOCK_PATTERNS : []),
4651
+ ...(config.blockRequests || []),
4652
+ ];
4653
+ const blockMatcher = compileBlockMatcher(blockPatterns);
4654
+ const telemetryMatcher = config.blockTelemetry ? compileBlockMatcher(TELEMETRY_BLOCK_PATTERNS) : null;
4655
+ const cdpBlockedUrls = blockPatterns.map((p) => `*${p}*`);
4656
+ let telemetryBlockedCount = 0;
4657
+
4486
4658
  const puppeteerRenderPaths = inc ? await inc.partition(puppeteerPaths) : puppeteerPaths;
4487
4659
  let incRendered = puppeteerRenderPaths.length;
4488
4660
  const puppeteerTotal = puppeteerRenderPaths.length;
@@ -4537,64 +4709,22 @@ async function runPrerender(config) {
4537
4709
  externalPinned: new Set(), externalValidated: new Map(),
4538
4710
  awaiting: new Set(), pending: [], nonCacheable: false, reason: null,
4539
4711
  };
4540
- const localOrigin = new URL(config.localUrl).origin;
4541
- const flag = (reason) => {
4542
- if (!capture.nonCacheable) { capture.nonCacheable = true; capture.reason = reason; }
4543
- };
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'); }
4712
+ const hooks = createCaptureHooks({
4713
+ capture,
4714
+ localOrigin: new URL(config.localUrl).origin,
4715
+ resolveRequest: (pathname) => resolveStaticRequestFile(rootResolved, pathname),
4716
+ isBlocked: blockMatcher,
4597
4717
  });
4718
+ page.on('request', (req) => hooks.request({ url: req.url(), method: req.method() }));
4719
+ page.on('requestfailed', (req) => hooks.requestFailed({ url: req.url() }));
4720
+ page.on('response', (res) => hooks.response({
4721
+ url: res.url(), status: res.status(), headers: res.headers(),
4722
+ requestHeaders: res.request().headers(),
4723
+ buffer: () => res.buffer(),
4724
+ }));
4725
+ }
4726
+ if (telemetryMatcher) {
4727
+ page.on('requestfailed', (req) => { if (telemetryMatcher(req.url())) telemetryBlockedCount++; });
4598
4728
  }
4599
4729
  // Render at a typical desktop viewport so layouts dependent on viewport
4600
4730
  // width (responsive flex/grid, container queries, media queries) settle
@@ -4604,6 +4734,13 @@ async function runPrerender(config) {
4604
4734
  // viewport-driven flex distribution.
4605
4735
  await page.setViewport({ width: 1200, height: 800, deviceScaleFactor: 1 });
4606
4736
  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) {
4740
+ const cdp = await page.createCDPSession();
4741
+ await cdp.send('Network.enable');
4742
+ await cdp.send('Network.setBlockedURLs', { urls: cdpBlockedUrls });
4743
+ }
4607
4744
  // Align <html lang> with the URL being prerendered before any app script runs.
4608
4745
  // initializeDataSourcesPlugin picks locale from document.documentElement.lang first; a mismatch
4609
4746
  // (e.g. headless default vs /en/...) leaves $x.* empty while x-route sections still render.
@@ -6059,8 +6196,15 @@ async function runPrerender(config) {
6059
6196
  }
6060
6197
 
6061
6198
  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`);
6199
+ if (!inc.state.skipEnabled) {
6200
+ process.stdout.write(`prerender: rendered ${incRendered} (ledger refreshed, skipping off)\n`);
6201
+ } else {
6202
+ const why = Object.entries(inc.state.reasons).sort((a, b) => b[1] - a[1]).map(([r, c]) => `${c} ${r}`);
6203
+ process.stdout.write(`prerender: skipped ${inc.state.skipped} unchanged, rendered ${incRendered}${why.length ? ` (${why.join(', ')})` : ''}\n`);
6204
+ }
6205
+ }
6206
+ if (telemetryBlockedCount > 0) {
6207
+ process.stdout.write(`prerender: blocked ${telemetryBlockedCount} telemetry requests (render.blockTelemetry: false to allow)\n`);
6064
6208
  }
6065
6209
 
6066
6210
  if (config.debugPrerender) {
@@ -6187,4 +6331,9 @@ export {
6187
6331
  isSafeRelPath,
6188
6332
  settleCaptureAwaiting,
6189
6333
  EXTERNAL_BODY_HASH_LIMIT,
6334
+ resolveIncrementalPolicy,
6335
+ classifyExternalResponse,
6336
+ createCaptureHooks,
6337
+ compileBlockMatcher,
6338
+ TELEMETRY_BLOCK_PATTERNS,
6190
6339
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mnfst-render",
3
- "version": "0.5.51",
3
+ "version": "0.5.52",
4
4
  "description": "Render Manifest sites to static HTML for SEO",
5
5
  "type": "module",
6
6
  "bin": {