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.
- package/package.json +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
|
+
"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
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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);
|