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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/serve.mjs +111 -13
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mnfst-run",
3
- "version": "1.0.3",
3
+ "version": "1.0.6",
4
4
  "description": "Zero-dependency dev server for Manifest projects",
5
5
  "type": "module",
6
6
  "bin": {
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 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 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
- if (ext === '.css') {
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
- req.on('close', () => { clients = clients.filter(c => c !== res); });
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
- server.once('error', err => {
197
- if (err.code === 'EADDRINUSE') tryListen(p + 1, attempt + 1);
198
- else throw err;
199
- });
200
- server.listen(p, () => {
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);