import { createReadStream, existsSync, statSync } from "node:fs"; import { createServer } from "node:http"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { GameDatabaseError, openGameDatabase } from "./database.mjs"; const MIME_TYPES = new Map([ [".css", "text/css; charset=utf-8"], [".glb", "model/gltf-binary"], [".html", "text/html; charset=utf-8"], [".ico", "image/x-icon"], [".jpeg", "image/jpeg"], [".jpg", "image/jpeg"], [".js", "text/javascript; charset=utf-8"], [".json", "application/json; charset=utf-8"], [".ktx2", "image/ktx2"], [".mp3", "audio/mpeg"], [".ogg", "audio/ogg"], [".png", "image/png"], [".sqlite", "application/vnd.sqlite3"], [".svg", "image/svg+xml"], [".wasm", "application/wasm"], [".webm", "video/webm"], [".webp", "image/webp"], ]); function json(response, status, body, extraHeaders = {}) { const content = Buffer.from(JSON.stringify(body)); response.writeHead(status, { "Content-Type": "application/json; charset=utf-8", "Content-Length": content.length, "Cache-Control": "no-store", ...extraHeaders, }); response.end(content); } function parseAllowedOrigins(value) { return new Set(String(value ?? "").split(",").map((entry) => entry.trim()).filter(Boolean)); } function bearerToken(request) { return /^Bearer\s+(.+)$/i.exec(String(request.headers.authorization ?? ""))?.[1] ?? ""; } async function readJson(request, limit) { const chunks = []; let size = 0; for await (const chunk of request) { size += chunk.length; if (size > limit) throw new GameDatabaseError("Request body is too large.", 413, "body_too_large"); chunks.push(chunk); } if (size === 0) return {}; try { return JSON.parse(Buffer.concat(chunks).toString("utf8")); } catch { throw new GameDatabaseError("Request body must be valid JSON.", 400, "invalid_json"); } } function resolveStaticFile(staticDir, pathname, spaFallback = true) { let decoded; try { decoded = decodeURIComponent(pathname); } catch { return null; } const relative = decoded.replace(/^\/+/, ""); const candidate = path.resolve(staticDir, relative || "index.html"); if (candidate !== staticDir && !candidate.startsWith(`${staticDir}${path.sep}`)) return null; if (existsSync(candidate) && statSync(candidate).isFile()) return candidate; if (spaFallback && !path.extname(relative)) { const index = path.join(staticDir, "index.html"); if (existsSync(index)) return index; } return null; } function parseRange(value, size) { const match = /^bytes=(\d*)-(\d*)$/.exec(String(value ?? "")); if (!match) return null; let start = match[1] ? Number(match[1]) : null; let end = match[2] ? Number(match[2]) : null; if (start === null && end !== null) { start = Math.max(0, size - end); end = size - 1; } else { start ??= 0; end ??= size - 1; } if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || end < start || start >= size) return null; return { start, end: Math.min(end, size - 1) }; } function serveStatic(request, response, staticDir, pathname, options = {}) { if (request.method !== "GET" && request.method !== "HEAD") return false; const filePath = resolveStaticFile(staticDir, pathname, options.spaFallback !== false); if (!filePath) return false; const stats = statSync(filePath); const extension = path.extname(filePath).toLowerCase(); const range = request.headers.range ? parseRange(request.headers.range, stats.size) : null; const headers = { "Content-Type": MIME_TYPES.get(extension) ?? "application/octet-stream", "Accept-Ranges": "bytes", "Cache-Control": options.immutable ? "public, max-age=31536000, immutable" : path.basename(filePath) === "index.html" || options.noCache ? "no-cache" : "public, max-age=3600", "X-Content-Type-Options": "nosniff", }; if (request.headers.range && !range) { response.writeHead(416, { ...headers, "Content-Range": `bytes */${stats.size}` }); response.end(); return true; } if (range) { response.writeHead(206, { ...headers, "Content-Length": range.end - range.start + 1, "Content-Range": `bytes ${range.start}-${range.end}/${stats.size}`, }); if (request.method === "HEAD") response.end(); else createReadStream(filePath, range).pipe(response); return true; } response.writeHead(200, { ...headers, "Content-Length": stats.size }); if (request.method === "HEAD") response.end(); else createReadStream(filePath).pipe(response); return true; } function originHeaders(request, allowedOrigins) { const origin = String(request.headers.origin ?? ""); if (!origin || !allowedOrigins.has(origin)) return {}; return { "Access-Control-Allow-Origin": origin, "Access-Control-Allow-Credentials": "true", Vary: "Origin" }; } function createAuthLimiter() { const buckets = new Map(); return (key) => { const now = Date.now(); const recent = (buckets.get(key) ?? []).filter((timestamp) => now - timestamp < 60_000); if (recent.length >= 12) return false; recent.push(now); buckets.set(key, recent); if (buckets.size > 1_000) { for (const [entry, timestamps] of buckets) { if (!timestamps.some((timestamp) => now - timestamp < 60_000)) buckets.delete(entry); } } return true; }; } export function createGameServer(options = {}) { const staticDir = path.resolve(options.staticDir ?? process.env.STATIC_DIR ?? "dist"); const contentDir = path.resolve(options.contentDir ?? process.env.CONTENT_DIR ?? "content"); const allowedOrigins = parseAllowedOrigins(options.corsOrigins ?? process.env.CORS_ORIGINS); const bodyLimit = Math.max(1024, Number(options.bodyLimit ?? process.env.MAX_JSON_BODY_BYTES ?? 5 * 1024 * 1024)); const trustProxy = String(options.trustProxy ?? process.env.TRUST_PROXY ?? "").toLowerCase() === "true"; const gameDatabase = options.database ?? openGameDatabase(options); const allowAuthRequest = createAuthLimiter(); gameDatabase.pruneExpiredSessions(); const server = createServer(async (request, response) => { const cors = originHeaders(request, allowedOrigins); const url = new URL(request.url ?? "/", "http://localhost"); response.setHeader("X-Frame-Options", "DENY"); response.setHeader("Referrer-Policy", "same-origin"); for (const [name, value] of Object.entries(cors)) response.setHeader(name, value); if (request.method === "OPTIONS") { if (request.headers.origin && Object.keys(cors).length === 0) { json(response, 403, { error: "Origin is not allowed.", code: "origin_not_allowed" }); return; } response.writeHead(204, { ...cors, "Access-Control-Allow-Headers": "Authorization, Content-Type", "Access-Control-Allow-Methods": "GET, HEAD, POST, PUT, OPTIONS", "Access-Control-Max-Age": "86400", }); response.end(); return; } try { if (url.pathname === "/api/health" && request.method === "GET") { json(response, 200, { ok: true, service: "healer-man", time: new Date().toISOString() }); return; } if ((url.pathname === "/api/auth/register" || url.pathname === "/api/auth/login") && request.method === "POST") { const forwarded = trustProxy ? String(request.headers["x-forwarded-for"] ?? "").split(",")[0]?.trim() : ""; if (!allowAuthRequest(forwarded || request.socket.remoteAddress || "unknown")) { throw new GameDatabaseError("Too many login attempts. Try again shortly.", 429, "rate_limited"); } const body = await readJson(request, 64 * 1024); const result = url.pathname.endsWith("register") ? await gameDatabase.register(body.username, body.password) : await gameDatabase.login(body.username, body.password); json(response, url.pathname.endsWith("register") ? 201 : 200, result); return; } if (url.pathname === "/api/auth/logout" && request.method === "POST") { gameDatabase.logout(bearerToken(request)); json(response, 200, { ok: true }); return; } if (url.pathname === "/api/me" && request.method === "GET") { json(response, 200, { account: gameDatabase.authenticate(bearerToken(request)) }); return; } if (url.pathname === "/api/cloud-save" && request.method === "GET") { const account = gameDatabase.authenticate(bearerToken(request)); json(response, 200, gameDatabase.getCloudSave(account.id)); return; } if (url.pathname === "/api/cloud-save" && request.method === "PUT") { const account = gameDatabase.authenticate(bearerToken(request)); const body = await readJson(request, bodyLimit); json(response, 200, gameDatabase.putCloudSave(account.id, body.data)); return; } if (url.pathname.startsWith("/api/")) { json(response, 404, { error: "API route not found.", code: "not_found" }); return; } if (url.pathname === "/content" || url.pathname.startsWith("/content/")) { const contentPath = url.pathname.slice("/content".length) || "/manifest.json"; const served = serveStatic(request, response, contentDir, contentPath, { spaFallback: false, immutable: contentPath.startsWith("/objects/"), noCache: !contentPath.startsWith("/objects/"), }); if (served) return; json(response, 404, { error: "Content file not found.", code: "content_not_found" }); return; } if (serveStatic(request, response, staticDir, url.pathname)) return; json(response, 404, { error: "Not found.", code: "not_found" }); } catch (error) { const known = error instanceof GameDatabaseError; const status = known ? error.status : 500; if (status >= 500) console.error(error); if (!response.headersSent) json(response, status, { error: known ? error.message : "The server could not complete the request.", code: known ? error.code : "internal_error", }); else response.destroy(error instanceof Error ? error : undefined); } }); server.on("close", () => gameDatabase.close()); return { server, database: gameDatabase, staticDir, contentDir }; } if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { const host = process.env.HOST ?? "0.0.0.0"; const port = Number(process.env.PORT ?? 4173); const runtime = createGameServer(); runtime.server.listen(port, host, () => { console.log(`Healer Man server listening on http://${host}:${port}`); console.log(`Serving ${runtime.staticDir}`); console.log(`Content directory: ${runtime.contentDir}`); console.log(`SQLite database: ${runtime.database.databasePath}`); }); }