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 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.
@@ -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(resolve);
37
- else resolve();
41
+ if (typeof Alpine.nextTick === 'function') Alpine.nextTick(done);
42
+ else done();
38
43
  });
39
44
  } else {
40
- queueMicrotask(resolve);
45
+ queueMicrotask(done);
41
46
  }
42
47
  } catch {
43
- resolve();
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 cfg?.ensureManifest?.();
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 window.ManifestComponentsSwapping.processAll(normalizedPath);
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: true,
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
- earlySnapshotUrl = await takeOgSnapshot(page, config.output, is404 ? '__404__' : pathSeg, globalAssetSig, ogCacheDir);
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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mnfst-render",
3
- "version": "0.5.42",
3
+ "version": "0.5.44",
4
4
  "description": "Render Manifest sites to static HTML for SEO",
5
5
  "type": "module",
6
6
  "bin": {