mnfst-render 0.5.50 → 0.5.51
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 +599 -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; }
|
|
@@ -577,6 +580,9 @@ function resolveConfig() {
|
|
|
577
580
|
paths: Array.isArray(pre.paths)
|
|
578
581
|
? pre.paths.map((p) => String(p).replace(/^\/+|\/+$/g, '')).filter(Boolean)
|
|
579
582
|
: [],
|
|
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),
|
|
580
586
|
dryRun: !!cli.dryRun,
|
|
581
587
|
forceLock: !!cli.forceLock,
|
|
582
588
|
debugPrerender: !!cli.debugPrerender,
|
|
@@ -3422,6 +3428,422 @@ function startStaticServer(rootDir) {
|
|
|
3422
3428
|
});
|
|
3423
3429
|
}
|
|
3424
3430
|
|
|
3431
|
+
// --- Incremental rendering (opt-in) -------------------------------------------
|
|
3432
|
+
// Ledger at <root>/.mnfst-cache/routes.json: per-route input fingerprints from
|
|
3433
|
+
// the last fully successful run. Skipping is only ever an optimization — any
|
|
3434
|
+
// uncertainty (missing file, failed resolution, parse error) falls back to
|
|
3435
|
+
// rendering, never to skipping.
|
|
3436
|
+
|
|
3437
|
+
const LEDGER_SCHEMA_VERSION = 2;
|
|
3438
|
+
|
|
3439
|
+
const sha256Full = (data) => createHash('sha256').update(data).digest('hex');
|
|
3440
|
+
|
|
3441
|
+
/** Deterministic JSON: object keys sorted recursively. */
|
|
3442
|
+
function stableStringify(value) {
|
|
3443
|
+
if (Array.isArray(value)) return '[' + value.map(stableStringify).join(',') + ']';
|
|
3444
|
+
if (value && typeof value === 'object') {
|
|
3445
|
+
return '{' + Object.keys(value).sort().map((k) => JSON.stringify(k) + ':' + stableStringify(value[k])).join(',') + '}';
|
|
3446
|
+
}
|
|
3447
|
+
return value === undefined ? 'null' : JSON.stringify(value);
|
|
3448
|
+
}
|
|
3449
|
+
|
|
3450
|
+
/** Nearest package.json identity — part of every signature so a renderer
|
|
3451
|
+
* upgrade invalidates the whole ledger. A run from the repo tree (not an
|
|
3452
|
+
* installed mnfst-render) also folds in the script's own content hash, since
|
|
3453
|
+
* the repo version doesn't bump per edit. Unidentifiable → never skip. */
|
|
3454
|
+
function rendererPackageIdentity() {
|
|
3455
|
+
let id = 'dev';
|
|
3456
|
+
for (const rel of ['package.json', '../package.json', '../../package.json']) {
|
|
3457
|
+
try {
|
|
3458
|
+
const pkg = JSON.parse(readFileSync(join(__dirname, rel), 'utf8'));
|
|
3459
|
+
if (pkg && pkg.version) { id = `${pkg.name || 'mnfst-render'}@${pkg.version}`; break; }
|
|
3460
|
+
} catch { /* keep looking */ }
|
|
3461
|
+
}
|
|
3462
|
+
if (!id.startsWith('mnfst-render@')) {
|
|
3463
|
+
try { id += '+' + sha256Full(readFileSync(fileURLToPath(import.meta.url))).slice(0, 16); }
|
|
3464
|
+
catch { id += '+' + Date.now(); }
|
|
3465
|
+
}
|
|
3466
|
+
return id;
|
|
3467
|
+
}
|
|
3468
|
+
|
|
3469
|
+
/** Same pathname → disk-file resolution as startStaticServer (incl. the SPA
|
|
3470
|
+
* index.html fallback); null when nothing would serve the path. */
|
|
3471
|
+
function resolveStaticRequestFile(rootDir, pathname) {
|
|
3472
|
+
const rootResolved = resolve(rootDir);
|
|
3473
|
+
const segments = String(pathname || '/').split('/').filter((s) => s && s !== '..');
|
|
3474
|
+
const rel = (p) => relative(rootResolved, p).split(sep).join('/');
|
|
3475
|
+
let resolvedPath;
|
|
3476
|
+
try { resolvedPath = resolve(join(rootResolved, ...segments)); } catch { resolvedPath = null; }
|
|
3477
|
+
if (resolvedPath && resolvedPath.startsWith(rootResolved)) {
|
|
3478
|
+
try {
|
|
3479
|
+
if (existsSync(resolvedPath)) {
|
|
3480
|
+
const st = statSync(resolvedPath);
|
|
3481
|
+
if (!st.isDirectory()) return rel(resolvedPath);
|
|
3482
|
+
const idx = join(resolvedPath, 'index.html');
|
|
3483
|
+
if (existsSync(idx)) return rel(idx);
|
|
3484
|
+
}
|
|
3485
|
+
} catch { /* fall through to the SPA fallback */ }
|
|
3486
|
+
}
|
|
3487
|
+
return existsSync(join(rootResolved, 'index.html')) ? 'index.html' : null;
|
|
3488
|
+
}
|
|
3489
|
+
|
|
3490
|
+
const PINNED_VERSION_RE = /@\d+\.\d+\.\d+/;
|
|
3491
|
+
|
|
3492
|
+
// Bodies above this are never fingerprinted — the route renders instead.
|
|
3493
|
+
const EXTERNAL_BODY_HASH_LIMIT = 8 * 1024 * 1024;
|
|
3494
|
+
|
|
3495
|
+
const externalValidatorToken = (url, v) =>
|
|
3496
|
+
`${url}#etag:${v?.etag || ''}|lm:${v?.lastModified || ''}|ch:${v?.contentHash || ''}`;
|
|
3497
|
+
|
|
3498
|
+
/** Post-drain settlement: an unpinned cross-origin request that never got a
|
|
3499
|
+
* processed response (memory-cache hit, late/unreadable body) is an
|
|
3500
|
+
* UNVERIFIED external dependency — the route must render. */
|
|
3501
|
+
function settleCaptureAwaiting(capture) {
|
|
3502
|
+
if (capture.awaiting && capture.awaiting.size > 0 && !capture.nonCacheable) {
|
|
3503
|
+
capture.nonCacheable = true;
|
|
3504
|
+
capture.reason = `unverified external: ${[...capture.awaiting][0]}`;
|
|
3505
|
+
}
|
|
3506
|
+
return capture;
|
|
3507
|
+
}
|
|
3508
|
+
|
|
3509
|
+
/** Re-check a recorded cross-origin validator. ETag/Last-Modified go out as
|
|
3510
|
+
* a conditional request where only a 304 counts as unchanged; a validator-less
|
|
3511
|
+
* resource was content-addressed at capture, so it is re-downloaded and its
|
|
3512
|
+
* bytes re-hashed. Any other status, a redirect, or a network failure means
|
|
3513
|
+
* render. Memoized per run. */
|
|
3514
|
+
function createExternalRevalidator({ request, download } = {}) {
|
|
3515
|
+
const requestFn = request || (async (url, headers) => {
|
|
3516
|
+
const ctrl = new AbortController();
|
|
3517
|
+
const t = setTimeout(() => ctrl.abort(), 10000);
|
|
3518
|
+
try {
|
|
3519
|
+
const res = await fetch(url, { method: 'GET', headers, redirect: 'manual', signal: ctrl.signal });
|
|
3520
|
+
try { await res.body?.cancel(); } catch { /* no body on 304 */ }
|
|
3521
|
+
return res.status;
|
|
3522
|
+
} finally { clearTimeout(t); }
|
|
3523
|
+
});
|
|
3524
|
+
const downloadFn = download || (async (url) => {
|
|
3525
|
+
const ctrl = new AbortController();
|
|
3526
|
+
const t = setTimeout(() => ctrl.abort(), 20000);
|
|
3527
|
+
try {
|
|
3528
|
+
const res = await fetch(url, { method: 'GET', redirect: 'manual', signal: ctrl.signal });
|
|
3529
|
+
if (res.status < 200 || res.status >= 300) return null;
|
|
3530
|
+
if (Number(res.headers.get('content-length') || 0) > EXTERNAL_BODY_HASH_LIMIT) return null;
|
|
3531
|
+
return Buffer.from(await res.arrayBuffer());
|
|
3532
|
+
} finally { clearTimeout(t); }
|
|
3533
|
+
});
|
|
3534
|
+
const cache = new Map();
|
|
3535
|
+
return (url, validator) => {
|
|
3536
|
+
const key = externalValidatorToken(url, validator);
|
|
3537
|
+
if (!cache.has(key)) {
|
|
3538
|
+
let check;
|
|
3539
|
+
if (validator?.etag || validator?.lastModified) {
|
|
3540
|
+
const headers = validator.etag
|
|
3541
|
+
? { 'If-None-Match': validator.etag }
|
|
3542
|
+
: { 'If-Modified-Since': validator.lastModified };
|
|
3543
|
+
check = Promise.resolve().then(() => requestFn(url, headers)).then((status) => status === 304);
|
|
3544
|
+
} else if (validator?.contentHash) {
|
|
3545
|
+
check = Promise.resolve().then(() => downloadFn(url))
|
|
3546
|
+
.then((body) => !!body && body.length <= EXTERNAL_BODY_HASH_LIMIT
|
|
3547
|
+
&& sha256Full(body) === validator.contentHash);
|
|
3548
|
+
} else {
|
|
3549
|
+
check = Promise.resolve(false);
|
|
3550
|
+
}
|
|
3551
|
+
cache.set(key, check.catch(() => false));
|
|
3552
|
+
}
|
|
3553
|
+
return cache.get(key);
|
|
3554
|
+
};
|
|
3555
|
+
}
|
|
3556
|
+
|
|
3557
|
+
/** relPath acceptable for joining under a base dir. */
|
|
3558
|
+
const isSafeRelPath = (rel) =>
|
|
3559
|
+
typeof rel === 'string' && rel.length > 0 && !rel.includes('\\') && rel[0] !== '/'
|
|
3560
|
+
&& !/^[A-Za-z]:/.test(rel) && !rel.split('/').some((s) => s === '..' || s === '');
|
|
3561
|
+
|
|
3562
|
+
function computeRouteSignature({ globalKey, inputPairs, requestedPairs = [], externalTokens }) {
|
|
3563
|
+
return sha256Full([
|
|
3564
|
+
globalKey,
|
|
3565
|
+
[...inputPairs].sort().join('\n'),
|
|
3566
|
+
[...requestedPairs].sort().join('\n'),
|
|
3567
|
+
[...externalTokens].sort().join('\n'),
|
|
3568
|
+
].join('|'));
|
|
3569
|
+
}
|
|
3570
|
+
|
|
3571
|
+
function routesLedgerPath(rootDir) { return join(rootDir, '.mnfst-cache', 'routes.json'); }
|
|
3572
|
+
|
|
3573
|
+
/** null on absence, parse failure, or schema mismatch — full render. */
|
|
3574
|
+
function loadRouteLedger(rootDir) {
|
|
3575
|
+
try {
|
|
3576
|
+
const parsed = JSON.parse(readFileSync(routesLedgerPath(rootDir), 'utf8'));
|
|
3577
|
+
if (!parsed || parsed.schemaVersion !== LEDGER_SCHEMA_VERSION) return null;
|
|
3578
|
+
if (!parsed.routes || typeof parsed.routes !== 'object') return null;
|
|
3579
|
+
return parsed;
|
|
3580
|
+
} catch { return null; }
|
|
3581
|
+
}
|
|
3582
|
+
|
|
3583
|
+
function rawHtmlCachePath(rootDir, routeKey) {
|
|
3584
|
+
return join(rootDir, '.mnfst-cache', 'render-html', `${sha256Full(String(routeKey)).slice(0, 24)}.html.gz`);
|
|
3585
|
+
}
|
|
3586
|
+
|
|
3587
|
+
/** Disk files loadContentForPrerender reads for `locale` — Node-side inputs
|
|
3588
|
+
* (head meta / x-binding resolution) that the page itself never fetches. */
|
|
3589
|
+
function contentFilesForPrerender(manifest, locale) {
|
|
3590
|
+
const data = manifest?.data?.content;
|
|
3591
|
+
if (!data) return [];
|
|
3592
|
+
const loc = locale || 'en';
|
|
3593
|
+
const strip = (p) => String(p).replace(/^\/+/, '');
|
|
3594
|
+
if (typeof data === 'string' && data.endsWith('.csv')) return [strip(data)];
|
|
3595
|
+
if (data && typeof data === 'object' && typeof data.locales === 'string') return [strip(data.locales)];
|
|
3596
|
+
if (data && typeof data === 'object' && !Array.isArray(data)) {
|
|
3597
|
+
const filePath = data[loc] || data[Object.keys(data)[0]];
|
|
3598
|
+
if (typeof filePath === 'string') return [strip(filePath)];
|
|
3599
|
+
}
|
|
3600
|
+
return [];
|
|
3601
|
+
}
|
|
3602
|
+
|
|
3603
|
+
/**
|
|
3604
|
+
* Per-route skip decision. Skip requires: a well-formed entry, every recorded
|
|
3605
|
+
* request pathname still resolving to the same file (a new physical file can
|
|
3606
|
+
* shadow a prior SPA-fallback resolution), every recorded input re-hashing to
|
|
3607
|
+
* its recorded value NOW, the recomputed signature matching (config/version/
|
|
3608
|
+
* manifest/externals), every recorded output byte-identical in the previous
|
|
3609
|
+
* final output (closes the edit→render-elsewhere→revert hole), and every
|
|
3610
|
+
* cross-origin validator answering 304 to a conditional request NOW.
|
|
3611
|
+
* Dependency-injected for unit tests.
|
|
3612
|
+
*/
|
|
3613
|
+
async function evaluateRouteSkip({ entry, hashInput, resolveRequest, revalidateExternal, globalKey, outputHash }) {
|
|
3614
|
+
if (!entry || typeof entry !== 'object') return { skip: false, reason: 'no ledger entry' };
|
|
3615
|
+
if (entry.nonCacheable) return { skip: false, reason: 'non-cacheable' };
|
|
3616
|
+
if (typeof entry.sig !== 'string' || !entry.inputs || typeof entry.inputs !== 'object'
|
|
3617
|
+
|| !entry.requested || typeof entry.requested !== 'object'
|
|
3618
|
+
|| !Array.isArray(entry.external)
|
|
3619
|
+
|| !entry.externalValidated || typeof entry.externalValidated !== 'object'
|
|
3620
|
+
|| !entry.outputs || typeof entry.outputs !== 'object'
|
|
3621
|
+
|| Object.keys(entry.outputs).length === 0) {
|
|
3622
|
+
return { skip: false, reason: 'malformed entry' };
|
|
3623
|
+
}
|
|
3624
|
+
const requestedPairs = [];
|
|
3625
|
+
for (const [pathname, relPath] of Object.entries(entry.requested)) {
|
|
3626
|
+
if (!isSafeRelPath(relPath)) return { skip: false, reason: `unsafe path in entry: ${relPath}` };
|
|
3627
|
+
if (resolveRequest(pathname) !== relPath) return { skip: false, reason: `resolution changed: ${pathname}` };
|
|
3628
|
+
requestedPairs.push(`${pathname}=>${relPath}`);
|
|
3629
|
+
}
|
|
3630
|
+
const inputPairs = [];
|
|
3631
|
+
for (const [relPath, recorded] of Object.entries(entry.inputs)) {
|
|
3632
|
+
if (!isSafeRelPath(relPath)) return { skip: false, reason: `unsafe path in entry: ${relPath}` };
|
|
3633
|
+
const now = hashInput(relPath);
|
|
3634
|
+
if (now !== recorded) return { skip: false, reason: `input changed: ${relPath}` };
|
|
3635
|
+
inputPairs.push(`${relPath}:${now}`);
|
|
3636
|
+
}
|
|
3637
|
+
const externalTokens = [
|
|
3638
|
+
...entry.external,
|
|
3639
|
+
...Object.entries(entry.externalValidated).map(([u, v]) => externalValidatorToken(u, v)),
|
|
3640
|
+
];
|
|
3641
|
+
if (computeRouteSignature({ globalKey, inputPairs, requestedPairs, externalTokens }) !== entry.sig) {
|
|
3642
|
+
return { skip: false, reason: 'signature mismatch' };
|
|
3643
|
+
}
|
|
3644
|
+
for (const [relPath, recorded] of Object.entries(entry.outputs)) {
|
|
3645
|
+
if (!isSafeRelPath(relPath)) return { skip: false, reason: `unsafe path in entry: ${relPath}` };
|
|
3646
|
+
if (outputHash(relPath) !== recorded) return { skip: false, reason: `previous output missing or changed: ${relPath}` };
|
|
3647
|
+
}
|
|
3648
|
+
// Network last: everything local must already agree.
|
|
3649
|
+
for (const [url, validator] of Object.entries(entry.externalValidated)) {
|
|
3650
|
+
if (!(await revalidateExternal(url, validator))) return { skip: false, reason: `external changed: ${url}` };
|
|
3651
|
+
}
|
|
3652
|
+
return { skip: true };
|
|
3653
|
+
}
|
|
3654
|
+
|
|
3655
|
+
/**
|
|
3656
|
+
* Run-scoped incremental state: ledger, memoized hashing/resolution, the skip
|
|
3657
|
+
* pass, per-route capture records, and the end-of-run ledger write.
|
|
3658
|
+
*/
|
|
3659
|
+
function createIncrementalRun({ rootResolved, previousOutput, stagingOutput, globalKey, deps }) {
|
|
3660
|
+
const ledger = loadRouteLedger(rootResolved);
|
|
3661
|
+
const revalidate = createExternalRevalidator();
|
|
3662
|
+
const hashFileAt = (base, relPath) => {
|
|
3663
|
+
try { return sha256Full(readFileSync(join(base, relPath))); } catch { return null; }
|
|
3664
|
+
};
|
|
3665
|
+
// 'absent' is a value, not a miss: a recorded-absent file that stays absent
|
|
3666
|
+
// matches, one that appears mismatches.
|
|
3667
|
+
const skipHashes = new Map();
|
|
3668
|
+
const hashInput = (relPath) => {
|
|
3669
|
+
if (!skipHashes.has(relPath)) skipHashes.set(relPath, hashFileAt(rootResolved, relPath) ?? 'absent');
|
|
3670
|
+
return skipHashes.get(relPath);
|
|
3671
|
+
};
|
|
3672
|
+
const captures = new Map(); // routeKey → capture record of a route rendered this run
|
|
3673
|
+
const carried = new Map(); // routeKey → previous entry revalidated and reused this run
|
|
3674
|
+
const state = { disabled: false, skipped: 0, reasons: {} };
|
|
3675
|
+
const disable = (reason) => {
|
|
3676
|
+
if (state.disabled) return;
|
|
3677
|
+
state.disabled = true;
|
|
3678
|
+
process.stdout.write(`prerender: incremental disabled for this run (${reason}) — rendering everything\n`);
|
|
3679
|
+
};
|
|
3680
|
+
// Why-rendered buckets for the run summary.
|
|
3681
|
+
const noteRender = (reason, entry) => {
|
|
3682
|
+
const r = String(reason || '');
|
|
3683
|
+
let bucket;
|
|
3684
|
+
if (r === 'non-cacheable') {
|
|
3685
|
+
const detail = String(entry?.reason || '').replace(/https?:\/\/\S+/g, '').replace(/\s+/g, ' ').trim().replace(/[:\s]+$/, '');
|
|
3686
|
+
bucket = detail ? `non-cacheable: ${detail}` : 'non-cacheable';
|
|
3687
|
+
} else if (r.startsWith('no ledger entry')) bucket = 'new';
|
|
3688
|
+
else if (r.startsWith('input ')) bucket = 'changed inputs';
|
|
3689
|
+
else if (r.startsWith('resolution changed')) bucket = 'changed resolution';
|
|
3690
|
+
else if (r.startsWith('external changed')) bucket = 'changed externals';
|
|
3691
|
+
else if (r.startsWith('signature mismatch')) bucket = 'changed signature';
|
|
3692
|
+
else if (r.startsWith('previous output')) bucket = 'previous output missing/changed';
|
|
3693
|
+
else bucket = r.slice(0, 48) || 'unknown';
|
|
3694
|
+
state.reasons[bucket] = (state.reasons[bucket] || 0) + 1;
|
|
3695
|
+
};
|
|
3696
|
+
|
|
3697
|
+
return {
|
|
3698
|
+
state,
|
|
3699
|
+
captures,
|
|
3700
|
+
recordRoute(routeKey, record) { captures.set(routeKey, record); },
|
|
3701
|
+
|
|
3702
|
+
/** Skip pass: returns the subset of `paths` that must render; copies the
|
|
3703
|
+
* previous outputs of the rest into staging and carries their entries. */
|
|
3704
|
+
async partition(paths) {
|
|
3705
|
+
if (!ledger) {
|
|
3706
|
+
for (let i = 0; i < paths.length; i++) noteRender('no ledger entry');
|
|
3707
|
+
return paths;
|
|
3708
|
+
}
|
|
3709
|
+
const toRender = [];
|
|
3710
|
+
for (const seg of paths) {
|
|
3711
|
+
if (state.disabled) { noteRender('incremental disabled'); toRender.push(seg); continue; }
|
|
3712
|
+
const entry = ledger.routes[seg];
|
|
3713
|
+
let verdict;
|
|
3714
|
+
try {
|
|
3715
|
+
verdict = await evaluateRouteSkip({
|
|
3716
|
+
entry, hashInput, globalKey,
|
|
3717
|
+
resolveRequest: (pathname) => resolveStaticRequestFile(rootResolved, pathname),
|
|
3718
|
+
revalidateExternal: revalidate,
|
|
3719
|
+
outputHash: (rel) => hashFileAt(previousOutput, rel),
|
|
3720
|
+
});
|
|
3721
|
+
} catch (err) {
|
|
3722
|
+
disable(`skip evaluation failed: ${err?.message || err}`);
|
|
3723
|
+
noteRender('incremental disabled');
|
|
3724
|
+
toRender.push(seg);
|
|
3725
|
+
continue;
|
|
3726
|
+
}
|
|
3727
|
+
// A base whose locale variants substitute needs its cached raw DOM
|
|
3728
|
+
// snapshot back; absent or unreadable → render the base.
|
|
3729
|
+
let rawHtml = null;
|
|
3730
|
+
if (verdict.skip && !entry.promoted && deps.hasPendingVariants(seg)) {
|
|
3731
|
+
try { rawHtml = gunzipSync(readFileSync(rawHtmlCachePath(rootResolved, seg))).toString('utf8'); }
|
|
3732
|
+
catch { rawHtml = null; }
|
|
3733
|
+
if (!rawHtml) verdict = { skip: false, reason: 'raw snapshot missing' };
|
|
3734
|
+
}
|
|
3735
|
+
if (verdict.skip) {
|
|
3736
|
+
try {
|
|
3737
|
+
for (const rel of Object.keys(entry.outputs)) {
|
|
3738
|
+
const dest = join(stagingOutput, rel);
|
|
3739
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
3740
|
+
cpSync(join(previousOutput, rel), dest);
|
|
3741
|
+
}
|
|
3742
|
+
} catch (err) {
|
|
3743
|
+
verdict = { skip: false, reason: `copy failed: ${err?.message || err}` };
|
|
3744
|
+
}
|
|
3745
|
+
}
|
|
3746
|
+
if (!verdict.skip) { noteRender(verdict.reason, entry); toRender.push(seg); continue; }
|
|
3747
|
+
if (entry.promoted) deps.promoteVariants(seg);
|
|
3748
|
+
else if (rawHtml) deps.cacheRawHtml(seg, rawHtml);
|
|
3749
|
+
if (Array.isArray(entry.utility)) for (const b of entry.utility) deps.pushUtilityBlock(b);
|
|
3750
|
+
carried.set(seg, entry);
|
|
3751
|
+
state.skipped++;
|
|
3752
|
+
process.stdout.write(` [skip] ${seg === '' ? '/' : '/' + seg} (unchanged)\n`);
|
|
3753
|
+
}
|
|
3754
|
+
return toRender;
|
|
3755
|
+
},
|
|
3756
|
+
|
|
3757
|
+
/** Written atomically, only after the staged output replaced the previous
|
|
3758
|
+
* one — covers exactly this run's skipped + successfully rendered routes.
|
|
3759
|
+
* Inputs are re-hashed HERE, not from the skip pass's memo, so a mid-run
|
|
3760
|
+
* edit is recorded as it is now and a later revert can't skip-match. */
|
|
3761
|
+
async writeLedger(finalOutput) {
|
|
3762
|
+
if (state.disabled) return false;
|
|
3763
|
+
const writeHashes = new Map();
|
|
3764
|
+
const hashNow = (rel) => {
|
|
3765
|
+
if (!writeHashes.has(rel)) writeHashes.set(rel, hashFileAt(rootResolved, rel) ?? 'absent');
|
|
3766
|
+
return writeHashes.get(rel);
|
|
3767
|
+
};
|
|
3768
|
+
const routes = {};
|
|
3769
|
+
for (const [key, entry] of carried) routes[key] = entry;
|
|
3770
|
+
for (const [key, cap] of captures) {
|
|
3771
|
+
if (cap.nonCacheable) { routes[key] = { nonCacheable: true, reason: cap.reason }; continue; }
|
|
3772
|
+
const requested = {};
|
|
3773
|
+
const requestedPairs = [];
|
|
3774
|
+
for (const pathname of [...cap.requested.keys()].sort()) {
|
|
3775
|
+
const rel = cap.requested.get(pathname);
|
|
3776
|
+
requested[pathname] = rel;
|
|
3777
|
+
requestedPairs.push(`${pathname}=>${rel}`);
|
|
3778
|
+
}
|
|
3779
|
+
const inputs = {};
|
|
3780
|
+
const inputPairs = [];
|
|
3781
|
+
for (const rel of [...cap.files].sort()) {
|
|
3782
|
+
const h = hashNow(rel);
|
|
3783
|
+
inputs[rel] = h;
|
|
3784
|
+
inputPairs.push(`${rel}:${h}`);
|
|
3785
|
+
}
|
|
3786
|
+
const outputs = {};
|
|
3787
|
+
let missingOut = null;
|
|
3788
|
+
for (const rel of cap.outputs) {
|
|
3789
|
+
const h = hashFileAt(finalOutput, rel);
|
|
3790
|
+
if (!h) { missingOut = rel; break; }
|
|
3791
|
+
outputs[rel] = h;
|
|
3792
|
+
}
|
|
3793
|
+
if (missingOut) { routes[key] = { nonCacheable: true, reason: `output unreadable: ${missingOut}` }; continue; }
|
|
3794
|
+
const external = [...cap.externalPinned].sort();
|
|
3795
|
+
const externalValidated = {};
|
|
3796
|
+
const externalTokens = [...external];
|
|
3797
|
+
for (const url of [...cap.externalValidated.keys()].sort()) {
|
|
3798
|
+
const v = cap.externalValidated.get(url);
|
|
3799
|
+
externalValidated[url] = v;
|
|
3800
|
+
externalTokens.push(externalValidatorToken(url, v));
|
|
3801
|
+
}
|
|
3802
|
+
const entry = {
|
|
3803
|
+
sig: computeRouteSignature({ globalKey, inputPairs, requestedPairs, externalTokens }),
|
|
3804
|
+
inputs, requested, external, externalValidated, outputs,
|
|
3805
|
+
};
|
|
3806
|
+
if (Array.isArray(cap.utility) && cap.utility.length) entry.utility = cap.utility;
|
|
3807
|
+
if (deps.isPromotedBase(key)) {
|
|
3808
|
+
entry.promoted = true;
|
|
3809
|
+
} else if (deps.hasPendingVariants(key)) {
|
|
3810
|
+
const raw = deps.getRawHtml(key);
|
|
3811
|
+
if (!raw) { routes[key] = { nonCacheable: true, reason: 'raw snapshot unavailable' }; continue; }
|
|
3812
|
+
const p = rawHtmlCachePath(rootResolved, key);
|
|
3813
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
3814
|
+
writeFileSync(p, gzipSync(Buffer.from(raw, 'utf8')));
|
|
3815
|
+
}
|
|
3816
|
+
routes[key] = entry;
|
|
3817
|
+
}
|
|
3818
|
+
const target = routesLedgerPath(rootResolved);
|
|
3819
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
3820
|
+
const tmp = `${target}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
|
|
3821
|
+
writeFileSync(tmp, JSON.stringify({
|
|
3822
|
+
schemaVersion: LEDGER_SCHEMA_VERSION,
|
|
3823
|
+
createdAt: new Date().toISOString(),
|
|
3824
|
+
routes,
|
|
3825
|
+
}) + '\n', 'utf8');
|
|
3826
|
+
renameSync(tmp, target);
|
|
3827
|
+
// GC: snapshots no route references, and orphaned ledger tmp files.
|
|
3828
|
+
try {
|
|
3829
|
+
const keep = new Set(Object.keys(routes).map((k) => basename(rawHtmlCachePath(rootResolved, k))));
|
|
3830
|
+
const snapDir = join(rootResolved, '.mnfst-cache', 'render-html');
|
|
3831
|
+
if (existsSync(snapDir)) {
|
|
3832
|
+
for (const f of readdirSync(snapDir)) {
|
|
3833
|
+
if (!keep.has(f)) { try { unlinkSync(join(snapDir, f)); } catch { /* in use elsewhere */ } }
|
|
3834
|
+
}
|
|
3835
|
+
}
|
|
3836
|
+
for (const f of readdirSync(join(rootResolved, '.mnfst-cache'))) {
|
|
3837
|
+
if (f.startsWith('routes.json.') && f.endsWith('.tmp')) {
|
|
3838
|
+
try { unlinkSync(join(rootResolved, '.mnfst-cache', f)); } catch { /* best-effort */ }
|
|
3839
|
+
}
|
|
3840
|
+
}
|
|
3841
|
+
} catch { /* GC is best-effort */ }
|
|
3842
|
+
return true;
|
|
3843
|
+
},
|
|
3844
|
+
};
|
|
3845
|
+
}
|
|
3846
|
+
|
|
3425
3847
|
// --- Output lock (one render per output dir) ---------------------------------
|
|
3426
3848
|
|
|
3427
3849
|
const LOCK_SUFFIX = '.mnfst-lock';
|
|
@@ -3980,7 +4402,11 @@ async function runPrerender(config) {
|
|
|
3980
4402
|
* segment). When it does, every locale variant of this base is moved from
|
|
3981
4403
|
* substitution to a real per-locale Puppeteer render.
|
|
3982
4404
|
*/
|
|
4405
|
+
// Bases whose locale variants were promoted this run — persisted in the
|
|
4406
|
+
// route ledger so a future skipped run re-promotes instead of substituting.
|
|
4407
|
+
const promotedBaseSegs = new Set();
|
|
3983
4408
|
function promoteLocaleVariants(basePathSeg) {
|
|
4409
|
+
promotedBaseSegs.add(basePathSeg);
|
|
3984
4410
|
const variants = variantsByBase.get(basePathSeg);
|
|
3985
4411
|
if (!variants || !variants.length) return;
|
|
3986
4412
|
process.stdout.write(
|
|
@@ -4010,9 +4436,58 @@ async function runPrerender(config) {
|
|
|
4010
4436
|
|
|
4011
4437
|
// baseHtmlCache: base path segment → raw DOM HTML captured before any Node.js transforms
|
|
4012
4438
|
const baseHtmlCache = new Map();
|
|
4013
|
-
const puppeteerTotal = puppeteerPaths.length;
|
|
4014
4439
|
|
|
4015
|
-
|
|
4440
|
+
// --- Incremental skip pass (opt-in; see the incremental-rendering section) --
|
|
4441
|
+
// 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.
|
|
4443
|
+
let inc = null;
|
|
4444
|
+
if (config.incremental && !config.serve) {
|
|
4445
|
+
// Input capture maps request pathnames to disk via the built-in server's
|
|
4446
|
+
// 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) {
|
|
4449
|
+
let manifestContentHash = 'missing';
|
|
4450
|
+
try { manifestContentHash = sha256Full(readFileSync(join(config.root, 'manifest.json'))); } catch { /* missing */ }
|
|
4451
|
+
// Output-relevant resolved options (CLI included). pathList is in here
|
|
4452
|
+
// because per-page breadcrumb structured data depends on the full set.
|
|
4453
|
+
const optionsSalt = sha256Full(stableStringify({
|
|
4454
|
+
wait: config.wait,
|
|
4455
|
+
waitAfterIdle: config.waitAfterIdle,
|
|
4456
|
+
routerBase: config.routerBase,
|
|
4457
|
+
localeRouteExclude: config.localeRouteExclude,
|
|
4458
|
+
locales: config.locales ?? null,
|
|
4459
|
+
redirects: config.redirects,
|
|
4460
|
+
localeSubstitution: config.localeSubstitution,
|
|
4461
|
+
localeSubstitutionExclude: config.localeSubstitutionExclude,
|
|
4462
|
+
localeSubstitutionExcludePaths: config.localeSubstitutionExcludePaths,
|
|
4463
|
+
paths: config.paths,
|
|
4464
|
+
lazyIcons: config.lazyIcons,
|
|
4465
|
+
seo: config.seo,
|
|
4466
|
+
liveUrl: config.liveUrl,
|
|
4467
|
+
utilitiesBundle: bundleUtilities,
|
|
4468
|
+
tailwindBuilt,
|
|
4469
|
+
pathList: [...pathList].sort(),
|
|
4470
|
+
}));
|
|
4471
|
+
inc = createIncrementalRun({
|
|
4472
|
+
rootResolved,
|
|
4473
|
+
previousOutput: finalOutput,
|
|
4474
|
+
stagingOutput: outputResolved,
|
|
4475
|
+
globalKey: `${LEDGER_SCHEMA_VERSION}|${rendererPackageIdentity()}|${optionsSalt}|${manifestContentHash}`,
|
|
4476
|
+
deps: {
|
|
4477
|
+
hasPendingVariants: (seg) => variantsByBase.has(seg),
|
|
4478
|
+
promoteVariants: promoteLocaleVariants,
|
|
4479
|
+
isPromotedBase: (seg) => promotedBaseSegs.has(seg),
|
|
4480
|
+
cacheRawHtml: (seg, html) => baseHtmlCache.set(seg, html),
|
|
4481
|
+
getRawHtml: (seg) => baseHtmlCache.get(seg),
|
|
4482
|
+
pushUtilityBlock: (b) => utilityBlocks.push(b),
|
|
4483
|
+
},
|
|
4484
|
+
});
|
|
4485
|
+
}
|
|
4486
|
+
const puppeteerRenderPaths = inc ? await inc.partition(puppeteerPaths) : puppeteerPaths;
|
|
4487
|
+
let incRendered = puppeteerRenderPaths.length;
|
|
4488
|
+
const puppeteerTotal = puppeteerRenderPaths.length;
|
|
4489
|
+
|
|
4490
|
+
process.stdout.write(`Prerendering ${pathTotal} path(s) (${puppeteerTotal} via Puppeteer, ${localeVariantPaths.length} via substitution${inc ? `, ${inc.state.skipped} skipped unchanged` : ''})...\n`);
|
|
4016
4491
|
|
|
4017
4492
|
// Asset-wide fingerprint used as a cache-invalidator for OG snapshots:
|
|
4018
4493
|
// changes to theme CSS, manifest config, or the root index.html mean every
|
|
@@ -4052,6 +4527,75 @@ async function runPrerender(config) {
|
|
|
4052
4527
|
// live for the whole call.
|
|
4053
4528
|
const page = await browser.newPage();
|
|
4054
4529
|
if (attempt) attempt.cancel = () => page.close();
|
|
4530
|
+
// Incremental input capture: same-origin GETs map to disk files via the
|
|
4531
|
+
// static server's own resolution; anything un-fingerprintable marks the
|
|
4532
|
+
// route non-cacheable (it will always render).
|
|
4533
|
+
let capture = null;
|
|
4534
|
+
if (inc && !inc.state.disabled) {
|
|
4535
|
+
capture = {
|
|
4536
|
+
requested: new Map(), files: new Set(),
|
|
4537
|
+
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; }
|
|
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'); }
|
|
4597
|
+
});
|
|
4598
|
+
}
|
|
4055
4599
|
// Render at a typical desktop viewport so layouts dependent on viewport
|
|
4056
4600
|
// width (responsive flex/grid, container queries, media queries) settle
|
|
4057
4601
|
// into their desktop variant. Without this the headless default (often
|
|
@@ -5328,9 +5872,11 @@ async function runPrerender(config) {
|
|
|
5328
5872
|
html = stripDataTailwindAttr(html);
|
|
5329
5873
|
}
|
|
5330
5874
|
html = debakeThemeClass(html);
|
|
5875
|
+
let routeUtilityBlocks = [];
|
|
5331
5876
|
if (bundleUtilities) {
|
|
5332
5877
|
const extracted = extractUtilityStyleBlocks(html);
|
|
5333
5878
|
html = extracted.html;
|
|
5879
|
+
routeUtilityBlocks = extracted.blocks;
|
|
5334
5880
|
for (const b of extracted.blocks) utilityBlocks.push(b);
|
|
5335
5881
|
}
|
|
5336
5882
|
if (tailwindBuilt) {
|
|
@@ -5387,6 +5933,24 @@ async function runPrerender(config) {
|
|
|
5387
5933
|
if (attempt?.abandoned) return; // a retry owns the output now
|
|
5388
5934
|
mkdirSync(outDir, { recursive: true });
|
|
5389
5935
|
writeFileSync(outFile, html, 'utf8');
|
|
5936
|
+
if (capture) {
|
|
5937
|
+
await Promise.allSettled(capture.pending); // drain in-flight body hashes
|
|
5938
|
+
settleCaptureAwaiting(capture); // anything still pending → render
|
|
5939
|
+
const outputs = [relative(config.output, outFile).split(sep).join('/')];
|
|
5940
|
+
if (earlySnapshotUrl) outputs.push(String(earlySnapshotUrl).replace(/^\//, ''));
|
|
5941
|
+
// Node-side head/meta inputs the page never fetches.
|
|
5942
|
+
for (const rel of contentFilesForPrerender(manifest, currentLocale)) capture.files.add(rel);
|
|
5943
|
+
inc.recordRoute(pathSeg, {
|
|
5944
|
+
requested: capture.requested,
|
|
5945
|
+
files: capture.files,
|
|
5946
|
+
externalPinned: capture.externalPinned,
|
|
5947
|
+
externalValidated: capture.externalValidated,
|
|
5948
|
+
nonCacheable: capture.nonCacheable,
|
|
5949
|
+
reason: capture.reason,
|
|
5950
|
+
outputs,
|
|
5951
|
+
utility: routeUtilityBlocks,
|
|
5952
|
+
});
|
|
5953
|
+
}
|
|
5390
5954
|
pushDebug({
|
|
5391
5955
|
path: displayPath,
|
|
5392
5956
|
stage: 'wrote',
|
|
@@ -5436,13 +6000,17 @@ async function runPrerender(config) {
|
|
|
5436
6000
|
}
|
|
5437
6001
|
|
|
5438
6002
|
try {
|
|
5439
|
-
await renderPuppeteerPaths(
|
|
6003
|
+
await renderPuppeteerPaths(puppeteerRenderPaths);
|
|
5440
6004
|
// Any base page found to have per-locale runtime content during the pass
|
|
5441
6005
|
// above gets its locale variants rendered here — real Puppeteer render
|
|
5442
6006
|
// per locale instead of the (incorrect) text substitution.
|
|
5443
6007
|
if (promotedPaths.length > 0) {
|
|
5444
|
-
|
|
5445
|
-
|
|
6008
|
+
const promotedToRender = inc ? await inc.partition(promotedPaths) : promotedPaths;
|
|
6009
|
+
incRendered += promotedToRender.length;
|
|
6010
|
+
if (promotedToRender.length > 0) {
|
|
6011
|
+
process.stdout.write(`prerender: rendering ${promotedToRender.length} promoted locale variant(s) via Puppeteer...\n`);
|
|
6012
|
+
await renderPuppeteerPaths(promotedToRender);
|
|
6013
|
+
}
|
|
5446
6014
|
}
|
|
5447
6015
|
} finally {
|
|
5448
6016
|
await closeBrowser(browser);
|
|
@@ -5490,6 +6058,11 @@ async function runPrerender(config) {
|
|
|
5490
6058
|
throw new Error(`prerender failed for ${failedPaths.length}/${pathTotal} paths. Sample: ${sample}`);
|
|
5491
6059
|
}
|
|
5492
6060
|
|
|
6061
|
+
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`);
|
|
6064
|
+
}
|
|
6065
|
+
|
|
5493
6066
|
if (config.debugPrerender) {
|
|
5494
6067
|
const reportPath = join(outputResolved, 'prerender.debug.json');
|
|
5495
6068
|
writeFileSync(reportPath, JSON.stringify({
|
|
@@ -5555,6 +6128,16 @@ async function runPrerender(config) {
|
|
|
5555
6128
|
if (existsSync(finalOutput)) rmSync(finalOutput, { recursive: true });
|
|
5556
6129
|
renameSync(stagingOutput, finalOutput);
|
|
5557
6130
|
config.output = finalOutput;
|
|
6131
|
+
|
|
6132
|
+
// Ledger only after the swap — a run that failed anywhere above never
|
|
6133
|
+
// records anything, so the next run re-renders whatever is uncertain.
|
|
6134
|
+
if (inc) {
|
|
6135
|
+
try {
|
|
6136
|
+
await inc.writeLedger(finalOutput);
|
|
6137
|
+
} catch (err) {
|
|
6138
|
+
process.stderr.write(`prerender: incremental ledger not written (${err?.message || err})\n`);
|
|
6139
|
+
}
|
|
6140
|
+
}
|
|
5558
6141
|
}
|
|
5559
6142
|
|
|
5560
6143
|
// Auto-run main() when this file is the direct entry point (node manifest.render.mjs)
|
|
@@ -5593,4 +6176,15 @@ export {
|
|
|
5593
6176
|
isLocaleSubstitutionExcludedPath,
|
|
5594
6177
|
loadAllLocaleContentData,
|
|
5595
6178
|
buildSubstitutionPairs,
|
|
6179
|
+
LEDGER_SCHEMA_VERSION,
|
|
6180
|
+
stableStringify,
|
|
6181
|
+
computeRouteSignature,
|
|
6182
|
+
evaluateRouteSkip,
|
|
6183
|
+
loadRouteLedger,
|
|
6184
|
+
resolveStaticRequestFile,
|
|
6185
|
+
createExternalRevalidator,
|
|
6186
|
+
contentFilesForPrerender,
|
|
6187
|
+
isSafeRelPath,
|
|
6188
|
+
settleCaptureAwaiting,
|
|
6189
|
+
EXTERNAL_BODY_HASH_LIMIT,
|
|
5596
6190
|
};
|