122 lines
4.3 KiB
JavaScript
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}`));
|