LosaDev Tools: Lost Saga modding tools in the browser
This commit is contained in:
commit
c5e04472fb
105 files changed
+38541
No files matched your search
+121
@@ -0,0 +1,121 @@
|
||||
// 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}`));
|
||||
Reference in new issue
Block a user