mnfst-run 1.0.19 → 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 +170 -38
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,
|
|
@@ -123,13 +132,23 @@ const LIVE_RELOAD_SCRIPT = `<script>
|
|
|
123
132
|
})();
|
|
124
133
|
\x3c/script>`;
|
|
125
134
|
|
|
126
|
-
// Read a dotenv-style file from the project root and return
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
// absent or fails to parse.
|
|
135
|
+
// Read a dotenv-style file from the project root and return two maps: `public`
|
|
136
|
+
// (vars eligible to ship to the browser) and `private` (vars kept server-side).
|
|
137
|
+
// Only the public map is injected into `window.env`; the private map is logged
|
|
138
|
+
// at startup so devs can see what was withheld, but never reaches HTML.
|
|
131
139
|
//
|
|
132
|
-
//
|
|
140
|
+
// Public/private split is by name prefix — matching the established convention
|
|
141
|
+
// (Astro `PUBLIC_`, SvelteKit `PUBLIC_`, Vite `VITE_`, Next `NEXT_PUBLIC_`):
|
|
142
|
+
// - PUBLIC_FOO=… → exposed via window.env.PUBLIC_FOO
|
|
143
|
+
// - MANIFEST_API_KEY=…, STRIPE_SECRET=…, anything else → server-side only
|
|
144
|
+
//
|
|
145
|
+
// Rationale: prior versions injected the entire .env, so the scaffold's own
|
|
146
|
+
// MANIFEST_API_KEY (which create-starter writes with a "treat like a password"
|
|
147
|
+
// comment) was visible in view-source on every served page. The prefix gate
|
|
148
|
+
// makes the rule explicit at the call site rather than relying on devs to know
|
|
149
|
+
// that .env values reach the browser.
|
|
150
|
+
//
|
|
151
|
+
// Supported parse subset (intentionally minimal, no expansion / multiline):
|
|
133
152
|
// - KEY=value (whitespace around `=` ok)
|
|
134
153
|
// - KEY="quoted" / KEY='…' (surrounding quotes stripped)
|
|
135
154
|
// - # comments and blank lines ignored
|
|
@@ -142,8 +161,9 @@ const LIVE_RELOAD_SCRIPT = `<script>
|
|
|
142
161
|
// by the host. See the Appwrite setup doc for the full pattern.
|
|
143
162
|
function loadEnvFile(rootDir) {
|
|
144
163
|
const envPath = join(rootDir, '.env');
|
|
145
|
-
if (!existsSync(envPath)) return {};
|
|
146
|
-
const
|
|
164
|
+
if (!existsSync(envPath)) return { public: {}, private: [] };
|
|
165
|
+
const publicEnv = {};
|
|
166
|
+
const privateNames = [];
|
|
147
167
|
try {
|
|
148
168
|
const text = readFileSync(envPath, 'utf8');
|
|
149
169
|
for (const line of text.split(/\r?\n/)) {
|
|
@@ -158,22 +178,24 @@ function loadEnvFile(rootDir) {
|
|
|
158
178
|
(value.startsWith("'") && value.endsWith("'"))) {
|
|
159
179
|
value = value.slice(1, -1);
|
|
160
180
|
}
|
|
161
|
-
|
|
181
|
+
if (key.startsWith('PUBLIC_')) publicEnv[key] = value;
|
|
182
|
+
else privateNames.push(key);
|
|
162
183
|
}
|
|
163
184
|
} catch (error) {
|
|
164
185
|
console.warn('[mnfst-run] Failed to parse .env:', error.message);
|
|
165
186
|
}
|
|
166
|
-
return
|
|
187
|
+
return { public: publicEnv, private: privateNames };
|
|
167
188
|
}
|
|
168
189
|
|
|
169
|
-
// Build a `<script>window.env = {…};</script>` tag from
|
|
170
|
-
// Returns '' when there are no vars (so the injection is a no-op for
|
|
171
|
-
//
|
|
172
|
-
// env value can't break out
|
|
173
|
-
|
|
174
|
-
|
|
190
|
+
// Build a `<script>window.env = {…};</script>` tag from the public env map.
|
|
191
|
+
// Returns '' when there are no public vars (so the injection is a no-op for
|
|
192
|
+
// projects whose .env contains only server-side secrets). Escapes any
|
|
193
|
+
// `</script` substring inside string values so an env value can't break out
|
|
194
|
+
// of the script tag.
|
|
195
|
+
function buildEnvInjectScript(publicEnv) {
|
|
196
|
+
const keys = Object.keys(publicEnv);
|
|
175
197
|
if (keys.length === 0) return '';
|
|
176
|
-
const json = JSON.stringify(
|
|
198
|
+
const json = JSON.stringify(publicEnv).replace(/<\/script/gi, '<\\/script');
|
|
177
199
|
return `<script>window.env = ${json};</script>`;
|
|
178
200
|
}
|
|
179
201
|
|
|
@@ -213,6 +235,12 @@ let openBrowserEnabled = !(
|
|
|
213
235
|
);
|
|
214
236
|
|
|
215
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;
|
|
216
244
|
|
|
217
245
|
for (let i = 0; i < args.length; i++) {
|
|
218
246
|
if ((args[i] === '--port' || args[i] === '-p') && args[i + 1]) { port = parseInt(args[++i], 10); continue; }
|
|
@@ -220,6 +248,7 @@ for (let i = 0; i < args.length; i++) {
|
|
|
220
248
|
if (args[i] === '--idle-shutdown' && args[i + 1]) { idleShutdownSec = parseInt(args[++i], 10); continue; }
|
|
221
249
|
if (args[i] === '--no-open') { openBrowserEnabled = false; continue; }
|
|
222
250
|
if (args[i] === '--open') { openBrowserEnabled = true; continue; }
|
|
251
|
+
if (args[i] === '--attach') { attachMode = true; continue; }
|
|
223
252
|
if (args[i] === '--list' || args[i] === '-l') { listMode = true; continue; }
|
|
224
253
|
if (!args[i].startsWith('-')) dir = args[i];
|
|
225
254
|
}
|
|
@@ -331,22 +360,44 @@ const root = resolve(process.cwd(), dir);
|
|
|
331
360
|
|
|
332
361
|
// Load .env from the serving root (if present) and pre-build the inject
|
|
333
362
|
// script. Kept as a single string so serveFile doesn't re-stringify on every
|
|
334
|
-
// HTML response. Empty string when no
|
|
335
|
-
// becomes a no-op for projects
|
|
336
|
-
const
|
|
337
|
-
const envInjectScript = buildEnvInjectScript(
|
|
338
|
-
const
|
|
339
|
-
if (
|
|
340
|
-
console.log(`Loaded ${
|
|
363
|
+
// HTML response. Empty string when no public vars exist — the injection step
|
|
364
|
+
// becomes a no-op for projects whose .env holds only server-side secrets.
|
|
365
|
+
const { public: publicEnv, private: privateEnvNames } = loadEnvFile(root);
|
|
366
|
+
const envInjectScript = buildEnvInjectScript(publicEnv);
|
|
367
|
+
const publicCount = Object.keys(publicEnv).length;
|
|
368
|
+
if (publicCount > 0) {
|
|
369
|
+
console.log(`Loaded ${publicCount} PUBLIC_ env var(s) into window.env`);
|
|
370
|
+
}
|
|
371
|
+
if (privateEnvNames.length > 0) {
|
|
372
|
+
// Loud about what was withheld so devs notice when something they expected
|
|
373
|
+
// in the browser is server-side only — and so a misplaced PUBLIC_ prefix is
|
|
374
|
+
// obvious from the startup log.
|
|
375
|
+
console.log(
|
|
376
|
+
`[mnfst-run] ${privateEnvNames.length} non-PUBLIC_ var(s) NOT injected ` +
|
|
377
|
+
`into window.env (kept server-side): ${privateEnvNames.join(', ')}`
|
|
378
|
+
);
|
|
341
379
|
}
|
|
342
380
|
|
|
343
|
-
|
|
344
|
-
|
|
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.
|
|
345
392
|
const existing = await findRunningServer(root);
|
|
346
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
|
+
}
|
|
347
399
|
const url = `http://localhost:${existing.port}`;
|
|
348
|
-
|
|
349
|
-
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`);
|
|
350
401
|
// Open the browser anyway — matches the experience of starting fresh.
|
|
351
402
|
// (Skipped under --no-open / Claude Code, where the preview panel is the browser.)
|
|
352
403
|
if (openBrowserEnabled) {
|
|
@@ -467,11 +518,12 @@ function serveFile(res, filePath) {
|
|
|
467
518
|
// Only inject into full HTML documents — not component fragments
|
|
468
519
|
const isFullDoc = /<!doctype\s/i.test(html) || /<html[\s>]/i.test(html);
|
|
469
520
|
if (isFullDoc) {
|
|
470
|
-
// 1) Inject window.env into <head> (when
|
|
521
|
+
// 1) Inject window.env into <head> (when public env vars exist) so the
|
|
471
522
|
// framework's manifest.json env-var substitution can resolve
|
|
472
523
|
// `${VAR}` placeholders before any plugin reads the manifest.
|
|
473
524
|
// Must come BEFORE framework scripts execute — <head> insertion
|
|
474
525
|
// guarantees that ordering regardless of where script tags sit.
|
|
526
|
+
// ONLY PUBLIC_-prefixed vars are eligible; see loadEnvFile().
|
|
475
527
|
let injected = html;
|
|
476
528
|
if (envInjectScript) {
|
|
477
529
|
injected = injected.includes('</head>')
|
|
@@ -621,8 +673,7 @@ const server = createServer((req, res) => {
|
|
|
621
673
|
});
|
|
622
674
|
|
|
623
675
|
// --- Auto-port ---
|
|
624
|
-
//
|
|
625
|
-
const label = dir === '.' ? basename(process.cwd()) : dir.replace(/\\/g, '/');
|
|
676
|
+
// (`label` is defined earlier, before the reuse/dedup check.)
|
|
626
677
|
|
|
627
678
|
function openBrowser(url) {
|
|
628
679
|
const cmd = process.platform === 'win32' ? `start ${url}`
|
|
@@ -631,6 +682,72 @@ function openBrowser(url) {
|
|
|
631
682
|
exec(cmd);
|
|
632
683
|
}
|
|
633
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
|
+
|
|
634
751
|
function tryListen(p, attempt = 0) {
|
|
635
752
|
if (attempt > 20) {
|
|
636
753
|
console.error('mnfst-run: could not find a free port after 20 attempts.');
|
|
@@ -644,14 +761,28 @@ function tryListen(p, attempt = 0) {
|
|
|
644
761
|
const onListening = () => {
|
|
645
762
|
server.removeListener('error', onError);
|
|
646
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.
|
|
647
768
|
writeRegistry(root, p);
|
|
769
|
+
weOwnServer = true;
|
|
648
770
|
console.log(`\n${label} running at ${url}\n`);
|
|
649
771
|
if (openBrowserEnabled) openBrowser(url);
|
|
650
772
|
};
|
|
651
773
|
const onError = err => {
|
|
652
774
|
server.removeListener('listening', onListening);
|
|
653
|
-
if (err.code
|
|
654
|
-
|
|
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);
|
|
655
786
|
};
|
|
656
787
|
server.once('listening', onListening);
|
|
657
788
|
server.once('error', onError);
|
|
@@ -661,10 +792,11 @@ function tryListen(p, attempt = 0) {
|
|
|
661
792
|
server.listen(p, '127.0.0.1');
|
|
662
793
|
}
|
|
663
794
|
|
|
664
|
-
// Clean up the registry entry on graceful exit
|
|
665
|
-
//
|
|
666
|
-
// process.exit
|
|
667
|
-
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); });
|
|
668
800
|
process.on('SIGINT', () => process.exit(0));
|
|
669
801
|
process.on('SIGTERM', () => process.exit(0));
|
|
670
802
|
|