mnfst-run 1.0.9 → 1.0.12

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/serve.mjs +235 -51
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mnfst-run",
3
- "version": "1.0.9",
3
+ "version": "1.0.12",
4
4
  "description": "Zero-dependency dev server for Manifest projects",
5
5
  "type": "module",
6
6
  "bin": {
package/serve.mjs CHANGED
@@ -4,10 +4,14 @@
4
4
  *
5
5
  * Usage:
6
6
  * npx mnfst-run [dir] [--port 5001] [--idle-shutdown 30] [--no-idle-shutdown]
7
+ * npx mnfst-run --list
7
8
  *
8
9
  * dir Directory to serve (default: current directory). Any
9
10
  * depth of nesting is valid, e.g.
10
11
  * npx mnfst-run docs/articles/publishing
12
+ * If a server is already running for this directory,
13
+ * prints its URL and exits instead of starting a
14
+ * duplicate. Use `--list` to see everything running.
11
15
  * --port Preferred port (default: PORT env var, then 5001).
12
16
  * Auto-increments if the port is already in use.
13
17
  * --idle-shutdown N Exit N seconds after the last preview tab closes
@@ -19,6 +23,8 @@
19
23
  * overnight with the preview tab still open.
20
24
  * --no-idle-shutdown Disable auto-shutdown (useful in CI / headless cases
21
25
  * where no browser will connect).
26
+ * --list Print all mnfst-run servers currently running on this
27
+ * machine and exit.
22
28
  *
23
29
  * SPA vs MPA is auto-detected: if the root index.html contains
24
30
  * <meta name="manifest:prerendered"> the server disables SPA fallback.
@@ -29,10 +35,16 @@
29
35
  * sources and updates Alpine store reactively (no reload)
30
36
  * other → full page reload
31
37
  */
32
- import { createServer } from 'http';
33
- import { readFileSync, statSync, watch } from 'fs';
34
- import { join, extname, resolve, basename } from 'path';
38
+ import { createServer, get as httpGet } from 'http';
39
+ import {
40
+ readFileSync, statSync, watch,
41
+ existsSync, writeFileSync, unlinkSync,
42
+ mkdirSync, readdirSync,
43
+ } from 'fs';
44
+ import { join, extname, resolve, basename, sep } from 'path';
35
45
  import { exec } from 'child_process';
46
+ import { tmpdir } from 'os';
47
+ import { createHash } from 'crypto';
36
48
 
37
49
  const MIME = {
38
50
  '.html': 'text/html; charset=utf-8',
@@ -114,17 +126,148 @@ let port = process.env.PORT ? parseInt(process.env.PORT, 10) : 5001;
114
126
  // sit idle overnight if the tab is still open. Disabled by
115
127
  // `--no-idle-shutdown` for CI / headless cases where no browser will connect.
116
128
  let idleShutdownSec = 30;
117
- let idleShutdownEnabled = true;
129
+ // Auto-disable idle shutdown when running under CI or Claude Code, where the
130
+ // host browser (puppeteer / headless Chromium) does not produce the normal
131
+ // `pagehide` beacon + SSE heartbeats that the live-tab tracker relies on.
132
+ // Without this, the server would self-exit 30s after launch even while the
133
+ // automation is actively driving it. Manual override still works via the
134
+ // `--no-idle-shutdown` / `--idle-shutdown <sec>` flags below.
135
+ let idleShutdownEnabled = !(
136
+ process.env.CI === 'true' ||
137
+ process.env.CLAUDE_CODE_ENTRYPOINT ||
138
+ process.env.CLAUDECODE
139
+ );
140
+
141
+ let listMode = false;
118
142
 
119
143
  for (let i = 0; i < args.length; i++) {
120
144
  if ((args[i] === '--port' || args[i] === '-p') && args[i + 1]) { port = parseInt(args[++i], 10); continue; }
121
145
  if (args[i] === '--no-idle-shutdown') { idleShutdownEnabled = false; continue; }
122
146
  if (args[i] === '--idle-shutdown' && args[i + 1]) { idleShutdownSec = parseInt(args[++i], 10); continue; }
147
+ if (args[i] === '--list' || args[i] === '-l') { listMode = true; continue; }
123
148
  if (!args[i].startsWith('-')) dir = args[i];
124
149
  }
125
150
 
151
+ // --- Running-server registry ---
152
+ // One JSON file per project under `$TMPDIR/mnfst-run/`, keyed by a hash of
153
+ // the absolute root. Each holds `{ root, port, pid, startedAt }`. Used for:
154
+ // 1. Dedup — a second `mnfst-run <dir>` for an already-running project
155
+ // prints the existing URL instead of spinning up another port.
156
+ // 2. `--list` — show what's currently running across all projects.
157
+ // Cleanup happens on graceful exit (idle-shutdown, Ctrl+C, SIGTERM). Crash
158
+ // recovery is automatic: stale entries are detected by the next startup via
159
+ // PID-alive + identity-endpoint check and unlinked.
160
+ const REGISTRY_DIR = join(tmpdir(), 'mnfst-run');
161
+ const IDENTITY_PATH = '/__mnfst_run__';
162
+
163
+ function registryFileFor(rootPath) {
164
+ const hash = createHash('sha1').update(rootPath).digest('hex').slice(0, 16);
165
+ return join(REGISTRY_DIR, hash + '.json');
166
+ }
167
+
168
+ function readRegistryEntry(file) {
169
+ try { return JSON.parse(readFileSync(file, 'utf8')); }
170
+ catch { return null; }
171
+ }
172
+
173
+ function pidAlive(pid) {
174
+ try { process.kill(pid, 0); return true; } catch { return false; }
175
+ }
176
+
177
+ // Quick probe of /__mnfst_run__ to confirm the entry isn't stale (PID may
178
+ // have been recycled to an unrelated process). Resolves to the parsed
179
+ // identity object on success, null on timeout / non-mnfst response.
180
+ function probeIdentity(p, timeoutMs = 400) {
181
+ return new Promise((resolveProbe) => {
182
+ let done = false;
183
+ const finish = (v) => { if (done) return; done = true; resolveProbe(v); };
184
+ const req = httpGet({ host: '127.0.0.1', port: p, path: IDENTITY_PATH, timeout: timeoutMs }, (res) => {
185
+ let body = '';
186
+ res.on('data', (c) => { body += c; if (body.length > 4096) { res.destroy(); finish(null); } });
187
+ res.on('end', () => { try { finish(JSON.parse(body)); } catch { finish(null); } });
188
+ });
189
+ req.on('error', () => finish(null));
190
+ req.on('timeout', () => { req.destroy(); finish(null); });
191
+ });
192
+ }
193
+
194
+ async function findRunningServer(rootPath) {
195
+ const file = registryFileFor(rootPath);
196
+ if (!existsSync(file)) return null;
197
+ const entry = readRegistryEntry(file);
198
+ if (!entry || entry.root !== rootPath || !pidAlive(entry.pid)) {
199
+ try { unlinkSync(file); } catch {}
200
+ return null;
201
+ }
202
+ const id = await probeIdentity(entry.port);
203
+ if (!id || id.root !== rootPath) {
204
+ try { unlinkSync(file); } catch {}
205
+ return null;
206
+ }
207
+ return entry;
208
+ }
209
+
210
+ function writeRegistry(rootPath, p) {
211
+ try { mkdirSync(REGISTRY_DIR, { recursive: true }); } catch {}
212
+ try {
213
+ writeFileSync(registryFileFor(rootPath), JSON.stringify({
214
+ root: rootPath,
215
+ port: p,
216
+ pid: process.pid,
217
+ startedAt: new Date().toISOString(),
218
+ }) + '\n');
219
+ } catch { /* registry is best-effort; serving still works without it */ }
220
+ }
221
+
222
+ function removeRegistry(rootPath) {
223
+ try { unlinkSync(registryFileFor(rootPath)); } catch {}
224
+ }
225
+
226
+ async function listRunningServers() {
227
+ let files;
228
+ try { files = readdirSync(REGISTRY_DIR); } catch { files = []; }
229
+ const rows = [];
230
+ for (const f of files) {
231
+ if (!f.endsWith('.json')) continue;
232
+ const file = join(REGISTRY_DIR, f);
233
+ const entry = readRegistryEntry(file);
234
+ if (!entry || !pidAlive(entry.pid)) { try { unlinkSync(file); } catch {} continue; }
235
+ const id = await probeIdentity(entry.port);
236
+ if (!id || id.root !== entry.root) { try { unlinkSync(file); } catch {} continue; }
237
+ rows.push(entry);
238
+ }
239
+ if (rows.length === 0) { console.log('No mnfst-run servers running.'); return; }
240
+ const portW = Math.max(4, ...rows.map(r => String(r.port).length));
241
+ const pidW = Math.max(3, ...rows.map(r => String(r.pid).length));
242
+ console.log(`${'PORT'.padEnd(portW)} ${'PID'.padEnd(pidW)} URL ROOT`);
243
+ for (const r of rows) {
244
+ const url = `http://localhost:${r.port}`;
245
+ console.log(`${String(r.port).padEnd(portW)} ${String(r.pid).padEnd(pidW)} ${url.padEnd(28)} ${r.root}`);
246
+ }
247
+ }
248
+
249
+ if (listMode) {
250
+ await listRunningServers();
251
+ process.exit(0);
252
+ }
253
+
126
254
  const root = resolve(process.cwd(), dir);
127
255
 
256
+ // Dedup: if a server is already serving this exact root, point the user at
257
+ // it (and open the browser, since that's what they were going to do anyway).
258
+ const existing = await findRunningServer(root);
259
+ if (existing) {
260
+ const url = `http://localhost:${existing.port}`;
261
+ const label0 = dir === '.' ? basename(process.cwd()) : dir.replace(/\\/g, '/');
262
+ console.log(`\n${label0} already running at ${url} (pid ${existing.pid})\n`);
263
+ // Open the browser anyway — matches the experience of starting fresh.
264
+ const cmd = process.platform === 'win32' ? `start ${url}`
265
+ : process.platform === 'darwin' ? `open ${url}`
266
+ : `xdg-open ${url}`;
267
+ exec(cmd);
268
+ process.exit(0);
269
+ }
270
+
128
271
  // --- Auto-detect MPA ---
129
272
  function detectMPA(rootDir) {
130
273
  try {
@@ -187,29 +330,6 @@ function dropTab(tabId) {
187
330
  if (t) { clearTimeout(t); staleTimers.delete(tabId); }
188
331
  }
189
332
 
190
- // --- .env support ---
191
- // Minimal dotenv parser. Skips comments/blank lines, splits on first `=`,
192
- // trims whitespace, strips wrapping single/double quotes. No multiline values,
193
- // no `${VAR}` substitution within .env itself — we just want plain KEY=VALUE.
194
- function parseDotenv(text) {
195
- const out = {};
196
- for (const rawLine of text.split(/\r?\n/)) {
197
- const line = rawLine.trim();
198
- if (!line || line.startsWith('#')) continue;
199
- const eq = line.indexOf('=');
200
- if (eq === -1) continue;
201
- const key = line.slice(0, eq).trim();
202
- if (!key) continue;
203
- let value = line.slice(eq + 1).trim();
204
- if ((value.startsWith('"') && value.endsWith('"')) ||
205
- (value.startsWith("'") && value.endsWith("'"))) {
206
- value = value.slice(1, -1);
207
- }
208
- out[key] = value;
209
- }
210
- return out;
211
- }
212
-
213
333
  // --- File watcher ---
214
334
  const IGNORE = /node_modules|\.git/;
215
335
  try {
@@ -218,10 +338,7 @@ try {
218
338
  clearTimeout(debounce);
219
339
  debounce = setTimeout(() => {
220
340
  const ext = extname(filename).toLowerCase();
221
- const base = basename(filename);
222
- if (base === '.env') {
223
- broadcast({ type: 'reload' });
224
- } else if (ext === '.css') {
341
+ if (ext === '.css') {
225
342
  broadcast({ type: 'css', file: '/' + filename.replace(/\\/g, '/') });
226
343
  } else if (['.csv', '.json', '.yaml', '.yml', '.md'].includes(ext)) {
227
344
  broadcast({ type: 'data' });
@@ -239,6 +356,18 @@ function isFile(p) {
239
356
  try { return statSync(p).isFile(); } catch { return false; }
240
357
  }
241
358
 
359
+ // Resolve a request path against `root` and refuse anything that escapes.
360
+ // `path.join` does NOT prevent `..` traversal — `join('/a/b', '/../../etc/passwd')`
361
+ // returns `/etc/passwd`. Use `path.resolve` + an explicit prefix check.
362
+ // Returns the absolute path on success, or null if the request would escape root
363
+ // (or contains a NUL byte).
364
+ function safeResolve(urlPath) {
365
+ if (urlPath.includes('\0')) return null;
366
+ const candidate = resolve(root, '.' + urlPath);
367
+ if (candidate !== root && !candidate.startsWith(root + sep)) return null;
368
+ return candidate;
369
+ }
370
+
242
371
  function serveFile(res, filePath) {
243
372
  const ext = extname(filePath).toLowerCase();
244
373
  const mime = MIME[ext] || 'application/octet-stream';
@@ -258,28 +387,58 @@ function serveFile(res, filePath) {
258
387
  res.end(body);
259
388
  }
260
389
 
390
+ // DNS-rebinding defence. Even though the server binds 127.0.0.1, a malicious
391
+ // public page (attacker.com) can perform DNS rebinding: initial resolution
392
+ // returns the attacker's IP so the dev visits it, then DNS is flipped to
393
+ // 127.0.0.1 so subsequent fetches reach mnfst-run while the browser still
394
+ // treats the page origin as attacker.com (giving attacker JS read access).
395
+ // The browser sends `Host: attacker.com` after the rebind — so reject any
396
+ // request whose Host header isn't a known-local form on our listening port.
397
+ function isLocalHostHeader(host, port) {
398
+ if (!host || typeof host !== 'string') return false;
399
+ const allowed = new Set([
400
+ `127.0.0.1:${port}`,
401
+ `localhost:${port}`,
402
+ `[::1]:${port}`,
403
+ ]);
404
+ return allowed.has(host.toLowerCase());
405
+ }
406
+
407
+ // Stricter check used by state-changing endpoints (close beacon). Same
408
+ // allowlist applied to the Origin header.
409
+ function isLocalOrigin(origin, port) {
410
+ if (!origin || typeof origin !== 'string') return false;
411
+ const allowed = new Set([
412
+ `http://127.0.0.1:${port}`,
413
+ `http://localhost:${port}`,
414
+ `http://[::1]:${port}`,
415
+ ]);
416
+ return allowed.has(origin.toLowerCase());
417
+ }
418
+
261
419
  // --- HTTP server ---
262
420
  const server = createServer((req, res) => {
263
421
  const urlPath = decodeURIComponent(req.url.split('?')[0]);
264
422
 
265
- // Virtual /env.js generated from .env at the project root.
266
- // Loaded by HTML before manifest.data.js so window.env is populated for
267
- // ${VAR} interpolation in manifest.json. Returns an empty no-op if no
268
- // .env exists, so the <script src="/env.js"> tag is always safe to include.
269
- if (urlPath === '/env.js') {
270
- const envPath = join(root, '.env');
271
- let body = 'window.env = window.env || {};';
272
- try {
273
- if (isFile(envPath)) {
274
- const env = parseDotenv(readFileSync(envPath, 'utf8'));
275
- body = `window.env = Object.assign(window.env || {}, ${JSON.stringify(env)});`;
276
- }
277
- } catch { /* fall through to no-op */ }
423
+ // Reject any request whose Host header doesn't match our listening origin —
424
+ // closes DNS-rebinding even though we're bound to loopback. server.address()
425
+ // is the source of truth for the actual port (auto-port may have shifted).
426
+ const listenPort = server.address()?.port;
427
+ if (listenPort && !isLocalHostHeader(req.headers.host, listenPort)) {
428
+ res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' });
429
+ res.end('403 Forbidden invalid Host header');
430
+ return;
431
+ }
432
+
433
+ // Identity endpoint: lets `mnfst-run` (and `--list`) confirm that a server
434
+ // on a registered port really is OUR server for the expected root, not
435
+ // some unrelated process that happened to inherit a recycled PID/port.
436
+ if (urlPath === IDENTITY_PATH) {
278
437
  res.writeHead(200, {
279
- 'Content-Type': 'application/javascript; charset=utf-8',
438
+ 'Content-Type': 'application/json; charset=utf-8',
280
439
  'Cache-Control': 'no-store',
281
440
  });
282
- res.end(body);
441
+ res.end(JSON.stringify({ name: 'mnfst-run', root, pid: process.pid }));
283
442
  return;
284
443
  }
285
444
 
@@ -321,7 +480,21 @@ const server = createServer((req, res) => {
321
480
  // Close beacon: fired by the injected script's pagehide handler when a tab
322
481
  // is actually being closed/navigated away from. This is the only signal
323
482
  // that drops a tab from openTabs in normal operation.
483
+ // Locked to POST + same-origin: the live-reload script already POSTs (both
484
+ // sendBeacon and the fetch fallback), so no DX cost; this closes the
485
+ // CSRF-via-GET surface where a third-party page could fire <img src=...>
486
+ // to nuke tabs (would still need an unguessable tabId, but defence in depth).
324
487
  if (urlPath === '/__mnfst_close__') {
488
+ if (req.method !== 'POST') {
489
+ res.writeHead(405, { 'Allow': 'POST' });
490
+ res.end();
491
+ return;
492
+ }
493
+ if (listenPort && !isLocalOrigin(req.headers.origin, listenPort)) {
494
+ res.writeHead(403);
495
+ res.end();
496
+ return;
497
+ }
325
498
  const tabId = new URL(req.url, 'http://localhost').searchParams.get('tabId');
326
499
  dropTab(tabId);
327
500
  res.writeHead(204);
@@ -330,11 +503,11 @@ const server = createServer((req, res) => {
330
503
  return;
331
504
  }
332
505
 
333
- const exact = join(root, urlPath);
334
- if (isFile(exact)) return serveFile(res, exact);
506
+ const exact = safeResolve(urlPath);
507
+ if (exact && isFile(exact)) return serveFile(res, exact);
335
508
 
336
- const index = join(root, urlPath.replace(/\/$/, ''), 'index.html');
337
- if (isFile(index)) return serveFile(res, index);
509
+ const indexPath = safeResolve(urlPath.replace(/\/$/, '') + '/index.html');
510
+ if (indexPath && isFile(indexPath)) return serveFile(res, indexPath);
338
511
 
339
512
  if (spa) {
340
513
  const fallback = join(root, 'index.html');
@@ -369,6 +542,7 @@ function tryListen(p, attempt = 0) {
369
542
  const onListening = () => {
370
543
  server.removeListener('error', onError);
371
544
  const url = `http://localhost:${p}`;
545
+ writeRegistry(root, p);
372
546
  console.log(`\n${label} running at ${url}\n`);
373
547
  openBrowser(url);
374
548
  };
@@ -379,7 +553,17 @@ function tryListen(p, attempt = 0) {
379
553
  };
380
554
  server.once('listening', onListening);
381
555
  server.once('error', onError);
382
- server.listen(p);
556
+ // Bind to loopback only. Without an explicit host Node listens on `::` (all
557
+ // interfaces), which exposes the dev server — and every file under `root` —
558
+ // to anyone sharing the network (café, hotel, conference, coworking).
559
+ server.listen(p, '127.0.0.1');
383
560
  }
384
561
 
562
+ // Clean up the registry entry on graceful exit. process.exit() (used by
563
+ // idle-shutdown) fires 'exit'; SIGINT/SIGTERM are translated into a
564
+ // process.exit so the same path runs for Ctrl+C and `kill <pid>`.
565
+ process.on('exit', () => removeRegistry(root));
566
+ process.on('SIGINT', () => process.exit(0));
567
+ process.on('SIGTERM', () => process.exit(0));
568
+
385
569
  tryListen(port);