Files

122 lines
4.3 KiB
JavaScript

// LosaDev Tools production server: serves the Vite build in dist/ with no extra dependencies.
// Settings come from .env next to this file (or the real environment, which wins):
// PORT port to listen on (default 4700)
// HOST address to bind (default 0.0.0.0; use 127.0.0.1 behind a reverse proxy)
// The tools read game files in the visitor's browser (File System Access API), which needs HTTPS
// on a real domain: put this behind the panel's reverse proxy with a certificate.
import http from "node:http";
import fs from "node:fs";
import path from "node:path";
import zlib from "node:zlib";
import { fileURLToPath } from "node:url";
const here = path.dirname(fileURLToPath(import.meta.url));
function loadEnv(file) {
let text;
try {
text = fs.readFileSync(file, "utf8");
} catch {
return;
}
for (const raw of text.split(/\r?\n/)) {
const line = raw.trim();
if (!line || line.startsWith("#")) continue;
const eq = line.indexOf("=");
if (eq < 1) continue;
const key = line.slice(0, eq).trim();
let value = line.slice(eq + 1).trim();
if (/^(["']).*\1$/.test(value)) value = value.slice(1, -1);
if (process.env[key] === undefined) process.env[key] = value;
}
}
loadEnv(path.join(here, ".env"));
const PORT = Number.parseInt(process.env.PORT, 10) || 4700;
const HOST = process.env.HOST || "0.0.0.0";
const ROOT = path.join(here, "dist");
if (!fs.existsSync(path.join(ROOT, "index.html"))) {
console.error("dist/index.html not found. Run `npm run build` first (or `npm run rebuild`).");
process.exit(1);
}
const TYPES = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".mjs": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".json": "application/json; charset=utf-8",
".map": "application/json; charset=utf-8",
".svg": "image/svg+xml",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".gif": "image/gif",
".ico": "image/x-icon",
".woff": "font/woff",
".woff2": "font/woff2",
".ttf": "font/ttf",
".wasm": "application/wasm",
".txt": "text/plain; charset=utf-8",
".xml": "application/xml; charset=utf-8",
".ini": "text/plain; charset=utf-8",
};
const COMPRESS = /^(text\/|application\/(json|javascript|xml|wasm)|image\/svg)/;
function resolveFile(urlPath) {
let rel;
try {
rel = decodeURIComponent(urlPath.split("?")[0]);
} catch {
return null;
}
const full = path.normalize(path.join(ROOT, rel));
if (full !== ROOT && !full.startsWith(ROOT + path.sep)) return null; // no ../ out of dist
try {
const st = fs.statSync(full);
if (st.isDirectory()) {
const index = path.join(full, "index.html");
return fs.existsSync(index) ? index : null;
}
return full;
} catch {
// Routes without a file extension fall back to the app (it routes by #hash, this is a safety net).
return path.extname(rel) ? null : path.join(ROOT, "index.html");
}
}
const server = http.createServer((req, res) => {
res.setHeader("X-Content-Type-Options", "nosniff");
res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
if (req.method !== "GET" && req.method !== "HEAD") {
res.writeHead(405, { Allow: "GET, HEAD" });
return res.end();
}
const file = resolveFile(req.url || "/");
if (!file) {
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
return res.end("Not found");
}
const type = TYPES[path.extname(file).toLowerCase()] || "application/octet-stream";
// Vite puts content-hashed files in /assets/: cache them for a year. Everything else
// (index.html, legacy/ files that use a ?v= query) is revalidated on every visit.
const hashed = req.url.startsWith("/assets/");
const headers = { "Content-Type": type, "Cache-Control": hashed ? "public, max-age=31536000, immutable" : "no-cache" };
const gzip = COMPRESS.test(type) && /\bgzip\b/.test(req.headers["accept-encoding"] || "");
if (gzip) {
headers["Content-Encoding"] = "gzip";
headers.Vary = "Accept-Encoding";
} else {
headers["Content-Length"] = fs.statSync(file).size;
}
res.writeHead(200, headers);
if (req.method === "HEAD") return res.end();
const stream = fs.createReadStream(file);
stream.on("error", () => res.destroy());
(gzip ? stream.pipe(zlib.createGzip()) : stream).pipe(res);
});
server.listen(PORT, HOST, () => console.log(`LosaDev Tools on http://${HOST === "0.0.0.0" ? "localhost" : HOST}:${PORT}`));