mnfst-run 1.0.3 → 1.0.7

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 +2 -2
  2. package/serve.mjs +197 -19
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mnfst-run",
3
- "version": "1.0.3",
3
+ "version": "1.0.7",
4
4
  "description": "Zero-dependency dev server for Manifest projects",
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,4 +27,4 @@
27
27
  "url": "git+https://github.com/andrewmatlock/Manifest.git",
28
28
  "directory": "packages/run"
29
29
  }
30
- }
30
+ }
package/serve.mjs CHANGED
@@ -3,12 +3,22 @@
3
3
  * mnfst-run — zero-dependency dev server for Manifest projects.
4
4
  *
5
5
  * Usage:
6
- * npx mnfst-run [dir] [--port 5001]
6
+ * npx mnfst-run [dir] [--port 5001] [--idle-shutdown 30] [--no-idle-shutdown]
7
7
  *
8
- * dir Directory to serve (default: current directory). Any depth of
9
- * nesting is valid, e.g. npx mnfst-run docs/articles/publishing
10
- * --port Preferred port (default: PORT env var, then 5001). Auto-increments
11
- * if the port is already in use.
8
+ * dir Directory to serve (default: current directory). Any
9
+ * depth of nesting is valid, e.g.
10
+ * npx mnfst-run docs/articles/publishing
11
+ * --port Preferred port (default: PORT env var, then 5001).
12
+ * Auto-increments if the port is already in use.
13
+ * --idle-shutdown N Exit N seconds after the last preview tab closes
14
+ * (default 30). A tab is only considered closed when
15
+ * it fires `pagehide` (real close/navigation) and the
16
+ * browser sends an explicit close beacon — SSE drops
17
+ * from sleep, network blips, or backgrounding do not
18
+ * count, so the server survives e.g. a laptop sleeping
19
+ * overnight with the preview tab still open.
20
+ * --no-idle-shutdown Disable auto-shutdown (useful in CI / headless cases
21
+ * where no browser will connect).
12
22
  *
13
23
  * SPA vs MPA is auto-detected: if the root index.html contains
14
24
  * <meta name="manifest:prerendered"> the server disables SPA fallback.
@@ -51,9 +61,19 @@ const MIME = {
51
61
  // - CSS changes → hot-swaps the matching <link> href (no reload, no flash)
52
62
  // - data file changes → dispatches manifest:dev-reload (data plugin re-fetches)
53
63
  // - other changes → full page reload
64
+ //
65
+ // Tab lifecycle:
66
+ // The script generates a per-tab id and passes it on every SSE connect so
67
+ // the server can match auto-reconnects (after sleep / network blips) back
68
+ // to the same tab. On real tab close it fires a `sendBeacon` to
69
+ // /__mnfst_close__ — that beacon, not the SSE drop, is what tells the
70
+ // server the tab is gone.
54
71
  const LIVE_RELOAD_SCRIPT = `<script>
55
72
  (function () {
56
- var es = new EventSource('/__mnfst_sse__');
73
+ var tabId = (window.crypto && crypto.randomUUID)
74
+ ? crypto.randomUUID()
75
+ : (Math.random().toString(36).slice(2) + Date.now().toString(36));
76
+ var es = new EventSource('/__mnfst_sse__?tabId=' + encodeURIComponent(tabId));
57
77
  es.onmessage = function (e) {
58
78
  var d = JSON.parse(e.data);
59
79
  if (d.type === 'css') {
@@ -67,7 +87,19 @@ const LIVE_RELOAD_SCRIPT = `<script>
67
87
  location.reload();
68
88
  }
69
89
  };
70
- es.onerror = function () { es.close(); };
90
+ // Don't close on error — let EventSource auto-reconnect (carries the same
91
+ // tabId, so the server sees it as the same tab waking back up).
92
+ function notifyClose() {
93
+ var url = '/__mnfst_close__?tabId=' + encodeURIComponent(tabId);
94
+ if (navigator.sendBeacon) navigator.sendBeacon(url);
95
+ else { try { fetch(url, { method: 'POST', keepalive: true }); } catch (_) {} }
96
+ }
97
+ // pagehide w/ persisted=false = real tab close or cross-doc navigation.
98
+ // persisted=true means BFCache (back/forward may restore) — leave it alone.
99
+ window.addEventListener('pagehide', function (e) {
100
+ if (e.persisted) return;
101
+ notifyClose();
102
+ });
71
103
  })();
72
104
  \x3c/script>`;
73
105
 
@@ -75,9 +107,19 @@ const LIVE_RELOAD_SCRIPT = `<script>
75
107
  const args = process.argv.slice(2);
76
108
  let dir = '.';
77
109
  let port = process.env.PORT ? parseInt(process.env.PORT, 10) : 5001;
110
+ // Auto-shutdown: when the last preview tab is explicitly closed (the page's
111
+ // `pagehide` handler beacons /__mnfst_close__) and stays closed for
112
+ // `idleShutdownSec`, the server exits. SSE drops from sleep, network blips,
113
+ // or background-throttling do NOT count as a close — the server is happy to
114
+ // sit idle overnight if the tab is still open. Disabled by
115
+ // `--no-idle-shutdown` for CI / headless cases where no browser will connect.
116
+ let idleShutdownSec = 30;
117
+ let idleShutdownEnabled = true;
78
118
 
79
119
  for (let i = 0; i < args.length; i++) {
80
120
  if ((args[i] === '--port' || args[i] === '-p') && args[i + 1]) { port = parseInt(args[++i], 10); continue; }
121
+ if (args[i] === '--no-idle-shutdown') { idleShutdownEnabled = false; continue; }
122
+ if (args[i] === '--idle-shutdown' && args[i + 1]) { idleShutdownSec = parseInt(args[++i], 10); continue; }
81
123
  if (!args[i].startsWith('-')) dir = args[i];
82
124
  }
83
125
 
@@ -91,13 +133,81 @@ function detectMPA(rootDir) {
91
133
  }
92
134
  const spa = !detectMPA(root);
93
135
 
94
- // --- SSE clients ---
95
- let clients = [];
136
+ // --- SSE clients & tab presence ---
137
+ // `clients` is the live SSE socket list (used to broadcast reload events).
138
+ // `openTabs` is the durable set of tabs the server thinks are still open —
139
+ // only mutated when a tab connects for the first time, when it sends an
140
+ // explicit close beacon, or when its SSE has been disconnected longer than
141
+ // ORPHAN_GRACE_MS (a safety net for browser crashes / kill -9, not a normal
142
+ // path). `staleTimers` holds the per-tab orphan timers so they can be
143
+ // cancelled on reconnect.
144
+ let clients = []; // [{ res, tabId }]
145
+ let openTabs = new Set();
146
+ let staleTimers = new Map(); // tabId -> setTimeout handle
96
147
  let debounce = null;
97
148
 
149
+ // If a tab's SSE drops and never reconnects within this window we give up on
150
+ // it. Long enough to survive overnight sleep, multi-hour breaks, and Chrome
151
+ // background-tab discards; short enough that a server orphaned by a real
152
+ // browser crash eventually exits on its own.
153
+ const ORPHAN_GRACE_MS = 24 * 60 * 60 * 1000;
154
+
98
155
  function broadcast(data) {
99
156
  const msg = `data: ${JSON.stringify(data)}\n\n`;
100
- clients.forEach(res => { try { res.write(msg); } catch { /* client gone */ } });
157
+ clients.forEach(({ res }) => { try { res.write(msg); } catch { /* client gone */ } });
158
+ }
159
+
160
+ // --- Idle auto-shutdown ---
161
+ // `everConnected` keeps the timer dormant until at least one tab has opened —
162
+ // otherwise the server would exit before the auto-launched browser tab
163
+ // finishes loading. `idleTimer` runs only while openTabs is empty; any new
164
+ // tab (or reconnect) cancels it. The grace window also covers hard-reload
165
+ // churn and same-site navigation: pagehide → close beacon → new page loads
166
+ // and reconnects, all within a second or two.
167
+ let everConnected = false;
168
+ let idleTimer = null;
169
+
170
+ function armIdleShutdown() {
171
+ if (!idleShutdownEnabled || !everConnected || idleTimer) return;
172
+ if (openTabs.size > 0) return;
173
+ idleTimer = setTimeout(() => {
174
+ console.log(`\nmnfst-run: all preview tabs closed for ${idleShutdownSec}s — shutting down.\n`);
175
+ process.exit(0);
176
+ }, idleShutdownSec * 1000);
177
+ }
178
+
179
+ function cancelIdleShutdown() {
180
+ if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; }
181
+ }
182
+
183
+ function dropTab(tabId) {
184
+ if (!tabId) return;
185
+ openTabs.delete(tabId);
186
+ const t = staleTimers.get(tabId);
187
+ if (t) { clearTimeout(t); staleTimers.delete(tabId); }
188
+ }
189
+
190
+ // --- .env support ---
191
+ // Minimal dotenv parser. Skips comments/blank lines, splits on first `=`,
192
+ // trims whitespace, strips wrapping single/double quotes. No multiline values,
193
+ // no `${VAR}` substitution within .env itself — we just want plain KEY=VALUE.
194
+ function parseDotenv(text) {
195
+ const out = {};
196
+ for (const rawLine of text.split(/\r?\n/)) {
197
+ const line = rawLine.trim();
198
+ if (!line || line.startsWith('#')) continue;
199
+ const eq = line.indexOf('=');
200
+ if (eq === -1) continue;
201
+ const key = line.slice(0, eq).trim();
202
+ if (!key) continue;
203
+ let value = line.slice(eq + 1).trim();
204
+ if ((value.startsWith('"') && value.endsWith('"')) ||
205
+ (value.startsWith("'") && value.endsWith("'"))) {
206
+ value = value.slice(1, -1);
207
+ }
208
+ out[key] = value;
209
+ }
210
+ return out;
101
211
  }
102
212
 
103
213
  // --- File watcher ---
@@ -108,7 +218,10 @@ try {
108
218
  clearTimeout(debounce);
109
219
  debounce = setTimeout(() => {
110
220
  const ext = extname(filename).toLowerCase();
111
- if (ext === '.css') {
221
+ const base = basename(filename);
222
+ if (base === '.env') {
223
+ broadcast({ type: 'reload' });
224
+ } else if (ext === '.css') {
112
225
  broadcast({ type: 'css', file: '/' + filename.replace(/\\/g, '/') });
113
226
  } else if (['.csv', '.json', '.yaml', '.yml', '.md'].includes(ext)) {
114
227
  broadcast({ type: 'data' });
@@ -149,16 +262,71 @@ function serveFile(res, filePath) {
149
262
  const server = createServer((req, res) => {
150
263
  const urlPath = decodeURIComponent(req.url.split('?')[0]);
151
264
 
265
+ // Virtual /env.js — generated from .env at the project root.
266
+ // Loaded by HTML before manifest.data.js so window.env is populated for
267
+ // ${VAR} interpolation in manifest.json. Returns an empty no-op if no
268
+ // .env exists, so the <script src="/env.js"> tag is always safe to include.
269
+ if (urlPath === '/env.js') {
270
+ const envPath = join(root, '.env');
271
+ let body = 'window.env = window.env || {};';
272
+ try {
273
+ if (isFile(envPath)) {
274
+ const env = parseDotenv(readFileSync(envPath, 'utf8'));
275
+ body = `window.env = Object.assign(window.env || {}, ${JSON.stringify(env)});`;
276
+ }
277
+ } catch { /* fall through to no-op */ }
278
+ res.writeHead(200, {
279
+ 'Content-Type': 'application/javascript; charset=utf-8',
280
+ 'Cache-Control': 'no-store',
281
+ });
282
+ res.end(body);
283
+ return;
284
+ }
285
+
152
286
  // SSE endpoint for live reload
153
287
  if (urlPath === '/__mnfst_sse__') {
288
+ const tabId = new URL(req.url, 'http://localhost').searchParams.get('tabId');
154
289
  res.writeHead(200, {
155
290
  'Content-Type': 'text/event-stream',
156
291
  'Cache-Control': 'no-cache',
157
292
  'Connection': 'keep-alive',
158
293
  });
159
294
  res.write(':\n\n'); // initial keep-alive comment
160
- clients.push(res);
161
- req.on('close', () => { clients = clients.filter(c => c !== res); });
295
+ clients.push({ res, tabId });
296
+ if (tabId) {
297
+ openTabs.add(tabId);
298
+ const pending = staleTimers.get(tabId);
299
+ if (pending) { clearTimeout(pending); staleTimers.delete(tabId); }
300
+ }
301
+ everConnected = true;
302
+ cancelIdleShutdown();
303
+ req.on('close', () => {
304
+ clients = clients.filter(c => c.res !== res);
305
+ // SSE socket is gone, but the tab itself might just be sleeping. Hold
306
+ // its slot in openTabs until either the EventSource auto-reconnects
307
+ // (carrying the same tabId), the tab beacons /__mnfst_close__, or the
308
+ // orphan grace expires.
309
+ if (tabId && openTabs.has(tabId) && !staleTimers.has(tabId)) {
310
+ const t = setTimeout(() => {
311
+ staleTimers.delete(tabId);
312
+ openTabs.delete(tabId);
313
+ if (openTabs.size === 0) armIdleShutdown();
314
+ }, ORPHAN_GRACE_MS);
315
+ staleTimers.set(tabId, t);
316
+ }
317
+ });
318
+ return;
319
+ }
320
+
321
+ // Close beacon: fired by the injected script's pagehide handler when a tab
322
+ // is actually being closed/navigated away from. This is the only signal
323
+ // that drops a tab from openTabs in normal operation.
324
+ if (urlPath === '/__mnfst_close__') {
325
+ const tabId = new URL(req.url, 'http://localhost').searchParams.get('tabId');
326
+ dropTab(tabId);
327
+ res.writeHead(204);
328
+ res.end();
329
+ if (openTabs.size === 0) armIdleShutdown();
162
330
  return;
163
331
  }
164
332
 
@@ -193,15 +361,25 @@ function tryListen(p, attempt = 0) {
193
361
  console.error('mnfst-run: could not find a free port after 20 attempts.');
194
362
  process.exit(1);
195
363
  }
196
- server.once('error', err => {
197
- if (err.code === 'EADDRINUSE') tryListen(p + 1, attempt + 1);
198
- else throw err;
199
- });
200
- server.listen(p, () => {
364
+ // Use explicit listeners so we can remove the pending 'listening' handler
365
+ // when retrying otherwise each failed attempt leaves a once('listening')
366
+ // handler registered, and the eventual successful listen fires ALL of them,
367
+ // opening a browser tab for every port that was tried (including ports
368
+ // already taken by other projects).
369
+ const onListening = () => {
370
+ server.removeListener('error', onError);
201
371
  const url = `http://localhost:${p}`;
202
372
  console.log(`\n${label} running at ${url}\n`);
203
373
  openBrowser(url);
204
- });
374
+ };
375
+ const onError = err => {
376
+ server.removeListener('listening', onListening);
377
+ if (err.code === 'EADDRINUSE') tryListen(p + 1, attempt + 1);
378
+ else throw err;
379
+ };
380
+ server.once('listening', onListening);
381
+ server.once('error', onError);
382
+ server.listen(p);
205
383
  }
206
384
 
207
385
  tryListen(port);