// 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}`));