mnfst-run 1.0.6 → 1.0.9
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 +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.
|
|
3
|
+
"version": "1.0.9",
|
|
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
|
|
14
|
-
* (default 30).
|
|
15
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
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
|
-
|
|
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
|
|
120
|
-
// loading. `idleTimer` runs only while
|
|
121
|
-
//
|
|
122
|
-
//
|
|
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 (
|
|
172
|
+
if (openTabs.size > 0) return;
|
|
129
173
|
idleTimer = setTimeout(() => {
|
|
130
|
-
console.log(`\nmnfst-run:
|
|
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
|
-
|
|
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
|
|