mnfst-render 0.5.38 → 0.5.40
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 +285 -152
- package/package.json +1 -1
package/manifest.render.mjs
CHANGED
|
@@ -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() {
|
|
@@ -227,12 +317,69 @@ function loadConfig(rootDir) {
|
|
|
227
317
|
}
|
|
228
318
|
|
|
229
319
|
function normalizeLocaleRouteExclude(val) {
|
|
320
|
+
const clean = (s) => String(s).trim().replace(/^\/+|\/+$/g, '');
|
|
230
321
|
if (val == null) return [];
|
|
231
|
-
if (Array.isArray(val)) return val.map(
|
|
232
|
-
if (typeof val === 'string') return val.split(',').map(
|
|
322
|
+
if (Array.isArray(val)) return val.map(clean).filter(Boolean);
|
|
323
|
+
if (typeof val === 'string') return val.split(',').map(clean).filter(Boolean);
|
|
233
324
|
return [];
|
|
234
325
|
}
|
|
235
326
|
|
|
327
|
+
/**
|
|
328
|
+
* Shared predicate: true when route segment `seg` (no leading slash) falls
|
|
329
|
+
* under one of the excluded prefixes — prefix-matched on whole segments, so
|
|
330
|
+
* "docs" excludes "docs" itself and "docs/group/article" but not "docsite".
|
|
331
|
+
* Used by locale route expansion, link prefixing, and (mirrored in
|
|
332
|
+
* mnfst-publish) the publisher's expected-file computation.
|
|
333
|
+
*/
|
|
334
|
+
function isLocaleRouteExcluded(seg, localeRouteExclude) {
|
|
335
|
+
if (!seg || !localeRouteExclude || !localeRouteExclude.length) return false;
|
|
336
|
+
const clean = String(seg).replace(/^\/+|\/+$/g, '');
|
|
337
|
+
for (const prefix of localeRouteExclude) {
|
|
338
|
+
if (clean === prefix || clean.startsWith(prefix + '/')) return true;
|
|
339
|
+
}
|
|
340
|
+
return false;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Expand discovered route segments into the full path list to render:
|
|
345
|
+
* every locale-neutral route as-is, plus a `<locale>/<route>` variant for
|
|
346
|
+
* every non-default locale (and for the default locale itself, under its
|
|
347
|
+
* own slug, when there's more than one locale) — except routes under an
|
|
348
|
+
* excluded prefix (localeRouteExclude), which are never locale-prefixed.
|
|
349
|
+
* Pure/side-effect-free so it can be unit tested without Puppeteer.
|
|
350
|
+
*/
|
|
351
|
+
function expandLocaleRoutePaths({ routeSegments, locales, defaultLocale, localeRouteExclude }) {
|
|
352
|
+
const localeList = locales || [];
|
|
353
|
+
const localeSet = new Set(localeList.map((l) => String(l).toLowerCase()));
|
|
354
|
+
const localeNeutralSegments = routeSegments.filter((seg) => {
|
|
355
|
+
if (!seg) return true;
|
|
356
|
+
const first = seg.split('/')[0].toLowerCase();
|
|
357
|
+
return !localeSet.has(first);
|
|
358
|
+
});
|
|
359
|
+
const paths = new Set();
|
|
360
|
+
paths.add('');
|
|
361
|
+
for (const seg of routeSegments) paths.add(seg);
|
|
362
|
+
for (const locale of localeList.slice(1)) {
|
|
363
|
+
paths.add(locale);
|
|
364
|
+
for (const seg of localeNeutralSegments) {
|
|
365
|
+
if (!seg) continue;
|
|
366
|
+
if (isLocaleRouteExcluded(seg, localeRouteExclude)) continue;
|
|
367
|
+
paths.add(`${locale}/${seg}`);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
// Default locale also under its slug (e.g. /en/, /en/page-1) so linking is
|
|
371
|
+
// symmetric with other locales; canonical points to root. Skip this when
|
|
372
|
+
// there's only one locale — the duplicates serve no purpose and bloat the
|
|
373
|
+
// output (every page would be written twice: at root AND under /en/).
|
|
374
|
+
if (defaultLocale && localeList.length > 1) {
|
|
375
|
+
paths.add(defaultLocale);
|
|
376
|
+
for (const seg of localeNeutralSegments) {
|
|
377
|
+
if (seg !== '' && !isLocaleRouteExcluded(seg, localeRouteExclude)) paths.add(`${defaultLocale}/${seg}`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return paths;
|
|
381
|
+
}
|
|
382
|
+
|
|
236
383
|
function resolveConfig() {
|
|
237
384
|
const cli = parseArgs();
|
|
238
385
|
const cwd = process.cwd();
|
|
@@ -1010,15 +1157,16 @@ function buildRootAssetPath(routerBasePath, filename) {
|
|
|
1010
1157
|
return '/' + path.replace(/\/{2,}/g, '/');
|
|
1011
1158
|
}
|
|
1012
1159
|
|
|
1013
|
-
/** Inject stylesheet link with root-absolute href (avoids ../ resolving under locale segments like /en/page/).
|
|
1014
|
-
|
|
1160
|
+
/** Inject stylesheet link with root-absolute href (avoids ../ resolving under locale segments like /en/page/).
|
|
1161
|
+
* `attrs` (e.g. `data-mnfst-utilities`) lets the runtime utilities plugin find and skip classes this sheet covers. */
|
|
1162
|
+
function postProcessInjectStylesheetLink(outputDir, filename, routerBasePath, attrs = '') {
|
|
1015
1163
|
const cssPath = join(outputDir, filename);
|
|
1016
1164
|
if (!existsSync(cssPath)) return;
|
|
1017
1165
|
const stat = statSync(cssPath);
|
|
1018
1166
|
if (stat.size === 0) return;
|
|
1019
1167
|
|
|
1020
1168
|
const href = buildRootAssetPath(routerBasePath, filename);
|
|
1021
|
-
const tag = `<link rel="stylesheet" href="${href}">`;
|
|
1169
|
+
const tag = `<link rel="stylesheet" href="${href}"${attrs ? ` ${attrs}` : ''}>`;
|
|
1022
1170
|
const files = walkHtmlFiles(outputDir);
|
|
1023
1171
|
for (const file of files) {
|
|
1024
1172
|
let html = readFileSync(file, 'utf8');
|
|
@@ -1476,7 +1624,6 @@ function buildSubstitutionPairs(defaultLocaleData, targetLocaleData) {
|
|
|
1476
1624
|
function prefixLocaleInternalLinks(html, locale, locales, localeRouteExclude) {
|
|
1477
1625
|
if (!locale || !locales || !locales.length) return html;
|
|
1478
1626
|
const localeSet = new Set(locales);
|
|
1479
|
-
const excludeSet = new Set(localeRouteExclude || []);
|
|
1480
1627
|
|
|
1481
1628
|
// Match <a ... href="..." ...> — capture the href value
|
|
1482
1629
|
return html.replace(
|
|
@@ -1497,7 +1644,7 @@ function prefixLocaleInternalLinks(html, locale, locales, localeRouteExclude) {
|
|
|
1497
1644
|
if (/\.(css|js|json|svg|png|jpg|jpeg|gif|ico|woff2?|ttf|eot|pdf|xml|txt)$/i.test(href)) return full;
|
|
1498
1645
|
|
|
1499
1646
|
// Respect localeRouteExclude — these routes stay locale-neutral
|
|
1500
|
-
if (
|
|
1647
|
+
if (isLocaleRouteExcluded(withoutSlash, localeRouteExclude)) return full;
|
|
1501
1648
|
|
|
1502
1649
|
// Prefix with locale
|
|
1503
1650
|
return `${prefix}/${locale}${href}${suffix}`;
|
|
@@ -2344,8 +2491,10 @@ async function injectMetaInDom(page, ctx) {
|
|
|
2344
2491
|
if (existingTypes.has(type)) continue;
|
|
2345
2492
|
if (def === false) continue;
|
|
2346
2493
|
if (type === 'BreadcrumbList' && def === true) {
|
|
2347
|
-
// Auto-derive from URL path segments
|
|
2494
|
+
// Auto-derive from URL path segments; skip crumbs whose URL has no
|
|
2495
|
+
// rendered page (avoids linking soft-404 intermediate paths).
|
|
2348
2496
|
const parts = location.pathname.split('/').filter(Boolean);
|
|
2497
|
+
const rendered = Array.isArray(ctx.renderedPaths) ? new Set(ctx.renderedPaths) : null;
|
|
2349
2498
|
const items = [{
|
|
2350
2499
|
'@type': 'ListItem',
|
|
2351
2500
|
position: 1,
|
|
@@ -2353,11 +2502,13 @@ async function injectMetaInDom(page, ctx) {
|
|
|
2353
2502
|
item: liveBase ? liveBase + '/' : '/',
|
|
2354
2503
|
}];
|
|
2355
2504
|
parts.forEach((seg, i) => {
|
|
2505
|
+
const crumbPath = parts.slice(0, i + 1).join('/');
|
|
2506
|
+
if (rendered && !rendered.has(crumbPath)) return;
|
|
2356
2507
|
items.push({
|
|
2357
2508
|
'@type': 'ListItem',
|
|
2358
|
-
position:
|
|
2509
|
+
position: items.length + 1,
|
|
2359
2510
|
name: seg.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
|
|
2360
|
-
item: liveBase ? `${liveBase}/${
|
|
2511
|
+
item: liveBase ? `${liveBase}/${crumbPath}` : '/' + crumbPath,
|
|
2361
2512
|
});
|
|
2362
2513
|
});
|
|
2363
2514
|
blocks.push({ '@context': 'https://schema.org', '@type': 'BreadcrumbList', itemListElement: items });
|
|
@@ -3119,35 +3270,11 @@ async function runPrerender(config) {
|
|
|
3119
3270
|
if (!segSet.has(p)) { routeSegments.push(p); segSet.add(p); }
|
|
3120
3271
|
}
|
|
3121
3272
|
}
|
|
3122
|
-
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
});
|
|
3128
|
-
const paths = new Set();
|
|
3129
|
-
paths.add('');
|
|
3130
|
-
|
|
3131
|
-
for (const seg of routeSegments) {
|
|
3132
|
-
paths.add(seg);
|
|
3133
|
-
}
|
|
3134
|
-
for (const locale of locales.slice(1)) {
|
|
3135
|
-
paths.add(locale);
|
|
3136
|
-
for (const seg of localeNeutralSegments) {
|
|
3137
|
-
if (!seg) continue;
|
|
3138
|
-
paths.add(`${locale}/${seg}`);
|
|
3139
|
-
}
|
|
3140
|
-
}
|
|
3141
|
-
// Default locale also under its slug (e.g. /en/, /en/page-1) so linking is
|
|
3142
|
-
// symmetric with other locales; canonical points to root. Skip this when
|
|
3143
|
-
// there's only one locale — the duplicates serve no purpose and bloat the
|
|
3144
|
-
// output (every page would be written twice: at root AND under /en/).
|
|
3145
|
-
if (defaultLocale && locales.length > 1) {
|
|
3146
|
-
paths.add(defaultLocale);
|
|
3147
|
-
for (const seg of localeNeutralSegments) {
|
|
3148
|
-
if (seg !== '') paths.add(`${defaultLocale}/${seg}`);
|
|
3149
|
-
}
|
|
3150
|
-
}
|
|
3273
|
+
// Expand routeSegments into the full path list: locale-neutral routes as-is,
|
|
3274
|
+
// plus a `<locale>/<route>` variant per locale (skipping localeRouteExclude
|
|
3275
|
+
// prefixes). Factored into a pure function so it's unit-testable without
|
|
3276
|
+
// Puppeteer — see expandLocaleRoutePaths.
|
|
3277
|
+
const paths = expandLocaleRoutePaths({ routeSegments, locales, defaultLocale, localeRouteExclude: config.localeRouteExclude });
|
|
3151
3278
|
|
|
3152
3279
|
const NOT_FOUND_PATH = '__prerender_404__'; // URL path that matches no route so router shows x-route="!*" (404)
|
|
3153
3280
|
const pathList = [...paths, NOT_FOUND_PATH];
|
|
@@ -3259,13 +3386,52 @@ async function runPrerender(config) {
|
|
|
3259
3386
|
// Recycle the browser every N processed pages to bound resource growth.
|
|
3260
3387
|
// Configurable via manifest.prerender.browserRecycleEvery.
|
|
3261
3388
|
const browserRecycleEvery = Math.max(0, pre.browserRecycleEvery ?? 40);
|
|
3262
|
-
|
|
3263
|
-
|
|
3264
|
-
//
|
|
3265
|
-
//
|
|
3266
|
-
//
|
|
3267
|
-
|
|
3268
|
-
|
|
3389
|
+
// Bounds on the recycle handshake and on a single page render. A stuck
|
|
3390
|
+
// page must never pin the whole run: each is capped, logged and stepped
|
|
3391
|
+
// over — and a page that hits the ceiling forces a browser recycle, since
|
|
3392
|
+
// a wedged page.evaluate() may have poisoned the shared browser process.
|
|
3393
|
+
// pageTimeout is configurable via manifest.prerender.pageTimeout; 0 disables.
|
|
3394
|
+
const drainTimeoutMs = Math.max(0, pre.recycleDrainTimeout ?? 60000);
|
|
3395
|
+
const recycleTimeoutMs = Math.max(0, pre.recycleTimeout ?? 180000);
|
|
3396
|
+
const pageTimeoutMs = Math.max(0, pre.pageTimeout ?? Math.max(120000, (config.wait ?? 30000) * 4));
|
|
3397
|
+
|
|
3398
|
+
// Close the browser without hanging: SIGKILL the process if close stalls.
|
|
3399
|
+
async function closeBrowser(target) {
|
|
3400
|
+
if (!target) return;
|
|
3401
|
+
const proc = typeof target.process === 'function' ? target.process() : null;
|
|
3402
|
+
const closed = await withDeadline(Promise.resolve(target.close()).catch(() => {}), 15000);
|
|
3403
|
+
if (!closed) {
|
|
3404
|
+
process.stderr.write('prerender: browser close timed out — killing the process\n');
|
|
3405
|
+
try { proc?.kill('SIGKILL'); } catch (_) {}
|
|
3406
|
+
}
|
|
3407
|
+
}
|
|
3408
|
+
|
|
3409
|
+
// Launch with a deadline and one retry; a hung launch would otherwise stall
|
|
3410
|
+
// every worker behind the recycle gate.
|
|
3411
|
+
async function launchBrowserBounded() {
|
|
3412
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
3413
|
+
let launched = null;
|
|
3414
|
+
const ok = await withDeadline(
|
|
3415
|
+
launchBrowser().then((b) => { launched = b; }),
|
|
3416
|
+
60000
|
|
3417
|
+
);
|
|
3418
|
+
if (ok && launched) return launched;
|
|
3419
|
+
process.stderr.write('prerender: browser launch timed out — retrying\n');
|
|
3420
|
+
}
|
|
3421
|
+
throw new Error('browser launch timed out');
|
|
3422
|
+
}
|
|
3423
|
+
|
|
3424
|
+
const recycleGate = createRecycleGate({
|
|
3425
|
+
every: browserRecycleEvery,
|
|
3426
|
+
drainTimeoutMs,
|
|
3427
|
+
recycleTimeoutMs,
|
|
3428
|
+
onLog: (message) => process.stderr.write(`prerender: ${message}\n`),
|
|
3429
|
+
recycle: async (processed) => {
|
|
3430
|
+
process.stdout.write(`prerender: recycling browser (processed ${processed} pages)\n`);
|
|
3431
|
+
await closeBrowser(browser);
|
|
3432
|
+
browser = await launchBrowserBounded();
|
|
3433
|
+
},
|
|
3434
|
+
});
|
|
3269
3435
|
const pathTotal = pathList.length;
|
|
3270
3436
|
const failedPaths = [];
|
|
3271
3437
|
const debugRows = [];
|
|
@@ -3354,7 +3520,7 @@ async function runPrerender(config) {
|
|
|
3354
3520
|
debugRows.push(row);
|
|
3355
3521
|
}
|
|
3356
3522
|
|
|
3357
|
-
async function processPath(pathSeg, pathIndex, { onRawHtml } = {}) {
|
|
3523
|
+
async function processPath(pathSeg, pathIndex, { onRawHtml, attempt } = {}) {
|
|
3358
3524
|
const is404 = pathSeg === NOT_FOUND_PATH;
|
|
3359
3525
|
const pathname = is404 ? `/${NOT_FOUND_PATH}` : (pathSeg ? `/${pathSeg}` : '/');
|
|
3360
3526
|
const displayPath = pathSeg === '' ? '/' : pathname;
|
|
@@ -3370,11 +3536,8 @@ async function runPrerender(config) {
|
|
|
3370
3536
|
: defaultLocale || 'en'
|
|
3371
3537
|
: defaultLocale || 'en';
|
|
3372
3538
|
|
|
3373
|
-
//
|
|
3374
|
-
//
|
|
3375
|
-
// browser being closed and the new one being launched — workers block
|
|
3376
|
-
// here instead of throwing "browser not ready".
|
|
3377
|
-
await browserReadyPromise;
|
|
3539
|
+
// No recycle can run while the caller holds a gate slot, so `browser` is
|
|
3540
|
+
// live for the whole call.
|
|
3378
3541
|
const page = await browser.newPage();
|
|
3379
3542
|
// Render at a typical desktop viewport so layouts dependent on viewport
|
|
3380
3543
|
// width (responsive flex/grid, container queries, media queries) settle
|
|
@@ -3388,6 +3551,9 @@ async function runPrerender(config) {
|
|
|
3388
3551
|
// initializeDataSourcesPlugin picks locale from document.documentElement.lang first; a mismatch
|
|
3389
3552
|
// (e.g. headless default vs /en/...) leaves $x.* empty while x-route sections still render.
|
|
3390
3553
|
await page.evaluateOnNewDocument((locale) => {
|
|
3554
|
+
// Render pass: the defer plugin keeps closed containers eager so the
|
|
3555
|
+
// snapshot carries their markup (hydration stashes it again).
|
|
3556
|
+
window.__manifestRender = true;
|
|
3391
3557
|
const apply = () => {
|
|
3392
3558
|
try {
|
|
3393
3559
|
if (locale && typeof locale === 'string') document.documentElement.lang = locale;
|
|
@@ -4532,6 +4698,7 @@ async function runPrerender(config) {
|
|
|
4532
4698
|
liveUrl: (config.liveUrl || '').replace(/\/$/, ''),
|
|
4533
4699
|
pathSeg: is404 ? '__404__' : pathSeg,
|
|
4534
4700
|
snapshotUrl: earlySnapshotUrl,
|
|
4701
|
+
renderedPaths: pathList.filter((p) => p !== NOT_FOUND_PATH),
|
|
4535
4702
|
});
|
|
4536
4703
|
|
|
4537
4704
|
let html = await page.evaluate(() => document.documentElement.outerHTML);
|
|
@@ -4623,6 +4790,7 @@ async function runPrerender(config) {
|
|
|
4623
4790
|
// (Hydration contract was already injected into the raw HTML before
|
|
4624
4791
|
// the Node.js post-processing pipeline ran, so it's already present.)
|
|
4625
4792
|
html = ensureDoctype(html);
|
|
4793
|
+
if (attempt?.abandoned) return; // a retry owns the output now
|
|
4626
4794
|
mkdirSync(outDir, { recursive: true });
|
|
4627
4795
|
writeFileSync(outFile, html, 'utf8');
|
|
4628
4796
|
pushDebug({
|
|
@@ -4633,13 +4801,13 @@ async function runPrerender(config) {
|
|
|
4633
4801
|
hasXForTemplate: html.includes('template x-for') || html.includes('template[x-for]'),
|
|
4634
4802
|
});
|
|
4635
4803
|
} catch (err) {
|
|
4636
|
-
|
|
4637
|
-
|
|
4638
|
-
|
|
4639
|
-
|
|
4640
|
-
if (
|
|
4641
|
-
|
|
4642
|
-
}
|
|
4804
|
+
if (attempt?.abandoned) return;
|
|
4805
|
+
const message = err && err.message ? err.message : String(err);
|
|
4806
|
+
// The worker owns retries and reporting; a shared array can't be popped
|
|
4807
|
+
// safely while other workers push to it.
|
|
4808
|
+
if (attempt) { attempt.failure = { path: displayPath, message }; return; }
|
|
4809
|
+
failedPaths.push({ path: displayPath, message });
|
|
4810
|
+
process.stderr.write(`prerender: failed ${displayPath}: ${message}\n`);
|
|
4643
4811
|
} finally {
|
|
4644
4812
|
try { await page.close(); } catch (_) { /* page may be gone if browser died */ }
|
|
4645
4813
|
}
|
|
@@ -4649,131 +4817,89 @@ async function runPrerender(config) {
|
|
|
4649
4817
|
// Any failures (e.g. transient navigation timeouts) are retried up to
|
|
4650
4818
|
// `maxRetries` times with a short backoff before being reported as fatal.
|
|
4651
4819
|
//
|
|
4652
|
-
// Browser recycling: after every `browserRecycleEvery` successful pages
|
|
4653
|
-
//
|
|
4654
|
-
//
|
|
4820
|
+
// Browser recycling: after every `browserRecycleEvery` successful pages the
|
|
4821
|
+
// gate pauses new work, drains the in-flight pages, swaps the browser and
|
|
4822
|
+
// resumes. Workers hold a gate slot only while they touch the browser.
|
|
4655
4823
|
try {
|
|
4656
4824
|
let index = 0;
|
|
4657
|
-
let activeWorkers = 0;
|
|
4658
|
-
const recycleGate = { resume: null, waitForZero: null };
|
|
4659
|
-
|
|
4660
|
-
const waitUntilZero = () => new Promise((resolve) => {
|
|
4661
|
-
if (activeWorkers === 0) return resolve();
|
|
4662
|
-
recycleGate.waitForZero = resolve;
|
|
4663
|
-
});
|
|
4664
|
-
const waitForResume = () => new Promise((resolve) => {
|
|
4665
|
-
if (!recycleLock.busy) return resolve();
|
|
4666
|
-
const prev = recycleGate.resume;
|
|
4667
|
-
recycleGate.resume = () => { if (prev) prev(); resolve(); };
|
|
4668
|
-
});
|
|
4669
|
-
|
|
4670
|
-
const maybeRecycleBrowser = async () => {
|
|
4671
|
-
if (browserRecycleEvery <= 0) return;
|
|
4672
|
-
if (pagesSinceRecycle < browserRecycleEvery) return;
|
|
4673
|
-
if (recycleLock.busy) return;
|
|
4674
|
-
recycleLock.busy = true;
|
|
4675
|
-
// Wait for all in-flight workers to finish their current page BEFORE
|
|
4676
|
-
// we gate `browserReadyPromise`, so workers already mid-processPath
|
|
4677
|
-
// don't deadlock awaiting a promise we haven't yet started.
|
|
4678
|
-
await waitUntilZero();
|
|
4679
|
-
// Now gate newPage() calls from any worker that enters processPath
|
|
4680
|
-
// after this point.
|
|
4681
|
-
let resolveReady;
|
|
4682
|
-
browserReadyPromise = new Promise((r) => { resolveReady = r; });
|
|
4683
|
-
try {
|
|
4684
|
-
process.stdout.write(`prerender: recycling browser (processed ${pagesSinceRecycle} pages)\n`);
|
|
4685
|
-
try { await browser.close(); } catch (_) {}
|
|
4686
|
-
browser = await launchBrowser();
|
|
4687
|
-
pagesSinceRecycle = 0;
|
|
4688
|
-
} finally {
|
|
4689
|
-
// Release the gate first so any waiting workers can proceed, then
|
|
4690
|
-
// clear the recycle lock so the outer while loop stops pausing.
|
|
4691
|
-
try { resolveReady(); } catch (_) {}
|
|
4692
|
-
recycleLock.busy = false;
|
|
4693
|
-
const r = recycleGate.resume;
|
|
4694
|
-
recycleGate.resume = null;
|
|
4695
|
-
if (r) r();
|
|
4696
|
-
}
|
|
4697
|
-
};
|
|
4698
4825
|
|
|
4699
4826
|
async function worker() {
|
|
4700
4827
|
while (true) {
|
|
4701
|
-
// Pause if a recycle is underway.
|
|
4702
|
-
if (recycleLock.busy) await waitForResume();
|
|
4703
|
-
// Also wait for any pending browser readiness (e.g. another worker
|
|
4704
|
-
// started a recycle while we were processing).
|
|
4705
|
-
await browserReadyPromise;
|
|
4706
|
-
|
|
4707
4828
|
const i = index++;
|
|
4708
4829
|
if (i >= puppeteerPaths.length) return;
|
|
4709
4830
|
const pathSeg = puppeteerPaths[i];
|
|
4831
|
+
const displayPath = pathSeg === '' ? '/' : (pathSeg === NOT_FOUND_PATH ? '/__prerender_404__' : '/' + pathSeg);
|
|
4710
4832
|
let attempt = 0;
|
|
4711
4833
|
while (true) {
|
|
4712
|
-
//
|
|
4713
|
-
|
|
4714
|
-
|
|
4715
|
-
|
|
4716
|
-
const failureCountBefore = failedPaths.length;
|
|
4717
|
-
activeWorkers++;
|
|
4834
|
+
// Per-attempt result: `failedPaths` is shared, so a retry must never
|
|
4835
|
+
// read or pop entries another worker pushed.
|
|
4836
|
+
const attemptToken = { abandoned: false, failure: null };
|
|
4837
|
+
await recycleGate.acquire();
|
|
4718
4838
|
try {
|
|
4719
|
-
|
|
4720
|
-
|
|
4721
|
-
|
|
4722
|
-
|
|
4723
|
-
|
|
4839
|
+
// Watchdog: a wedged renderer never returns, so cap the attempt.
|
|
4840
|
+
const finished = await withDeadline(
|
|
4841
|
+
processPath(pathSeg, i, {
|
|
4842
|
+
attempt: attemptToken,
|
|
4843
|
+
onRawHtml: (seg, html) => {
|
|
4844
|
+
if (seg !== NOT_FOUND_PATH) baseHtmlCache.set(seg || '', html);
|
|
4845
|
+
},
|
|
4846
|
+
}),
|
|
4847
|
+
pageTimeoutMs
|
|
4848
|
+
);
|
|
4849
|
+
if (!finished) {
|
|
4850
|
+
attemptToken.abandoned = true;
|
|
4851
|
+
attemptToken.failure = { path: displayPath, message: `page render exceeded ${pageTimeoutMs}ms` };
|
|
4852
|
+
// A timed-out page is presumed to have wedged the browser (e.g.
|
|
4853
|
+
// a hung page.evaluate on a dead Chrome process) — force a
|
|
4854
|
+
// recycle once this path is done, even if a later retry
|
|
4855
|
+
// "succeeds" by reusing the same possibly-poisoned browser.
|
|
4856
|
+
recycleGate.requestRecycle();
|
|
4857
|
+
}
|
|
4724
4858
|
} catch (err) {
|
|
4725
|
-
// Unexpected exception escaped processPath (e.g. browser died
|
|
4726
|
-
|
|
4727
|
-
|
|
4728
|
-
failedPaths.push({
|
|
4729
|
-
path: pathSeg === '' ? '/' : '/' + pathSeg,
|
|
4859
|
+
// Unexpected exception escaped processPath (e.g. browser died mid-call).
|
|
4860
|
+
attemptToken.failure = {
|
|
4861
|
+
path: displayPath,
|
|
4730
4862
|
message: err && err.message ? err.message : String(err),
|
|
4731
|
-
}
|
|
4732
|
-
if (failedPaths.length <= 10) {
|
|
4733
|
-
process.stderr.write(`prerender: worker exception on ${pathSeg || '/'}: ${failedPaths[failedPaths.length - 1].message}\n`);
|
|
4734
|
-
}
|
|
4863
|
+
};
|
|
4735
4864
|
} finally {
|
|
4736
|
-
|
|
4737
|
-
if (activeWorkers === 0 && recycleGate.waitForZero) {
|
|
4738
|
-
const z = recycleGate.waitForZero;
|
|
4739
|
-
recycleGate.waitForZero = null;
|
|
4740
|
-
z();
|
|
4741
|
-
}
|
|
4865
|
+
recycleGate.release();
|
|
4742
4866
|
}
|
|
4743
|
-
if (
|
|
4744
|
-
|
|
4867
|
+
if (!attemptToken.failure) {
|
|
4868
|
+
recycleGate.countPage();
|
|
4745
4869
|
break; // success
|
|
4746
4870
|
}
|
|
4747
4871
|
if (attempt >= maxRetries) {
|
|
4748
4872
|
// Exhausted retries — likely an unstable browser (e.g. cascading
|
|
4749
|
-
// "detached Frame" errors). Force
|
|
4750
|
-
|
|
4751
|
-
|
|
4873
|
+
// "detached Frame" errors). Force the next path onto a fresh one.
|
|
4874
|
+
failedPaths.push(attemptToken.failure);
|
|
4875
|
+
if (failedPaths.length <= 10) {
|
|
4876
|
+
process.stderr.write(`prerender: failed ${displayPath}: ${attemptToken.failure.message}\n`);
|
|
4877
|
+
}
|
|
4878
|
+
recycleGate.countPage();
|
|
4879
|
+
recycleGate.requestRecycle();
|
|
4752
4880
|
break;
|
|
4753
4881
|
}
|
|
4754
4882
|
// Halfway through retries with no success → preemptively recycle the
|
|
4755
4883
|
// browser before the next attempt. This unblocks cascading frame
|
|
4756
4884
|
// failures where the browser process needs a fresh start.
|
|
4757
|
-
if (attempt + 1 >= Math.ceil(maxRetries / 2) &&
|
|
4758
|
-
|
|
4759
|
-
await
|
|
4885
|
+
if (attempt + 1 >= Math.ceil(maxRetries / 2) && recycleGate.pages > 0) {
|
|
4886
|
+
recycleGate.requestRecycle();
|
|
4887
|
+
await recycleGate.maybeRecycle();
|
|
4760
4888
|
}
|
|
4761
|
-
failedPaths.pop();
|
|
4762
4889
|
attempt++;
|
|
4763
|
-
|
|
4764
|
-
process.stderr.write(`prerender: retrying ${displayPath} (attempt ${attempt + 1}/${maxRetries + 1})\n`);
|
|
4890
|
+
process.stderr.write(`prerender: retrying ${displayPath} (${attemptToken.failure.message}) (attempt ${attempt + 1}/${maxRetries + 1})\n`);
|
|
4765
4891
|
await new Promise((r) => setTimeout(r, 500 * attempt));
|
|
4766
4892
|
}
|
|
4767
|
-
// Attempt recycle after each completed path (only one worker
|
|
4768
|
-
//
|
|
4769
|
-
await
|
|
4893
|
+
// Attempt recycle after each completed path (only one worker performs
|
|
4894
|
+
// it; the rest block in acquire()).
|
|
4895
|
+
await recycleGate.maybeRecycle();
|
|
4770
4896
|
}
|
|
4771
4897
|
}
|
|
4772
4898
|
await Promise.all(
|
|
4773
4899
|
Array.from({ length: Math.min(concurrency, puppeteerPaths.length || 1) }, () => worker())
|
|
4774
4900
|
);
|
|
4775
4901
|
} finally {
|
|
4776
|
-
|
|
4902
|
+
await closeBrowser(browser);
|
|
4777
4903
|
}
|
|
4778
4904
|
|
|
4779
4905
|
// Phase 2: Node.js — generate locale variants via text substitution
|
|
@@ -4842,7 +4968,7 @@ async function runPrerender(config) {
|
|
|
4842
4968
|
} else {
|
|
4843
4969
|
writeFileSync(join(outputResolved, 'prerender.utilities.css'), `${utilMerged}\n`, 'utf8');
|
|
4844
4970
|
process.stdout.write('prerender: wrote prerender.utilities.css (Manifest custom utilities)\n');
|
|
4845
|
-
postProcessInjectStylesheetLink(outputResolved, 'prerender.utilities.css', routerBasePath || '');
|
|
4971
|
+
postProcessInjectStylesheetLink(outputResolved, 'prerender.utilities.css', routerBasePath || '', 'data-mnfst-utilities');
|
|
4846
4972
|
}
|
|
4847
4973
|
}
|
|
4848
4974
|
}
|
|
@@ -4902,4 +5028,11 @@ if (_isDirectEntry) {
|
|
|
4902
5028
|
}
|
|
4903
5029
|
|
|
4904
5030
|
// Exports for the CLI bin script and for unit testing.
|
|
4905
|
-
export {
|
|
5031
|
+
export {
|
|
5032
|
+
main,
|
|
5033
|
+
markPrerenderedManifestComponents,
|
|
5034
|
+
normalizeLocaleRouteExclude,
|
|
5035
|
+
isLocaleRouteExcluded,
|
|
5036
|
+
expandLocaleRoutePaths,
|
|
5037
|
+
prefixLocaleInternalLinks,
|
|
5038
|
+
};
|