mnfst-render 0.5.37 → 0.5.39
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 +260 -123
- package/package.json +1 -1
package/manifest.render.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
/* Manifest Render */
|
|
4
4
|
|
|
5
|
-
import { readFileSync, readSync, mkdirSync, writeFileSync, existsSync, rmSync, statSync, readdirSync, cpSync, unlinkSync, renameSync } from 'node:fs';
|
|
5
|
+
import { readFileSync, readSync, mkdirSync, writeFileSync, appendFileSync, existsSync, rmSync, statSync, readdirSync, cpSync, unlinkSync, renameSync } from 'node:fs';
|
|
6
6
|
import { spawnSync } from 'node:child_process';
|
|
7
7
|
import { join, resolve, dirname, relative, basename, sep } from 'node:path';
|
|
8
8
|
import { createServer } from 'node:http';
|
|
@@ -189,6 +189,96 @@ async function waitForManifestRenderReady(page, { allLocales, currentLocale, tim
|
|
|
189
189
|
// intentional and benign — the DOM is still captured.
|
|
190
190
|
}
|
|
191
191
|
|
|
192
|
+
// --- Browser recycle gate ----------------------------------------------------
|
|
193
|
+
|
|
194
|
+
/** Bound a promise: resolves true if it settles first, false on timeout. */
|
|
195
|
+
function withDeadline(promise, ms) {
|
|
196
|
+
if (!(ms > 0)) return Promise.resolve(promise).then(() => true);
|
|
197
|
+
let timer;
|
|
198
|
+
const settled = Promise.resolve(promise).then(
|
|
199
|
+
() => { clearTimeout(timer); return true; },
|
|
200
|
+
(err) => { clearTimeout(timer); throw err; }
|
|
201
|
+
);
|
|
202
|
+
return Promise.race([
|
|
203
|
+
settled,
|
|
204
|
+
new Promise((resolve) => {
|
|
205
|
+
timer = setTimeout(() => resolve(false), ms);
|
|
206
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
207
|
+
}),
|
|
208
|
+
]);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Coordinates N render workers around a shared browser that is swapped out
|
|
213
|
+
* every `every` pages. Workers hold a slot (acquire/release) for exactly as
|
|
214
|
+
* long as they touch the browser; a recycle pauses new slots first, then
|
|
215
|
+
* drains the in-flight ones, so no worker can ever be blocked on the gate
|
|
216
|
+
* while it is still counted as active. Every wait is bounded: a stuck page
|
|
217
|
+
* or a stuck browser swap logs and continues instead of hanging the run.
|
|
218
|
+
* Exported for unit tests.
|
|
219
|
+
*/
|
|
220
|
+
export function createRecycleGate({
|
|
221
|
+
every = 40,
|
|
222
|
+
recycle = async () => {},
|
|
223
|
+
drainTimeoutMs = 60000,
|
|
224
|
+
recycleTimeoutMs = 180000,
|
|
225
|
+
onLog = () => {},
|
|
226
|
+
} = {}) {
|
|
227
|
+
let active = 0;
|
|
228
|
+
let pages = 0;
|
|
229
|
+
let paused = false;
|
|
230
|
+
const zeroWaiters = [];
|
|
231
|
+
const resumeWaiters = [];
|
|
232
|
+
const wake = (list) => { for (const resolve of list.splice(0)) resolve(); };
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
get active() { return active; },
|
|
236
|
+
get pages() { return pages; },
|
|
237
|
+
get paused() { return paused; },
|
|
238
|
+
/** Claim a browser slot; blocks while a recycle is pending or running. */
|
|
239
|
+
async acquire() {
|
|
240
|
+
while (paused) await new Promise((resolve) => { resumeWaiters.push(resolve); });
|
|
241
|
+
active++;
|
|
242
|
+
},
|
|
243
|
+
release() {
|
|
244
|
+
active = Math.max(0, active - 1);
|
|
245
|
+
if (active === 0) wake(zeroWaiters);
|
|
246
|
+
},
|
|
247
|
+
/** Count a page against the recycle threshold. */
|
|
248
|
+
countPage() { pages++; },
|
|
249
|
+
/** Make the next maybeRecycle() fire (unstable browser after retries). */
|
|
250
|
+
requestRecycle() { if (every > 0) pages = Math.max(pages, every); },
|
|
251
|
+
/** Swap the browser if the threshold is reached. Never throws. */
|
|
252
|
+
async maybeRecycle() {
|
|
253
|
+
if (every <= 0 || paused || pages < every) return false;
|
|
254
|
+
paused = true; // no new slots from here on — active can only fall
|
|
255
|
+
const processed = pages;
|
|
256
|
+
try {
|
|
257
|
+
if (active > 0) {
|
|
258
|
+
const drained = await withDeadline(
|
|
259
|
+
new Promise((resolve) => { zeroWaiters.push(resolve); }),
|
|
260
|
+
drainTimeoutMs
|
|
261
|
+
);
|
|
262
|
+
if (!drained) {
|
|
263
|
+
onLog(`recycle: ${active} page(s) still in flight after ${drainTimeoutMs}ms — recycling anyway`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
const swapped = await withDeadline(recycle(processed), recycleTimeoutMs);
|
|
267
|
+
if (!swapped) onLog(`recycle: browser swap exceeded ${recycleTimeoutMs}ms — continuing`);
|
|
268
|
+
pages = 0;
|
|
269
|
+
return swapped;
|
|
270
|
+
} catch (err) {
|
|
271
|
+
onLog(`recycle: failed (${err && err.message ? err.message : err}) — continuing`);
|
|
272
|
+
pages = 0;
|
|
273
|
+
return false;
|
|
274
|
+
} finally {
|
|
275
|
+
paused = false;
|
|
276
|
+
wake(resumeWaiters);
|
|
277
|
+
}
|
|
278
|
+
},
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
192
282
|
// --- Config ------------------------------------------------------------------
|
|
193
283
|
|
|
194
284
|
function parseArgs() {
|
|
@@ -282,6 +372,10 @@ function resolveConfig() {
|
|
|
282
372
|
: [],
|
|
283
373
|
dryRun: !!cli.dryRun,
|
|
284
374
|
debugPrerender: !!cli.debugPrerender,
|
|
375
|
+
// render.icons: "lazy" strips prerender-inlined x-icon SVGs from the
|
|
376
|
+
// output — the icons plugin re-renders them at boot (decorative content;
|
|
377
|
+
// an icon-heavy nav duplicates kilobytes of identical SVG per link).
|
|
378
|
+
lazyIcons: pre.icons === 'lazy',
|
|
285
379
|
// Cap on the manifest:render-ready wait. When the data plugin dispatches
|
|
286
380
|
// the event, we resolve immediately; when it doesn't (most projects), we
|
|
287
381
|
// fall back to the timeout. 10s gives slow data plugin pipelines a
|
|
@@ -1006,15 +1100,16 @@ function buildRootAssetPath(routerBasePath, filename) {
|
|
|
1006
1100
|
return '/' + path.replace(/\/{2,}/g, '/');
|
|
1007
1101
|
}
|
|
1008
1102
|
|
|
1009
|
-
/** Inject stylesheet link with root-absolute href (avoids ../ resolving under locale segments like /en/page/).
|
|
1010
|
-
|
|
1103
|
+
/** Inject stylesheet link with root-absolute href (avoids ../ resolving under locale segments like /en/page/).
|
|
1104
|
+
* `attrs` (e.g. `data-mnfst-utilities`) lets the runtime utilities plugin find and skip classes this sheet covers. */
|
|
1105
|
+
function postProcessInjectStylesheetLink(outputDir, filename, routerBasePath, attrs = '') {
|
|
1011
1106
|
const cssPath = join(outputDir, filename);
|
|
1012
1107
|
if (!existsSync(cssPath)) return;
|
|
1013
1108
|
const stat = statSync(cssPath);
|
|
1014
1109
|
if (stat.size === 0) return;
|
|
1015
1110
|
|
|
1016
1111
|
const href = buildRootAssetPath(routerBasePath, filename);
|
|
1017
|
-
const tag = `<link rel="stylesheet" href="${href}">`;
|
|
1112
|
+
const tag = `<link rel="stylesheet" href="${href}"${attrs ? ` ${attrs}` : ''}>`;
|
|
1018
1113
|
const files = walkHtmlFiles(outputDir);
|
|
1019
1114
|
for (const file of files) {
|
|
1020
1115
|
let html = readFileSync(file, 'utf8');
|
|
@@ -1127,6 +1222,18 @@ function stripRedundantImgSrcBindings(html) {
|
|
|
1127
1222
|
*/
|
|
1128
1223
|
// Prerender inlined Iconify SVG under <i x-icon="iterator.icon">; clear x-icon value so Alpine does not evaluate
|
|
1129
1224
|
// loop/item expressions while the attribute remains for CSS (e.g. inline layout that keys off [x-icon]).
|
|
1225
|
+
// render.icons: "lazy" — drop prerender-inlined icon SVGs from the output.
|
|
1226
|
+
// Each svg carries its resolved name (data-icon), which is written back into
|
|
1227
|
+
// the host's x-icon attribute as a static, scope-free value — so the icons
|
|
1228
|
+
// plugin re-renders it at boot even on baked clones whose expression attrs
|
|
1229
|
+
// were emptied. Only svg-as-first-child hosts match; anything else stays inline.
|
|
1230
|
+
function stripLazyIconSvgs(html) {
|
|
1231
|
+
return html.replace(
|
|
1232
|
+
/(x-icon\s*=\s*")[^"]*("[^>]*>\s*)<svg\b[^>]*\bdata-icon="([^"]+)"[^>]*>[\s\S]*?<\/svg>/gi,
|
|
1233
|
+
(m, pre, post, name) => `${pre}${name}${post}`
|
|
1234
|
+
);
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1130
1237
|
function stripResolvedXIconDirectives(html) {
|
|
1131
1238
|
return html.replace(/<i\b([^>]*)>([\s\S]*?)<\/i>/gi, (full, attrs, inner) => {
|
|
1132
1239
|
if (isHydrateMarkedAttrs(attrs)) return full;
|
|
@@ -1593,6 +1700,7 @@ function generateLocaleVariantHtml({
|
|
|
1593
1700
|
html = stripRedundantImgSrcBindings(html);
|
|
1594
1701
|
html = stripEmptyInlineMaskStyles(html);
|
|
1595
1702
|
html = stripResolvedXIconDirectives(html);
|
|
1703
|
+
if (config.lazyIcons) html = stripLazyIconSvgs(html);
|
|
1596
1704
|
// markPrerenderedManifestComponents must run BEFORE stripPrerenderHydrateMarkers so it can
|
|
1597
1705
|
// detect data-hydrate markers and skip components inside hydrate islands.
|
|
1598
1706
|
html = markPrerenderedManifestComponents(html);
|
|
@@ -1874,7 +1982,8 @@ async function waitForVisualSettle(page, { settleMs = 300, mdWaitMs = 5000, capM
|
|
|
1874
1982
|
const bootPending = !!window.__manifestReadyCoordinator && !window.__manifestReady;
|
|
1875
1983
|
const inflight = (window.__manifestMarkdownPending || 0) > 0;
|
|
1876
1984
|
const mdPending = cur.mdEmpty > 0 && Date.now() - start < mdWaitMs;
|
|
1877
|
-
const utilPending =
|
|
1985
|
+
const utilPending = (window.__manifestUtilitiesPending || 0) > 0
|
|
1986
|
+
|| (elapsed < mdWaitMs + 4000 && unresolvedVariants() > 0);
|
|
1878
1987
|
// A throttled compiler can DROP the request that would resolve them —
|
|
1879
1988
|
// a class mutation re-triggers its observer's schedule.
|
|
1880
1989
|
if (utilPending && elapsed % 500 < 100) document.body.classList.toggle('mnfst-og-nudge');
|
|
@@ -1991,13 +2100,26 @@ async function takeOgSnapshot(page, outputDir, pathSeg, globalAssetSignature, ca
|
|
|
1991
2100
|
// surface (headless pages can sit frames behind after heavy updates).
|
|
1992
2101
|
await page.screenshot({ type: 'png', clip: { x: 0, y: 0, width: 8, height: 8 }, captureBeyondViewport: false }).catch(() => { });
|
|
1993
2102
|
await new Promise((r) => setTimeout(r, 120));
|
|
1994
|
-
|
|
2103
|
+
const shoot = () => page.screenshot({
|
|
1995
2104
|
path: filePath,
|
|
1996
2105
|
type: 'png',
|
|
1997
2106
|
clip: { x: 0, y: 0, width: 1200, height: 630 },
|
|
1998
2107
|
omitBackground: false,
|
|
1999
2108
|
captureBeyondViewport: false,
|
|
2000
2109
|
});
|
|
2110
|
+
await shoot();
|
|
2111
|
+
// Residual-race retry: a text-heavy page yielding a shell-weight card
|
|
2112
|
+
// means the main content missed the frame — settle again and reshoot.
|
|
2113
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
2114
|
+
let size = 0;
|
|
2115
|
+
try { size = statSync(filePath).size; } catch { break; }
|
|
2116
|
+
if (size >= 34 * 1024) break;
|
|
2117
|
+
const textLen = await page.evaluate(() => document.body?.innerText.length || 0).catch(() => 0);
|
|
2118
|
+
if (textLen < 2500) break;
|
|
2119
|
+
await new Promise((r) => setTimeout(r, 1200));
|
|
2120
|
+
await waitForVisualSettle(page, { settleMs: 400, capMs: 5000 });
|
|
2121
|
+
await shoot();
|
|
2122
|
+
}
|
|
2001
2123
|
// Sanity check: a blank 1200×630 PNG (header only, white body) is ~8–10KB;
|
|
2002
2124
|
// a content-rich page is 50KB+. When the resulting file is suspiciously
|
|
2003
2125
|
// small the snapshot is treated as failed and the renderer falls through
|
|
@@ -2313,8 +2435,10 @@ async function injectMetaInDom(page, ctx) {
|
|
|
2313
2435
|
if (existingTypes.has(type)) continue;
|
|
2314
2436
|
if (def === false) continue;
|
|
2315
2437
|
if (type === 'BreadcrumbList' && def === true) {
|
|
2316
|
-
// Auto-derive from URL path segments
|
|
2438
|
+
// Auto-derive from URL path segments; skip crumbs whose URL has no
|
|
2439
|
+
// rendered page (avoids linking soft-404 intermediate paths).
|
|
2317
2440
|
const parts = location.pathname.split('/').filter(Boolean);
|
|
2441
|
+
const rendered = Array.isArray(ctx.renderedPaths) ? new Set(ctx.renderedPaths) : null;
|
|
2318
2442
|
const items = [{
|
|
2319
2443
|
'@type': 'ListItem',
|
|
2320
2444
|
position: 1,
|
|
@@ -2322,11 +2446,13 @@ async function injectMetaInDom(page, ctx) {
|
|
|
2322
2446
|
item: liveBase ? liveBase + '/' : '/',
|
|
2323
2447
|
}];
|
|
2324
2448
|
parts.forEach((seg, i) => {
|
|
2449
|
+
const crumbPath = parts.slice(0, i + 1).join('/');
|
|
2450
|
+
if (rendered && !rendered.has(crumbPath)) return;
|
|
2325
2451
|
items.push({
|
|
2326
2452
|
'@type': 'ListItem',
|
|
2327
|
-
position:
|
|
2453
|
+
position: items.length + 1,
|
|
2328
2454
|
name: seg.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
|
|
2329
|
-
item: liveBase ? `${liveBase}/${
|
|
2455
|
+
item: liveBase ? `${liveBase}/${crumbPath}` : '/' + crumbPath,
|
|
2330
2456
|
});
|
|
2331
2457
|
});
|
|
2332
2458
|
blocks.push({ '@context': 'https://schema.org', '@type': 'BreadcrumbList', itemListElement: items });
|
|
@@ -3228,13 +3354,49 @@ async function runPrerender(config) {
|
|
|
3228
3354
|
// Recycle the browser every N processed pages to bound resource growth.
|
|
3229
3355
|
// Configurable via manifest.prerender.browserRecycleEvery.
|
|
3230
3356
|
const browserRecycleEvery = Math.max(0, pre.browserRecycleEvery ?? 40);
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
|
|
3234
|
-
|
|
3235
|
-
|
|
3236
|
-
|
|
3237
|
-
|
|
3357
|
+
// Bounds on the recycle handshake and on a single page render. A stuck
|
|
3358
|
+
// page must never pin the whole run: each is capped, logged and stepped over.
|
|
3359
|
+
const drainTimeoutMs = Math.max(0, pre.recycleDrainTimeout ?? 60000);
|
|
3360
|
+
const recycleTimeoutMs = Math.max(0, pre.recycleTimeout ?? 180000);
|
|
3361
|
+
const pageTimeoutMs = Math.max(0, pre.pageTimeout ?? Math.max(120000, (config.wait ?? 30000) * 4));
|
|
3362
|
+
|
|
3363
|
+
// Close the browser without hanging: SIGKILL the process if close stalls.
|
|
3364
|
+
async function closeBrowser(target) {
|
|
3365
|
+
if (!target) return;
|
|
3366
|
+
const proc = typeof target.process === 'function' ? target.process() : null;
|
|
3367
|
+
const closed = await withDeadline(Promise.resolve(target.close()).catch(() => {}), 15000);
|
|
3368
|
+
if (!closed) {
|
|
3369
|
+
process.stderr.write('prerender: browser close timed out — killing the process\n');
|
|
3370
|
+
try { proc?.kill('SIGKILL'); } catch (_) {}
|
|
3371
|
+
}
|
|
3372
|
+
}
|
|
3373
|
+
|
|
3374
|
+
// Launch with a deadline and one retry; a hung launch would otherwise stall
|
|
3375
|
+
// every worker behind the recycle gate.
|
|
3376
|
+
async function launchBrowserBounded() {
|
|
3377
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
3378
|
+
let launched = null;
|
|
3379
|
+
const ok = await withDeadline(
|
|
3380
|
+
launchBrowser().then((b) => { launched = b; }),
|
|
3381
|
+
60000
|
|
3382
|
+
);
|
|
3383
|
+
if (ok && launched) return launched;
|
|
3384
|
+
process.stderr.write('prerender: browser launch timed out — retrying\n');
|
|
3385
|
+
}
|
|
3386
|
+
throw new Error('browser launch timed out');
|
|
3387
|
+
}
|
|
3388
|
+
|
|
3389
|
+
const recycleGate = createRecycleGate({
|
|
3390
|
+
every: browserRecycleEvery,
|
|
3391
|
+
drainTimeoutMs,
|
|
3392
|
+
recycleTimeoutMs,
|
|
3393
|
+
onLog: (message) => process.stderr.write(`prerender: ${message}\n`),
|
|
3394
|
+
recycle: async (processed) => {
|
|
3395
|
+
process.stdout.write(`prerender: recycling browser (processed ${processed} pages)\n`);
|
|
3396
|
+
await closeBrowser(browser);
|
|
3397
|
+
browser = await launchBrowserBounded();
|
|
3398
|
+
},
|
|
3399
|
+
});
|
|
3238
3400
|
const pathTotal = pathList.length;
|
|
3239
3401
|
const failedPaths = [];
|
|
3240
3402
|
const debugRows = [];
|
|
@@ -3323,7 +3485,7 @@ async function runPrerender(config) {
|
|
|
3323
3485
|
debugRows.push(row);
|
|
3324
3486
|
}
|
|
3325
3487
|
|
|
3326
|
-
async function processPath(pathSeg, pathIndex, { onRawHtml } = {}) {
|
|
3488
|
+
async function processPath(pathSeg, pathIndex, { onRawHtml, attempt } = {}) {
|
|
3327
3489
|
const is404 = pathSeg === NOT_FOUND_PATH;
|
|
3328
3490
|
const pathname = is404 ? `/${NOT_FOUND_PATH}` : (pathSeg ? `/${pathSeg}` : '/');
|
|
3329
3491
|
const displayPath = pathSeg === '' ? '/' : pathname;
|
|
@@ -3339,11 +3501,8 @@ async function runPrerender(config) {
|
|
|
3339
3501
|
: defaultLocale || 'en'
|
|
3340
3502
|
: defaultLocale || 'en';
|
|
3341
3503
|
|
|
3342
|
-
//
|
|
3343
|
-
//
|
|
3344
|
-
// browser being closed and the new one being launched — workers block
|
|
3345
|
-
// here instead of throwing "browser not ready".
|
|
3346
|
-
await browserReadyPromise;
|
|
3504
|
+
// No recycle can run while the caller holds a gate slot, so `browser` is
|
|
3505
|
+
// live for the whole call.
|
|
3347
3506
|
const page = await browser.newPage();
|
|
3348
3507
|
// Render at a typical desktop viewport so layouts dependent on viewport
|
|
3349
3508
|
// width (responsive flex/grid, container queries, media queries) settle
|
|
@@ -3357,6 +3516,9 @@ async function runPrerender(config) {
|
|
|
3357
3516
|
// initializeDataSourcesPlugin picks locale from document.documentElement.lang first; a mismatch
|
|
3358
3517
|
// (e.g. headless default vs /en/...) leaves $x.* empty while x-route sections still render.
|
|
3359
3518
|
await page.evaluateOnNewDocument((locale) => {
|
|
3519
|
+
// Render pass: the defer plugin keeps closed containers eager so the
|
|
3520
|
+
// snapshot carries their markup (hydration stashes it again).
|
|
3521
|
+
window.__manifestRender = true;
|
|
3360
3522
|
const apply = () => {
|
|
3361
3523
|
try {
|
|
3362
3524
|
if (locale && typeof locale === 'string') document.documentElement.lang = locale;
|
|
@@ -4447,6 +4609,16 @@ async function runPrerender(config) {
|
|
|
4447
4609
|
});
|
|
4448
4610
|
});
|
|
4449
4611
|
|
|
4612
|
+
// (render.icons: "lazy" is applied on the serialized HTML string — see
|
|
4613
|
+
// stripLazyIconSvgs. Doing it in the live DOM re-triggers the icons
|
|
4614
|
+
// plugin's observer, which re-injects every svg before serialization.)
|
|
4615
|
+
|
|
4616
|
+
// x-virtual recomputes spacer geometry at boot; measured heights vary
|
|
4617
|
+
// run to run — zero them so identical content serializes identically.
|
|
4618
|
+
await page.evaluate(() => {
|
|
4619
|
+
document.querySelectorAll('[data-virtual-spacer]').forEach((el) => { el.style.height = '0px'; });
|
|
4620
|
+
});
|
|
4621
|
+
|
|
4450
4622
|
// Deterministic output: randomized CSS anchor-positioning names
|
|
4451
4623
|
// (--dropdown-x7f2, --popover-…, --anchor-…) are regenerated per run and
|
|
4452
4624
|
// made every render a phantom diff. Renumber them sequentially in DOM
|
|
@@ -4491,6 +4663,7 @@ async function runPrerender(config) {
|
|
|
4491
4663
|
liveUrl: (config.liveUrl || '').replace(/\/$/, ''),
|
|
4492
4664
|
pathSeg: is404 ? '__404__' : pathSeg,
|
|
4493
4665
|
snapshotUrl: earlySnapshotUrl,
|
|
4666
|
+
renderedPaths: pathList.filter((p) => p !== NOT_FOUND_PATH),
|
|
4494
4667
|
});
|
|
4495
4668
|
|
|
4496
4669
|
let html = await page.evaluate(() => document.documentElement.outerHTML);
|
|
@@ -4547,6 +4720,7 @@ async function runPrerender(config) {
|
|
|
4547
4720
|
html = stripRedundantImgSrcBindings(html);
|
|
4548
4721
|
html = stripEmptyInlineMaskStyles(html);
|
|
4549
4722
|
html = stripResolvedXIconDirectives(html);
|
|
4723
|
+
if (config.lazyIcons) html = stripLazyIconSvgs(html);
|
|
4550
4724
|
html = markPrerenderedManifestComponents(html);
|
|
4551
4725
|
|
|
4552
4726
|
// Prefix internal <a> links with the locale for non-default locales so
|
|
@@ -4581,6 +4755,7 @@ async function runPrerender(config) {
|
|
|
4581
4755
|
// (Hydration contract was already injected into the raw HTML before
|
|
4582
4756
|
// the Node.js post-processing pipeline ran, so it's already present.)
|
|
4583
4757
|
html = ensureDoctype(html);
|
|
4758
|
+
if (attempt?.abandoned) return; // a retry owns the output now
|
|
4584
4759
|
mkdirSync(outDir, { recursive: true });
|
|
4585
4760
|
writeFileSync(outFile, html, 'utf8');
|
|
4586
4761
|
pushDebug({
|
|
@@ -4591,13 +4766,13 @@ async function runPrerender(config) {
|
|
|
4591
4766
|
hasXForTemplate: html.includes('template x-for') || html.includes('template[x-for]'),
|
|
4592
4767
|
});
|
|
4593
4768
|
} catch (err) {
|
|
4594
|
-
|
|
4595
|
-
|
|
4596
|
-
|
|
4597
|
-
|
|
4598
|
-
if (
|
|
4599
|
-
|
|
4600
|
-
}
|
|
4769
|
+
if (attempt?.abandoned) return;
|
|
4770
|
+
const message = err && err.message ? err.message : String(err);
|
|
4771
|
+
// The worker owns retries and reporting; a shared array can't be popped
|
|
4772
|
+
// safely while other workers push to it.
|
|
4773
|
+
if (attempt) { attempt.failure = { path: displayPath, message }; return; }
|
|
4774
|
+
failedPaths.push({ path: displayPath, message });
|
|
4775
|
+
process.stderr.write(`prerender: failed ${displayPath}: ${message}\n`);
|
|
4601
4776
|
} finally {
|
|
4602
4777
|
try { await page.close(); } catch (_) { /* page may be gone if browser died */ }
|
|
4603
4778
|
}
|
|
@@ -4607,131 +4782,84 @@ async function runPrerender(config) {
|
|
|
4607
4782
|
// Any failures (e.g. transient navigation timeouts) are retried up to
|
|
4608
4783
|
// `maxRetries` times with a short backoff before being reported as fatal.
|
|
4609
4784
|
//
|
|
4610
|
-
// Browser recycling: after every `browserRecycleEvery` successful pages
|
|
4611
|
-
//
|
|
4612
|
-
//
|
|
4785
|
+
// Browser recycling: after every `browserRecycleEvery` successful pages the
|
|
4786
|
+
// gate pauses new work, drains the in-flight pages, swaps the browser and
|
|
4787
|
+
// resumes. Workers hold a gate slot only while they touch the browser.
|
|
4613
4788
|
try {
|
|
4614
4789
|
let index = 0;
|
|
4615
|
-
let activeWorkers = 0;
|
|
4616
|
-
const recycleGate = { resume: null, waitForZero: null };
|
|
4617
|
-
|
|
4618
|
-
const waitUntilZero = () => new Promise((resolve) => {
|
|
4619
|
-
if (activeWorkers === 0) return resolve();
|
|
4620
|
-
recycleGate.waitForZero = resolve;
|
|
4621
|
-
});
|
|
4622
|
-
const waitForResume = () => new Promise((resolve) => {
|
|
4623
|
-
if (!recycleLock.busy) return resolve();
|
|
4624
|
-
const prev = recycleGate.resume;
|
|
4625
|
-
recycleGate.resume = () => { if (prev) prev(); resolve(); };
|
|
4626
|
-
});
|
|
4627
|
-
|
|
4628
|
-
const maybeRecycleBrowser = async () => {
|
|
4629
|
-
if (browserRecycleEvery <= 0) return;
|
|
4630
|
-
if (pagesSinceRecycle < browserRecycleEvery) return;
|
|
4631
|
-
if (recycleLock.busy) return;
|
|
4632
|
-
recycleLock.busy = true;
|
|
4633
|
-
// Wait for all in-flight workers to finish their current page BEFORE
|
|
4634
|
-
// we gate `browserReadyPromise`, so workers already mid-processPath
|
|
4635
|
-
// don't deadlock awaiting a promise we haven't yet started.
|
|
4636
|
-
await waitUntilZero();
|
|
4637
|
-
// Now gate newPage() calls from any worker that enters processPath
|
|
4638
|
-
// after this point.
|
|
4639
|
-
let resolveReady;
|
|
4640
|
-
browserReadyPromise = new Promise((r) => { resolveReady = r; });
|
|
4641
|
-
try {
|
|
4642
|
-
process.stdout.write(`prerender: recycling browser (processed ${pagesSinceRecycle} pages)\n`);
|
|
4643
|
-
try { await browser.close(); } catch (_) {}
|
|
4644
|
-
browser = await launchBrowser();
|
|
4645
|
-
pagesSinceRecycle = 0;
|
|
4646
|
-
} finally {
|
|
4647
|
-
// Release the gate first so any waiting workers can proceed, then
|
|
4648
|
-
// clear the recycle lock so the outer while loop stops pausing.
|
|
4649
|
-
try { resolveReady(); } catch (_) {}
|
|
4650
|
-
recycleLock.busy = false;
|
|
4651
|
-
const r = recycleGate.resume;
|
|
4652
|
-
recycleGate.resume = null;
|
|
4653
|
-
if (r) r();
|
|
4654
|
-
}
|
|
4655
|
-
};
|
|
4656
4790
|
|
|
4657
4791
|
async function worker() {
|
|
4658
4792
|
while (true) {
|
|
4659
|
-
// Pause if a recycle is underway.
|
|
4660
|
-
if (recycleLock.busy) await waitForResume();
|
|
4661
|
-
// Also wait for any pending browser readiness (e.g. another worker
|
|
4662
|
-
// started a recycle while we were processing).
|
|
4663
|
-
await browserReadyPromise;
|
|
4664
|
-
|
|
4665
4793
|
const i = index++;
|
|
4666
4794
|
if (i >= puppeteerPaths.length) return;
|
|
4667
4795
|
const pathSeg = puppeteerPaths[i];
|
|
4796
|
+
const displayPath = pathSeg === '' ? '/' : (pathSeg === NOT_FOUND_PATH ? '/__prerender_404__' : '/' + pathSeg);
|
|
4668
4797
|
let attempt = 0;
|
|
4669
4798
|
while (true) {
|
|
4670
|
-
//
|
|
4671
|
-
|
|
4672
|
-
|
|
4673
|
-
|
|
4674
|
-
const failureCountBefore = failedPaths.length;
|
|
4675
|
-
activeWorkers++;
|
|
4799
|
+
// Per-attempt result: `failedPaths` is shared, so a retry must never
|
|
4800
|
+
// read or pop entries another worker pushed.
|
|
4801
|
+
const attemptToken = { abandoned: false, failure: null };
|
|
4802
|
+
await recycleGate.acquire();
|
|
4676
4803
|
try {
|
|
4677
|
-
|
|
4678
|
-
|
|
4679
|
-
|
|
4680
|
-
|
|
4681
|
-
|
|
4804
|
+
// Watchdog: a wedged renderer never returns, so cap the attempt.
|
|
4805
|
+
const finished = await withDeadline(
|
|
4806
|
+
processPath(pathSeg, i, {
|
|
4807
|
+
attempt: attemptToken,
|
|
4808
|
+
onRawHtml: (seg, html) => {
|
|
4809
|
+
if (seg !== NOT_FOUND_PATH) baseHtmlCache.set(seg || '', html);
|
|
4810
|
+
},
|
|
4811
|
+
}),
|
|
4812
|
+
pageTimeoutMs
|
|
4813
|
+
);
|
|
4814
|
+
if (!finished) {
|
|
4815
|
+
attemptToken.abandoned = true;
|
|
4816
|
+
attemptToken.failure = { path: displayPath, message: `page render exceeded ${pageTimeoutMs}ms` };
|
|
4817
|
+
}
|
|
4682
4818
|
} catch (err) {
|
|
4683
|
-
// Unexpected exception escaped processPath (e.g. browser died
|
|
4684
|
-
|
|
4685
|
-
|
|
4686
|
-
failedPaths.push({
|
|
4687
|
-
path: pathSeg === '' ? '/' : '/' + pathSeg,
|
|
4819
|
+
// Unexpected exception escaped processPath (e.g. browser died mid-call).
|
|
4820
|
+
attemptToken.failure = {
|
|
4821
|
+
path: displayPath,
|
|
4688
4822
|
message: err && err.message ? err.message : String(err),
|
|
4689
|
-
}
|
|
4690
|
-
if (failedPaths.length <= 10) {
|
|
4691
|
-
process.stderr.write(`prerender: worker exception on ${pathSeg || '/'}: ${failedPaths[failedPaths.length - 1].message}\n`);
|
|
4692
|
-
}
|
|
4823
|
+
};
|
|
4693
4824
|
} finally {
|
|
4694
|
-
|
|
4695
|
-
if (activeWorkers === 0 && recycleGate.waitForZero) {
|
|
4696
|
-
const z = recycleGate.waitForZero;
|
|
4697
|
-
recycleGate.waitForZero = null;
|
|
4698
|
-
z();
|
|
4699
|
-
}
|
|
4825
|
+
recycleGate.release();
|
|
4700
4826
|
}
|
|
4701
|
-
if (
|
|
4702
|
-
|
|
4827
|
+
if (!attemptToken.failure) {
|
|
4828
|
+
recycleGate.countPage();
|
|
4703
4829
|
break; // success
|
|
4704
4830
|
}
|
|
4705
4831
|
if (attempt >= maxRetries) {
|
|
4706
4832
|
// Exhausted retries — likely an unstable browser (e.g. cascading
|
|
4707
|
-
// "detached Frame" errors). Force
|
|
4708
|
-
|
|
4709
|
-
|
|
4833
|
+
// "detached Frame" errors). Force the next path onto a fresh one.
|
|
4834
|
+
failedPaths.push(attemptToken.failure);
|
|
4835
|
+
if (failedPaths.length <= 10) {
|
|
4836
|
+
process.stderr.write(`prerender: failed ${displayPath}: ${attemptToken.failure.message}\n`);
|
|
4837
|
+
}
|
|
4838
|
+
recycleGate.countPage();
|
|
4839
|
+
recycleGate.requestRecycle();
|
|
4710
4840
|
break;
|
|
4711
4841
|
}
|
|
4712
4842
|
// Halfway through retries with no success → preemptively recycle the
|
|
4713
4843
|
// browser before the next attempt. This unblocks cascading frame
|
|
4714
4844
|
// failures where the browser process needs a fresh start.
|
|
4715
|
-
if (attempt + 1 >= Math.ceil(maxRetries / 2) &&
|
|
4716
|
-
|
|
4717
|
-
await
|
|
4845
|
+
if (attempt + 1 >= Math.ceil(maxRetries / 2) && recycleGate.pages > 0) {
|
|
4846
|
+
recycleGate.requestRecycle();
|
|
4847
|
+
await recycleGate.maybeRecycle();
|
|
4718
4848
|
}
|
|
4719
|
-
failedPaths.pop();
|
|
4720
4849
|
attempt++;
|
|
4721
|
-
|
|
4722
|
-
process.stderr.write(`prerender: retrying ${displayPath} (attempt ${attempt + 1}/${maxRetries + 1})\n`);
|
|
4850
|
+
process.stderr.write(`prerender: retrying ${displayPath} (${attemptToken.failure.message}) (attempt ${attempt + 1}/${maxRetries + 1})\n`);
|
|
4723
4851
|
await new Promise((r) => setTimeout(r, 500 * attempt));
|
|
4724
4852
|
}
|
|
4725
|
-
// Attempt recycle after each completed path (only one worker
|
|
4726
|
-
//
|
|
4727
|
-
await
|
|
4853
|
+
// Attempt recycle after each completed path (only one worker performs
|
|
4854
|
+
// it; the rest block in acquire()).
|
|
4855
|
+
await recycleGate.maybeRecycle();
|
|
4728
4856
|
}
|
|
4729
4857
|
}
|
|
4730
4858
|
await Promise.all(
|
|
4731
4859
|
Array.from({ length: Math.min(concurrency, puppeteerPaths.length || 1) }, () => worker())
|
|
4732
4860
|
);
|
|
4733
4861
|
} finally {
|
|
4734
|
-
|
|
4862
|
+
await closeBrowser(browser);
|
|
4735
4863
|
}
|
|
4736
4864
|
|
|
4737
4865
|
// Phase 2: Node.js — generate locale variants via text substitution
|
|
@@ -4790,9 +4918,18 @@ async function runPrerender(config) {
|
|
|
4790
4918
|
if (bundleUtilities) {
|
|
4791
4919
|
const utilMerged = mergeUtilityCssBlocks(utilityBlocks);
|
|
4792
4920
|
if (utilMerged.trim()) {
|
|
4793
|
-
|
|
4794
|
-
|
|
4795
|
-
|
|
4921
|
+
// Append to prerender.tailwind.css when it exists — one render-blocking
|
|
4922
|
+
// stylesheet instead of two, same cascade order (utilities last). The
|
|
4923
|
+
// separate file remains only for projects without a Tailwind build.
|
|
4924
|
+
const twPath = join(outputResolved, 'prerender.tailwind.css');
|
|
4925
|
+
if (existsSync(twPath)) {
|
|
4926
|
+
appendFileSync(twPath, `\n/* Manifest custom utilities */\n${utilMerged}\n`, 'utf8');
|
|
4927
|
+
process.stdout.write('prerender: merged custom utilities into prerender.tailwind.css\n');
|
|
4928
|
+
} else {
|
|
4929
|
+
writeFileSync(join(outputResolved, 'prerender.utilities.css'), `${utilMerged}\n`, 'utf8');
|
|
4930
|
+
process.stdout.write('prerender: wrote prerender.utilities.css (Manifest custom utilities)\n');
|
|
4931
|
+
postProcessInjectStylesheetLink(outputResolved, 'prerender.utilities.css', routerBasePath || '', 'data-mnfst-utilities');
|
|
4932
|
+
}
|
|
4796
4933
|
}
|
|
4797
4934
|
}
|
|
4798
4935
|
|