mnfst-render 0.5.42 → 0.5.44
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/README.md +6 -0
- package/manifest.render.mjs +191 -13
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -9,3 +9,9 @@ npx mnfst-render --root .
|
|
|
9
9
|
```
|
|
10
10
|
|
|
11
11
|
The command reads `manifest.json` and writes rendered pages to `manifest.render.output` (default `website`).
|
|
12
|
+
|
|
13
|
+
## Concurrent renders
|
|
14
|
+
|
|
15
|
+
Only one render may write an output directory at a time. While running, a render holds `<output>.mnfst-lock` (next to the output directory) recording its PID, host, start time, and command. A second render against the same output exits immediately with an error naming the running render.
|
|
16
|
+
|
|
17
|
+
A lock left behind by a process that no longer exists is taken over automatically. To take over a lock regardless, pass `--force-lock`; the render that lost the lock will not replace the output when it finishes.
|
package/manifest.render.mjs
CHANGED
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
/* Manifest Render */
|
|
4
4
|
|
|
5
|
-
import { readFileSync, readSync, mkdirSync, writeFileSync, appendFileSync, existsSync, rmSync, statSync, readdirSync, cpSync, unlinkSync, renameSync } from 'node:fs';
|
|
5
|
+
import { readFileSync, readSync, mkdirSync, writeFileSync, appendFileSync, existsSync, rmSync, statSync, readdirSync, cpSync, unlinkSync, renameSync, linkSync } 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';
|
|
9
|
-
import { cpus } from 'node:os';
|
|
9
|
+
import { cpus, hostname } from 'node:os';
|
|
10
10
|
import { createRequire } from 'node:module';
|
|
11
11
|
import { fileURLToPath } from 'node:url';
|
|
12
|
-
import { createHash } from 'node:crypto';
|
|
12
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
13
13
|
|
|
14
14
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
15
15
|
const require = createRequire(import.meta.url);
|
|
@@ -27,20 +27,25 @@ async function importFromProject(moduleName) {
|
|
|
27
27
|
|
|
28
28
|
|
|
29
29
|
async function flushAlpineEffects(page) {
|
|
30
|
+
// Alpine's scheduler can swallow a nextTick callback (a re-triggered
|
|
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
33
|
await page
|
|
31
34
|
.evaluate(() => {
|
|
32
35
|
return new Promise((resolve) => {
|
|
36
|
+
const cap = setTimeout(resolve, 3000);
|
|
37
|
+
const done = () => { clearTimeout(cap); resolve(); };
|
|
33
38
|
try {
|
|
34
39
|
if (typeof Alpine !== 'undefined' && typeof Alpine.nextTick === 'function') {
|
|
35
40
|
Alpine.nextTick(() => {
|
|
36
|
-
if (typeof Alpine.nextTick === 'function') Alpine.nextTick(
|
|
37
|
-
else
|
|
41
|
+
if (typeof Alpine.nextTick === 'function') Alpine.nextTick(done);
|
|
42
|
+
else done();
|
|
38
43
|
});
|
|
39
44
|
} else {
|
|
40
|
-
queueMicrotask(
|
|
45
|
+
queueMicrotask(done);
|
|
41
46
|
}
|
|
42
47
|
} catch {
|
|
43
|
-
|
|
48
|
+
done();
|
|
44
49
|
}
|
|
45
50
|
});
|
|
46
51
|
})
|
|
@@ -149,7 +154,10 @@ async function waitForManifestRenderReady(page, { allLocales, currentLocale, tim
|
|
|
149
154
|
try {
|
|
150
155
|
const cfg = window.ManifestDataConfig;
|
|
151
156
|
const main = window.ManifestDataMain;
|
|
152
|
-
const manifest = await
|
|
157
|
+
const manifest = await Promise.race([
|
|
158
|
+
Promise.resolve(cfg?.ensureManifest?.()),
|
|
159
|
+
new Promise((resolve) => setTimeout(resolve, 5000)),
|
|
160
|
+
]);
|
|
153
161
|
if (manifest?.data && typeof main?.loadDataSource === 'function') {
|
|
154
162
|
const isAppwrite = cfg.isAppwriteCollection;
|
|
155
163
|
for (const [name, source] of Object.entries(manifest.data)) {
|
|
@@ -164,9 +172,14 @@ async function waitForManifestRenderReady(page, { allLocales, currentLocale, tim
|
|
|
164
172
|
|
|
165
173
|
// 6. Run component swapping explicitly so components tied to this route render
|
|
166
174
|
// and trigger any $x accesses that start on-demand data loads.
|
|
175
|
+
// Bounded: a stranded component-load promise must not hang the
|
|
176
|
+
// pipeline past the render-ready timeout (step 7 would never run).
|
|
167
177
|
if (window.ManifestComponentsSwapping?.processAll) {
|
|
168
178
|
try {
|
|
169
|
-
await
|
|
179
|
+
await Promise.race([
|
|
180
|
+
window.ManifestComponentsSwapping.processAll(normalizedPath),
|
|
181
|
+
new Promise((resolve) => setTimeout(resolve, ms)),
|
|
182
|
+
]);
|
|
170
183
|
} catch (e) {
|
|
171
184
|
return { ok: false, reason: 'processAll-error', message: String(e?.message || e) };
|
|
172
185
|
}
|
|
@@ -314,6 +327,7 @@ function parseArgs() {
|
|
|
314
327
|
if (args[i] === '--concurrency' && args[i + 1]) { out.concurrency = parseInt(args[++i], 10); continue; }
|
|
315
328
|
if (args[i] === '--retries' && args[i + 1]) { out.retries = parseInt(args[++i], 10); continue; }
|
|
316
329
|
if (args[i] === '--dry-run') { out.dryRun = true; continue; }
|
|
330
|
+
if (args[i] === '--force-lock') { out.forceLock = true; continue; }
|
|
317
331
|
if (args[i] === '--debug-prerender') { out.debugPrerender = true; continue; }
|
|
318
332
|
if (args[i] === '--locale-substitution') { out.localeSubstitution = true; continue; }
|
|
319
333
|
if (args[i] === '--no-locale-substitution') { out.localeSubstitution = false; continue; }
|
|
@@ -482,6 +496,7 @@ function resolveConfig() {
|
|
|
482
496
|
? pre.paths.map((p) => String(p).replace(/^\/+|\/+$/g, '')).filter(Boolean)
|
|
483
497
|
: [],
|
|
484
498
|
dryRun: !!cli.dryRun,
|
|
499
|
+
forceLock: !!cli.forceLock,
|
|
485
500
|
debugPrerender: !!cli.debugPrerender,
|
|
486
501
|
// render.icons: "lazy" strips prerender-inlined x-icon SVGs from the
|
|
487
502
|
// output — the icons plugin re-renders them at boot (decorative content;
|
|
@@ -3223,6 +3238,121 @@ function startStaticServer(rootDir) {
|
|
|
3223
3238
|
});
|
|
3224
3239
|
}
|
|
3225
3240
|
|
|
3241
|
+
// --- Output lock (one render per output dir) ---------------------------------
|
|
3242
|
+
|
|
3243
|
+
const LOCK_SUFFIX = '.mnfst-lock';
|
|
3244
|
+
|
|
3245
|
+
function outputLockPath(outputDir) {
|
|
3246
|
+
return resolve(outputDir) + LOCK_SUFFIX;
|
|
3247
|
+
}
|
|
3248
|
+
|
|
3249
|
+
function pidAlive(pid) {
|
|
3250
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
3251
|
+
try { process.kill(pid, 0); return true; } catch (err) { return err.code === 'EPERM'; }
|
|
3252
|
+
}
|
|
3253
|
+
|
|
3254
|
+
function readLock(lockPath) {
|
|
3255
|
+
try { return JSON.parse(readFileSync(lockPath, 'utf8')); } catch { return null; }
|
|
3256
|
+
}
|
|
3257
|
+
|
|
3258
|
+
function describeLock(entry, lockPath) {
|
|
3259
|
+
if (!entry) return `unreadable lock file ${lockPath}`;
|
|
3260
|
+
const started = entry.startedAt ? ` since ${entry.startedAt}` : '';
|
|
3261
|
+
const host = entry.hostname && entry.hostname !== hostname() ? ` on ${entry.hostname}` : '';
|
|
3262
|
+
const cmd = entry.argv ? `\n command: ${entry.argv}` : '';
|
|
3263
|
+
return `pid ${entry.pid}${host}${started}${cmd}`;
|
|
3264
|
+
}
|
|
3265
|
+
|
|
3266
|
+
function lockIsStale(entry) {
|
|
3267
|
+
if (!entry) return false;
|
|
3268
|
+
if (entry.hostname && entry.hostname !== hostname()) return false;
|
|
3269
|
+
return !pidAlive(entry.pid);
|
|
3270
|
+
}
|
|
3271
|
+
|
|
3272
|
+
function lockedError(outputDir, lockPath, entry) {
|
|
3273
|
+
const err = new Error(
|
|
3274
|
+
`prerender: another render is already writing ${outputDir}\n` +
|
|
3275
|
+
` held by ${describeLock(entry, lockPath)}\n` +
|
|
3276
|
+
` lock: ${lockPath}\n` +
|
|
3277
|
+
`Wait for it to finish, or rerun with --force-lock if that render is gone.`
|
|
3278
|
+
);
|
|
3279
|
+
err.code = 'MNFST_RENDER_LOCKED';
|
|
3280
|
+
err.lock = entry;
|
|
3281
|
+
return err;
|
|
3282
|
+
}
|
|
3283
|
+
|
|
3284
|
+
/** Remove the lock only if it still carries `token` (undefined = unconditionally). */
|
|
3285
|
+
function removeLockIfToken(lockPath, token) {
|
|
3286
|
+
const aside = `${lockPath}.${process.pid}.${randomBytes(4).toString('hex')}`;
|
|
3287
|
+
try { renameSync(lockPath, aside); } catch { return false; }
|
|
3288
|
+
const moved = readLock(aside);
|
|
3289
|
+
if (token === undefined || moved?.token === token) {
|
|
3290
|
+
try { unlinkSync(aside); } catch {}
|
|
3291
|
+
return true;
|
|
3292
|
+
}
|
|
3293
|
+
try { linkSync(aside, lockPath); } catch {}
|
|
3294
|
+
try { unlinkSync(aside); } catch {}
|
|
3295
|
+
return false;
|
|
3296
|
+
}
|
|
3297
|
+
|
|
3298
|
+
function acquireOutputLock(outputDir, { force = false, root = null, onLog = () => {} } = {}) {
|
|
3299
|
+
const lockPath = outputLockPath(outputDir);
|
|
3300
|
+
const entry = {
|
|
3301
|
+
pid: process.pid,
|
|
3302
|
+
hostname: hostname(),
|
|
3303
|
+
startedAt: new Date().toISOString(),
|
|
3304
|
+
root,
|
|
3305
|
+
output: resolve(outputDir),
|
|
3306
|
+
argv: process.argv.slice(1).join(' '),
|
|
3307
|
+
token: randomBytes(12).toString('hex'),
|
|
3308
|
+
};
|
|
3309
|
+
mkdirSync(dirname(lockPath), { recursive: true });
|
|
3310
|
+
const tmp = `${lockPath}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
|
|
3311
|
+
writeFileSync(tmp, JSON.stringify(entry, null, 2) + '\n', 'utf8');
|
|
3312
|
+
let acquired = false;
|
|
3313
|
+
try {
|
|
3314
|
+
for (let attempt = 0; attempt < 5 && !acquired; attempt++) {
|
|
3315
|
+
try {
|
|
3316
|
+
linkSync(tmp, lockPath);
|
|
3317
|
+
acquired = true;
|
|
3318
|
+
break;
|
|
3319
|
+
} catch (err) {
|
|
3320
|
+
if (err.code !== 'EEXIST') throw err;
|
|
3321
|
+
}
|
|
3322
|
+
const held = readLock(lockPath);
|
|
3323
|
+
if (force) {
|
|
3324
|
+
onLog(`prerender: --force-lock: taking over lock from ${describeLock(held, lockPath)}`);
|
|
3325
|
+
removeLockIfToken(lockPath, held ? held.token : undefined);
|
|
3326
|
+
} else if (lockIsStale(held)) {
|
|
3327
|
+
onLog(`prerender: removing stale lock from ${describeLock(held, lockPath)} (process is gone)`);
|
|
3328
|
+
removeLockIfToken(lockPath, held.token);
|
|
3329
|
+
} else {
|
|
3330
|
+
throw lockedError(outputDir, lockPath, held);
|
|
3331
|
+
}
|
|
3332
|
+
}
|
|
3333
|
+
} finally {
|
|
3334
|
+
try { unlinkSync(tmp); } catch {}
|
|
3335
|
+
}
|
|
3336
|
+
if (!acquired) throw lockedError(outputDir, lockPath, readLock(lockPath));
|
|
3337
|
+
|
|
3338
|
+
let released = false;
|
|
3339
|
+
const lock = {
|
|
3340
|
+
path: lockPath,
|
|
3341
|
+
entry,
|
|
3342
|
+
held() {
|
|
3343
|
+
return !released && readLock(lockPath)?.token === entry.token;
|
|
3344
|
+
},
|
|
3345
|
+
release() {
|
|
3346
|
+
if (released) return;
|
|
3347
|
+
released = true;
|
|
3348
|
+
process.off('exit', lock.release);
|
|
3349
|
+
removeLockIfToken(lockPath, entry.token);
|
|
3350
|
+
},
|
|
3351
|
+
};
|
|
3352
|
+
process.on('exit', lock.release);
|
|
3353
|
+
return lock;
|
|
3354
|
+
}
|
|
3355
|
+
|
|
3226
3356
|
// --- Copy project into output so website is self-contained (e.g. for Appwrite). ---
|
|
3227
3357
|
// Internal/tooling files stay out of the published output: agent instructions,
|
|
3228
3358
|
// the schema copy, and OS metadata anywhere in the tree.
|
|
@@ -3290,6 +3420,27 @@ function stripTailwindCssImportsFromOutput(outputDir) {
|
|
|
3290
3420
|
async function main() {
|
|
3291
3421
|
const config = resolveConfig();
|
|
3292
3422
|
const startedAt = Date.now();
|
|
3423
|
+
let lock = null;
|
|
3424
|
+
if (!config.dryRun) {
|
|
3425
|
+
try {
|
|
3426
|
+
lock = acquireOutputLock(config.output, {
|
|
3427
|
+
force: config.forceLock,
|
|
3428
|
+
root: config.root,
|
|
3429
|
+
onLog: (message) => console.warn(message),
|
|
3430
|
+
});
|
|
3431
|
+
} catch (err) {
|
|
3432
|
+
if (err.code !== 'MNFST_RENDER_LOCKED') throw err;
|
|
3433
|
+
console.error(err.message);
|
|
3434
|
+
process.exit(1);
|
|
3435
|
+
}
|
|
3436
|
+
config.lock = lock;
|
|
3437
|
+
}
|
|
3438
|
+
const onSignal = (signal) => {
|
|
3439
|
+
if (lock) lock.release();
|
|
3440
|
+
process.exit(signal === 'SIGINT' ? 130 : 143);
|
|
3441
|
+
};
|
|
3442
|
+
process.once('SIGINT', onSignal);
|
|
3443
|
+
process.once('SIGTERM', onSignal);
|
|
3293
3444
|
let staticServer = null;
|
|
3294
3445
|
if (config.serve) {
|
|
3295
3446
|
const { server, url } = await startStaticServer(config.root);
|
|
@@ -3302,6 +3453,9 @@ async function main() {
|
|
|
3302
3453
|
if (staticServer) {
|
|
3303
3454
|
await new Promise((res) => staticServer.close(res));
|
|
3304
3455
|
}
|
|
3456
|
+
if (lock) lock.release();
|
|
3457
|
+
process.off('SIGINT', onSignal);
|
|
3458
|
+
process.off('SIGTERM', onSignal);
|
|
3305
3459
|
}
|
|
3306
3460
|
const elapsedMs = Date.now() - startedAt;
|
|
3307
3461
|
const totalSeconds = Math.floor(elapsedMs / 1000);
|
|
@@ -3409,11 +3563,14 @@ async function runPrerender(config) {
|
|
|
3409
3563
|
// Don't copy the previous build (the final output dir) into staging — only the
|
|
3410
3564
|
// staging dir's own basename is excluded by copyProjectIntoDist.
|
|
3411
3565
|
const finalBasename = basename(finalOutput);
|
|
3566
|
+
const lockBasename = basename(outputLockPath(finalOutput));
|
|
3412
3567
|
COPY_EXCLUDE.add(finalBasename);
|
|
3568
|
+
COPY_EXCLUDE.add(lockBasename);
|
|
3413
3569
|
try {
|
|
3414
3570
|
copyProjectIntoDist(rootResolved, outputResolved);
|
|
3415
3571
|
} finally {
|
|
3416
3572
|
COPY_EXCLUDE.delete(finalBasename);
|
|
3573
|
+
COPY_EXCLUDE.delete(lockBasename);
|
|
3417
3574
|
}
|
|
3418
3575
|
// Env placeholders (${VAR}) only resolve at runtime via window.env (populated
|
|
3419
3576
|
// by the mnfst-run dev server from .env). A static prod build has no
|
|
@@ -3464,11 +3621,20 @@ async function runPrerender(config) {
|
|
|
3464
3621
|
console.error(' npm i -D puppeteer-core @sparticuz/chromium');
|
|
3465
3622
|
process.exit(1);
|
|
3466
3623
|
}
|
|
3624
|
+
// chrome-headless-shell: the new headless mode's captureScreenshot can
|
|
3625
|
+
// hang forever on macOS (frames never produced), which wedges og
|
|
3626
|
+
// snapshots and, through the shared browser, stalls sibling pages.
|
|
3467
3627
|
return await puppeteer.default.launch({
|
|
3468
|
-
headless:
|
|
3628
|
+
headless: 'shell',
|
|
3469
3629
|
args: [
|
|
3470
3630
|
'--no-sandbox',
|
|
3471
3631
|
'--disable-setuid-sandbox',
|
|
3632
|
+
// Concurrency keeps sibling tabs backgrounded; without these flags a
|
|
3633
|
+
// backgrounded tab stops producing frames and firing rAF/timers, so
|
|
3634
|
+
// its captureScreenshot (og snapshots) and settle waits hang forever.
|
|
3635
|
+
'--disable-background-timer-throttling',
|
|
3636
|
+
'--disable-backgrounding-occluded-windows',
|
|
3637
|
+
'--disable-renderer-backgrounding',
|
|
3472
3638
|
],
|
|
3473
3639
|
});
|
|
3474
3640
|
}
|
|
@@ -3928,7 +4094,8 @@ async function runPrerender(config) {
|
|
|
3928
4094
|
// small ones settle in well under 1 second.
|
|
3929
4095
|
await page.waitForNetworkIdle({ idleTime: 1000, timeout: 8000 }).catch(() => { });
|
|
3930
4096
|
|
|
3931
|
-
// DOM stability after network idle.
|
|
4097
|
+
// DOM stability after network idle. Hard-capped: a page that animates
|
|
4098
|
+
// continuously must not reset the quiet window forever.
|
|
3932
4099
|
await page.evaluate(() => {
|
|
3933
4100
|
return new Promise((resolve) => {
|
|
3934
4101
|
const observer = new MutationObserver(() => {
|
|
@@ -3936,8 +4103,9 @@ async function runPrerender(config) {
|
|
|
3936
4103
|
stable = setTimeout(finish, 500);
|
|
3937
4104
|
});
|
|
3938
4105
|
observer.observe(document.documentElement, { childList: true, subtree: true, characterData: true });
|
|
3939
|
-
const finish = () => { observer.disconnect(); resolve(); };
|
|
4106
|
+
const finish = () => { clearTimeout(cap); observer.disconnect(); resolve(); };
|
|
3940
4107
|
let stable = setTimeout(finish, 500);
|
|
4108
|
+
const cap = setTimeout(finish, 15000);
|
|
3941
4109
|
});
|
|
3942
4110
|
}).catch(() => { });
|
|
3943
4111
|
|
|
@@ -3968,7 +4136,12 @@ async function runPrerender(config) {
|
|
|
3968
4136
|
const ogImageHandled = !!config.seo.meta?.image
|
|
3969
4137
|
|| await page.evaluate(() => !!document.head.querySelector('meta[property="og:image"]'));
|
|
3970
4138
|
if (!ogImageHandled) {
|
|
3971
|
-
|
|
4139
|
+
// Bounded: a CDP call that never returns must cost this page its og
|
|
4140
|
+
// image, not the whole render (the meta fallback image covers it).
|
|
4141
|
+
earlySnapshotUrl = await Promise.race([
|
|
4142
|
+
takeOgSnapshot(page, config.output, is404 ? '__404__' : pathSeg, globalAssetSig, ogCacheDir),
|
|
4143
|
+
new Promise((resolve) => setTimeout(() => resolve(null), 30000)),
|
|
4144
|
+
]);
|
|
3972
4145
|
}
|
|
3973
4146
|
}
|
|
3974
4147
|
|
|
@@ -5230,6 +5403,9 @@ async function runPrerender(config) {
|
|
|
5230
5403
|
// Atomic swap: replace the previous output with the freshly built staging dir.
|
|
5231
5404
|
// rename() is atomic on the same filesystem (staging is a sibling of output),
|
|
5232
5405
|
// so a consumer never sees a partially written output directory.
|
|
5406
|
+
if (config.lock && !config.lock.held()) {
|
|
5407
|
+
throw new Error(`output lock ${config.lock.path} was taken over by another render; not replacing ${finalOutput}`);
|
|
5408
|
+
}
|
|
5233
5409
|
if (existsSync(finalOutput)) rmSync(finalOutput, { recursive: true });
|
|
5234
5410
|
renameSync(stagingOutput, finalOutput);
|
|
5235
5411
|
config.output = finalOutput;
|
|
@@ -5257,6 +5433,8 @@ if (_isDirectEntry) {
|
|
|
5257
5433
|
// Exports for the CLI bin script and for unit testing.
|
|
5258
5434
|
export {
|
|
5259
5435
|
main,
|
|
5436
|
+
acquireOutputLock,
|
|
5437
|
+
outputLockPath,
|
|
5260
5438
|
markPrerenderedManifestComponents,
|
|
5261
5439
|
normalizeLocaleRouteExclude,
|
|
5262
5440
|
isLocaleRouteExcluded,
|