tools/serve.mjs (4614B)
1 #!/usr/bin/env node
2 // Serve the assembled site in _site/ at http://127.0.0.1:8790 for a local
3 // preview. No dependencies. Run `node tools/build-site.mjs` first, or use
4 // `npm run serve`, which does both. Set PORT to use another port.
5 //
6 // It answers GET and HEAD only, serves index.html for a directory, and
7 // redirects a directory without a trailing slash, so relative links on /kr/
8 // work as on GitHub Pages. A path is served only if its real path, with
9 // symlinks resolved, is that same file inside _site/, so nothing outside
10 // _site/ can be read. Where the file system reports the true case (Windows),
11 // a wrong-case link fails here as it would on GitHub Pages. Downloads (.php,
12 // .sh) are sent as plain text.
13
14 import { createServer } from 'node:http';
15 import { createReadStream } from 'node:fs';
16 import { realpath, stat } from 'node:fs/promises';
17 import { extname, join, relative } from 'node:path';
18 import { fileURLToPath } from 'node:url';
19
20 const HOST = '127.0.0.1';
21 const PORT = Number(process.env.PORT) || 8790;
22 const ROOT = fileURLToPath(new URL('../_site/', import.meta.url));
23
24 const TYPES = {
25 '.html': 'text/html; charset=utf-8',
26 '.js': 'text/javascript; charset=utf-8',
27 '.mjs': 'text/javascript; charset=utf-8',
28 '.css': 'text/css; charset=utf-8',
29 '.json': 'application/json; charset=utf-8',
30 '.svg': 'image/svg+xml',
31 '.woff2': 'font/woff2',
32 '.php': 'text/plain; charset=utf-8',
33 '.sh': 'text/plain; charset=utf-8',
34 '.txt': 'text/plain; charset=utf-8',
35 '': 'text/plain; charset=utf-8', // SHA256SUMS
36 };
37
38 // The request target as a URL. A path such as //kr stays a path; it is not read as a host.
39 function parseTarget(target) {
40 try {
41 return target.startsWith('/') ? new URL(`http://${HOST}${target}`) : new URL(target);
42 } catch {
43 return null;
44 }
45 }
46
47 // The decoded path segments, or null if one could step outside _site/ or is
48 // not a plain file name (separators, a drive or stream colon, NUL).
49 function segmentsOf(pathname) {
50 const out = [];
51 for (const raw of pathname.split('/')) {
52 if (raw === '') continue;
53 let s;
54 try {
55 s = decodeURIComponent(raw);
56 } catch {
57 return null;
58 }
59 if (s === '.' || s === '..' || /[\\/:\0]/.test(s)) return null;
60 out.push(s);
61 }
62 return out;
63 }
64
65 // The real path of file relative to the real _site/ (symlinks resolved, true case).
66 async function realRelative(file) {
67 const [root, real] = await Promise.all([realpath(ROOT), realpath(file)]);
68 return relative(root, real);
69 }
70
71 function send(res, status, body, headers = {}) {
72 res.writeHead(status, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store', ...headers });
73 res.end(res.req.method === 'HEAD' ? undefined : body + '\n');
74 }
75
76 async function handle(req, res) {
77 if (req.method !== 'GET' && req.method !== 'HEAD') return send(res, 405, 'Method not allowed', { allow: 'GET, HEAD' });
78
79 const url = parseTarget(req.url || '/');
80 const segments = url && segmentsOf(url.pathname);
81 if (!segments) return send(res, 400, 'Bad request');
82
83 let info = await stat(join(ROOT, ...segments)).catch(() => null);
84 if (info?.isDirectory()) {
85 if (!url.pathname.endsWith('/')) {
86 const location = ['', ...segments.map(encodeURIComponent), ''].join('/') + url.search;
87 return send(res, 301, 'Moved', { location });
88 }
89 segments.push('index.html');
90 info = await stat(join(ROOT, ...segments)).catch(() => null);
91 }
92 const file = join(ROOT, ...segments);
93 if (!info?.isFile() || (await realRelative(file)) !== join(...segments)) return send(res, 404, 'Not found');
94
95 res.writeHead(200, {
96 'content-type': TYPES[extname(file).toLowerCase()] || 'application/octet-stream',
97 'content-length': info.size,
98 'cache-control': 'no-store',
99 'x-content-type-options': 'nosniff',
100 });
101 if (req.method === 'HEAD') return res.end();
102 createReadStream(file).on('error', () => res.destroy()).pipe(res);
103 }
104
105 const server = createServer((req, res) => {
106 handle(req, res).catch((err) => {
107 console.error(err);
108 if (!res.headersSent) send(res, 500, 'Internal error');
109 else res.destroy();
110 });
111 });
112
113 server.on('error', (err) => {
114 console.error(err.code === 'EADDRINUSE' ? `Port ${PORT} on ${HOST} is already in use. Set PORT to use another one.` : err.message);
115 process.exit(1);
116 });
117
118 stat(join(ROOT, 'index.html')).then(
119 () => server.listen(PORT, HOST, () => console.log(`Serving _site/ at http://${HOST}:${PORT}/ (Ctrl+C to stop)`)),
120 () => {
121 console.error('_site/index.html not found. Run `node tools/build-site.mjs` first, or `npm run serve`.');
122 process.exit(1);
123 },
124 );