mnfst-run 1.0.0
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/bin/mnfst-run.js +2 -0
- package/package.json +30 -0
- package/serve.mjs +185 -0
package/bin/mnfst-run.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "mnfst-run",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Zero-dependency dev server for Manifest projects",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"mnfst-run": "./bin/mnfst-run.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"serve.mjs",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"publishConfig": {
|
|
15
|
+
"access": "public"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"manifest",
|
|
19
|
+
"mnfst",
|
|
20
|
+
"serve",
|
|
21
|
+
"dev-server"
|
|
22
|
+
],
|
|
23
|
+
"author": "Andrew Matlock",
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/andrewmatlock/Manifest.git",
|
|
28
|
+
"directory": "packages/run"
|
|
29
|
+
}
|
|
30
|
+
}
|
package/serve.mjs
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* mnfst-run — zero-dependency dev server for Manifest projects.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* npx mnfst-run [dir] [--port 5001]
|
|
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.
|
|
12
|
+
*
|
|
13
|
+
* SPA vs MPA is auto-detected: if the root index.html contains
|
|
14
|
+
* <meta name="manifest:prerendered"> the server disables SPA fallback.
|
|
15
|
+
*
|
|
16
|
+
* Live reload: CSS changes hot-swap the stylesheet without a page reload.
|
|
17
|
+
* All other file changes trigger a full browser refresh.
|
|
18
|
+
*/
|
|
19
|
+
import { createServer } from 'http';
|
|
20
|
+
import { readFileSync, statSync, watch } from 'fs';
|
|
21
|
+
import { join, extname, resolve } from 'path';
|
|
22
|
+
|
|
23
|
+
const MIME = {
|
|
24
|
+
'.html': 'text/html; charset=utf-8',
|
|
25
|
+
'.js': 'application/javascript; charset=utf-8',
|
|
26
|
+
'.mjs': 'application/javascript; charset=utf-8',
|
|
27
|
+
'.css': 'text/css; charset=utf-8',
|
|
28
|
+
'.json': 'application/json; charset=utf-8',
|
|
29
|
+
'.svg': 'image/svg+xml',
|
|
30
|
+
'.png': 'image/png',
|
|
31
|
+
'.jpg': 'image/jpeg',
|
|
32
|
+
'.jpeg': 'image/jpeg',
|
|
33
|
+
'.gif': 'image/gif',
|
|
34
|
+
'.webp': 'image/webp',
|
|
35
|
+
'.ico': 'image/x-icon',
|
|
36
|
+
'.woff': 'font/woff',
|
|
37
|
+
'.woff2':'font/woff2',
|
|
38
|
+
'.ttf': 'font/ttf',
|
|
39
|
+
'.xml': 'application/xml; charset=utf-8',
|
|
40
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
41
|
+
'.md': 'text/plain; charset=utf-8',
|
|
42
|
+
'.webmanifest': 'application/manifest+json',
|
|
43
|
+
'.map': 'application/json',
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// Injected into every HTML response — connects to the SSE stream and handles
|
|
47
|
+
// CSS hot-swap or full reload depending on what changed.
|
|
48
|
+
const LIVE_RELOAD_SCRIPT = `<script>
|
|
49
|
+
(function () {
|
|
50
|
+
var es = new EventSource('/__mnfst_sse__');
|
|
51
|
+
es.onmessage = function (e) {
|
|
52
|
+
var d = JSON.parse(e.data);
|
|
53
|
+
if (d.type === 'css') {
|
|
54
|
+
document.querySelectorAll('link[rel="stylesheet"]').forEach(function (l) {
|
|
55
|
+
var base = l.href.split('?')[0];
|
|
56
|
+
if (base.endsWith(d.file)) l.href = base + '?t=' + Date.now();
|
|
57
|
+
});
|
|
58
|
+
} else {
|
|
59
|
+
location.reload();
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
es.onerror = function () { es.close(); };
|
|
63
|
+
})();
|
|
64
|
+
</script>`;
|
|
65
|
+
|
|
66
|
+
// --- CLI args ---
|
|
67
|
+
const args = process.argv.slice(2);
|
|
68
|
+
let dir = '.';
|
|
69
|
+
let port = process.env.PORT ? parseInt(process.env.PORT, 10) : 5001;
|
|
70
|
+
|
|
71
|
+
for (let i = 0; i < args.length; i++) {
|
|
72
|
+
if ((args[i] === '--port' || args[i] === '-p') && args[i + 1]) { port = parseInt(args[++i], 10); continue; }
|
|
73
|
+
if (!args[i].startsWith('-')) dir = args[i];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const root = resolve(process.cwd(), dir);
|
|
77
|
+
|
|
78
|
+
// --- Auto-detect MPA ---
|
|
79
|
+
function detectMPA(rootDir) {
|
|
80
|
+
try {
|
|
81
|
+
return /name=["']manifest:prerendered["']/i.test(readFileSync(join(rootDir, 'index.html'), 'utf8'));
|
|
82
|
+
} catch { return false; }
|
|
83
|
+
}
|
|
84
|
+
const spa = !detectMPA(root);
|
|
85
|
+
const mode = spa ? 'SPA' : 'MPA';
|
|
86
|
+
|
|
87
|
+
// --- SSE clients ---
|
|
88
|
+
let clients = [];
|
|
89
|
+
let debounce = null;
|
|
90
|
+
|
|
91
|
+
function broadcast(data) {
|
|
92
|
+
const msg = `data: ${JSON.stringify(data)}\n\n`;
|
|
93
|
+
clients.forEach(res => { try { res.write(msg); } catch { /* client gone */ } });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// --- File watcher ---
|
|
97
|
+
const IGNORE = /node_modules|\.git/;
|
|
98
|
+
try {
|
|
99
|
+
watch(root, { recursive: true }, (_event, filename) => {
|
|
100
|
+
if (!filename || IGNORE.test(filename)) return;
|
|
101
|
+
clearTimeout(debounce);
|
|
102
|
+
debounce = setTimeout(() => {
|
|
103
|
+
const ext = extname(filename).toLowerCase();
|
|
104
|
+
if (ext === '.css') {
|
|
105
|
+
broadcast({ type: 'css', file: '/' + filename.replace(/\\/g, '/') });
|
|
106
|
+
} else {
|
|
107
|
+
broadcast({ type: 'reload' });
|
|
108
|
+
}
|
|
109
|
+
}, 60);
|
|
110
|
+
});
|
|
111
|
+
} catch {
|
|
112
|
+
// fs.watch unavailable in this environment — live reload disabled
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// --- File serving ---
|
|
116
|
+
function isFile(p) {
|
|
117
|
+
try { return statSync(p).isFile(); } catch { return false; }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function serveFile(res, filePath) {
|
|
121
|
+
const ext = extname(filePath).toLowerCase();
|
|
122
|
+
const mime = MIME[ext] || 'application/octet-stream';
|
|
123
|
+
let body = readFileSync(filePath);
|
|
124
|
+
if (ext === '.html') {
|
|
125
|
+
let html = body.toString('utf8');
|
|
126
|
+
// Inject before </body>; fall back to appending if tag absent
|
|
127
|
+
html = html.includes('</body>')
|
|
128
|
+
? html.replace('</body>', LIVE_RELOAD_SCRIPT + '</body>')
|
|
129
|
+
: html + LIVE_RELOAD_SCRIPT;
|
|
130
|
+
body = Buffer.from(html, 'utf8');
|
|
131
|
+
}
|
|
132
|
+
res.writeHead(200, { 'Content-Type': mime });
|
|
133
|
+
res.end(body);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// --- HTTP server ---
|
|
137
|
+
const server = createServer((req, res) => {
|
|
138
|
+
const urlPath = decodeURIComponent(req.url.split('?')[0]);
|
|
139
|
+
|
|
140
|
+
// SSE endpoint for live reload
|
|
141
|
+
if (urlPath === '/__mnfst_sse__') {
|
|
142
|
+
res.writeHead(200, {
|
|
143
|
+
'Content-Type': 'text/event-stream',
|
|
144
|
+
'Cache-Control': 'no-cache',
|
|
145
|
+
'Connection': 'keep-alive',
|
|
146
|
+
});
|
|
147
|
+
res.write(':\n\n'); // initial keep-alive comment
|
|
148
|
+
clients.push(res);
|
|
149
|
+
req.on('close', () => { clients = clients.filter(c => c !== res); });
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const exact = join(root, urlPath);
|
|
154
|
+
if (isFile(exact)) return serveFile(res, exact);
|
|
155
|
+
|
|
156
|
+
const index = join(root, urlPath.replace(/\/$/, ''), 'index.html');
|
|
157
|
+
if (isFile(index)) return serveFile(res, index);
|
|
158
|
+
|
|
159
|
+
if (spa) {
|
|
160
|
+
const fallback = join(root, 'index.html');
|
|
161
|
+
if (isFile(fallback)) return serveFile(res, fallback);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
165
|
+
res.end('404 Not Found');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// --- Auto-port ---
|
|
169
|
+
function tryListen(p, attempt = 0) {
|
|
170
|
+
if (attempt > 20) {
|
|
171
|
+
console.error('mnfst-run: could not find a free port after 20 attempts.');
|
|
172
|
+
process.exit(1);
|
|
173
|
+
}
|
|
174
|
+
server.once('error', err => {
|
|
175
|
+
if (err.code === 'EADDRINUSE') tryListen(p + 1, attempt + 1);
|
|
176
|
+
else throw err;
|
|
177
|
+
});
|
|
178
|
+
server.listen(p, () => {
|
|
179
|
+
if (p !== port) console.log(`mnfst-run: port ${port} in use, using ${p} instead.`);
|
|
180
|
+
console.log(`\n mnfst-run [${mode}] http://localhost:${p}`);
|
|
181
|
+
console.log(` root: ${root}\n`);
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
tryListen(port);
|