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.
- package/package.json +1 -1
- package/serve.mjs +122 -13
package/package.json
CHANGED
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
|
-
|
|
366
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
677
|
-
|
|
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
|
|
688
|
-
//
|
|
689
|
-
// process.exit
|
|
690
|
-
process.
|
|
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
|
|