mnfst-run 1.0.3 → 1.0.6
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 +1 -1
- package/serve.mjs +111 -13
package/package.json
CHANGED
package/serve.mjs
CHANGED
|
@@ -3,12 +3,18 @@
|
|
|
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 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.
|
|
16
|
+
* --no-idle-shutdown Disable auto-shutdown (useful in CI / headless cases
|
|
17
|
+
* where no browser will connect).
|
|
12
18
|
*
|
|
13
19
|
* SPA vs MPA is auto-detected: if the root index.html contains
|
|
14
20
|
* <meta name="manifest:prerendered"> the server disables SPA fallback.
|
|
@@ -75,9 +81,17 @@ const LIVE_RELOAD_SCRIPT = `<script>
|
|
|
75
81
|
const args = process.argv.slice(2);
|
|
76
82
|
let dir = '.';
|
|
77
83
|
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).
|
|
88
|
+
let idleShutdownSec = 30;
|
|
89
|
+
let idleShutdownEnabled = true;
|
|
78
90
|
|
|
79
91
|
for (let i = 0; i < args.length; i++) {
|
|
80
92
|
if ((args[i] === '--port' || args[i] === '-p') && args[i + 1]) { port = parseInt(args[++i], 10); continue; }
|
|
93
|
+
if (args[i] === '--no-idle-shutdown') { idleShutdownEnabled = false; continue; }
|
|
94
|
+
if (args[i] === '--idle-shutdown' && args[i + 1]) { idleShutdownSec = parseInt(args[++i], 10); continue; }
|
|
81
95
|
if (!args[i].startsWith('-')) dir = args[i];
|
|
82
96
|
}
|
|
83
97
|
|
|
@@ -100,6 +114,51 @@ function broadcast(data) {
|
|
|
100
114
|
clients.forEach(res => { try { res.write(msg); } catch { /* client gone */ } });
|
|
101
115
|
}
|
|
102
116
|
|
|
117
|
+
// --- Idle auto-shutdown ---
|
|
118
|
+
// `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).
|
|
123
|
+
let everConnected = false;
|
|
124
|
+
let idleTimer = null;
|
|
125
|
+
|
|
126
|
+
function armIdleShutdown() {
|
|
127
|
+
if (!idleShutdownEnabled || !everConnected || idleTimer) return;
|
|
128
|
+
if (clients.length > 0) return;
|
|
129
|
+
idleTimer = setTimeout(() => {
|
|
130
|
+
console.log(`\nmnfst-run: no open tabs for ${idleShutdownSec}s — shutting down.\n`);
|
|
131
|
+
process.exit(0);
|
|
132
|
+
}, idleShutdownSec * 1000);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function cancelIdleShutdown() {
|
|
136
|
+
if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// --- .env support ---
|
|
140
|
+
// Minimal dotenv parser. Skips comments/blank lines, splits on first `=`,
|
|
141
|
+
// trims whitespace, strips wrapping single/double quotes. No multiline values,
|
|
142
|
+
// no `${VAR}` substitution within .env itself — we just want plain KEY=VALUE.
|
|
143
|
+
function parseDotenv(text) {
|
|
144
|
+
const out = {};
|
|
145
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
146
|
+
const line = rawLine.trim();
|
|
147
|
+
if (!line || line.startsWith('#')) continue;
|
|
148
|
+
const eq = line.indexOf('=');
|
|
149
|
+
if (eq === -1) continue;
|
|
150
|
+
const key = line.slice(0, eq).trim();
|
|
151
|
+
if (!key) continue;
|
|
152
|
+
let value = line.slice(eq + 1).trim();
|
|
153
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
154
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
155
|
+
value = value.slice(1, -1);
|
|
156
|
+
}
|
|
157
|
+
out[key] = value;
|
|
158
|
+
}
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
|
|
103
162
|
// --- File watcher ---
|
|
104
163
|
const IGNORE = /node_modules|\.git/;
|
|
105
164
|
try {
|
|
@@ -108,7 +167,10 @@ try {
|
|
|
108
167
|
clearTimeout(debounce);
|
|
109
168
|
debounce = setTimeout(() => {
|
|
110
169
|
const ext = extname(filename).toLowerCase();
|
|
111
|
-
|
|
170
|
+
const base = basename(filename);
|
|
171
|
+
if (base === '.env') {
|
|
172
|
+
broadcast({ type: 'reload' });
|
|
173
|
+
} else if (ext === '.css') {
|
|
112
174
|
broadcast({ type: 'css', file: '/' + filename.replace(/\\/g, '/') });
|
|
113
175
|
} else if (['.csv', '.json', '.yaml', '.yml', '.md'].includes(ext)) {
|
|
114
176
|
broadcast({ type: 'data' });
|
|
@@ -149,6 +211,27 @@ function serveFile(res, filePath) {
|
|
|
149
211
|
const server = createServer((req, res) => {
|
|
150
212
|
const urlPath = decodeURIComponent(req.url.split('?')[0]);
|
|
151
213
|
|
|
214
|
+
// Virtual /env.js — generated from .env at the project root.
|
|
215
|
+
// Loaded by HTML before manifest.data.js so window.env is populated for
|
|
216
|
+
// ${VAR} interpolation in manifest.json. Returns an empty no-op if no
|
|
217
|
+
// .env exists, so the <script src="/env.js"> tag is always safe to include.
|
|
218
|
+
if (urlPath === '/env.js') {
|
|
219
|
+
const envPath = join(root, '.env');
|
|
220
|
+
let body = 'window.env = window.env || {};';
|
|
221
|
+
try {
|
|
222
|
+
if (isFile(envPath)) {
|
|
223
|
+
const env = parseDotenv(readFileSync(envPath, 'utf8'));
|
|
224
|
+
body = `window.env = Object.assign(window.env || {}, ${JSON.stringify(env)});`;
|
|
225
|
+
}
|
|
226
|
+
} catch { /* fall through to no-op */ }
|
|
227
|
+
res.writeHead(200, {
|
|
228
|
+
'Content-Type': 'application/javascript; charset=utf-8',
|
|
229
|
+
'Cache-Control': 'no-store',
|
|
230
|
+
});
|
|
231
|
+
res.end(body);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
152
235
|
// SSE endpoint for live reload
|
|
153
236
|
if (urlPath === '/__mnfst_sse__') {
|
|
154
237
|
res.writeHead(200, {
|
|
@@ -158,7 +241,12 @@ const server = createServer((req, res) => {
|
|
|
158
241
|
});
|
|
159
242
|
res.write(':\n\n'); // initial keep-alive comment
|
|
160
243
|
clients.push(res);
|
|
161
|
-
|
|
244
|
+
everConnected = true;
|
|
245
|
+
cancelIdleShutdown();
|
|
246
|
+
req.on('close', () => {
|
|
247
|
+
clients = clients.filter(c => c !== res);
|
|
248
|
+
if (clients.length === 0) armIdleShutdown();
|
|
249
|
+
});
|
|
162
250
|
return;
|
|
163
251
|
}
|
|
164
252
|
|
|
@@ -193,15 +281,25 @@ function tryListen(p, attempt = 0) {
|
|
|
193
281
|
console.error('mnfst-run: could not find a free port after 20 attempts.');
|
|
194
282
|
process.exit(1);
|
|
195
283
|
}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
284
|
+
// Use explicit listeners so we can remove the pending 'listening' handler
|
|
285
|
+
// when retrying — otherwise each failed attempt leaves a once('listening')
|
|
286
|
+
// handler registered, and the eventual successful listen fires ALL of them,
|
|
287
|
+
// opening a browser tab for every port that was tried (including ports
|
|
288
|
+
// already taken by other projects).
|
|
289
|
+
const onListening = () => {
|
|
290
|
+
server.removeListener('error', onError);
|
|
201
291
|
const url = `http://localhost:${p}`;
|
|
202
292
|
console.log(`\n${label} running at ${url}\n`);
|
|
203
293
|
openBrowser(url);
|
|
204
|
-
}
|
|
294
|
+
};
|
|
295
|
+
const onError = err => {
|
|
296
|
+
server.removeListener('listening', onListening);
|
|
297
|
+
if (err.code === 'EADDRINUSE') tryListen(p + 1, attempt + 1);
|
|
298
|
+
else throw err;
|
|
299
|
+
};
|
|
300
|
+
server.once('listening', onListening);
|
|
301
|
+
server.once('error', onError);
|
|
302
|
+
server.listen(p);
|
|
205
303
|
}
|
|
206
304
|
|
|
207
305
|
tryListen(port);
|