mnfst-run 1.0.7 → 1.0.10
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 +155 -2
package/package.json
CHANGED
package/serve.mjs
CHANGED
|
@@ -4,10 +4,14 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Usage:
|
|
6
6
|
* npx mnfst-run [dir] [--port 5001] [--idle-shutdown 30] [--no-idle-shutdown]
|
|
7
|
+
* npx mnfst-run --list
|
|
7
8
|
*
|
|
8
9
|
* dir Directory to serve (default: current directory). Any
|
|
9
10
|
* depth of nesting is valid, e.g.
|
|
10
11
|
* npx mnfst-run docs/articles/publishing
|
|
12
|
+
* If a server is already running for this directory,
|
|
13
|
+
* prints its URL and exits instead of starting a
|
|
14
|
+
* duplicate. Use `--list` to see everything running.
|
|
11
15
|
* --port Preferred port (default: PORT env var, then 5001).
|
|
12
16
|
* Auto-increments if the port is already in use.
|
|
13
17
|
* --idle-shutdown N Exit N seconds after the last preview tab closes
|
|
@@ -19,6 +23,8 @@
|
|
|
19
23
|
* overnight with the preview tab still open.
|
|
20
24
|
* --no-idle-shutdown Disable auto-shutdown (useful in CI / headless cases
|
|
21
25
|
* where no browser will connect).
|
|
26
|
+
* --list Print all mnfst-run servers currently running on this
|
|
27
|
+
* machine and exit.
|
|
22
28
|
*
|
|
23
29
|
* SPA vs MPA is auto-detected: if the root index.html contains
|
|
24
30
|
* <meta name="manifest:prerendered"> the server disables SPA fallback.
|
|
@@ -29,10 +35,16 @@
|
|
|
29
35
|
* sources and updates Alpine store reactively (no reload)
|
|
30
36
|
* other → full page reload
|
|
31
37
|
*/
|
|
32
|
-
import { createServer }
|
|
33
|
-
import {
|
|
38
|
+
import { createServer, get as httpGet } from 'http';
|
|
39
|
+
import {
|
|
40
|
+
readFileSync, statSync, watch,
|
|
41
|
+
existsSync, writeFileSync, unlinkSync,
|
|
42
|
+
mkdirSync, readdirSync,
|
|
43
|
+
} from 'fs';
|
|
34
44
|
import { join, extname, resolve, basename } from 'path';
|
|
35
45
|
import { exec } from 'child_process';
|
|
46
|
+
import { tmpdir } from 'os';
|
|
47
|
+
import { createHash } from 'crypto';
|
|
36
48
|
|
|
37
49
|
const MIME = {
|
|
38
50
|
'.html': 'text/html; charset=utf-8',
|
|
@@ -116,15 +128,136 @@ let port = process.env.PORT ? parseInt(process.env.PORT, 10) : 5001;
|
|
|
116
128
|
let idleShutdownSec = 30;
|
|
117
129
|
let idleShutdownEnabled = true;
|
|
118
130
|
|
|
131
|
+
let listMode = false;
|
|
132
|
+
|
|
119
133
|
for (let i = 0; i < args.length; i++) {
|
|
120
134
|
if ((args[i] === '--port' || args[i] === '-p') && args[i + 1]) { port = parseInt(args[++i], 10); continue; }
|
|
121
135
|
if (args[i] === '--no-idle-shutdown') { idleShutdownEnabled = false; continue; }
|
|
122
136
|
if (args[i] === '--idle-shutdown' && args[i + 1]) { idleShutdownSec = parseInt(args[++i], 10); continue; }
|
|
137
|
+
if (args[i] === '--list' || args[i] === '-l') { listMode = true; continue; }
|
|
123
138
|
if (!args[i].startsWith('-')) dir = args[i];
|
|
124
139
|
}
|
|
125
140
|
|
|
141
|
+
// --- Running-server registry ---
|
|
142
|
+
// One JSON file per project under `$TMPDIR/mnfst-run/`, keyed by a hash of
|
|
143
|
+
// the absolute root. Each holds `{ root, port, pid, startedAt }`. Used for:
|
|
144
|
+
// 1. Dedup — a second `mnfst-run <dir>` for an already-running project
|
|
145
|
+
// prints the existing URL instead of spinning up another port.
|
|
146
|
+
// 2. `--list` — show what's currently running across all projects.
|
|
147
|
+
// Cleanup happens on graceful exit (idle-shutdown, Ctrl+C, SIGTERM). Crash
|
|
148
|
+
// recovery is automatic: stale entries are detected by the next startup via
|
|
149
|
+
// PID-alive + identity-endpoint check and unlinked.
|
|
150
|
+
const REGISTRY_DIR = join(tmpdir(), 'mnfst-run');
|
|
151
|
+
const IDENTITY_PATH = '/__mnfst_run__';
|
|
152
|
+
|
|
153
|
+
function registryFileFor(rootPath) {
|
|
154
|
+
const hash = createHash('sha1').update(rootPath).digest('hex').slice(0, 16);
|
|
155
|
+
return join(REGISTRY_DIR, hash + '.json');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function readRegistryEntry(file) {
|
|
159
|
+
try { return JSON.parse(readFileSync(file, 'utf8')); }
|
|
160
|
+
catch { return null; }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function pidAlive(pid) {
|
|
164
|
+
try { process.kill(pid, 0); return true; } catch { return false; }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Quick probe of /__mnfst_run__ to confirm the entry isn't stale (PID may
|
|
168
|
+
// have been recycled to an unrelated process). Resolves to the parsed
|
|
169
|
+
// identity object on success, null on timeout / non-mnfst response.
|
|
170
|
+
function probeIdentity(p, timeoutMs = 400) {
|
|
171
|
+
return new Promise((resolveProbe) => {
|
|
172
|
+
let done = false;
|
|
173
|
+
const finish = (v) => { if (done) return; done = true; resolveProbe(v); };
|
|
174
|
+
const req = httpGet({ host: '127.0.0.1', port: p, path: IDENTITY_PATH, timeout: timeoutMs }, (res) => {
|
|
175
|
+
let body = '';
|
|
176
|
+
res.on('data', (c) => { body += c; if (body.length > 4096) { res.destroy(); finish(null); } });
|
|
177
|
+
res.on('end', () => { try { finish(JSON.parse(body)); } catch { finish(null); } });
|
|
178
|
+
});
|
|
179
|
+
req.on('error', () => finish(null));
|
|
180
|
+
req.on('timeout', () => { req.destroy(); finish(null); });
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function findRunningServer(rootPath) {
|
|
185
|
+
const file = registryFileFor(rootPath);
|
|
186
|
+
if (!existsSync(file)) return null;
|
|
187
|
+
const entry = readRegistryEntry(file);
|
|
188
|
+
if (!entry || entry.root !== rootPath || !pidAlive(entry.pid)) {
|
|
189
|
+
try { unlinkSync(file); } catch {}
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
const id = await probeIdentity(entry.port);
|
|
193
|
+
if (!id || id.root !== rootPath) {
|
|
194
|
+
try { unlinkSync(file); } catch {}
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
return entry;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function writeRegistry(rootPath, p) {
|
|
201
|
+
try { mkdirSync(REGISTRY_DIR, { recursive: true }); } catch {}
|
|
202
|
+
try {
|
|
203
|
+
writeFileSync(registryFileFor(rootPath), JSON.stringify({
|
|
204
|
+
root: rootPath,
|
|
205
|
+
port: p,
|
|
206
|
+
pid: process.pid,
|
|
207
|
+
startedAt: new Date().toISOString(),
|
|
208
|
+
}) + '\n');
|
|
209
|
+
} catch { /* registry is best-effort; serving still works without it */ }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function removeRegistry(rootPath) {
|
|
213
|
+
try { unlinkSync(registryFileFor(rootPath)); } catch {}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function listRunningServers() {
|
|
217
|
+
let files;
|
|
218
|
+
try { files = readdirSync(REGISTRY_DIR); } catch { files = []; }
|
|
219
|
+
const rows = [];
|
|
220
|
+
for (const f of files) {
|
|
221
|
+
if (!f.endsWith('.json')) continue;
|
|
222
|
+
const file = join(REGISTRY_DIR, f);
|
|
223
|
+
const entry = readRegistryEntry(file);
|
|
224
|
+
if (!entry || !pidAlive(entry.pid)) { try { unlinkSync(file); } catch {} continue; }
|
|
225
|
+
const id = await probeIdentity(entry.port);
|
|
226
|
+
if (!id || id.root !== entry.root) { try { unlinkSync(file); } catch {} continue; }
|
|
227
|
+
rows.push(entry);
|
|
228
|
+
}
|
|
229
|
+
if (rows.length === 0) { console.log('No mnfst-run servers running.'); return; }
|
|
230
|
+
const portW = Math.max(4, ...rows.map(r => String(r.port).length));
|
|
231
|
+
const pidW = Math.max(3, ...rows.map(r => String(r.pid).length));
|
|
232
|
+
console.log(`${'PORT'.padEnd(portW)} ${'PID'.padEnd(pidW)} URL ROOT`);
|
|
233
|
+
for (const r of rows) {
|
|
234
|
+
const url = `http://localhost:${r.port}`;
|
|
235
|
+
console.log(`${String(r.port).padEnd(portW)} ${String(r.pid).padEnd(pidW)} ${url.padEnd(28)} ${r.root}`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (listMode) {
|
|
240
|
+
await listRunningServers();
|
|
241
|
+
process.exit(0);
|
|
242
|
+
}
|
|
243
|
+
|
|
126
244
|
const root = resolve(process.cwd(), dir);
|
|
127
245
|
|
|
246
|
+
// Dedup: if a server is already serving this exact root, point the user at
|
|
247
|
+
// it (and open the browser, since that's what they were going to do anyway).
|
|
248
|
+
const existing = await findRunningServer(root);
|
|
249
|
+
if (existing) {
|
|
250
|
+
const url = `http://localhost:${existing.port}`;
|
|
251
|
+
const label0 = dir === '.' ? basename(process.cwd()) : dir.replace(/\\/g, '/');
|
|
252
|
+
console.log(`\n${label0} already running at ${url} (pid ${existing.pid})\n`);
|
|
253
|
+
// Open the browser anyway — matches the experience of starting fresh.
|
|
254
|
+
const cmd = process.platform === 'win32' ? `start ${url}`
|
|
255
|
+
: process.platform === 'darwin' ? `open ${url}`
|
|
256
|
+
: `xdg-open ${url}`;
|
|
257
|
+
exec(cmd);
|
|
258
|
+
process.exit(0);
|
|
259
|
+
}
|
|
260
|
+
|
|
128
261
|
// --- Auto-detect MPA ---
|
|
129
262
|
function detectMPA(rootDir) {
|
|
130
263
|
try {
|
|
@@ -262,6 +395,18 @@ function serveFile(res, filePath) {
|
|
|
262
395
|
const server = createServer((req, res) => {
|
|
263
396
|
const urlPath = decodeURIComponent(req.url.split('?')[0]);
|
|
264
397
|
|
|
398
|
+
// Identity endpoint: lets `mnfst-run` (and `--list`) confirm that a server
|
|
399
|
+
// on a registered port really is OUR server for the expected root, not
|
|
400
|
+
// some unrelated process that happened to inherit a recycled PID/port.
|
|
401
|
+
if (urlPath === IDENTITY_PATH) {
|
|
402
|
+
res.writeHead(200, {
|
|
403
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
404
|
+
'Cache-Control': 'no-store',
|
|
405
|
+
});
|
|
406
|
+
res.end(JSON.stringify({ name: 'mnfst-run', root, pid: process.pid }));
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
|
|
265
410
|
// Virtual /env.js — generated from .env at the project root.
|
|
266
411
|
// Loaded by HTML before manifest.data.js so window.env is populated for
|
|
267
412
|
// ${VAR} interpolation in manifest.json. Returns an empty no-op if no
|
|
@@ -369,6 +514,7 @@ function tryListen(p, attempt = 0) {
|
|
|
369
514
|
const onListening = () => {
|
|
370
515
|
server.removeListener('error', onError);
|
|
371
516
|
const url = `http://localhost:${p}`;
|
|
517
|
+
writeRegistry(root, p);
|
|
372
518
|
console.log(`\n${label} running at ${url}\n`);
|
|
373
519
|
openBrowser(url);
|
|
374
520
|
};
|
|
@@ -382,4 +528,11 @@ function tryListen(p, attempt = 0) {
|
|
|
382
528
|
server.listen(p);
|
|
383
529
|
}
|
|
384
530
|
|
|
531
|
+
// Clean up the registry entry on graceful exit. process.exit() (used by
|
|
532
|
+
// idle-shutdown) fires 'exit'; SIGINT/SIGTERM are translated into a
|
|
533
|
+
// process.exit so the same path runs for Ctrl+C and `kill <pid>`.
|
|
534
|
+
process.on('exit', () => removeRegistry(root));
|
|
535
|
+
process.on('SIGINT', () => process.exit(0));
|
|
536
|
+
process.on('SIGTERM', () => process.exit(0));
|
|
537
|
+
|
|
385
538
|
tryListen(port);
|