mnfst-run 1.0.20 → 1.0.21

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 +122 -13
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mnfst-run",
3
- "version": "1.0.20",
3
+ "version": "1.0.21",
4
4
  "description": "Zero-dependency dev server for Manifest projects",
5
5
  "type": "module",
6
6
  "bin": {
package/serve.mjs CHANGED
@@ -31,6 +31,15 @@
31
31
  * panel already shows the page, so a second browser tab
32
32
  * is just noise. Put `--no-open` in an LLM preview's
33
33
  * launch config to guarantee suppression.
34
+ * --attach Supervised/agent mode (e.g. an LLM preview panel that
35
+ * assigns a port and tracks the process it spawns). Never
36
+ * starts a SECOND dev server for a project already running:
37
+ * if a server for this root exists on another port, it
38
+ * binds the assigned port and reverse-proxies to that real
39
+ * server (live reload included); if it's already on the
40
+ * assigned port it just attaches; otherwise it starts one
41
+ * normally. Let the supervisor pick the port (no --port;
42
+ * it's read from PORT). Pair with `--no-open`.
34
43
  * --list Print all mnfst-run servers currently running on this
35
44
  * machine and exit.
36
45
  *
@@ -43,7 +52,7 @@
43
52
  * sources and updates Alpine store reactively (no reload)
44
53
  * other → full page reload
45
54
  */
46
- import { createServer, get as httpGet } from 'http';
55
+ import { createServer, get as httpGet, request as httpRequest } from 'http';
47
56
  import {
48
57
  readFileSync, statSync, watch,
49
58
  existsSync, writeFileSync, unlinkSync,
@@ -226,6 +235,12 @@ let openBrowserEnabled = !(
226
235
  );
227
236
 
228
237
  let listMode = false;
238
+ // --attach: supervised/agent mode (e.g. Claude Code's preview panel). Guarantees
239
+ // a live server in the FOREGROUND on the requested --port: if that exact port is
240
+ // already serving this root, stay attached to it (don't exit) instead of bailing;
241
+ // a separate server the user started on another port is left alone. Never writes
242
+ // or deletes the running-server registry, so it can't clobber the user's entry.
243
+ let attachMode = false;
229
244
 
230
245
  for (let i = 0; i < args.length; i++) {
231
246
  if ((args[i] === '--port' || args[i] === '-p') && args[i + 1]) { port = parseInt(args[++i], 10); continue; }
@@ -233,6 +248,7 @@ for (let i = 0; i < args.length; i++) {
233
248
  if (args[i] === '--idle-shutdown' && args[i + 1]) { idleShutdownSec = parseInt(args[++i], 10); continue; }
234
249
  if (args[i] === '--no-open') { openBrowserEnabled = false; continue; }
235
250
  if (args[i] === '--open') { openBrowserEnabled = true; continue; }
251
+ if (args[i] === '--attach') { attachMode = true; continue; }
236
252
  if (args[i] === '--list' || args[i] === '-l') { listMode = true; continue; }
237
253
  if (!args[i].startsWith('-')) dir = args[i];
238
254
  }
@@ -362,13 +378,26 @@ if (privateEnvNames.length > 0) {
362
378
  );
363
379
  }
364
380
 
365
- // Dedup: if a server is already serving this exact root, point the user at
366
- // it (and open the browser, since that's what they were going to do anyway).
381
+ const label = dir === '.' ? basename(process.cwd()) : dir.replace(/\\/g, '/');
382
+
383
+ // If a server is already serving this exact root, REUSE it — never start a
384
+ // second dev server for the same project.
385
+ // - manual use: print the URL and exit.
386
+ // - --attach (supervisor, e.g. Claude Code's preview panel): the panel only
387
+ // uses a server on the port it assigned us, and can't point at a server it
388
+ // didn't spawn. So if the existing server is on OUR port, just attach; if
389
+ // it's on a different port, bind our port and reverse-proxy to it — the
390
+ // existing server stays the only real dev server (file-watch, live reload),
391
+ // and the proxy is a thin pass-through the panel can track.
367
392
  const existing = await findRunningServer(root);
368
393
  if (existing) {
394
+ if (attachMode) {
395
+ if (existing.port === port) attachToExisting(existing.port); // already on our port — just keep alive
396
+ else startProxy(port, existing.port); // bridge our port → the real server
397
+ await new Promise(() => {}); // block here — never fall through and start a duplicate
398
+ }
369
399
  const url = `http://localhost:${existing.port}`;
370
- const label0 = dir === '.' ? basename(process.cwd()) : dir.replace(/\\/g, '/');
371
- console.log(`\n${label0} already running at ${url} (pid ${existing.pid})\n`);
400
+ console.log(`\n${label} already running at ${url} (pid ${existing.pid})\n`);
372
401
  // Open the browser anyway — matches the experience of starting fresh.
373
402
  // (Skipped under --no-open / Claude Code, where the preview panel is the browser.)
374
403
  if (openBrowserEnabled) {
@@ -644,8 +673,7 @@ const server = createServer((req, res) => {
644
673
  });
645
674
 
646
675
  // --- Auto-port ---
647
- // Human-readable label: the dir arg as given, or the cwd folder name if serving '.'
648
- const label = dir === '.' ? basename(process.cwd()) : dir.replace(/\\/g, '/');
676
+ // (`label` is defined earlier, before the reuse/dedup check.)
649
677
 
650
678
  function openBrowser(url) {
651
679
  const cmd = process.platform === 'win32' ? `start ${url}`
@@ -654,6 +682,72 @@ function openBrowser(url) {
654
682
  exec(cmd);
655
683
  }
656
684
 
685
+ // Whether THIS process owns the running-server registry entry for `root`.
686
+ // Stays false in --attach mode (we never claim the entry), so the exit handler
687
+ // can't delete an entry belonging to the user's own server for the same root.
688
+ let weOwnServer = false;
689
+
690
+ // --attach: the requested port is already serving this root. Stay in the
691
+ // foreground so the supervising preview panel keeps tracking this process,
692
+ // without owning the server — never touch its registry; just re-probe and exit
693
+ // once it goes away.
694
+ function attachToExisting(p) {
695
+ const url = `http://localhost:${p}`;
696
+ console.log(`\n${label} already running at ${url} — attached.\n`);
697
+ if (openBrowserEnabled) openBrowser(url);
698
+ watchUpstream(p);
699
+ }
700
+
701
+ // Exit once the server we're bridging/attached to goes away — we're only a
702
+ // pass-through, so there's nothing to serve without it.
703
+ function watchUpstream(upstreamPort) {
704
+ setInterval(async () => {
705
+ const id = await probeIdentity(upstreamPort);
706
+ if (!id || id.root !== root) {
707
+ console.log('\nmnfst-run: the server it was bridging has stopped — exiting.\n');
708
+ process.exit(0);
709
+ }
710
+ }, 5000);
711
+ }
712
+
713
+ // --attach: a real dev server for this root is already running on `upstreamPort`,
714
+ // but the preview panel can only use a server on the port it assigned us
715
+ // (`listenPort`). Bind that port and transparently reverse-proxy every request
716
+ // to the real server — including the live-reload SSE stream — so the existing
717
+ // server stays the ONE dev server and the panel still works. We don't own a
718
+ // server, so we never touch the registry.
719
+ function startProxy(listenPort, upstreamPort) {
720
+ const proxy = createServer((creq, cres) => {
721
+ // Rewrite host/origin to the upstream so its loopback host + same-origin
722
+ // checks pass (the client speaks to us on listenPort, the server on upstream).
723
+ const headers = { ...creq.headers, host: `localhost:${upstreamPort}` };
724
+ if (headers.origin) headers.origin = `http://localhost:${upstreamPort}`;
725
+ if (headers.referer) {
726
+ headers.referer = headers.referer.split(`localhost:${listenPort}`).join(`localhost:${upstreamPort}`);
727
+ }
728
+ const preq = httpRequest(
729
+ { host: '127.0.0.1', port: upstreamPort, method: creq.method, path: creq.url, headers },
730
+ (pres) => {
731
+ cres.writeHead(pres.statusCode || 502, pres.headers);
732
+ pres.pipe(cres); // stream — keeps SSE (text/event-stream) flowing live
733
+ },
734
+ );
735
+ preq.on('error', () => { try { cres.writeHead(502); cres.end('mnfst-run proxy: upstream unavailable'); } catch { /* client gone */ } });
736
+ creq.pipe(preq);
737
+ });
738
+ proxy.on('error', (err) => {
739
+ console.error(`mnfst-run: could not bind proxy port ${listenPort}: ${err.code || err.message}`);
740
+ process.exit(1);
741
+ });
742
+ proxy.listen(listenPort, '127.0.0.1', () => {
743
+ console.log(
744
+ `\n${label} already running at http://localhost:${upstreamPort} — ` +
745
+ `bridged to http://localhost:${listenPort} for the preview panel.\n`,
746
+ );
747
+ });
748
+ watchUpstream(upstreamPort);
749
+ }
750
+
657
751
  function tryListen(p, attempt = 0) {
658
752
  if (attempt > 20) {
659
753
  console.error('mnfst-run: could not find a free port after 20 attempts.');
@@ -667,14 +761,28 @@ function tryListen(p, attempt = 0) {
667
761
  const onListening = () => {
668
762
  server.removeListener('error', onError);
669
763
  const url = `http://localhost:${p}`;
764
+ // A successful fresh bind means WE are the server for this root — register it
765
+ // (so a later manual `mnfst-run` for the same project reuses it instead of
766
+ // starting another). In --attach we only reach here when nothing was already
767
+ // running, so there's no entry to clobber.
670
768
  writeRegistry(root, p);
769
+ weOwnServer = true;
671
770
  console.log(`\n${label} running at ${url}\n`);
672
771
  if (openBrowserEnabled) openBrowser(url);
673
772
  };
674
773
  const onError = err => {
675
774
  server.removeListener('listening', onListening);
676
- if (err.code === 'EADDRINUSE') tryListen(p + 1, attempt + 1);
677
- else throw err;
775
+ if (err.code !== 'EADDRINUSE') { throw err; }
776
+ // Under --attach, if the requested port is already OUR project, attach to it
777
+ // (stay alive) rather than spawn a duplicate on the next port up.
778
+ if (attachMode && attempt === 0) {
779
+ probeIdentity(p).then((id) => {
780
+ if (id && id.root === root) attachToExisting(p);
781
+ else tryListen(p + 1, attempt + 1);
782
+ });
783
+ return;
784
+ }
785
+ tryListen(p + 1, attempt + 1);
678
786
  };
679
787
  server.once('listening', onListening);
680
788
  server.once('error', onError);
@@ -684,10 +792,11 @@ function tryListen(p, attempt = 0) {
684
792
  server.listen(p, '127.0.0.1');
685
793
  }
686
794
 
687
- // Clean up the registry entry on graceful exit. process.exit() (used by
688
- // idle-shutdown) fires 'exit'; SIGINT/SIGTERM are translated into a
689
- // process.exit so the same path runs for Ctrl+C and `kill <pid>`.
690
- process.on('exit', () => removeRegistry(root));
795
+ // Clean up the registry entry on graceful exit but only if we actually own it
796
+ // (never in --attach mode, where the entry may belong to the user's server).
797
+ // process.exit() (used by idle-shutdown) fires 'exit'; SIGINT/SIGTERM are
798
+ // translated into a process.exit so the same path runs for Ctrl+C and `kill`.
799
+ process.on('exit', () => { if (weOwnServer) removeRegistry(root); });
691
800
  process.on('SIGINT', () => process.exit(0));
692
801
  process.on('SIGTERM', () => process.exit(0));
693
802