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.
- package/package.json +1 -1
- package/serve.mjs +235 -51
package/package.json
CHANGED
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 }
|
|
33
|
-
import {
|
|
34
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
-
|
|
269
|
-
if (
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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/
|
|
438
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
280
439
|
'Cache-Control': 'no-store',
|
|
281
440
|
});
|
|
282
|
-
res.end(
|
|
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 =
|
|
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
|
|
337
|
-
if (isFile(
|
|
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
|
-
|
|
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);
|