mnfst-render 0.5.43 → 0.5.45
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 +202 -144
- package/package.json +2 -2
package/manifest.render.mjs
CHANGED
|
@@ -27,20 +27,23 @@ async function importFromProject(moduleName) {
|
|
|
27
27
|
|
|
28
28
|
|
|
29
29
|
async function flushAlpineEffects(page) {
|
|
30
|
+
// Bounded: Alpine's scheduler can swallow a nextTick callback
|
|
30
31
|
await page
|
|
31
32
|
.evaluate(() => {
|
|
32
33
|
return new Promise((resolve) => {
|
|
34
|
+
const cap = setTimeout(resolve, 3000);
|
|
35
|
+
const done = () => { clearTimeout(cap); resolve(); };
|
|
33
36
|
try {
|
|
34
37
|
if (typeof Alpine !== 'undefined' && typeof Alpine.nextTick === 'function') {
|
|
35
38
|
Alpine.nextTick(() => {
|
|
36
|
-
if (typeof Alpine.nextTick === 'function') Alpine.nextTick(
|
|
37
|
-
else
|
|
39
|
+
if (typeof Alpine.nextTick === 'function') Alpine.nextTick(done);
|
|
40
|
+
else done();
|
|
38
41
|
});
|
|
39
42
|
} else {
|
|
40
|
-
queueMicrotask(
|
|
43
|
+
queueMicrotask(done);
|
|
41
44
|
}
|
|
42
45
|
} catch {
|
|
43
|
-
|
|
46
|
+
done();
|
|
44
47
|
}
|
|
45
48
|
});
|
|
46
49
|
})
|
|
@@ -149,7 +152,10 @@ async function waitForManifestRenderReady(page, { allLocales, currentLocale, tim
|
|
|
149
152
|
try {
|
|
150
153
|
const cfg = window.ManifestDataConfig;
|
|
151
154
|
const main = window.ManifestDataMain;
|
|
152
|
-
const manifest = await
|
|
155
|
+
const manifest = await Promise.race([
|
|
156
|
+
Promise.resolve(cfg?.ensureManifest?.()),
|
|
157
|
+
new Promise((resolve) => setTimeout(resolve, 5000)),
|
|
158
|
+
]);
|
|
153
159
|
if (manifest?.data && typeof main?.loadDataSource === 'function') {
|
|
154
160
|
const isAppwrite = cfg.isAppwriteCollection;
|
|
155
161
|
for (const [name, source] of Object.entries(manifest.data)) {
|
|
@@ -164,9 +170,13 @@ async function waitForManifestRenderReady(page, { allLocales, currentLocale, tim
|
|
|
164
170
|
|
|
165
171
|
// 6. Run component swapping explicitly so components tied to this route render
|
|
166
172
|
// and trigger any $x accesses that start on-demand data loads.
|
|
173
|
+
// Bounded: a stranded component load must not hang the pipeline.
|
|
167
174
|
if (window.ManifestComponentsSwapping?.processAll) {
|
|
168
175
|
try {
|
|
169
|
-
await
|
|
176
|
+
await Promise.race([
|
|
177
|
+
window.ManifestComponentsSwapping.processAll(normalizedPath),
|
|
178
|
+
new Promise((resolve) => setTimeout(resolve, ms)),
|
|
179
|
+
]);
|
|
170
180
|
} catch (e) {
|
|
171
181
|
return { ok: false, reason: 'processAll-error', message: String(e?.message || e) };
|
|
172
182
|
}
|
|
@@ -219,16 +229,16 @@ export function isTransientFrameError(message) {
|
|
|
219
229
|
}
|
|
220
230
|
|
|
221
231
|
/**
|
|
222
|
-
* Coordinates
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
* or a stuck browser swap logs and continues instead of hanging the run.
|
|
228
|
-
* 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.
|
|
229
237
|
*/
|
|
230
238
|
export function createRecycleGate({
|
|
231
239
|
every = 40,
|
|
240
|
+
forcedCooldown = Math.max(1, Math.ceil(every / 4)),
|
|
241
|
+
healthy = () => true,
|
|
232
242
|
recycle = async () => {},
|
|
233
243
|
drainTimeoutMs = 60000,
|
|
234
244
|
recycleTimeoutMs = 180000,
|
|
@@ -236,60 +246,70 @@ export function createRecycleGate({
|
|
|
236
246
|
} = {}) {
|
|
237
247
|
let active = 0;
|
|
238
248
|
let pages = 0;
|
|
249
|
+
let sinceForced = Infinity;
|
|
250
|
+
let forced = false;
|
|
239
251
|
let paused = false;
|
|
252
|
+
let generation = 0;
|
|
253
|
+
let recycles = 0;
|
|
254
|
+
let forcedRecycles = 0;
|
|
240
255
|
const zeroWaiters = [];
|
|
241
256
|
const resumeWaiters = [];
|
|
242
257
|
const wake = (list) => { for (const resolve of list.splice(0)) resolve(); };
|
|
258
|
+
const due = () => every > 0 && (pages >= every || (forced && (sinceForced >= forcedCooldown || !healthy())));
|
|
243
259
|
|
|
244
260
|
return {
|
|
245
261
|
get active() { return active; },
|
|
246
262
|
get pages() { return pages; },
|
|
247
263
|
get paused() { return paused; },
|
|
248
|
-
|
|
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. */
|
|
249
269
|
async acquire() {
|
|
250
270
|
while (paused) await new Promise((resolve) => { resumeWaiters.push(resolve); });
|
|
251
271
|
active++;
|
|
272
|
+
return generation;
|
|
252
273
|
},
|
|
253
274
|
release() {
|
|
254
275
|
active = Math.max(0, active - 1);
|
|
255
276
|
if (active === 0) wake(zeroWaiters);
|
|
256
277
|
},
|
|
257
|
-
/** Count
|
|
258
|
-
countPage() { pages++; },
|
|
259
|
-
/**
|
|
260
|
-
requestRecycle() {
|
|
261
|
-
|
|
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. */
|
|
262
288
|
async maybeRecycle() {
|
|
263
|
-
if (
|
|
264
|
-
paused = true;
|
|
289
|
+
if (paused || !due()) return false;
|
|
290
|
+
paused = true;
|
|
265
291
|
const processed = pages;
|
|
292
|
+
const isForced = pages < every;
|
|
266
293
|
try {
|
|
267
|
-
|
|
268
|
-
// Never swap the browser under a live page — a per-page caller
|
|
269
|
-
// timeout already bounds how long any page can hold a slot, so
|
|
270
|
-
// waiting here cannot deadlock. drainTimeoutMs is configured to
|
|
271
|
-
// exceed that per-page timeout (see recycleDrainTimeout default in
|
|
272
|
-
// resolveConfig), so hitting it means a page leaked past its own
|
|
273
|
-
// timeout without releasing — a bug, not a normal recycle path.
|
|
274
|
-
// Log it and keep waiting rather than recycle while it's live.
|
|
294
|
+
while (active > 0) {
|
|
275
295
|
const drained = await withDeadline(
|
|
276
296
|
new Promise((resolve) => { zeroWaiters.push(resolve); }),
|
|
277
297
|
drainTimeoutMs
|
|
278
298
|
);
|
|
279
|
-
if (!drained) {
|
|
280
|
-
onLog(`recycle: ${active} page(s) still in flight after ${drainTimeoutMs}ms — waiting (a leaked page never released its slot)`);
|
|
281
|
-
await new Promise((resolve) => { zeroWaiters.push(resolve); });
|
|
282
|
-
}
|
|
299
|
+
if (!drained) onLog(`recycle: ${active} page(s) still in flight after ${drainTimeoutMs}ms — waiting (a leaked page never released its slot)`);
|
|
283
300
|
}
|
|
284
|
-
|
|
301
|
+
recycles++;
|
|
302
|
+
if (isForced) { forcedRecycles++; sinceForced = 0; }
|
|
303
|
+
const swapped = await withDeadline(recycle(processed, { forced: isForced }), recycleTimeoutMs);
|
|
285
304
|
if (!swapped) onLog(`recycle: browser swap exceeded ${recycleTimeoutMs}ms — continuing`);
|
|
286
|
-
pages = 0;
|
|
287
305
|
return swapped;
|
|
288
306
|
} catch (err) {
|
|
289
307
|
onLog(`recycle: failed (${err && err.message ? err.message : err}) — continuing`);
|
|
290
|
-
pages = 0;
|
|
291
308
|
return false;
|
|
292
309
|
} finally {
|
|
310
|
+
pages = 0;
|
|
311
|
+
forced = false;
|
|
312
|
+
generation++;
|
|
293
313
|
paused = false;
|
|
294
314
|
wake(resumeWaiters);
|
|
295
315
|
}
|
|
@@ -297,6 +317,81 @@ export function createRecycleGate({
|
|
|
297
317
|
};
|
|
298
318
|
}
|
|
299
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
|
+
|
|
300
395
|
// --- Config ------------------------------------------------------------------
|
|
301
396
|
|
|
302
397
|
function parseArgs() {
|
|
@@ -3608,11 +3703,16 @@ async function runPrerender(config) {
|
|
|
3608
3703
|
console.error(' npm i -D puppeteer-core @sparticuz/chromium');
|
|
3609
3704
|
process.exit(1);
|
|
3610
3705
|
}
|
|
3706
|
+
// Headless shell: new headless can hang captureScreenshot on macOS
|
|
3611
3707
|
return await puppeteer.default.launch({
|
|
3612
|
-
headless:
|
|
3708
|
+
headless: 'shell',
|
|
3613
3709
|
args: [
|
|
3614
3710
|
'--no-sandbox',
|
|
3615
3711
|
'--disable-setuid-sandbox',
|
|
3712
|
+
// Background tabs must keep producing frames and timers
|
|
3713
|
+
'--disable-background-timer-throttling',
|
|
3714
|
+
'--disable-backgrounding-occluded-windows',
|
|
3715
|
+
'--disable-renderer-backgrounding',
|
|
3616
3716
|
],
|
|
3617
3717
|
});
|
|
3618
3718
|
}
|
|
@@ -3673,9 +3773,10 @@ async function runPrerender(config) {
|
|
|
3673
3773
|
every: browserRecycleEvery,
|
|
3674
3774
|
drainTimeoutMs,
|
|
3675
3775
|
recycleTimeoutMs,
|
|
3776
|
+
healthy: () => !!browser && browser.connected !== false,
|
|
3676
3777
|
onLog: (message) => process.stderr.write(`prerender: ${message}\n`),
|
|
3677
|
-
recycle: async (processed) => {
|
|
3678
|
-
process.stdout.write(`prerender: recycling browser (processed ${processed} pages)\n`);
|
|
3778
|
+
recycle: async (processed, { forced } = {}) => {
|
|
3779
|
+
process.stdout.write(`prerender: recycling browser (${forced ? `forced after ${processed}` : `processed ${processed}`} pages)\n`);
|
|
3679
3780
|
await closeBrowser(browser);
|
|
3680
3781
|
browser = await launchBrowserBounded();
|
|
3681
3782
|
},
|
|
@@ -3833,6 +3934,7 @@ async function runPrerender(config) {
|
|
|
3833
3934
|
// No recycle can run while the caller holds a gate slot, so `browser` is
|
|
3834
3935
|
// live for the whole call.
|
|
3835
3936
|
const page = await browser.newPage();
|
|
3937
|
+
if (attempt) attempt.cancel = () => page.close();
|
|
3836
3938
|
// Render at a typical desktop viewport so layouts dependent on viewport
|
|
3837
3939
|
// width (responsive flex/grid, container queries, media queries) settle
|
|
3838
3940
|
// into their desktop variant. Without this the headless default (often
|
|
@@ -4072,7 +4174,7 @@ async function runPrerender(config) {
|
|
|
4072
4174
|
// small ones settle in well under 1 second.
|
|
4073
4175
|
await page.waitForNetworkIdle({ idleTime: 1000, timeout: 8000 }).catch(() => { });
|
|
4074
4176
|
|
|
4075
|
-
// DOM stability after network idle.
|
|
4177
|
+
// DOM stability after network idle (hard-capped).
|
|
4076
4178
|
await page.evaluate(() => {
|
|
4077
4179
|
return new Promise((resolve) => {
|
|
4078
4180
|
const observer = new MutationObserver(() => {
|
|
@@ -4080,8 +4182,9 @@ async function runPrerender(config) {
|
|
|
4080
4182
|
stable = setTimeout(finish, 500);
|
|
4081
4183
|
});
|
|
4082
4184
|
observer.observe(document.documentElement, { childList: true, subtree: true, characterData: true });
|
|
4083
|
-
const finish = () => { observer.disconnect(); resolve(); };
|
|
4185
|
+
const finish = () => { clearTimeout(cap); observer.disconnect(); resolve(); };
|
|
4084
4186
|
let stable = setTimeout(finish, 500);
|
|
4187
|
+
const cap = setTimeout(finish, 15000);
|
|
4085
4188
|
});
|
|
4086
4189
|
}).catch(() => { });
|
|
4087
4190
|
|
|
@@ -4112,7 +4215,11 @@ async function runPrerender(config) {
|
|
|
4112
4215
|
const ogImageHandled = !!config.seo.meta?.image
|
|
4113
4216
|
|| await page.evaluate(() => !!document.head.querySelector('meta[property="og:image"]'));
|
|
4114
4217
|
if (!ogImageHandled) {
|
|
4115
|
-
|
|
4218
|
+
// Bounded: a hung CDP call costs only this page's og image
|
|
4219
|
+
earlySnapshotUrl = await Promise.race([
|
|
4220
|
+
takeOgSnapshot(page, config.output, is404 ? '__404__' : pathSeg, globalAssetSig, ogCacheDir),
|
|
4221
|
+
new Promise((resolve) => setTimeout(() => resolve(null), 30000)),
|
|
4222
|
+
]);
|
|
4116
4223
|
}
|
|
4117
4224
|
}
|
|
4118
4225
|
|
|
@@ -4286,6 +4393,25 @@ async function runPrerender(config) {
|
|
|
4286
4393
|
await page.evaluate(() => {
|
|
4287
4394
|
document.querySelectorAll('[x-markdown]').forEach((el) => {
|
|
4288
4395
|
if (!el.textContent.trim() && !el.innerHTML.trim()) return;
|
|
4396
|
+
// Only FILE-BACKED markdown is safe to freeze: its content is baked and
|
|
4397
|
+
// the runtime would only re-fetch and re-render the same thing (with an
|
|
4398
|
+
// opacity flash, or a wipe when a route-keyed source starts empty). The
|
|
4399
|
+
// plugin stamps those with data-mnfst-md-src before fetching. An inline
|
|
4400
|
+
// reactive value (x-markdown="note") must KEEP its binding — stripping
|
|
4401
|
+
// it froze live-updating markdown mirrors on prerendered pages.
|
|
4402
|
+
const src = el.getAttribute('data-mnfst-md-src');
|
|
4403
|
+
const literal = /^\s*(['"`]).*\1\s*$/.test(el.getAttribute('x-markdown') || '');
|
|
4404
|
+
if (!src && !literal) {
|
|
4405
|
+
// Reactive: keep the binding, still clean the plugin's leftover styles.
|
|
4406
|
+
const s = el.getAttribute('style') || '';
|
|
4407
|
+
if (s) {
|
|
4408
|
+
const c = s.replace(/\bopacity\s*:\s*0(?:\.\d+)?\s*;?/gi, '')
|
|
4409
|
+
.replace(/\btransition\s*:\s*opacity[^;]*;?/gi, '')
|
|
4410
|
+
.replace(/;\s*;/g, ';').replace(/^\s*;\s*|\s*;\s*$/g, '').trim();
|
|
4411
|
+
if (c) el.setAttribute('style', c); else el.removeAttribute('style');
|
|
4412
|
+
}
|
|
4413
|
+
return;
|
|
4414
|
+
}
|
|
4289
4415
|
el.removeAttribute('x-markdown');
|
|
4290
4416
|
// Clean up opacity-0 + transition inline styles the plugin left behind.
|
|
4291
4417
|
const style = el.getAttribute('style') || '';
|
|
@@ -4715,14 +4841,17 @@ async function runPrerender(config) {
|
|
|
4715
4841
|
if (name.startsWith(':')) boundAttr = name.slice(1);
|
|
4716
4842
|
else if (name.startsWith('x-bind:')) boundAttr = name.slice('x-bind:'.length);
|
|
4717
4843
|
if (boundAttr) {
|
|
4844
|
+
// Removing :foo triggers Alpine's binding teardown, which
|
|
4845
|
+
// restores the bound attribute to its pre-binding value
|
|
4846
|
+
// (empty for clones whose template had no static class).
|
|
4847
|
+
// Snapshot the eval'd value and re-set it after removal so
|
|
4848
|
+
// the baked attribute survives the unbind. A binding whose
|
|
4849
|
+
// value evaluated to null (no attribute) is removed all the
|
|
4850
|
+
// same — left in place it re-evaluates at runtime outside
|
|
4851
|
+
// the loop scope and throws on every render.
|
|
4718
4852
|
const concrete = node.getAttribute(boundAttr);
|
|
4853
|
+
node.removeAttribute(name);
|
|
4719
4854
|
if (concrete != null && String(concrete).trim() !== '') {
|
|
4720
|
-
// Removing :foo triggers Alpine's binding teardown, which
|
|
4721
|
-
// restores the bound attribute to its pre-binding value
|
|
4722
|
-
// (empty for clones whose template had no static class).
|
|
4723
|
-
// Snapshot the eval'd value and re-set it after removal so
|
|
4724
|
-
// the baked attribute survives the unbind.
|
|
4725
|
-
node.removeAttribute(name);
|
|
4726
4855
|
node.setAttribute(boundAttr, concrete);
|
|
4727
4856
|
}
|
|
4728
4857
|
continue;
|
|
@@ -5161,103 +5290,32 @@ async function runPrerender(config) {
|
|
|
5161
5290
|
}
|
|
5162
5291
|
}
|
|
5163
5292
|
|
|
5164
|
-
// Phase 1: Puppeteer — render base paths, cache raw DOM for substitution
|
|
5165
|
-
// Any failures (e.g. transient navigation timeouts) are retried up to
|
|
5166
|
-
// `maxRetries` times with a short backoff before being reported as fatal.
|
|
5167
|
-
//
|
|
5168
|
-
// Browser recycling: after every `browserRecycleEvery` successful pages the
|
|
5169
|
-
// gate pauses new work, drains the in-flight pages, swaps the browser and
|
|
5170
|
-
// resumes. Workers hold a gate slot only while they touch the browser.
|
|
5171
|
-
//
|
|
5172
|
-
// A failure whose message matches a transient Puppeteer/browser-recycle race
|
|
5173
|
-
// (detached frame, destroyed execution context) is requeued immediately —
|
|
5174
|
-
// no backoff — since waiting doesn't help a race and each attempt already
|
|
5175
|
-
// gets its own fresh `pageTimeoutMs` budget.
|
|
5293
|
+
// Phase 1: Puppeteer — render base paths, cache raw DOM for substitution
|
|
5176
5294
|
async function renderPuppeteerPaths(paths) {
|
|
5177
|
-
|
|
5178
|
-
|
|
5179
|
-
|
|
5180
|
-
|
|
5181
|
-
|
|
5182
|
-
|
|
5183
|
-
|
|
5184
|
-
|
|
5185
|
-
|
|
5186
|
-
|
|
5187
|
-
|
|
5188
|
-
|
|
5189
|
-
|
|
5190
|
-
|
|
5191
|
-
|
|
5192
|
-
|
|
5193
|
-
|
|
5194
|
-
|
|
5195
|
-
|
|
5196
|
-
|
|
5197
|
-
|
|
5198
|
-
|
|
5199
|
-
|
|
5200
|
-
|
|
5201
|
-
}),
|
|
5202
|
-
pageTimeoutMs
|
|
5203
|
-
);
|
|
5204
|
-
if (!finished) {
|
|
5205
|
-
attemptToken.abandoned = true;
|
|
5206
|
-
attemptToken.failure = { path: displayPath, message: `page render exceeded ${pageTimeoutMs}ms` };
|
|
5207
|
-
// A timed-out page is presumed to have wedged the browser (e.g.
|
|
5208
|
-
// a hung page.evaluate on a dead Chrome process) — force a
|
|
5209
|
-
// recycle once this path is done, even if a later retry
|
|
5210
|
-
// "succeeds" by reusing the same possibly-poisoned browser.
|
|
5211
|
-
recycleGate.requestRecycle();
|
|
5212
|
-
}
|
|
5213
|
-
} catch (err) {
|
|
5214
|
-
// Unexpected exception escaped processPath (e.g. browser died mid-call).
|
|
5215
|
-
attemptToken.failure = {
|
|
5216
|
-
path: displayPath,
|
|
5217
|
-
message: err && err.message ? err.message : String(err),
|
|
5218
|
-
};
|
|
5219
|
-
} finally {
|
|
5220
|
-
recycleGate.release();
|
|
5221
|
-
}
|
|
5222
|
-
if (!attemptToken.failure) {
|
|
5223
|
-
recycleGate.countPage();
|
|
5224
|
-
break; // success
|
|
5225
|
-
}
|
|
5226
|
-
if (attempt >= maxRetries) {
|
|
5227
|
-
// Exhausted retries — likely an unstable browser (e.g. cascading
|
|
5228
|
-
// "detached Frame" errors). Force the next path onto a fresh one.
|
|
5229
|
-
failedPaths.push(attemptToken.failure);
|
|
5230
|
-
if (failedPaths.length <= 10) {
|
|
5231
|
-
process.stderr.write(`prerender: failed ${displayPath}: ${attemptToken.failure.message}\n`);
|
|
5232
|
-
}
|
|
5233
|
-
recycleGate.countPage();
|
|
5234
|
-
recycleGate.requestRecycle();
|
|
5235
|
-
break;
|
|
5236
|
-
}
|
|
5237
|
-
const immediateRequeue = isTransientFrameError(attemptToken.failure.message);
|
|
5238
|
-
// Halfway through retries with no success → preemptively recycle the
|
|
5239
|
-
// browser before the next attempt. This unblocks cascading frame
|
|
5240
|
-
// failures where the browser process needs a fresh start. Skipped
|
|
5241
|
-
// for a transient frame error — that's likely already a recycle
|
|
5242
|
-
// race, not a signal to force another one.
|
|
5243
|
-
if (!immediateRequeue && attempt + 1 >= Math.ceil(maxRetries / 2) && recycleGate.pages > 0) {
|
|
5244
|
-
recycleGate.requestRecycle();
|
|
5245
|
-
await recycleGate.maybeRecycle();
|
|
5246
|
-
}
|
|
5247
|
-
attempt++;
|
|
5248
|
-
process.stderr.write(
|
|
5249
|
-
`prerender: retrying ${displayPath} (${attemptToken.failure.message}) (attempt ${attempt + 1}/${maxRetries + 1})${immediateRequeue ? ' — immediate requeue' : ''}\n`
|
|
5250
|
-
);
|
|
5251
|
-
if (!immediateRequeue) await new Promise((r) => setTimeout(r, 500 * attempt));
|
|
5252
|
-
}
|
|
5253
|
-
// Attempt recycle after each completed path (only one worker performs
|
|
5254
|
-
// it; the rest block in acquire()).
|
|
5255
|
-
await recycleGate.maybeRecycle();
|
|
5256
|
-
}
|
|
5257
|
-
}
|
|
5258
|
-
await Promise.all(
|
|
5259
|
-
Array.from({ length: Math.min(concurrency, total || 1) }, () => worker())
|
|
5260
|
-
);
|
|
5295
|
+
const failures = await runRenderQueue({
|
|
5296
|
+
items: paths,
|
|
5297
|
+
concurrency,
|
|
5298
|
+
gate: recycleGate,
|
|
5299
|
+
pageTimeoutMs,
|
|
5300
|
+
maxRetries,
|
|
5301
|
+
labelOf: (seg) => (seg === '' ? '/' : (seg === NOT_FOUND_PATH ? '/__prerender_404__' : '/' + seg)),
|
|
5302
|
+
render: (pathSeg, i, attempt) => processPath(pathSeg, i, {
|
|
5303
|
+
attempt,
|
|
5304
|
+
onRawHtml: (seg, html) => {
|
|
5305
|
+
if (seg !== NOT_FOUND_PATH) baseHtmlCache.set(seg || '', html);
|
|
5306
|
+
},
|
|
5307
|
+
onLocaleDependentContent: promoteLocaleVariants,
|
|
5308
|
+
}),
|
|
5309
|
+
onRetry: (failure, attempt, immediate) => {
|
|
5310
|
+
process.stderr.write(
|
|
5311
|
+
`prerender: retrying ${failure.path} (${failure.message}) (attempt ${attempt + 1}/${maxRetries + 1})${immediate ? ' — immediate requeue' : ''}\n`
|
|
5312
|
+
);
|
|
5313
|
+
},
|
|
5314
|
+
onFail: (failure, count) => {
|
|
5315
|
+
if (count <= 10) process.stderr.write(`prerender: failed ${failure.path}: ${failure.message}\n`);
|
|
5316
|
+
},
|
|
5317
|
+
});
|
|
5318
|
+
failedPaths.push(...failures);
|
|
5261
5319
|
}
|
|
5262
5320
|
|
|
5263
5321
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mnfst-render",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.45",
|
|
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
|
}
|