mnfst-render 0.5.50 → 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.
- package/manifest.render.mjs +748 -5
- package/package.json +1 -1
package/manifest.render.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import { cpus, hostname } from 'node:os';
|
|
|
10
10
|
import { createRequire } from 'node:module';
|
|
11
11
|
import { fileURLToPath } from 'node:url';
|
|
12
12
|
import { createHash, randomBytes } from 'node:crypto';
|
|
13
|
+
import { gzipSync, gunzipSync } from 'node:zlib';
|
|
13
14
|
|
|
14
15
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
15
16
|
const require = createRequire(import.meta.url);
|
|
@@ -408,6 +409,8 @@ function parseArgs() {
|
|
|
408
409
|
if (args[i] === '--wait-after-idle' && args[i + 1]) { out.waitAfterIdle = parseInt(args[++i], 10); continue; }
|
|
409
410
|
if (args[i] === '--concurrency' && args[i + 1]) { out.concurrency = parseInt(args[++i], 10); continue; }
|
|
410
411
|
if (args[i] === '--retries' && args[i + 1]) { out.retries = parseInt(args[++i], 10); continue; }
|
|
412
|
+
if (args[i] === '--incremental') { out.incremental = true; continue; }
|
|
413
|
+
if (args[i] === '--no-incremental') { out.noIncremental = true; continue; }
|
|
411
414
|
if (args[i] === '--dry-run') { out.dryRun = true; continue; }
|
|
412
415
|
if (args[i] === '--force-lock') { out.forceLock = true; continue; }
|
|
413
416
|
if (args[i] === '--debug-prerender') { out.debugPrerender = true; continue; }
|
|
@@ -521,6 +524,25 @@ function expandLocaleRoutePaths({ routeSegments, locales, defaultLocale, localeR
|
|
|
521
524
|
return paths;
|
|
522
525
|
}
|
|
523
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
|
+
|
|
524
546
|
function resolveConfig() {
|
|
525
547
|
const cli = parseArgs();
|
|
526
548
|
const cwd = process.cwd();
|
|
@@ -577,6 +599,21 @@ function resolveConfig() {
|
|
|
577
599
|
paths: Array.isArray(pre.paths)
|
|
578
600
|
? pre.paths.map((p) => String(p).replace(/^\/+|\/+$/g, '')).filter(Boolean)
|
|
579
601
|
: [],
|
|
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,
|
|
580
617
|
dryRun: !!cli.dryRun,
|
|
581
618
|
forceLock: !!cli.forceLock,
|
|
582
619
|
debugPrerender: !!cli.debugPrerender,
|
|
@@ -3422,6 +3459,538 @@ function startStaticServer(rootDir) {
|
|
|
3422
3459
|
});
|
|
3423
3460
|
}
|
|
3424
3461
|
|
|
3462
|
+
// --- Incremental rendering (opt-in) -------------------------------------------
|
|
3463
|
+
// Ledger at <root>/.mnfst-cache/routes.json: per-route input fingerprints from
|
|
3464
|
+
// the last fully successful run. Skipping is only ever an optimization — any
|
|
3465
|
+
// uncertainty (missing file, failed resolution, parse error) falls back to
|
|
3466
|
+
// rendering, never to skipping.
|
|
3467
|
+
|
|
3468
|
+
const LEDGER_SCHEMA_VERSION = 2;
|
|
3469
|
+
|
|
3470
|
+
const sha256Full = (data) => createHash('sha256').update(data).digest('hex');
|
|
3471
|
+
|
|
3472
|
+
/** Deterministic JSON: object keys sorted recursively. */
|
|
3473
|
+
function stableStringify(value) {
|
|
3474
|
+
if (Array.isArray(value)) return '[' + value.map(stableStringify).join(',') + ']';
|
|
3475
|
+
if (value && typeof value === 'object') {
|
|
3476
|
+
return '{' + Object.keys(value).sort().map((k) => JSON.stringify(k) + ':' + stableStringify(value[k])).join(',') + '}';
|
|
3477
|
+
}
|
|
3478
|
+
return value === undefined ? 'null' : JSON.stringify(value);
|
|
3479
|
+
}
|
|
3480
|
+
|
|
3481
|
+
/** Nearest package.json identity — part of every signature so a renderer
|
|
3482
|
+
* upgrade invalidates the whole ledger. A run from the repo tree (not an
|
|
3483
|
+
* installed mnfst-render) also folds in the script's own content hash, since
|
|
3484
|
+
* the repo version doesn't bump per edit. Unidentifiable → never skip. */
|
|
3485
|
+
function rendererPackageIdentity() {
|
|
3486
|
+
let id = 'dev';
|
|
3487
|
+
for (const rel of ['package.json', '../package.json', '../../package.json']) {
|
|
3488
|
+
try {
|
|
3489
|
+
const pkg = JSON.parse(readFileSync(join(__dirname, rel), 'utf8'));
|
|
3490
|
+
if (pkg && pkg.version) { id = `${pkg.name || 'mnfst-render'}@${pkg.version}`; break; }
|
|
3491
|
+
} catch { /* keep looking */ }
|
|
3492
|
+
}
|
|
3493
|
+
if (!id.startsWith('mnfst-render@')) {
|
|
3494
|
+
try { id += '+' + sha256Full(readFileSync(fileURLToPath(import.meta.url))).slice(0, 16); }
|
|
3495
|
+
catch { id += '+' + Date.now(); }
|
|
3496
|
+
}
|
|
3497
|
+
return id;
|
|
3498
|
+
}
|
|
3499
|
+
|
|
3500
|
+
/** Same pathname → disk-file resolution as startStaticServer (incl. the SPA
|
|
3501
|
+
* index.html fallback); null when nothing would serve the path. */
|
|
3502
|
+
function resolveStaticRequestFile(rootDir, pathname) {
|
|
3503
|
+
const rootResolved = resolve(rootDir);
|
|
3504
|
+
const segments = String(pathname || '/').split('/').filter((s) => s && s !== '..');
|
|
3505
|
+
const rel = (p) => relative(rootResolved, p).split(sep).join('/');
|
|
3506
|
+
let resolvedPath;
|
|
3507
|
+
try { resolvedPath = resolve(join(rootResolved, ...segments)); } catch { resolvedPath = null; }
|
|
3508
|
+
if (resolvedPath && resolvedPath.startsWith(rootResolved)) {
|
|
3509
|
+
try {
|
|
3510
|
+
if (existsSync(resolvedPath)) {
|
|
3511
|
+
const st = statSync(resolvedPath);
|
|
3512
|
+
if (!st.isDirectory()) return rel(resolvedPath);
|
|
3513
|
+
const idx = join(resolvedPath, 'index.html');
|
|
3514
|
+
if (existsSync(idx)) return rel(idx);
|
|
3515
|
+
}
|
|
3516
|
+
} catch { /* fall through to the SPA fallback */ }
|
|
3517
|
+
}
|
|
3518
|
+
return existsSync(join(rootResolved, 'index.html')) ? 'index.html' : null;
|
|
3519
|
+
}
|
|
3520
|
+
|
|
3521
|
+
const PINNED_VERSION_RE = /@\d+\.\d+\.\d+/;
|
|
3522
|
+
|
|
3523
|
+
// Bodies above this are never fingerprinted — the route renders instead.
|
|
3524
|
+
const EXTERNAL_BODY_HASH_LIMIT = 8 * 1024 * 1024;
|
|
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
|
+
|
|
3641
|
+
const externalValidatorToken = (url, v) =>
|
|
3642
|
+
`${url}#etag:${v?.etag || ''}|lm:${v?.lastModified || ''}|ch:${v?.contentHash || ''}`;
|
|
3643
|
+
|
|
3644
|
+
/** Post-drain settlement: an unpinned cross-origin request that never got a
|
|
3645
|
+
* processed response (memory-cache hit, late/unreadable body) is an
|
|
3646
|
+
* UNVERIFIED external dependency — the route must render. */
|
|
3647
|
+
function settleCaptureAwaiting(capture) {
|
|
3648
|
+
if (capture.awaiting && capture.awaiting.size > 0 && !capture.nonCacheable) {
|
|
3649
|
+
capture.nonCacheable = true;
|
|
3650
|
+
capture.reason = `unverified external: ${[...capture.awaiting][0]}`;
|
|
3651
|
+
}
|
|
3652
|
+
return capture;
|
|
3653
|
+
}
|
|
3654
|
+
|
|
3655
|
+
/** Re-check a recorded cross-origin validator. ETag/Last-Modified go out as
|
|
3656
|
+
* a conditional request where only a 304 counts as unchanged; a validator-less
|
|
3657
|
+
* resource was content-addressed at capture, so it is re-downloaded and its
|
|
3658
|
+
* bytes re-hashed. Any other status, a redirect, or a network failure means
|
|
3659
|
+
* render. Memoized per run. */
|
|
3660
|
+
function createExternalRevalidator({ request, download } = {}) {
|
|
3661
|
+
const requestFn = request || (async (url, headers) => {
|
|
3662
|
+
const ctrl = new AbortController();
|
|
3663
|
+
const t = setTimeout(() => ctrl.abort(), 10000);
|
|
3664
|
+
try {
|
|
3665
|
+
const res = await fetch(url, { method: 'GET', headers, redirect: 'manual', signal: ctrl.signal });
|
|
3666
|
+
try { await res.body?.cancel(); } catch { /* no body on 304 */ }
|
|
3667
|
+
return res.status;
|
|
3668
|
+
} finally { clearTimeout(t); }
|
|
3669
|
+
});
|
|
3670
|
+
const downloadFn = download || (async (url) => {
|
|
3671
|
+
const ctrl = new AbortController();
|
|
3672
|
+
const t = setTimeout(() => ctrl.abort(), 20000);
|
|
3673
|
+
try {
|
|
3674
|
+
const res = await fetch(url, { method: 'GET', redirect: 'manual', signal: ctrl.signal });
|
|
3675
|
+
if (res.status < 200 || res.status >= 300) return null;
|
|
3676
|
+
if (Number(res.headers.get('content-length') || 0) > EXTERNAL_BODY_HASH_LIMIT) return null;
|
|
3677
|
+
return Buffer.from(await res.arrayBuffer());
|
|
3678
|
+
} finally { clearTimeout(t); }
|
|
3679
|
+
});
|
|
3680
|
+
const cache = new Map();
|
|
3681
|
+
return (url, validator) => {
|
|
3682
|
+
const key = externalValidatorToken(url, validator);
|
|
3683
|
+
if (!cache.has(key)) {
|
|
3684
|
+
let check;
|
|
3685
|
+
if (validator?.etag || validator?.lastModified) {
|
|
3686
|
+
const headers = validator.etag
|
|
3687
|
+
? { 'If-None-Match': validator.etag }
|
|
3688
|
+
: { 'If-Modified-Since': validator.lastModified };
|
|
3689
|
+
check = Promise.resolve().then(() => requestFn(url, headers)).then((status) => status === 304);
|
|
3690
|
+
} else if (validator?.contentHash) {
|
|
3691
|
+
check = Promise.resolve().then(() => downloadFn(url))
|
|
3692
|
+
.then((body) => !!body && body.length <= EXTERNAL_BODY_HASH_LIMIT
|
|
3693
|
+
&& sha256Full(body) === validator.contentHash);
|
|
3694
|
+
} else {
|
|
3695
|
+
check = Promise.resolve(false);
|
|
3696
|
+
}
|
|
3697
|
+
cache.set(key, check.catch(() => false));
|
|
3698
|
+
}
|
|
3699
|
+
return cache.get(key);
|
|
3700
|
+
};
|
|
3701
|
+
}
|
|
3702
|
+
|
|
3703
|
+
/** relPath acceptable for joining under a base dir. */
|
|
3704
|
+
const isSafeRelPath = (rel) =>
|
|
3705
|
+
typeof rel === 'string' && rel.length > 0 && !rel.includes('\\') && rel[0] !== '/'
|
|
3706
|
+
&& !/^[A-Za-z]:/.test(rel) && !rel.split('/').some((s) => s === '..' || s === '');
|
|
3707
|
+
|
|
3708
|
+
function computeRouteSignature({ globalKey, inputPairs, requestedPairs = [], externalTokens }) {
|
|
3709
|
+
return sha256Full([
|
|
3710
|
+
globalKey,
|
|
3711
|
+
[...inputPairs].sort().join('\n'),
|
|
3712
|
+
[...requestedPairs].sort().join('\n'),
|
|
3713
|
+
[...externalTokens].sort().join('\n'),
|
|
3714
|
+
].join('|'));
|
|
3715
|
+
}
|
|
3716
|
+
|
|
3717
|
+
function routesLedgerPath(rootDir) { return join(rootDir, '.mnfst-cache', 'routes.json'); }
|
|
3718
|
+
|
|
3719
|
+
/** null on absence, parse failure, or schema mismatch — full render. */
|
|
3720
|
+
function loadRouteLedger(rootDir) {
|
|
3721
|
+
try {
|
|
3722
|
+
const parsed = JSON.parse(readFileSync(routesLedgerPath(rootDir), 'utf8'));
|
|
3723
|
+
if (!parsed || parsed.schemaVersion !== LEDGER_SCHEMA_VERSION) return null;
|
|
3724
|
+
if (!parsed.routes || typeof parsed.routes !== 'object') return null;
|
|
3725
|
+
return parsed;
|
|
3726
|
+
} catch { return null; }
|
|
3727
|
+
}
|
|
3728
|
+
|
|
3729
|
+
function rawHtmlCachePath(rootDir, routeKey) {
|
|
3730
|
+
return join(rootDir, '.mnfst-cache', 'render-html', `${sha256Full(String(routeKey)).slice(0, 24)}.html.gz`);
|
|
3731
|
+
}
|
|
3732
|
+
|
|
3733
|
+
/** Disk files loadContentForPrerender reads for `locale` — Node-side inputs
|
|
3734
|
+
* (head meta / x-binding resolution) that the page itself never fetches. */
|
|
3735
|
+
function contentFilesForPrerender(manifest, locale) {
|
|
3736
|
+
const data = manifest?.data?.content;
|
|
3737
|
+
if (!data) return [];
|
|
3738
|
+
const loc = locale || 'en';
|
|
3739
|
+
const strip = (p) => String(p).replace(/^\/+/, '');
|
|
3740
|
+
if (typeof data === 'string' && data.endsWith('.csv')) return [strip(data)];
|
|
3741
|
+
if (data && typeof data === 'object' && typeof data.locales === 'string') return [strip(data.locales)];
|
|
3742
|
+
if (data && typeof data === 'object' && !Array.isArray(data)) {
|
|
3743
|
+
const filePath = data[loc] || data[Object.keys(data)[0]];
|
|
3744
|
+
if (typeof filePath === 'string') return [strip(filePath)];
|
|
3745
|
+
}
|
|
3746
|
+
return [];
|
|
3747
|
+
}
|
|
3748
|
+
|
|
3749
|
+
/**
|
|
3750
|
+
* Per-route skip decision. Skip requires: a well-formed entry, every recorded
|
|
3751
|
+
* request pathname still resolving to the same file (a new physical file can
|
|
3752
|
+
* shadow a prior SPA-fallback resolution), every recorded input re-hashing to
|
|
3753
|
+
* its recorded value NOW, the recomputed signature matching (config/version/
|
|
3754
|
+
* manifest/externals), every recorded output byte-identical in the previous
|
|
3755
|
+
* final output (closes the edit→render-elsewhere→revert hole), and every
|
|
3756
|
+
* cross-origin validator answering 304 to a conditional request NOW.
|
|
3757
|
+
* Dependency-injected for unit tests.
|
|
3758
|
+
*/
|
|
3759
|
+
async function evaluateRouteSkip({ entry, hashInput, resolveRequest, revalidateExternal, globalKey, outputHash }) {
|
|
3760
|
+
if (!entry || typeof entry !== 'object') return { skip: false, reason: 'no ledger entry' };
|
|
3761
|
+
if (entry.nonCacheable) return { skip: false, reason: 'non-cacheable' };
|
|
3762
|
+
if (typeof entry.sig !== 'string' || !entry.inputs || typeof entry.inputs !== 'object'
|
|
3763
|
+
|| !entry.requested || typeof entry.requested !== 'object'
|
|
3764
|
+
|| !Array.isArray(entry.external)
|
|
3765
|
+
|| !entry.externalValidated || typeof entry.externalValidated !== 'object'
|
|
3766
|
+
|| !entry.outputs || typeof entry.outputs !== 'object'
|
|
3767
|
+
|| Object.keys(entry.outputs).length === 0) {
|
|
3768
|
+
return { skip: false, reason: 'malformed entry' };
|
|
3769
|
+
}
|
|
3770
|
+
const requestedPairs = [];
|
|
3771
|
+
for (const [pathname, relPath] of Object.entries(entry.requested)) {
|
|
3772
|
+
if (!isSafeRelPath(relPath)) return { skip: false, reason: `unsafe path in entry: ${relPath}` };
|
|
3773
|
+
if (resolveRequest(pathname) !== relPath) return { skip: false, reason: `resolution changed: ${pathname}` };
|
|
3774
|
+
requestedPairs.push(`${pathname}=>${relPath}`);
|
|
3775
|
+
}
|
|
3776
|
+
const inputPairs = [];
|
|
3777
|
+
for (const [relPath, recorded] of Object.entries(entry.inputs)) {
|
|
3778
|
+
if (!isSafeRelPath(relPath)) return { skip: false, reason: `unsafe path in entry: ${relPath}` };
|
|
3779
|
+
const now = hashInput(relPath);
|
|
3780
|
+
if (now !== recorded) return { skip: false, reason: `input changed: ${relPath}` };
|
|
3781
|
+
inputPairs.push(`${relPath}:${now}`);
|
|
3782
|
+
}
|
|
3783
|
+
const externalTokens = [
|
|
3784
|
+
...entry.external,
|
|
3785
|
+
...Object.entries(entry.externalValidated).map(([u, v]) => externalValidatorToken(u, v)),
|
|
3786
|
+
];
|
|
3787
|
+
if (computeRouteSignature({ globalKey, inputPairs, requestedPairs, externalTokens }) !== entry.sig) {
|
|
3788
|
+
return { skip: false, reason: 'signature mismatch' };
|
|
3789
|
+
}
|
|
3790
|
+
for (const [relPath, recorded] of Object.entries(entry.outputs)) {
|
|
3791
|
+
if (!isSafeRelPath(relPath)) return { skip: false, reason: `unsafe path in entry: ${relPath}` };
|
|
3792
|
+
if (outputHash(relPath) !== recorded) return { skip: false, reason: `previous output missing or changed: ${relPath}` };
|
|
3793
|
+
}
|
|
3794
|
+
// Network last: everything local must already agree.
|
|
3795
|
+
for (const [url, validator] of Object.entries(entry.externalValidated)) {
|
|
3796
|
+
if (!(await revalidateExternal(url, validator))) return { skip: false, reason: `external changed: ${url}` };
|
|
3797
|
+
}
|
|
3798
|
+
return { skip: true };
|
|
3799
|
+
}
|
|
3800
|
+
|
|
3801
|
+
/**
|
|
3802
|
+
* Run-scoped incremental state: ledger, memoized hashing/resolution, the skip
|
|
3803
|
+
* pass, per-route capture records, and the end-of-run ledger write.
|
|
3804
|
+
*/
|
|
3805
|
+
function createIncrementalRun({ rootResolved, previousOutput, stagingOutput, globalKey, deps, skipEnabled = true }) {
|
|
3806
|
+
const ledger = loadRouteLedger(rootResolved);
|
|
3807
|
+
const revalidate = createExternalRevalidator();
|
|
3808
|
+
const hashFileAt = (base, relPath) => {
|
|
3809
|
+
try { return sha256Full(readFileSync(join(base, relPath))); } catch { return null; }
|
|
3810
|
+
};
|
|
3811
|
+
// 'absent' is a value, not a miss: a recorded-absent file that stays absent
|
|
3812
|
+
// matches, one that appears mismatches.
|
|
3813
|
+
const skipHashes = new Map();
|
|
3814
|
+
const hashInput = (relPath) => {
|
|
3815
|
+
if (!skipHashes.has(relPath)) skipHashes.set(relPath, hashFileAt(rootResolved, relPath) ?? 'absent');
|
|
3816
|
+
return skipHashes.get(relPath);
|
|
3817
|
+
};
|
|
3818
|
+
const captures = new Map(); // routeKey → capture record of a route rendered this run
|
|
3819
|
+
const carried = new Map(); // routeKey → previous entry revalidated and reused this run
|
|
3820
|
+
const state = { disabled: false, skipped: 0, reasons: {}, skipEnabled };
|
|
3821
|
+
const disable = (reason) => {
|
|
3822
|
+
if (state.disabled) return;
|
|
3823
|
+
state.disabled = true;
|
|
3824
|
+
process.stdout.write(`prerender: incremental disabled for this run (${reason}) — rendering everything\n`);
|
|
3825
|
+
};
|
|
3826
|
+
// Why-rendered buckets for the run summary.
|
|
3827
|
+
const noteRender = (reason, entry) => {
|
|
3828
|
+
const r = String(reason || '');
|
|
3829
|
+
let bucket;
|
|
3830
|
+
if (r === 'non-cacheable') {
|
|
3831
|
+
const detail = String(entry?.reason || '').replace(/https?:\/\/\S+/g, '').replace(/\s+/g, ' ').trim().replace(/[:\s]+$/, '');
|
|
3832
|
+
bucket = detail ? `non-cacheable: ${detail}` : 'non-cacheable';
|
|
3833
|
+
} else if (r.startsWith('no ledger entry')) bucket = 'new';
|
|
3834
|
+
else if (r.startsWith('input ')) bucket = 'changed inputs';
|
|
3835
|
+
else if (r.startsWith('resolution changed')) bucket = 'changed resolution';
|
|
3836
|
+
else if (r.startsWith('external changed')) bucket = 'changed externals';
|
|
3837
|
+
else if (r.startsWith('signature mismatch')) bucket = 'changed signature';
|
|
3838
|
+
else if (r.startsWith('previous output')) bucket = 'previous output missing/changed';
|
|
3839
|
+
else bucket = r.slice(0, 48) || 'unknown';
|
|
3840
|
+
state.reasons[bucket] = (state.reasons[bucket] || 0) + 1;
|
|
3841
|
+
};
|
|
3842
|
+
|
|
3843
|
+
return {
|
|
3844
|
+
state,
|
|
3845
|
+
captures,
|
|
3846
|
+
recordRoute(routeKey, record) { captures.set(routeKey, record); },
|
|
3847
|
+
|
|
3848
|
+
/** Skip pass: returns the subset of `paths` that must render; copies the
|
|
3849
|
+
* previous outputs of the rest into staging and carries their entries. */
|
|
3850
|
+
async partition(paths) {
|
|
3851
|
+
if (!skipEnabled) return paths; // recording-only run: render all, refresh the ledger
|
|
3852
|
+
if (!ledger) {
|
|
3853
|
+
for (let i = 0; i < paths.length; i++) noteRender('no ledger entry');
|
|
3854
|
+
return paths;
|
|
3855
|
+
}
|
|
3856
|
+
const toRender = [];
|
|
3857
|
+
for (const seg of paths) {
|
|
3858
|
+
if (state.disabled) { noteRender('incremental disabled'); toRender.push(seg); continue; }
|
|
3859
|
+
const entry = ledger.routes[seg];
|
|
3860
|
+
let verdict;
|
|
3861
|
+
try {
|
|
3862
|
+
verdict = await evaluateRouteSkip({
|
|
3863
|
+
entry, hashInput, globalKey,
|
|
3864
|
+
resolveRequest: (pathname) => resolveStaticRequestFile(rootResolved, pathname),
|
|
3865
|
+
revalidateExternal: revalidate,
|
|
3866
|
+
outputHash: (rel) => hashFileAt(previousOutput, rel),
|
|
3867
|
+
});
|
|
3868
|
+
} catch (err) {
|
|
3869
|
+
disable(`skip evaluation failed: ${err?.message || err}`);
|
|
3870
|
+
noteRender('incremental disabled');
|
|
3871
|
+
toRender.push(seg);
|
|
3872
|
+
continue;
|
|
3873
|
+
}
|
|
3874
|
+
// A base whose locale variants substitute needs its cached raw DOM
|
|
3875
|
+
// snapshot back; absent or unreadable → render the base.
|
|
3876
|
+
let rawHtml = null;
|
|
3877
|
+
if (verdict.skip && !entry.promoted && deps.hasPendingVariants(seg)) {
|
|
3878
|
+
try { rawHtml = gunzipSync(readFileSync(rawHtmlCachePath(rootResolved, seg))).toString('utf8'); }
|
|
3879
|
+
catch { rawHtml = null; }
|
|
3880
|
+
if (!rawHtml) verdict = { skip: false, reason: 'raw snapshot missing' };
|
|
3881
|
+
}
|
|
3882
|
+
if (verdict.skip) {
|
|
3883
|
+
try {
|
|
3884
|
+
for (const rel of Object.keys(entry.outputs)) {
|
|
3885
|
+
const dest = join(stagingOutput, rel);
|
|
3886
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
3887
|
+
cpSync(join(previousOutput, rel), dest);
|
|
3888
|
+
}
|
|
3889
|
+
} catch (err) {
|
|
3890
|
+
verdict = { skip: false, reason: `copy failed: ${err?.message || err}` };
|
|
3891
|
+
}
|
|
3892
|
+
}
|
|
3893
|
+
if (!verdict.skip) { noteRender(verdict.reason, entry); toRender.push(seg); continue; }
|
|
3894
|
+
if (entry.promoted) deps.promoteVariants(seg);
|
|
3895
|
+
else if (rawHtml) deps.cacheRawHtml(seg, rawHtml);
|
|
3896
|
+
if (Array.isArray(entry.utility)) for (const b of entry.utility) deps.pushUtilityBlock(b);
|
|
3897
|
+
carried.set(seg, entry);
|
|
3898
|
+
state.skipped++;
|
|
3899
|
+
process.stdout.write(` [skip] ${seg === '' ? '/' : '/' + seg} (unchanged)\n`);
|
|
3900
|
+
}
|
|
3901
|
+
return toRender;
|
|
3902
|
+
},
|
|
3903
|
+
|
|
3904
|
+
/** Written atomically, only after the staged output replaced the previous
|
|
3905
|
+
* one — covers exactly this run's skipped + successfully rendered routes.
|
|
3906
|
+
* Inputs are re-hashed HERE, not from the skip pass's memo, so a mid-run
|
|
3907
|
+
* edit is recorded as it is now and a later revert can't skip-match. */
|
|
3908
|
+
async writeLedger(finalOutput) {
|
|
3909
|
+
if (state.disabled) return false;
|
|
3910
|
+
const writeHashes = new Map();
|
|
3911
|
+
const hashNow = (rel) => {
|
|
3912
|
+
if (!writeHashes.has(rel)) writeHashes.set(rel, hashFileAt(rootResolved, rel) ?? 'absent');
|
|
3913
|
+
return writeHashes.get(rel);
|
|
3914
|
+
};
|
|
3915
|
+
const routes = {};
|
|
3916
|
+
for (const [key, entry] of carried) routes[key] = entry;
|
|
3917
|
+
for (const [key, cap] of captures) {
|
|
3918
|
+
if (cap.nonCacheable) { routes[key] = { nonCacheable: true, reason: cap.reason }; continue; }
|
|
3919
|
+
const requested = {};
|
|
3920
|
+
const requestedPairs = [];
|
|
3921
|
+
for (const pathname of [...cap.requested.keys()].sort()) {
|
|
3922
|
+
const rel = cap.requested.get(pathname);
|
|
3923
|
+
requested[pathname] = rel;
|
|
3924
|
+
requestedPairs.push(`${pathname}=>${rel}`);
|
|
3925
|
+
}
|
|
3926
|
+
const inputs = {};
|
|
3927
|
+
const inputPairs = [];
|
|
3928
|
+
for (const rel of [...cap.files].sort()) {
|
|
3929
|
+
const h = hashNow(rel);
|
|
3930
|
+
inputs[rel] = h;
|
|
3931
|
+
inputPairs.push(`${rel}:${h}`);
|
|
3932
|
+
}
|
|
3933
|
+
const outputs = {};
|
|
3934
|
+
let missingOut = null;
|
|
3935
|
+
for (const rel of cap.outputs) {
|
|
3936
|
+
const h = hashFileAt(finalOutput, rel);
|
|
3937
|
+
if (!h) { missingOut = rel; break; }
|
|
3938
|
+
outputs[rel] = h;
|
|
3939
|
+
}
|
|
3940
|
+
if (missingOut) { routes[key] = { nonCacheable: true, reason: `output unreadable: ${missingOut}` }; continue; }
|
|
3941
|
+
const external = [...cap.externalPinned].sort();
|
|
3942
|
+
const externalValidated = {};
|
|
3943
|
+
const externalTokens = [...external];
|
|
3944
|
+
for (const url of [...cap.externalValidated.keys()].sort()) {
|
|
3945
|
+
const v = cap.externalValidated.get(url);
|
|
3946
|
+
externalValidated[url] = v;
|
|
3947
|
+
externalTokens.push(externalValidatorToken(url, v));
|
|
3948
|
+
}
|
|
3949
|
+
const entry = {
|
|
3950
|
+
sig: computeRouteSignature({ globalKey, inputPairs, requestedPairs, externalTokens }),
|
|
3951
|
+
inputs, requested, external, externalValidated, outputs,
|
|
3952
|
+
};
|
|
3953
|
+
if (Array.isArray(cap.utility) && cap.utility.length) entry.utility = cap.utility;
|
|
3954
|
+
if (deps.isPromotedBase(key)) {
|
|
3955
|
+
entry.promoted = true;
|
|
3956
|
+
} else if (deps.hasPendingVariants(key)) {
|
|
3957
|
+
const raw = deps.getRawHtml(key);
|
|
3958
|
+
if (!raw) { routes[key] = { nonCacheable: true, reason: 'raw snapshot unavailable' }; continue; }
|
|
3959
|
+
const p = rawHtmlCachePath(rootResolved, key);
|
|
3960
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
3961
|
+
writeFileSync(p, gzipSync(Buffer.from(raw, 'utf8')));
|
|
3962
|
+
}
|
|
3963
|
+
routes[key] = entry;
|
|
3964
|
+
}
|
|
3965
|
+
const target = routesLedgerPath(rootResolved);
|
|
3966
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
3967
|
+
const tmp = `${target}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
|
|
3968
|
+
writeFileSync(tmp, JSON.stringify({
|
|
3969
|
+
schemaVersion: LEDGER_SCHEMA_VERSION,
|
|
3970
|
+
createdAt: new Date().toISOString(),
|
|
3971
|
+
routes,
|
|
3972
|
+
}) + '\n', 'utf8');
|
|
3973
|
+
renameSync(tmp, target);
|
|
3974
|
+
// GC: snapshots no route references, and orphaned ledger tmp files.
|
|
3975
|
+
try {
|
|
3976
|
+
const keep = new Set(Object.keys(routes).map((k) => basename(rawHtmlCachePath(rootResolved, k))));
|
|
3977
|
+
const snapDir = join(rootResolved, '.mnfst-cache', 'render-html');
|
|
3978
|
+
if (existsSync(snapDir)) {
|
|
3979
|
+
for (const f of readdirSync(snapDir)) {
|
|
3980
|
+
if (!keep.has(f)) { try { unlinkSync(join(snapDir, f)); } catch { /* in use elsewhere */ } }
|
|
3981
|
+
}
|
|
3982
|
+
}
|
|
3983
|
+
for (const f of readdirSync(join(rootResolved, '.mnfst-cache'))) {
|
|
3984
|
+
if (f.startsWith('routes.json.') && f.endsWith('.tmp')) {
|
|
3985
|
+
try { unlinkSync(join(rootResolved, '.mnfst-cache', f)); } catch { /* best-effort */ }
|
|
3986
|
+
}
|
|
3987
|
+
}
|
|
3988
|
+
} catch { /* GC is best-effort */ }
|
|
3989
|
+
return true;
|
|
3990
|
+
},
|
|
3991
|
+
};
|
|
3992
|
+
}
|
|
3993
|
+
|
|
3425
3994
|
// --- Output lock (one render per output dir) ---------------------------------
|
|
3426
3995
|
|
|
3427
3996
|
const LOCK_SUFFIX = '.mnfst-lock';
|
|
@@ -3980,7 +4549,11 @@ async function runPrerender(config) {
|
|
|
3980
4549
|
* segment). When it does, every locale variant of this base is moved from
|
|
3981
4550
|
* substitution to a real per-locale Puppeteer render.
|
|
3982
4551
|
*/
|
|
4552
|
+
// Bases whose locale variants were promoted this run — persisted in the
|
|
4553
|
+
// route ledger so a future skipped run re-promotes instead of substituting.
|
|
4554
|
+
const promotedBaseSegs = new Set();
|
|
3983
4555
|
function promoteLocaleVariants(basePathSeg) {
|
|
4556
|
+
promotedBaseSegs.add(basePathSeg);
|
|
3984
4557
|
const variants = variantsByBase.get(basePathSeg);
|
|
3985
4558
|
if (!variants || !variants.length) return;
|
|
3986
4559
|
process.stdout.write(
|
|
@@ -4010,9 +4583,83 @@ async function runPrerender(config) {
|
|
|
4010
4583
|
|
|
4011
4584
|
// baseHtmlCache: base path segment → raw DOM HTML captured before any Node.js transforms
|
|
4012
4585
|
const baseHtmlCache = new Map();
|
|
4013
|
-
const puppeteerTotal = puppeteerPaths.length;
|
|
4014
4586
|
|
|
4015
|
-
|
|
4587
|
+
// --- Incremental skip pass (see the incremental-rendering section) ----------
|
|
4588
|
+
// Skips reuse files from the PREVIOUS final output, which still exists while
|
|
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.
|
|
4592
|
+
let inc = null;
|
|
4593
|
+
if (config.incrementalRecord && !config.serve) {
|
|
4594
|
+
// Input capture maps request pathnames to disk via the built-in server's
|
|
4595
|
+
// resolution; an external --local server voids that mapping.
|
|
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
|
+
}
|
|
4603
|
+
let manifestContentHash = 'missing';
|
|
4604
|
+
try { manifestContentHash = sha256Full(readFileSync(join(config.root, 'manifest.json'))); } catch { /* missing */ }
|
|
4605
|
+
// Output-relevant resolved options (CLI included). pathList is in here
|
|
4606
|
+
// because per-page breadcrumb structured data depends on the full set.
|
|
4607
|
+
const optionsSalt = sha256Full(stableStringify({
|
|
4608
|
+
wait: config.wait,
|
|
4609
|
+
waitAfterIdle: config.waitAfterIdle,
|
|
4610
|
+
routerBase: config.routerBase,
|
|
4611
|
+
localeRouteExclude: config.localeRouteExclude,
|
|
4612
|
+
locales: config.locales ?? null,
|
|
4613
|
+
redirects: config.redirects,
|
|
4614
|
+
localeSubstitution: config.localeSubstitution,
|
|
4615
|
+
localeSubstitutionExclude: config.localeSubstitutionExclude,
|
|
4616
|
+
localeSubstitutionExcludePaths: config.localeSubstitutionExcludePaths,
|
|
4617
|
+
paths: config.paths,
|
|
4618
|
+
lazyIcons: config.lazyIcons,
|
|
4619
|
+
seo: config.seo,
|
|
4620
|
+
liveUrl: config.liveUrl,
|
|
4621
|
+
utilitiesBundle: bundleUtilities,
|
|
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,
|
|
4626
|
+
pathList: [...pathList].sort(),
|
|
4627
|
+
}));
|
|
4628
|
+
inc = createIncrementalRun({
|
|
4629
|
+
rootResolved,
|
|
4630
|
+
previousOutput: finalOutput,
|
|
4631
|
+
stagingOutput: outputResolved,
|
|
4632
|
+
globalKey: `${LEDGER_SCHEMA_VERSION}|${rendererPackageIdentity()}|${optionsSalt}|${manifestContentHash}`,
|
|
4633
|
+
deps: {
|
|
4634
|
+
hasPendingVariants: (seg) => variantsByBase.has(seg),
|
|
4635
|
+
promoteVariants: promoteLocaleVariants,
|
|
4636
|
+
isPromotedBase: (seg) => promotedBaseSegs.has(seg),
|
|
4637
|
+
cacheRawHtml: (seg, html) => baseHtmlCache.set(seg, html),
|
|
4638
|
+
getRawHtml: (seg) => baseHtmlCache.get(seg),
|
|
4639
|
+
pushUtilityBlock: (b) => utilityBlocks.push(b),
|
|
4640
|
+
},
|
|
4641
|
+
skipEnabled: config.incremental,
|
|
4642
|
+
});
|
|
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
|
+
|
|
4658
|
+
const puppeteerRenderPaths = inc ? await inc.partition(puppeteerPaths) : puppeteerPaths;
|
|
4659
|
+
let incRendered = puppeteerRenderPaths.length;
|
|
4660
|
+
const puppeteerTotal = puppeteerRenderPaths.length;
|
|
4661
|
+
|
|
4662
|
+
process.stdout.write(`Prerendering ${pathTotal} path(s) (${puppeteerTotal} via Puppeteer, ${localeVariantPaths.length} via substitution${inc ? `, ${inc.state.skipped} skipped unchanged` : ''})...\n`);
|
|
4016
4663
|
|
|
4017
4664
|
// Asset-wide fingerprint used as a cache-invalidator for OG snapshots:
|
|
4018
4665
|
// changes to theme CSS, manifest config, or the root index.html mean every
|
|
@@ -4052,6 +4699,33 @@ async function runPrerender(config) {
|
|
|
4052
4699
|
// live for the whole call.
|
|
4053
4700
|
const page = await browser.newPage();
|
|
4054
4701
|
if (attempt) attempt.cancel = () => page.close();
|
|
4702
|
+
// Incremental input capture: same-origin GETs map to disk files via the
|
|
4703
|
+
// static server's own resolution; anything un-fingerprintable marks the
|
|
4704
|
+
// route non-cacheable (it will always render).
|
|
4705
|
+
let capture = null;
|
|
4706
|
+
if (inc && !inc.state.disabled) {
|
|
4707
|
+
capture = {
|
|
4708
|
+
requested: new Map(), files: new Set(),
|
|
4709
|
+
externalPinned: new Set(), externalValidated: new Map(),
|
|
4710
|
+
awaiting: new Set(), pending: [], nonCacheable: false, reason: null,
|
|
4711
|
+
};
|
|
4712
|
+
const hooks = createCaptureHooks({
|
|
4713
|
+
capture,
|
|
4714
|
+
localOrigin: new URL(config.localUrl).origin,
|
|
4715
|
+
resolveRequest: (pathname) => resolveStaticRequestFile(rootResolved, pathname),
|
|
4716
|
+
isBlocked: blockMatcher,
|
|
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++; });
|
|
4728
|
+
}
|
|
4055
4729
|
// Render at a typical desktop viewport so layouts dependent on viewport
|
|
4056
4730
|
// width (responsive flex/grid, container queries, media queries) settle
|
|
4057
4731
|
// into their desktop variant. Without this the headless default (often
|
|
@@ -4060,6 +4734,13 @@ async function runPrerender(config) {
|
|
|
4060
4734
|
// viewport-driven flex distribution.
|
|
4061
4735
|
await page.setViewport({ width: 1200, height: 800, deviceScaleFactor: 1 });
|
|
4062
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
|
+
}
|
|
4063
4744
|
// Align <html lang> with the URL being prerendered before any app script runs.
|
|
4064
4745
|
// initializeDataSourcesPlugin picks locale from document.documentElement.lang first; a mismatch
|
|
4065
4746
|
// (e.g. headless default vs /en/...) leaves $x.* empty while x-route sections still render.
|
|
@@ -5328,9 +6009,11 @@ async function runPrerender(config) {
|
|
|
5328
6009
|
html = stripDataTailwindAttr(html);
|
|
5329
6010
|
}
|
|
5330
6011
|
html = debakeThemeClass(html);
|
|
6012
|
+
let routeUtilityBlocks = [];
|
|
5331
6013
|
if (bundleUtilities) {
|
|
5332
6014
|
const extracted = extractUtilityStyleBlocks(html);
|
|
5333
6015
|
html = extracted.html;
|
|
6016
|
+
routeUtilityBlocks = extracted.blocks;
|
|
5334
6017
|
for (const b of extracted.blocks) utilityBlocks.push(b);
|
|
5335
6018
|
}
|
|
5336
6019
|
if (tailwindBuilt) {
|
|
@@ -5387,6 +6070,24 @@ async function runPrerender(config) {
|
|
|
5387
6070
|
if (attempt?.abandoned) return; // a retry owns the output now
|
|
5388
6071
|
mkdirSync(outDir, { recursive: true });
|
|
5389
6072
|
writeFileSync(outFile, html, 'utf8');
|
|
6073
|
+
if (capture) {
|
|
6074
|
+
await Promise.allSettled(capture.pending); // drain in-flight body hashes
|
|
6075
|
+
settleCaptureAwaiting(capture); // anything still pending → render
|
|
6076
|
+
const outputs = [relative(config.output, outFile).split(sep).join('/')];
|
|
6077
|
+
if (earlySnapshotUrl) outputs.push(String(earlySnapshotUrl).replace(/^\//, ''));
|
|
6078
|
+
// Node-side head/meta inputs the page never fetches.
|
|
6079
|
+
for (const rel of contentFilesForPrerender(manifest, currentLocale)) capture.files.add(rel);
|
|
6080
|
+
inc.recordRoute(pathSeg, {
|
|
6081
|
+
requested: capture.requested,
|
|
6082
|
+
files: capture.files,
|
|
6083
|
+
externalPinned: capture.externalPinned,
|
|
6084
|
+
externalValidated: capture.externalValidated,
|
|
6085
|
+
nonCacheable: capture.nonCacheable,
|
|
6086
|
+
reason: capture.reason,
|
|
6087
|
+
outputs,
|
|
6088
|
+
utility: routeUtilityBlocks,
|
|
6089
|
+
});
|
|
6090
|
+
}
|
|
5390
6091
|
pushDebug({
|
|
5391
6092
|
path: displayPath,
|
|
5392
6093
|
stage: 'wrote',
|
|
@@ -5436,13 +6137,17 @@ async function runPrerender(config) {
|
|
|
5436
6137
|
}
|
|
5437
6138
|
|
|
5438
6139
|
try {
|
|
5439
|
-
await renderPuppeteerPaths(
|
|
6140
|
+
await renderPuppeteerPaths(puppeteerRenderPaths);
|
|
5440
6141
|
// Any base page found to have per-locale runtime content during the pass
|
|
5441
6142
|
// above gets its locale variants rendered here — real Puppeteer render
|
|
5442
6143
|
// per locale instead of the (incorrect) text substitution.
|
|
5443
6144
|
if (promotedPaths.length > 0) {
|
|
5444
|
-
|
|
5445
|
-
|
|
6145
|
+
const promotedToRender = inc ? await inc.partition(promotedPaths) : promotedPaths;
|
|
6146
|
+
incRendered += promotedToRender.length;
|
|
6147
|
+
if (promotedToRender.length > 0) {
|
|
6148
|
+
process.stdout.write(`prerender: rendering ${promotedToRender.length} promoted locale variant(s) via Puppeteer...\n`);
|
|
6149
|
+
await renderPuppeteerPaths(promotedToRender);
|
|
6150
|
+
}
|
|
5446
6151
|
}
|
|
5447
6152
|
} finally {
|
|
5448
6153
|
await closeBrowser(browser);
|
|
@@ -5490,6 +6195,18 @@ async function runPrerender(config) {
|
|
|
5490
6195
|
throw new Error(`prerender failed for ${failedPaths.length}/${pathTotal} paths. Sample: ${sample}`);
|
|
5491
6196
|
}
|
|
5492
6197
|
|
|
6198
|
+
if (inc) {
|
|
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`);
|
|
6208
|
+
}
|
|
6209
|
+
|
|
5493
6210
|
if (config.debugPrerender) {
|
|
5494
6211
|
const reportPath = join(outputResolved, 'prerender.debug.json');
|
|
5495
6212
|
writeFileSync(reportPath, JSON.stringify({
|
|
@@ -5555,6 +6272,16 @@ async function runPrerender(config) {
|
|
|
5555
6272
|
if (existsSync(finalOutput)) rmSync(finalOutput, { recursive: true });
|
|
5556
6273
|
renameSync(stagingOutput, finalOutput);
|
|
5557
6274
|
config.output = finalOutput;
|
|
6275
|
+
|
|
6276
|
+
// Ledger only after the swap — a run that failed anywhere above never
|
|
6277
|
+
// records anything, so the next run re-renders whatever is uncertain.
|
|
6278
|
+
if (inc) {
|
|
6279
|
+
try {
|
|
6280
|
+
await inc.writeLedger(finalOutput);
|
|
6281
|
+
} catch (err) {
|
|
6282
|
+
process.stderr.write(`prerender: incremental ledger not written (${err?.message || err})\n`);
|
|
6283
|
+
}
|
|
6284
|
+
}
|
|
5558
6285
|
}
|
|
5559
6286
|
|
|
5560
6287
|
// Auto-run main() when this file is the direct entry point (node manifest.render.mjs)
|
|
@@ -5593,4 +6320,20 @@ export {
|
|
|
5593
6320
|
isLocaleSubstitutionExcludedPath,
|
|
5594
6321
|
loadAllLocaleContentData,
|
|
5595
6322
|
buildSubstitutionPairs,
|
|
6323
|
+
LEDGER_SCHEMA_VERSION,
|
|
6324
|
+
stableStringify,
|
|
6325
|
+
computeRouteSignature,
|
|
6326
|
+
evaluateRouteSkip,
|
|
6327
|
+
loadRouteLedger,
|
|
6328
|
+
resolveStaticRequestFile,
|
|
6329
|
+
createExternalRevalidator,
|
|
6330
|
+
contentFilesForPrerender,
|
|
6331
|
+
isSafeRelPath,
|
|
6332
|
+
settleCaptureAwaiting,
|
|
6333
|
+
EXTERNAL_BODY_HASH_LIMIT,
|
|
6334
|
+
resolveIncrementalPolicy,
|
|
6335
|
+
classifyExternalResponse,
|
|
6336
|
+
createCaptureHooks,
|
|
6337
|
+
compileBlockMatcher,
|
|
6338
|
+
TELEMETRY_BLOCK_PATTERNS,
|
|
5596
6339
|
};
|