mnfst-run 1.0.9 → 1.0.10

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 +155 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mnfst-run",
3
- "version": "1.0.9",
3
+ "version": "1.0.10",
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';
38
+ import { createServer, get as httpGet } from 'http';
39
+ import {
40
+ readFileSync, statSync, watch,
41
+ existsSync, writeFileSync, unlinkSync,
42
+ mkdirSync, readdirSync,
43
+ } from 'fs';
34
44
  import { join, extname, resolve, basename } 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',
@@ -116,15 +128,136 @@ let port = process.env.PORT ? parseInt(process.env.PORT, 10) : 5001;
116
128
  let idleShutdownSec = 30;
117
129
  let idleShutdownEnabled = true;
118
130
 
131
+ let listMode = false;
132
+
119
133
  for (let i = 0; i < args.length; i++) {
120
134
  if ((args[i] === '--port' || args[i] === '-p') && args[i + 1]) { port = parseInt(args[++i], 10); continue; }
121
135
  if (args[i] === '--no-idle-shutdown') { idleShutdownEnabled = false; continue; }
122
136
  if (args[i] === '--idle-shutdown' && args[i + 1]) { idleShutdownSec = parseInt(args[++i], 10); continue; }
137
+ if (args[i] === '--list' || args[i] === '-l') { listMode = true; continue; }
123
138
  if (!args[i].startsWith('-')) dir = args[i];
124
139
  }
125
140
 
141
+ // --- Running-server registry ---
142
+ // One JSON file per project under `$TMPDIR/mnfst-run/`, keyed by a hash of
143
+ // the absolute root. Each holds `{ root, port, pid, startedAt }`. Used for:
144
+ // 1. Dedup — a second `mnfst-run <dir>` for an already-running project
145
+ // prints the existing URL instead of spinning up another port.
146
+ // 2. `--list` — show what's currently running across all projects.
147
+ // Cleanup happens on graceful exit (idle-shutdown, Ctrl+C, SIGTERM). Crash
148
+ // recovery is automatic: stale entries are detected by the next startup via
149
+ // PID-alive + identity-endpoint check and unlinked.
150
+ const REGISTRY_DIR = join(tmpdir(), 'mnfst-run');
151
+ const IDENTITY_PATH = '/__mnfst_run__';
152
+
153
+ function registryFileFor(rootPath) {
154
+ const hash = createHash('sha1').update(rootPath).digest('hex').slice(0, 16);
155
+ return join(REGISTRY_DIR, hash + '.json');
156
+ }
157
+
158
+ function readRegistryEntry(file) {
159
+ try { return JSON.parse(readFileSync(file, 'utf8')); }
160
+ catch { return null; }
161
+ }
162
+
163
+ function pidAlive(pid) {
164
+ try { process.kill(pid, 0); return true; } catch { return false; }
165
+ }
166
+
167
+ // Quick probe of /__mnfst_run__ to confirm the entry isn't stale (PID may
168
+ // have been recycled to an unrelated process). Resolves to the parsed
169
+ // identity object on success, null on timeout / non-mnfst response.
170
+ function probeIdentity(p, timeoutMs = 400) {
171
+ return new Promise((resolveProbe) => {
172
+ let done = false;
173
+ const finish = (v) => { if (done) return; done = true; resolveProbe(v); };
174
+ const req = httpGet({ host: '127.0.0.1', port: p, path: IDENTITY_PATH, timeout: timeoutMs }, (res) => {
175
+ let body = '';
176
+ res.on('data', (c) => { body += c; if (body.length > 4096) { res.destroy(); finish(null); } });
177
+ res.on('end', () => { try { finish(JSON.parse(body)); } catch { finish(null); } });
178
+ });
179
+ req.on('error', () => finish(null));
180
+ req.on('timeout', () => { req.destroy(); finish(null); });
181
+ });
182
+ }
183
+
184
+ async function findRunningServer(rootPath) {
185
+ const file = registryFileFor(rootPath);
186
+ if (!existsSync(file)) return null;
187
+ const entry = readRegistryEntry(file);
188
+ if (!entry || entry.root !== rootPath || !pidAlive(entry.pid)) {
189
+ try { unlinkSync(file); } catch {}
190
+ return null;
191
+ }
192
+ const id = await probeIdentity(entry.port);
193
+ if (!id || id.root !== rootPath) {
194
+ try { unlinkSync(file); } catch {}
195
+ return null;
196
+ }
197
+ return entry;
198
+ }
199
+
200
+ function writeRegistry(rootPath, p) {
201
+ try { mkdirSync(REGISTRY_DIR, { recursive: true }); } catch {}
202
+ try {
203
+ writeFileSync(registryFileFor(rootPath), JSON.stringify({
204
+ root: rootPath,
205
+ port: p,
206
+ pid: process.pid,
207
+ startedAt: new Date().toISOString(),
208
+ }) + '\n');
209
+ } catch { /* registry is best-effort; serving still works without it */ }
210
+ }
211
+
212
+ function removeRegistry(rootPath) {
213
+ try { unlinkSync(registryFileFor(rootPath)); } catch {}
214
+ }
215
+
216
+ async function listRunningServers() {
217
+ let files;
218
+ try { files = readdirSync(REGISTRY_DIR); } catch { files = []; }
219
+ const rows = [];
220
+ for (const f of files) {
221
+ if (!f.endsWith('.json')) continue;
222
+ const file = join(REGISTRY_DIR, f);
223
+ const entry = readRegistryEntry(file);
224
+ if (!entry || !pidAlive(entry.pid)) { try { unlinkSync(file); } catch {} continue; }
225
+ const id = await probeIdentity(entry.port);
226
+ if (!id || id.root !== entry.root) { try { unlinkSync(file); } catch {} continue; }
227
+ rows.push(entry);
228
+ }
229
+ if (rows.length === 0) { console.log('No mnfst-run servers running.'); return; }
230
+ const portW = Math.max(4, ...rows.map(r => String(r.port).length));
231
+ const pidW = Math.max(3, ...rows.map(r => String(r.pid).length));
232
+ console.log(`${'PORT'.padEnd(portW)} ${'PID'.padEnd(pidW)} URL ROOT`);
233
+ for (const r of rows) {
234
+ const url = `http://localhost:${r.port}`;
235
+ console.log(`${String(r.port).padEnd(portW)} ${String(r.pid).padEnd(pidW)} ${url.padEnd(28)} ${r.root}`);
236
+ }
237
+ }
238
+
239
+ if (listMode) {
240
+ await listRunningServers();
241
+ process.exit(0);
242
+ }
243
+
126
244
  const root = resolve(process.cwd(), dir);
127
245
 
246
+ // Dedup: if a server is already serving this exact root, point the user at
247
+ // it (and open the browser, since that's what they were going to do anyway).
248
+ const existing = await findRunningServer(root);
249
+ if (existing) {
250
+ const url = `http://localhost:${existing.port}`;
251
+ const label0 = dir === '.' ? basename(process.cwd()) : dir.replace(/\\/g, '/');
252
+ console.log(`\n${label0} already running at ${url} (pid ${existing.pid})\n`);
253
+ // Open the browser anyway — matches the experience of starting fresh.
254
+ const cmd = process.platform === 'win32' ? `start ${url}`
255
+ : process.platform === 'darwin' ? `open ${url}`
256
+ : `xdg-open ${url}`;
257
+ exec(cmd);
258
+ process.exit(0);
259
+ }
260
+
128
261
  // --- Auto-detect MPA ---
129
262
  function detectMPA(rootDir) {
130
263
  try {
@@ -262,6 +395,18 @@ function serveFile(res, filePath) {
262
395
  const server = createServer((req, res) => {
263
396
  const urlPath = decodeURIComponent(req.url.split('?')[0]);
264
397
 
398
+ // Identity endpoint: lets `mnfst-run` (and `--list`) confirm that a server
399
+ // on a registered port really is OUR server for the expected root, not
400
+ // some unrelated process that happened to inherit a recycled PID/port.
401
+ if (urlPath === IDENTITY_PATH) {
402
+ res.writeHead(200, {
403
+ 'Content-Type': 'application/json; charset=utf-8',
404
+ 'Cache-Control': 'no-store',
405
+ });
406
+ res.end(JSON.stringify({ name: 'mnfst-run', root, pid: process.pid }));
407
+ return;
408
+ }
409
+
265
410
  // Virtual /env.js — generated from .env at the project root.
266
411
  // Loaded by HTML before manifest.data.js so window.env is populated for
267
412
  // ${VAR} interpolation in manifest.json. Returns an empty no-op if no
@@ -369,6 +514,7 @@ function tryListen(p, attempt = 0) {
369
514
  const onListening = () => {
370
515
  server.removeListener('error', onError);
371
516
  const url = `http://localhost:${p}`;
517
+ writeRegistry(root, p);
372
518
  console.log(`\n${label} running at ${url}\n`);
373
519
  openBrowser(url);
374
520
  };
@@ -382,4 +528,11 @@ function tryListen(p, attempt = 0) {
382
528
  server.listen(p);
383
529
  }
384
530
 
531
+ // Clean up the registry entry on graceful exit. process.exit() (used by
532
+ // idle-shutdown) fires 'exit'; SIGINT/SIGTERM are translated into a
533
+ // process.exit so the same path runs for Ctrl+C and `kill <pid>`.
534
+ process.on('exit', () => removeRegistry(root));
535
+ process.on('SIGINT', () => process.exit(0));
536
+ process.on('SIGTERM', () => process.exit(0));
537
+
385
538
  tryListen(port);