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.
- package/manifest.render.mjs +449 -74
- package/package.json +1 -1
package/manifest.render.mjs
CHANGED
|
@@ -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
|
|
584
|
-
//
|
|
585
|
-
|
|
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
|
|
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) {
|
|
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 (
|
|
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.
|
|
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.
|
|
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
|
-
|
|
4448
|
-
|
|
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
|
-
|
|
4545
|
-
|
|
4546
|
-
|
|
4547
|
-
|
|
4548
|
-
|
|
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:
|
|
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
|
-
|
|
6063
|
-
|
|
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
|
};
|