mnfst-run 1.0.6 → 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 +101 -21
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mnfst-run",
3
- "version": "1.0.6",
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
@@ -10,9 +10,13 @@
10
10
  * npx mnfst-run docs/articles/publishing
11
11
  * --port Preferred port (default: PORT env var, then 5001).
12
12
  * Auto-increments if the port is already in use.
13
- * --idle-shutdown N Exit after N seconds with no open browser tabs
14
- * (default 30). Only arms once a tab has connected, so
15
- * the auto-launched browser has time to load.
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.
16
20
  * --no-idle-shutdown Disable auto-shutdown (useful in CI / headless cases
17
21
  * where no browser will connect).
18
22
  *
@@ -57,9 +61,19 @@ const MIME = {
57
61
  // - CSS changes → hot-swaps the matching <link> href (no reload, no flash)
58
62
  // - data file changes → dispatches manifest:dev-reload (data plugin re-fetches)
59
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.
60
71
  const LIVE_RELOAD_SCRIPT = `<script>
61
72
  (function () {
62
- 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));
63
77
  es.onmessage = function (e) {
64
78
  var d = JSON.parse(e.data);
65
79
  if (d.type === 'css') {
@@ -73,7 +87,19 @@ const LIVE_RELOAD_SCRIPT = `<script>
73
87
  location.reload();
74
88
  }
75
89
  };
76
- 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
+ });
77
103
  })();
78
104
  \x3c/script>`;
79
105
 
@@ -81,10 +107,12 @@ const LIVE_RELOAD_SCRIPT = `<script>
81
107
  const args = process.argv.slice(2);
82
108
  let dir = '.';
83
109
  let port = process.env.PORT ? parseInt(process.env.PORT, 10) : 5001;
84
- // Auto-shutdown: when the last open browser tab disconnects (SSE drops to 0
85
- // clients) and stays gone for `idleShutdownSec`, the server exits. Cancelled
86
- // by `--no-idle-shutdown` (e.g. CI, headless smoke tests, or any case where
87
- // no browser will ever connect).
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.
88
116
  let idleShutdownSec = 30;
89
117
  let idleShutdownEnabled = true;
90
118
 
@@ -105,29 +133,45 @@ function detectMPA(rootDir) {
105
133
  }
106
134
  const spa = !detectMPA(root);
107
135
 
108
- // --- SSE clients ---
109
- 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
110
147
  let debounce = null;
111
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
+
112
155
  function broadcast(data) {
113
156
  const msg = `data: ${JSON.stringify(data)}\n\n`;
114
- clients.forEach(res => { try { res.write(msg); } catch { /* client gone */ } });
157
+ clients.forEach(({ res }) => { try { res.write(msg); } catch { /* client gone */ } });
115
158
  }
116
159
 
117
160
  // --- Idle auto-shutdown ---
118
161
  // `everConnected` keeps the timer dormant until at least one tab has opened —
119
- // otherwise the server would exit before the auto-launched browser tab finishes
120
- // loading. `idleTimer` runs only while clients.length === 0; any new SSE
121
- // connection cancels it. The grace window also covers hard-reload churn (Cmd+R
122
- // drops the SSE briefly, then reconnects in well under a second).
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.
123
167
  let everConnected = false;
124
168
  let idleTimer = null;
125
169
 
126
170
  function armIdleShutdown() {
127
171
  if (!idleShutdownEnabled || !everConnected || idleTimer) return;
128
- if (clients.length > 0) return;
172
+ if (openTabs.size > 0) return;
129
173
  idleTimer = setTimeout(() => {
130
- console.log(`\nmnfst-run: no open tabs for ${idleShutdownSec}s — shutting down.\n`);
174
+ console.log(`\nmnfst-run: all preview tabs closed for ${idleShutdownSec}s — shutting down.\n`);
131
175
  process.exit(0);
132
176
  }, idleShutdownSec * 1000);
133
177
  }
@@ -136,6 +180,13 @@ function cancelIdleShutdown() {
136
180
  if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; }
137
181
  }
138
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
+
139
190
  // --- .env support ---
140
191
  // Minimal dotenv parser. Skips comments/blank lines, splits on first `=`,
141
192
  // trims whitespace, strips wrapping single/double quotes. No multiline values,
@@ -234,22 +285,51 @@ const server = createServer((req, res) => {
234
285
 
235
286
  // SSE endpoint for live reload
236
287
  if (urlPath === '/__mnfst_sse__') {
288
+ const tabId = new URL(req.url, 'http://localhost').searchParams.get('tabId');
237
289
  res.writeHead(200, {
238
290
  'Content-Type': 'text/event-stream',
239
291
  'Cache-Control': 'no-cache',
240
292
  'Connection': 'keep-alive',
241
293
  });
242
294
  res.write(':\n\n'); // initial keep-alive comment
243
- clients.push(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
+ }
244
301
  everConnected = true;
245
302
  cancelIdleShutdown();
246
303
  req.on('close', () => {
247
- clients = clients.filter(c => c !== res);
248
- if (clients.length === 0) armIdleShutdown();
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
+ }
249
317
  });
250
318
  return;
251
319
  }
252
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();
330
+ return;
331
+ }
332
+
253
333
  const exact = join(root, urlPath);
254
334
  if (isFile(exact)) return serveFile(res, exact);
255
335