108 lines
3.1 KiB
JavaScript
108 lines
3.1 KiB
JavaScript
import { createServer } from 'node:http';
|
|
import { readFile, stat } from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import { walk } from './walk.mjs';
|
|
|
|
export const dist = path.resolve('dist');
|
|
|
|
const INDEX_FILE = 'index.html';
|
|
|
|
export async function requireDist() {
|
|
try {
|
|
await stat(dist);
|
|
} catch {
|
|
throw new Error('dist/ does not exist. Run npm run build first.');
|
|
}
|
|
}
|
|
|
|
// Derives the site's route list from the HTML files in dist/.
|
|
export async function discoverRoutes() {
|
|
const files = await walk(dist);
|
|
const routes = new Set();
|
|
|
|
for (const file of files) {
|
|
if (!file.endsWith('.html')) continue;
|
|
const rel = path.relative(dist, file).replaceAll(path.sep, '/');
|
|
if (rel === '404.html') continue;
|
|
if (rel === INDEX_FILE) {
|
|
routes.add('/');
|
|
} else if (rel.endsWith(`/${INDEX_FILE}`)) {
|
|
routes.add('/' + rel.slice(0, -INDEX_FILE.length));
|
|
} else {
|
|
routes.add('/' + rel.replace(/\.html$/, '/'));
|
|
}
|
|
}
|
|
|
|
return [...routes].sort();
|
|
}
|
|
|
|
// Maps a request URL (or pathname) to a file in dist/, mirroring static-host
|
|
// semantics: the path itself, its directory index.html, or the extensionless
|
|
// .html variant. Returns null when nothing matches.
|
|
export async function resolveFile(url) {
|
|
const parsed = new URL(url, 'http://localhost');
|
|
const safePath = path
|
|
.normalize(decodeURIComponent(parsed.pathname))
|
|
.replace(/^\/+/, '')
|
|
.replace(/^(\.\.(\/|\\|$))+/, '');
|
|
const candidate = path.join(dist, safePath);
|
|
const candidates = [
|
|
candidate,
|
|
path.join(candidate, 'index.html'),
|
|
path.join(dist, `${safePath}.html`),
|
|
];
|
|
|
|
for (const file of candidates) {
|
|
try {
|
|
const fileStat = await stat(file);
|
|
if (fileStat.isFile()) return file;
|
|
} catch {
|
|
// Try the next candidate.
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
const MIME = {
|
|
'.html': 'text/html; charset=utf-8',
|
|
'.css': 'text/css; charset=utf-8',
|
|
'.js': 'text/javascript; charset=utf-8',
|
|
'.svg': 'image/svg+xml',
|
|
'.png': 'image/png',
|
|
'.jpg': 'image/jpeg',
|
|
'.jpeg': 'image/jpeg',
|
|
'.webp': 'image/webp',
|
|
'.avif': 'image/avif',
|
|
'.ico': 'image/x-icon',
|
|
'.woff': 'font/woff',
|
|
'.woff2': 'font/woff2',
|
|
'.mp4': 'video/mp4',
|
|
'.webm': 'video/webm',
|
|
'.vtt': 'text/vtt; charset=utf-8',
|
|
'.pdf': 'application/pdf',
|
|
};
|
|
|
|
export function contentType(file) {
|
|
const ext = path.extname(file).toLowerCase();
|
|
return MIME[ext] ?? 'application/octet-stream';
|
|
}
|
|
|
|
// Serves dist/ on an ephemeral localhost port, falling back to 404.html.
|
|
// Returns the server (callers own closing it) and the assigned port.
|
|
export async function startDistServer() {
|
|
const server = createServer(async (req, res) => {
|
|
try {
|
|
const file = (await resolveFile(req.url ?? '/')) ?? path.join(dist, '404.html');
|
|
const body = await readFile(file);
|
|
res.writeHead(200, { 'content-type': contentType(file) });
|
|
res.end(body);
|
|
} catch (error) {
|
|
res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' });
|
|
res.end(String(error));
|
|
}
|
|
});
|
|
|
|
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
return { server, port: server.address().port };
|
|
}
|