132 lines
4.6 KiB
JavaScript
132 lines
4.6 KiB
JavaScript
import { createReadStream, realpathSync, statSync } from "node:fs";
|
|
import { createServer } from "node:http";
|
|
import { networkInterfaces } from "node:os";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const ROOT = realpathSync(path.dirname(fileURLToPath(import.meta.url)));
|
|
const LAN_MODE = process.argv.includes("--lan");
|
|
const HOST = LAN_MODE ? "0.0.0.0" : process.env.HOST || "127.0.0.1";
|
|
const PORT = Number.parseInt(process.env.PORT || "4173", 10);
|
|
const TYPES = new Map([
|
|
[".css", "text/css; charset=utf-8"],
|
|
[".html", "text/html; charset=utf-8"],
|
|
[".js", "text/javascript; charset=utf-8"],
|
|
[".json", "application/json; charset=utf-8"],
|
|
[".mjs", "text/javascript; charset=utf-8"],
|
|
[".svg", "image/svg+xml"],
|
|
]);
|
|
const PUBLIC_PATHS = new Set([
|
|
"/index.html",
|
|
"/styles.css",
|
|
"/js/app.js",
|
|
"/js/charts.js",
|
|
"/js/data.js",
|
|
"/public/data/age_risk_curve.json",
|
|
"/public/data/cohort_scorecard.json",
|
|
"/public/data/coverage_quality.json",
|
|
"/public/data/data_manifest.json",
|
|
"/public/data/filter_catalog.json",
|
|
"/public/data/model_diagnostics.json",
|
|
"/public/data/overview_period_county.json",
|
|
"/public/data/sha256_manifest.json",
|
|
]);
|
|
|
|
function safeFile(requestUrl) {
|
|
let pathname;
|
|
try {
|
|
pathname = decodeURIComponent(new URL(requestUrl, "http://local").pathname);
|
|
} catch {
|
|
return { file: null, status: 400 };
|
|
}
|
|
if (pathname === "/") pathname = "/index.html";
|
|
if (!PUBLIC_PATHS.has(pathname)) return { file: null, status: 404 };
|
|
try {
|
|
const candidate = realpathSync(path.join(ROOT, pathname.slice(1)));
|
|
if (!candidate.startsWith(`${ROOT}${path.sep}`) || !statSync(candidate).isFile()) {
|
|
return { file: null, status: 404 };
|
|
}
|
|
return { file: candidate, status: 200 };
|
|
} catch {
|
|
return { file: null, status: 404 };
|
|
}
|
|
}
|
|
|
|
// Tailscale and similar VPN tunnels show up as extra "LAN" interfaces but are not
|
|
// useful for an in-room demo, so keep them out of the printed URL list.
|
|
function isTunnelInterface(interfaceName, address) {
|
|
if (/^(utun|tailscale|tun|ppp|ipsec)/i.test(interfaceName)) return true;
|
|
const [first, second] = address.split(".").map(Number);
|
|
return first === 100 && second >= 64 && second <= 127; // 100.64.0.0/10 (CGNAT/tailnet)
|
|
}
|
|
|
|
function displayUrls(host, port) {
|
|
if (host !== "0.0.0.0") {
|
|
const displayHost = host === "::" ? "::1" : host;
|
|
const urlHost = displayHost.includes(":") ? `[${displayHost}]` : displayHost;
|
|
return [{ label: "Open", url: `http://${urlHost}:${port}` }];
|
|
}
|
|
|
|
const entries = [{ label: "This computer", url: `http://127.0.0.1:${port}` }];
|
|
const seen = new Set();
|
|
for (const [interfaceName, addresses] of Object.entries(networkInterfaces())) {
|
|
for (const address of addresses || []) {
|
|
if (address.family !== "IPv4" || address.internal || seen.has(address.address)) continue;
|
|
if (isTunnelInterface(interfaceName, address.address)) continue;
|
|
seen.add(address.address);
|
|
entries.push({
|
|
label: `Other devices (${interfaceName})`,
|
|
url: `http://${address.address}:${port}`,
|
|
});
|
|
}
|
|
}
|
|
return entries;
|
|
}
|
|
|
|
const server = createServer((request, response) => {
|
|
if (!request.url || !["GET", "HEAD"].includes(request.method || "")) {
|
|
response.writeHead(405, { Allow: "GET, HEAD" });
|
|
response.end("Method not allowed");
|
|
return;
|
|
}
|
|
const resolved = safeFile(request.url);
|
|
if (!resolved.file) {
|
|
response.writeHead(resolved.status, { "Content-Type": "text/plain; charset=utf-8" });
|
|
response.end(resolved.status === 400 ? "Bad request" : "Not found");
|
|
return;
|
|
}
|
|
const file = resolved.file;
|
|
const extension = path.extname(file).toLowerCase();
|
|
response.writeHead(200, {
|
|
"Content-Type": TYPES.get(extension) || "application/octet-stream",
|
|
"Cache-Control": "no-store",
|
|
"X-Content-Type-Options": "nosniff",
|
|
"Referrer-Policy": "no-referrer",
|
|
});
|
|
if (request.method === "HEAD") {
|
|
response.end();
|
|
return;
|
|
}
|
|
createReadStream(file).on("error", () => response.destroy()).pipe(response);
|
|
});
|
|
|
|
server.on("error", (error) => {
|
|
if (error.code === "EADDRINUSE") {
|
|
process.stderr.write(
|
|
`Dashboard port ${PORT} is already in use. Stop the existing server or set a different PORT.\n`,
|
|
);
|
|
} else {
|
|
process.stderr.write(`Dashboard server could not start (${error.code || "unknown error"}).\n`);
|
|
}
|
|
process.exitCode = 1;
|
|
});
|
|
|
|
server.listen(PORT, HOST, () => {
|
|
const address = server.address();
|
|
const actualPort = typeof address === "object" && address ? address.port : PORT;
|
|
const urls = displayUrls(HOST, actualPort)
|
|
.map(({ label, url }) => ` ${label}: ${url}`)
|
|
.join("\n");
|
|
process.stdout.write(`Utah Vehicle Health dashboard:\n${urls}\n`);
|
|
});
|