mnfst-render 0.5.44 → 0.5.46
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 +206 -153
- package/package.json +2 -2
package/manifest.render.mjs
CHANGED
|
@@ -27,9 +27,7 @@ async function importFromProject(moduleName) {
|
|
|
27
27
|
|
|
28
28
|
|
|
29
29
|
async function flushAlpineEffects(page) {
|
|
30
|
-
// Alpine's scheduler can swallow a nextTick callback
|
|
31
|
-
// already-flushed job is dropped), so this wait must be bounded or a page
|
|
32
|
-
// hangs idle until the per-page timeout kills it.
|
|
30
|
+
// Bounded: Alpine's scheduler can swallow a nextTick callback
|
|
33
31
|
await page
|
|
34
32
|
.evaluate(() => {
|
|
35
33
|
return new Promise((resolve) => {
|
|
@@ -172,8 +170,7 @@ async function waitForManifestRenderReady(page, { allLocales, currentLocale, tim
|
|
|
172
170
|
|
|
173
171
|
// 6. Run component swapping explicitly so components tied to this route render
|
|
174
172
|
// and trigger any $x accesses that start on-demand data loads.
|
|
175
|
-
// Bounded: a stranded component
|
|
176
|
-
// pipeline past the render-ready timeout (step 7 would never run).
|
|
173
|
+
// Bounded: a stranded component load must not hang the pipeline.
|
|
177
174
|
if (window.ManifestComponentsSwapping?.processAll) {
|
|
178
175
|
try {
|
|
179
176
|
await Promise.race([
|
|
@@ -232,16 +229,16 @@ export function isTransientFrameError(message) {
|
|
|
232
229
|
}
|
|
233
230
|
|
|
234
231
|
/**
|
|
235
|
-
* Coordinates
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
* or a stuck browser swap logs and continues instead of hanging the run.
|
|
241
|
-
* Exported for unit tests.
|
|
232
|
+
* Coordinates render workers around a shared browser swapped every `every`
|
|
233
|
+
* completed paths. A recycle pauses new slots, drains in-flight ones, swaps,
|
|
234
|
+
* resumes. Forced recycles are coalesced per browser generation and rate
|
|
235
|
+
* limited by `forcedCooldown` completed paths, unless `healthy()` reports a
|
|
236
|
+
* dead browser. Exported for unit tests.
|
|
242
237
|
*/
|
|
243
238
|
export function createRecycleGate({
|
|
244
239
|
every = 40,
|
|
240
|
+
forcedCooldown = Math.max(1, Math.ceil(every / 4)),
|
|
241
|
+
healthy = () => true,
|
|
245
242
|
recycle = async () => {},
|
|
246
243
|
drainTimeoutMs = 60000,
|
|
247
244
|
recycleTimeoutMs = 180000,
|
|
@@ -249,60 +246,70 @@ export function createRecycleGate({
|
|
|
249
246
|
} = {}) {
|
|
250
247
|
let active = 0;
|
|
251
248
|
let pages = 0;
|
|
249
|
+
let sinceForced = Infinity;
|
|
250
|
+
let forced = false;
|
|
252
251
|
let paused = false;
|
|
252
|
+
let generation = 0;
|
|
253
|
+
let recycles = 0;
|
|
254
|
+
let forcedRecycles = 0;
|
|
253
255
|
const zeroWaiters = [];
|
|
254
256
|
const resumeWaiters = [];
|
|
255
257
|
const wake = (list) => { for (const resolve of list.splice(0)) resolve(); };
|
|
258
|
+
const due = () => every > 0 && (pages >= every || (forced && (sinceForced >= forcedCooldown || !healthy())));
|
|
256
259
|
|
|
257
260
|
return {
|
|
258
261
|
get active() { return active; },
|
|
259
262
|
get pages() { return pages; },
|
|
260
263
|
get paused() { return paused; },
|
|
261
|
-
|
|
264
|
+
get generation() { return generation; },
|
|
265
|
+
get forced() { return forced; },
|
|
266
|
+
get recycles() { return recycles; },
|
|
267
|
+
get forcedRecycles() { return forcedRecycles; },
|
|
268
|
+
/** Claim a browser slot; resolves with the browser generation. */
|
|
262
269
|
async acquire() {
|
|
263
270
|
while (paused) await new Promise((resolve) => { resumeWaiters.push(resolve); });
|
|
264
271
|
active++;
|
|
272
|
+
return generation;
|
|
265
273
|
},
|
|
266
274
|
release() {
|
|
267
275
|
active = Math.max(0, active - 1);
|
|
268
276
|
if (active === 0) wake(zeroWaiters);
|
|
269
277
|
},
|
|
270
|
-
/** Count
|
|
271
|
-
countPage() { pages++; },
|
|
272
|
-
/**
|
|
273
|
-
requestRecycle() {
|
|
274
|
-
|
|
278
|
+
/** Count one completed path. */
|
|
279
|
+
countPage() { pages++; sinceForced++; },
|
|
280
|
+
/** Ask for a fresh browser; ignored if `gen` is already replaced. */
|
|
281
|
+
requestRecycle(gen) {
|
|
282
|
+
if (every <= 0) return false;
|
|
283
|
+
if (gen !== undefined && gen !== generation) return false;
|
|
284
|
+
forced = true;
|
|
285
|
+
return true;
|
|
286
|
+
},
|
|
287
|
+
/** Swap the browser if due. Never throws. */
|
|
275
288
|
async maybeRecycle() {
|
|
276
|
-
if (
|
|
277
|
-
paused = true;
|
|
289
|
+
if (paused || !due()) return false;
|
|
290
|
+
paused = true;
|
|
278
291
|
const processed = pages;
|
|
292
|
+
const isForced = pages < every;
|
|
279
293
|
try {
|
|
280
|
-
|
|
281
|
-
// Never swap the browser under a live page — a per-page caller
|
|
282
|
-
// timeout already bounds how long any page can hold a slot, so
|
|
283
|
-
// waiting here cannot deadlock. drainTimeoutMs is configured to
|
|
284
|
-
// exceed that per-page timeout (see recycleDrainTimeout default in
|
|
285
|
-
// resolveConfig), so hitting it means a page leaked past its own
|
|
286
|
-
// timeout without releasing — a bug, not a normal recycle path.
|
|
287
|
-
// Log it and keep waiting rather than recycle while it's live.
|
|
294
|
+
while (active > 0) {
|
|
288
295
|
const drained = await withDeadline(
|
|
289
296
|
new Promise((resolve) => { zeroWaiters.push(resolve); }),
|
|
290
297
|
drainTimeoutMs
|
|
291
298
|
);
|
|
292
|
-
if (!drained) {
|
|
293
|
-
onLog(`recycle: ${active} page(s) still in flight after ${drainTimeoutMs}ms — waiting (a leaked page never released its slot)`);
|
|
294
|
-
await new Promise((resolve) => { zeroWaiters.push(resolve); });
|
|
295
|
-
}
|
|
299
|
+
if (!drained) onLog(`recycle: ${active} page(s) still in flight after ${drainTimeoutMs}ms — waiting (a leaked page never released its slot)`);
|
|
296
300
|
}
|
|
297
|
-
|
|
301
|
+
recycles++;
|
|
302
|
+
if (isForced) { forcedRecycles++; sinceForced = 0; }
|
|
303
|
+
const swapped = await withDeadline(recycle(processed, { forced: isForced }), recycleTimeoutMs);
|
|
298
304
|
if (!swapped) onLog(`recycle: browser swap exceeded ${recycleTimeoutMs}ms — continuing`);
|
|
299
|
-
pages = 0;
|
|
300
305
|
return swapped;
|
|
301
306
|
} catch (err) {
|
|
302
307
|
onLog(`recycle: failed (${err && err.message ? err.message : err}) — continuing`);
|
|
303
|
-
pages = 0;
|
|
304
308
|
return false;
|
|
305
309
|
} finally {
|
|
310
|
+
pages = 0;
|
|
311
|
+
forced = false;
|
|
312
|
+
generation++;
|
|
306
313
|
paused = false;
|
|
307
314
|
wake(resumeWaiters);
|
|
308
315
|
}
|
|
@@ -310,6 +317,81 @@ export function createRecycleGate({
|
|
|
310
317
|
};
|
|
311
318
|
}
|
|
312
319
|
|
|
320
|
+
/**
|
|
321
|
+
* Render `items` through the gate with per-attempt watchdog and retries.
|
|
322
|
+
* `render(item, index, token)` may set `token.cancel` to tear down a wedged
|
|
323
|
+
* attempt; a timed-out attempt keeps its slot until cancelled/settled
|
|
324
|
+
* (bounded by `abandonGraceMs`) so a swap never lands under it.
|
|
325
|
+
* Exported for unit tests.
|
|
326
|
+
*/
|
|
327
|
+
export async function runRenderQueue({
|
|
328
|
+
items,
|
|
329
|
+
concurrency = 1,
|
|
330
|
+
gate,
|
|
331
|
+
render,
|
|
332
|
+
pageTimeoutMs = 0,
|
|
333
|
+
maxRetries = 2,
|
|
334
|
+
abandonGraceMs = 15000,
|
|
335
|
+
labelOf = (item) => String(item),
|
|
336
|
+
onRetry = () => {},
|
|
337
|
+
onFail = () => {},
|
|
338
|
+
} = {}) {
|
|
339
|
+
let index = 0;
|
|
340
|
+
const failures = [];
|
|
341
|
+
|
|
342
|
+
async function worker() {
|
|
343
|
+
while (true) {
|
|
344
|
+
const i = index++;
|
|
345
|
+
if (i >= items.length) return;
|
|
346
|
+
const item = items[i];
|
|
347
|
+
const label = labelOf(item);
|
|
348
|
+
let attempt = 0;
|
|
349
|
+
while (true) {
|
|
350
|
+
const token = { abandoned: false, failure: null, cancel: null };
|
|
351
|
+
const gen = await gate.acquire();
|
|
352
|
+
let timedOut = false;
|
|
353
|
+
try {
|
|
354
|
+
const run = Promise.resolve().then(() => render(item, i, token));
|
|
355
|
+
const finished = await withDeadline(run, pageTimeoutMs);
|
|
356
|
+
if (!finished) {
|
|
357
|
+
timedOut = true;
|
|
358
|
+
token.abandoned = true;
|
|
359
|
+
token.failure = { path: label, message: `page render exceeded ${pageTimeoutMs}ms` };
|
|
360
|
+
// Hold the slot until the orphan is torn down
|
|
361
|
+
await withDeadline(Promise.resolve().then(() => token.cancel?.()).catch(() => {}), abandonGraceMs);
|
|
362
|
+
await withDeadline(run.catch(() => {}), abandonGraceMs);
|
|
363
|
+
}
|
|
364
|
+
} catch (err) {
|
|
365
|
+
token.failure = { path: label, message: err && err.message ? err.message : String(err) };
|
|
366
|
+
} finally {
|
|
367
|
+
gate.release();
|
|
368
|
+
}
|
|
369
|
+
if (!token.failure) { gate.countPage(); break; }
|
|
370
|
+
if (attempt >= maxRetries) {
|
|
371
|
+
failures.push(token.failure);
|
|
372
|
+
onFail(token.failure, failures.length);
|
|
373
|
+
gate.countPage();
|
|
374
|
+
gate.requestRecycle(gen);
|
|
375
|
+
break;
|
|
376
|
+
}
|
|
377
|
+
const immediate = isTransientFrameError(token.failure.message);
|
|
378
|
+
// Suspect browser: fresh one before the retry
|
|
379
|
+
if (timedOut || (!immediate && attempt + 1 >= Math.ceil(maxRetries / 2))) {
|
|
380
|
+
gate.requestRecycle(gen);
|
|
381
|
+
await gate.maybeRecycle();
|
|
382
|
+
}
|
|
383
|
+
attempt++;
|
|
384
|
+
onRetry(token.failure, attempt, immediate);
|
|
385
|
+
if (!immediate) await new Promise((r) => setTimeout(r, 500 * attempt));
|
|
386
|
+
}
|
|
387
|
+
await gate.maybeRecycle();
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, items.length || 1) }, () => worker()));
|
|
392
|
+
return failures;
|
|
393
|
+
}
|
|
394
|
+
|
|
313
395
|
// --- Config ------------------------------------------------------------------
|
|
314
396
|
|
|
315
397
|
function parseArgs() {
|
|
@@ -758,20 +840,43 @@ function discoverDataPaths(manifest, rootDir, wildcardBases = [], locales = [])
|
|
|
758
840
|
return wildcardBases.some((base) => rest.startsWith(base + '/'));
|
|
759
841
|
}
|
|
760
842
|
|
|
843
|
+
// A source mounts under the wildcard route named after it: `docs/*` for key
|
|
844
|
+
// `docs`, or a nested base ending in the key (`docs/framework/*` for key
|
|
845
|
+
// `framework`).
|
|
846
|
+
const mounts = new Map();
|
|
847
|
+
function mountFor(sourceKey) {
|
|
848
|
+
if (!sourceKey) return null;
|
|
849
|
+
if (mounts.has(sourceKey)) return mounts.get(sourceKey);
|
|
850
|
+
let mount = null;
|
|
851
|
+
if (wildcardBases.includes(sourceKey)) mount = sourceKey;
|
|
852
|
+
else {
|
|
853
|
+
const nested = wildcardBases.filter((b) => b.split('/').pop() === sourceKey);
|
|
854
|
+
if (nested.length === 1) {
|
|
855
|
+
mount = nested[0];
|
|
856
|
+
process.stderr.write(`prerender: data source "${sourceKey}" mounts under ${mount}/*\n`);
|
|
857
|
+
} else if (nested.length > 1) {
|
|
858
|
+
process.stderr.write(`prerender: data source "${sourceKey}" matches ${nested.map((b) => b + '/*').join(', ')}; not mounted (ambiguous)\n`);
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
mounts.set(sourceKey, mount);
|
|
862
|
+
return mount;
|
|
863
|
+
}
|
|
864
|
+
|
|
761
865
|
function expandCandidates(rawPath, sourceKey) {
|
|
762
866
|
const p = String(rawPath || '').replace(/^\/+|\/+$/g, '');
|
|
763
867
|
if (!p) return [];
|
|
764
868
|
const candidates = [p];
|
|
765
869
|
if (wildcardBases.length === 0) return candidates;
|
|
766
|
-
|
|
870
|
+
const mount = mountFor(sourceKey);
|
|
871
|
+
if (!mount) return candidates;
|
|
767
872
|
const parts = p.split('/');
|
|
768
873
|
const hasLocalePrefix = parts.length > 1 && localeSet.has(parts[0].toLowerCase());
|
|
769
874
|
if (hasLocalePrefix) {
|
|
770
875
|
const locale = parts[0];
|
|
771
876
|
const rest = parts.slice(1).join('/');
|
|
772
|
-
if (rest && !rest.startsWith(
|
|
773
|
-
} else if (!p.startsWith(
|
|
774
|
-
candidates.push(`${
|
|
877
|
+
if (rest && !rest.startsWith(mount + '/')) candidates.push(`${locale}/${mount}/${rest}`);
|
|
878
|
+
} else if (!p.startsWith(mount + '/')) {
|
|
879
|
+
candidates.push(`${mount}/${p}`);
|
|
775
880
|
}
|
|
776
881
|
return candidates;
|
|
777
882
|
}
|
|
@@ -3621,17 +3726,13 @@ async function runPrerender(config) {
|
|
|
3621
3726
|
console.error(' npm i -D puppeteer-core @sparticuz/chromium');
|
|
3622
3727
|
process.exit(1);
|
|
3623
3728
|
}
|
|
3624
|
-
//
|
|
3625
|
-
// hang forever on macOS (frames never produced), which wedges og
|
|
3626
|
-
// snapshots and, through the shared browser, stalls sibling pages.
|
|
3729
|
+
// Headless shell: new headless can hang captureScreenshot on macOS
|
|
3627
3730
|
return await puppeteer.default.launch({
|
|
3628
3731
|
headless: 'shell',
|
|
3629
3732
|
args: [
|
|
3630
3733
|
'--no-sandbox',
|
|
3631
3734
|
'--disable-setuid-sandbox',
|
|
3632
|
-
//
|
|
3633
|
-
// backgrounded tab stops producing frames and firing rAF/timers, so
|
|
3634
|
-
// its captureScreenshot (og snapshots) and settle waits hang forever.
|
|
3735
|
+
// Background tabs must keep producing frames and timers
|
|
3635
3736
|
'--disable-background-timer-throttling',
|
|
3636
3737
|
'--disable-backgrounding-occluded-windows',
|
|
3637
3738
|
'--disable-renderer-backgrounding',
|
|
@@ -3695,9 +3796,10 @@ async function runPrerender(config) {
|
|
|
3695
3796
|
every: browserRecycleEvery,
|
|
3696
3797
|
drainTimeoutMs,
|
|
3697
3798
|
recycleTimeoutMs,
|
|
3799
|
+
healthy: () => !!browser && browser.connected !== false,
|
|
3698
3800
|
onLog: (message) => process.stderr.write(`prerender: ${message}\n`),
|
|
3699
|
-
recycle: async (processed) => {
|
|
3700
|
-
process.stdout.write(`prerender: recycling browser (processed ${processed} pages)\n`);
|
|
3801
|
+
recycle: async (processed, { forced } = {}) => {
|
|
3802
|
+
process.stdout.write(`prerender: recycling browser (${forced ? `forced after ${processed}` : `processed ${processed}`} pages)\n`);
|
|
3701
3803
|
await closeBrowser(browser);
|
|
3702
3804
|
browser = await launchBrowserBounded();
|
|
3703
3805
|
},
|
|
@@ -3855,6 +3957,7 @@ async function runPrerender(config) {
|
|
|
3855
3957
|
// No recycle can run while the caller holds a gate slot, so `browser` is
|
|
3856
3958
|
// live for the whole call.
|
|
3857
3959
|
const page = await browser.newPage();
|
|
3960
|
+
if (attempt) attempt.cancel = () => page.close();
|
|
3858
3961
|
// Render at a typical desktop viewport so layouts dependent on viewport
|
|
3859
3962
|
// width (responsive flex/grid, container queries, media queries) settle
|
|
3860
3963
|
// into their desktop variant. Without this the headless default (often
|
|
@@ -4094,8 +4197,7 @@ async function runPrerender(config) {
|
|
|
4094
4197
|
// small ones settle in well under 1 second.
|
|
4095
4198
|
await page.waitForNetworkIdle({ idleTime: 1000, timeout: 8000 }).catch(() => { });
|
|
4096
4199
|
|
|
4097
|
-
// DOM stability after network idle
|
|
4098
|
-
// continuously must not reset the quiet window forever.
|
|
4200
|
+
// DOM stability after network idle (hard-capped).
|
|
4099
4201
|
await page.evaluate(() => {
|
|
4100
4202
|
return new Promise((resolve) => {
|
|
4101
4203
|
const observer = new MutationObserver(() => {
|
|
@@ -4136,8 +4238,7 @@ async function runPrerender(config) {
|
|
|
4136
4238
|
const ogImageHandled = !!config.seo.meta?.image
|
|
4137
4239
|
|| await page.evaluate(() => !!document.head.querySelector('meta[property="og:image"]'));
|
|
4138
4240
|
if (!ogImageHandled) {
|
|
4139
|
-
// Bounded: a CDP call
|
|
4140
|
-
// image, not the whole render (the meta fallback image covers it).
|
|
4241
|
+
// Bounded: a hung CDP call costs only this page's og image
|
|
4141
4242
|
earlySnapshotUrl = await Promise.race([
|
|
4142
4243
|
takeOgSnapshot(page, config.output, is404 ? '__404__' : pathSeg, globalAssetSig, ogCacheDir),
|
|
4143
4244
|
new Promise((resolve) => setTimeout(() => resolve(null), 30000)),
|
|
@@ -4315,6 +4416,25 @@ async function runPrerender(config) {
|
|
|
4315
4416
|
await page.evaluate(() => {
|
|
4316
4417
|
document.querySelectorAll('[x-markdown]').forEach((el) => {
|
|
4317
4418
|
if (!el.textContent.trim() && !el.innerHTML.trim()) return;
|
|
4419
|
+
// Only FILE-BACKED markdown is safe to freeze: its content is baked and
|
|
4420
|
+
// the runtime would only re-fetch and re-render the same thing (with an
|
|
4421
|
+
// opacity flash, or a wipe when a route-keyed source starts empty). The
|
|
4422
|
+
// plugin stamps those with data-mnfst-md-src before fetching. An inline
|
|
4423
|
+
// reactive value (x-markdown="note") must KEEP its binding — stripping
|
|
4424
|
+
// it froze live-updating markdown mirrors on prerendered pages.
|
|
4425
|
+
const src = el.getAttribute('data-mnfst-md-src');
|
|
4426
|
+
const literal = /^\s*(['"`]).*\1\s*$/.test(el.getAttribute('x-markdown') || '');
|
|
4427
|
+
if (!src && !literal) {
|
|
4428
|
+
// Reactive: keep the binding, still clean the plugin's leftover styles.
|
|
4429
|
+
const s = el.getAttribute('style') || '';
|
|
4430
|
+
if (s) {
|
|
4431
|
+
const c = s.replace(/\bopacity\s*:\s*0(?:\.\d+)?\s*;?/gi, '')
|
|
4432
|
+
.replace(/\btransition\s*:\s*opacity[^;]*;?/gi, '')
|
|
4433
|
+
.replace(/;\s*;/g, ';').replace(/^\s*;\s*|\s*;\s*$/g, '').trim();
|
|
4434
|
+
if (c) el.setAttribute('style', c); else el.removeAttribute('style');
|
|
4435
|
+
}
|
|
4436
|
+
return;
|
|
4437
|
+
}
|
|
4318
4438
|
el.removeAttribute('x-markdown');
|
|
4319
4439
|
// Clean up opacity-0 + transition inline styles the plugin left behind.
|
|
4320
4440
|
const style = el.getAttribute('style') || '';
|
|
@@ -4744,14 +4864,17 @@ async function runPrerender(config) {
|
|
|
4744
4864
|
if (name.startsWith(':')) boundAttr = name.slice(1);
|
|
4745
4865
|
else if (name.startsWith('x-bind:')) boundAttr = name.slice('x-bind:'.length);
|
|
4746
4866
|
if (boundAttr) {
|
|
4867
|
+
// Removing :foo triggers Alpine's binding teardown, which
|
|
4868
|
+
// restores the bound attribute to its pre-binding value
|
|
4869
|
+
// (empty for clones whose template had no static class).
|
|
4870
|
+
// Snapshot the eval'd value and re-set it after removal so
|
|
4871
|
+
// the baked attribute survives the unbind. A binding whose
|
|
4872
|
+
// value evaluated to null (no attribute) is removed all the
|
|
4873
|
+
// same — left in place it re-evaluates at runtime outside
|
|
4874
|
+
// the loop scope and throws on every render.
|
|
4747
4875
|
const concrete = node.getAttribute(boundAttr);
|
|
4876
|
+
node.removeAttribute(name);
|
|
4748
4877
|
if (concrete != null && String(concrete).trim() !== '') {
|
|
4749
|
-
// Removing :foo triggers Alpine's binding teardown, which
|
|
4750
|
-
// restores the bound attribute to its pre-binding value
|
|
4751
|
-
// (empty for clones whose template had no static class).
|
|
4752
|
-
// Snapshot the eval'd value and re-set it after removal so
|
|
4753
|
-
// the baked attribute survives the unbind.
|
|
4754
|
-
node.removeAttribute(name);
|
|
4755
4878
|
node.setAttribute(boundAttr, concrete);
|
|
4756
4879
|
}
|
|
4757
4880
|
continue;
|
|
@@ -5190,103 +5313,32 @@ async function runPrerender(config) {
|
|
|
5190
5313
|
}
|
|
5191
5314
|
}
|
|
5192
5315
|
|
|
5193
|
-
// Phase 1: Puppeteer — render base paths, cache raw DOM for substitution
|
|
5194
|
-
// Any failures (e.g. transient navigation timeouts) are retried up to
|
|
5195
|
-
// `maxRetries` times with a short backoff before being reported as fatal.
|
|
5196
|
-
//
|
|
5197
|
-
// Browser recycling: after every `browserRecycleEvery` successful pages the
|
|
5198
|
-
// gate pauses new work, drains the in-flight pages, swaps the browser and
|
|
5199
|
-
// resumes. Workers hold a gate slot only while they touch the browser.
|
|
5200
|
-
//
|
|
5201
|
-
// A failure whose message matches a transient Puppeteer/browser-recycle race
|
|
5202
|
-
// (detached frame, destroyed execution context) is requeued immediately —
|
|
5203
|
-
// no backoff — since waiting doesn't help a race and each attempt already
|
|
5204
|
-
// gets its own fresh `pageTimeoutMs` budget.
|
|
5316
|
+
// Phase 1: Puppeteer — render base paths, cache raw DOM for substitution
|
|
5205
5317
|
async function renderPuppeteerPaths(paths) {
|
|
5206
|
-
|
|
5207
|
-
|
|
5208
|
-
|
|
5209
|
-
|
|
5210
|
-
|
|
5211
|
-
|
|
5212
|
-
|
|
5213
|
-
|
|
5214
|
-
|
|
5215
|
-
|
|
5216
|
-
|
|
5217
|
-
|
|
5218
|
-
|
|
5219
|
-
|
|
5220
|
-
|
|
5221
|
-
|
|
5222
|
-
|
|
5223
|
-
|
|
5224
|
-
|
|
5225
|
-
|
|
5226
|
-
|
|
5227
|
-
|
|
5228
|
-
|
|
5229
|
-
|
|
5230
|
-
}),
|
|
5231
|
-
pageTimeoutMs
|
|
5232
|
-
);
|
|
5233
|
-
if (!finished) {
|
|
5234
|
-
attemptToken.abandoned = true;
|
|
5235
|
-
attemptToken.failure = { path: displayPath, message: `page render exceeded ${pageTimeoutMs}ms` };
|
|
5236
|
-
// A timed-out page is presumed to have wedged the browser (e.g.
|
|
5237
|
-
// a hung page.evaluate on a dead Chrome process) — force a
|
|
5238
|
-
// recycle once this path is done, even if a later retry
|
|
5239
|
-
// "succeeds" by reusing the same possibly-poisoned browser.
|
|
5240
|
-
recycleGate.requestRecycle();
|
|
5241
|
-
}
|
|
5242
|
-
} catch (err) {
|
|
5243
|
-
// Unexpected exception escaped processPath (e.g. browser died mid-call).
|
|
5244
|
-
attemptToken.failure = {
|
|
5245
|
-
path: displayPath,
|
|
5246
|
-
message: err && err.message ? err.message : String(err),
|
|
5247
|
-
};
|
|
5248
|
-
} finally {
|
|
5249
|
-
recycleGate.release();
|
|
5250
|
-
}
|
|
5251
|
-
if (!attemptToken.failure) {
|
|
5252
|
-
recycleGate.countPage();
|
|
5253
|
-
break; // success
|
|
5254
|
-
}
|
|
5255
|
-
if (attempt >= maxRetries) {
|
|
5256
|
-
// Exhausted retries — likely an unstable browser (e.g. cascading
|
|
5257
|
-
// "detached Frame" errors). Force the next path onto a fresh one.
|
|
5258
|
-
failedPaths.push(attemptToken.failure);
|
|
5259
|
-
if (failedPaths.length <= 10) {
|
|
5260
|
-
process.stderr.write(`prerender: failed ${displayPath}: ${attemptToken.failure.message}\n`);
|
|
5261
|
-
}
|
|
5262
|
-
recycleGate.countPage();
|
|
5263
|
-
recycleGate.requestRecycle();
|
|
5264
|
-
break;
|
|
5265
|
-
}
|
|
5266
|
-
const immediateRequeue = isTransientFrameError(attemptToken.failure.message);
|
|
5267
|
-
// Halfway through retries with no success → preemptively recycle the
|
|
5268
|
-
// browser before the next attempt. This unblocks cascading frame
|
|
5269
|
-
// failures where the browser process needs a fresh start. Skipped
|
|
5270
|
-
// for a transient frame error — that's likely already a recycle
|
|
5271
|
-
// race, not a signal to force another one.
|
|
5272
|
-
if (!immediateRequeue && attempt + 1 >= Math.ceil(maxRetries / 2) && recycleGate.pages > 0) {
|
|
5273
|
-
recycleGate.requestRecycle();
|
|
5274
|
-
await recycleGate.maybeRecycle();
|
|
5275
|
-
}
|
|
5276
|
-
attempt++;
|
|
5277
|
-
process.stderr.write(
|
|
5278
|
-
`prerender: retrying ${displayPath} (${attemptToken.failure.message}) (attempt ${attempt + 1}/${maxRetries + 1})${immediateRequeue ? ' — immediate requeue' : ''}\n`
|
|
5279
|
-
);
|
|
5280
|
-
if (!immediateRequeue) await new Promise((r) => setTimeout(r, 500 * attempt));
|
|
5281
|
-
}
|
|
5282
|
-
// Attempt recycle after each completed path (only one worker performs
|
|
5283
|
-
// it; the rest block in acquire()).
|
|
5284
|
-
await recycleGate.maybeRecycle();
|
|
5285
|
-
}
|
|
5286
|
-
}
|
|
5287
|
-
await Promise.all(
|
|
5288
|
-
Array.from({ length: Math.min(concurrency, total || 1) }, () => worker())
|
|
5289
|
-
);
|
|
5318
|
+
const failures = await runRenderQueue({
|
|
5319
|
+
items: paths,
|
|
5320
|
+
concurrency,
|
|
5321
|
+
gate: recycleGate,
|
|
5322
|
+
pageTimeoutMs,
|
|
5323
|
+
maxRetries,
|
|
5324
|
+
labelOf: (seg) => (seg === '' ? '/' : (seg === NOT_FOUND_PATH ? '/__prerender_404__' : '/' + seg)),
|
|
5325
|
+
render: (pathSeg, i, attempt) => processPath(pathSeg, i, {
|
|
5326
|
+
attempt,
|
|
5327
|
+
onRawHtml: (seg, html) => {
|
|
5328
|
+
if (seg !== NOT_FOUND_PATH) baseHtmlCache.set(seg || '', html);
|
|
5329
|
+
},
|
|
5330
|
+
onLocaleDependentContent: promoteLocaleVariants,
|
|
5331
|
+
}),
|
|
5332
|
+
onRetry: (failure, attempt, immediate) => {
|
|
5333
|
+
process.stderr.write(
|
|
5334
|
+
`prerender: retrying ${failure.path} (${failure.message}) (attempt ${attempt + 1}/${maxRetries + 1})${immediate ? ' — immediate requeue' : ''}\n`
|
|
5335
|
+
);
|
|
5336
|
+
},
|
|
5337
|
+
onFail: (failure, count) => {
|
|
5338
|
+
if (count <= 10) process.stderr.write(`prerender: failed ${failure.path}: ${failure.message}\n`);
|
|
5339
|
+
},
|
|
5340
|
+
});
|
|
5341
|
+
failedPaths.push(...failures);
|
|
5290
5342
|
}
|
|
5291
5343
|
|
|
5292
5344
|
try {
|
|
@@ -5433,6 +5485,7 @@ if (_isDirectEntry) {
|
|
|
5433
5485
|
// Exports for the CLI bin script and for unit testing.
|
|
5434
5486
|
export {
|
|
5435
5487
|
main,
|
|
5488
|
+
discoverRoutes,
|
|
5436
5489
|
acquireOutputLock,
|
|
5437
5490
|
outputLockPath,
|
|
5438
5491
|
markPrerenderedManifestComponents,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mnfst-render",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.46",
|
|
4
4
|
"description": "Render Manifest sites to static HTML for SEO",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"license": "MIT",
|
|
33
33
|
"repository": {
|
|
34
34
|
"type": "git",
|
|
35
|
-
"url": "git+https://github.com/
|
|
35
|
+
"url": "git+https://github.com/Manifest-X/Manifest.git",
|
|
36
36
|
"directory": "packages/render"
|
|
37
37
|
}
|
|
38
38
|
}
|