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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/serve.mjs +170 -38
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mnfst-run",
3
- "version": "1.0.19",
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,
@@ -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 a plain object.
127
- // Used to populate `window.env` in served HTML so manifest.json's `${VAR}`
128
- // placeholders resolve at runtime matching the documented developer-facing
129
- // behaviour without requiring a build step. Returns {} when the file is
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
- // Supported subset (intentionally minimal, no expansion / multiline / etc.):
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 env = {};
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
- env[key] = value;
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 env;
187
+ return { public: publicEnv, private: privateNames };
167
188
  }
168
189
 
169
- // Build a `<script>window.env = {…};</script>` tag from a parsed env map.
170
- // Returns '' when there are no vars (so the injection is a no-op for projects
171
- // without a .env). Escapes any `</script` substring inside string values so an
172
- // env value can't break out of the script tag.
173
- function buildEnvInjectScript(envVars) {
174
- const keys = Object.keys(envVars);
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(envVars).replace(/<\/script/gi, '<\\/script');
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 .env exists — the injection step
335
- // becomes a no-op for projects that don't use env vars.
336
- const envVars = loadEnvFile(root);
337
- const envInjectScript = buildEnvInjectScript(envVars);
338
- const envCount = Object.keys(envVars).length;
339
- if (envCount > 0) {
340
- console.log(`Loaded ${envCount} env var(s) from .env into window.env`);
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
- // Dedup: if a server is already serving this exact root, point the user at
344
- // 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.
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
- const label0 = dir === '.' ? basename(process.cwd()) : dir.replace(/\\/g, '/');
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 .env present) so the
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
- // Human-readable label: the dir arg as given, or the cwd folder name if serving '.'
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 === 'EADDRINUSE') tryListen(p + 1, attempt + 1);
654
- 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);
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. process.exit() (used by
665
- // idle-shutdown) fires 'exit'; SIGINT/SIGTERM are translated into a
666
- // process.exit so the same path runs for Ctrl+C and `kill <pid>`.
667
- 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); });
668
800
  process.on('SIGINT', () => process.exit(0));
669
801
  process.on('SIGTERM', () => process.exit(0));
670
802