Release v0.1.6 2026-07-12
This commit is contained in:
@@ -0,0 +1,400 @@
|
||||
import { createHash, randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
|
||||
import { mkdirSync, readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
|
||||
const SESSION_LIFETIME_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
const MAX_JSON_BYTES = 1024 * 1024;
|
||||
const AUTH_WINDOW_MS = 15 * 60 * 1000;
|
||||
const AUTH_ATTEMPTS_PER_WINDOW = 20;
|
||||
const authAttempts = new Map();
|
||||
|
||||
function apiError(message, status = 400) {
|
||||
const error = new Error(message);
|
||||
error.status = status;
|
||||
return error;
|
||||
}
|
||||
|
||||
function sendJson(response, status, body) {
|
||||
response.statusCode = status;
|
||||
response.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||
response.setHeader("Cache-Control", "no-store");
|
||||
response.setHeader("X-Content-Type-Options", "nosniff");
|
||||
response.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function configuredCorsOrigins() {
|
||||
return String(process.env.CORS_ORIGINS ?? "")
|
||||
.split(",")
|
||||
.map((origin) => origin.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function setCorsHeaders(request, response) {
|
||||
const origin = request.headers.origin;
|
||||
if (!origin) return;
|
||||
const configured = configuredCorsOrigins();
|
||||
if (!configured.includes("*") && !configured.includes(origin)) return;
|
||||
response.setHeader("Access-Control-Allow-Origin", origin);
|
||||
response.setHeader("Access-Control-Allow-Headers", "Authorization,Content-Type");
|
||||
response.setHeader("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS");
|
||||
response.setHeader("Access-Control-Max-Age", "86400");
|
||||
response.setHeader("Vary", "Origin");
|
||||
}
|
||||
|
||||
async function readJson(request) {
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
for await (const chunk of request) {
|
||||
size += chunk.length;
|
||||
if (size > MAX_JSON_BYTES) throw apiError("Request body is too large.", 413);
|
||||
chunks.push(chunk);
|
||||
}
|
||||
try {
|
||||
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
||||
} catch {
|
||||
throw apiError("Request body must be valid JSON.");
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalUsername(value) {
|
||||
return String(value ?? "").trim().toLocaleLowerCase();
|
||||
}
|
||||
|
||||
function validateUsername(value) {
|
||||
const username = String(value ?? "").trim();
|
||||
if (!/^[A-Za-z0-9_]{3,20}$/.test(username)) {
|
||||
throw apiError("Username must be 3–20 letters, numbers, or underscores.");
|
||||
}
|
||||
return username;
|
||||
}
|
||||
|
||||
function validatePassword(value) {
|
||||
const password = String(value ?? "");
|
||||
if (password.length < 10 || password.length > 128) {
|
||||
throw apiError("Password must be 10–128 characters.");
|
||||
}
|
||||
return password;
|
||||
}
|
||||
|
||||
function passwordDigest(password, salt) {
|
||||
return scryptSync(password, salt, 64).toString("hex");
|
||||
}
|
||||
|
||||
function verifyPassword(password, account) {
|
||||
const actual = Buffer.from(passwordDigest(password, account.passwordSalt), "hex");
|
||||
const expected = Buffer.from(account.passwordHash, "hex");
|
||||
return actual.length === expected.length && timingSafeEqual(actual, expected);
|
||||
}
|
||||
|
||||
function tokenHash(token) {
|
||||
return createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
function bearerToken(request) {
|
||||
const authorization = String(request.headers.authorization ?? "");
|
||||
return authorization.startsWith("Bearer ") ? authorization.slice(7).trim() : "";
|
||||
}
|
||||
|
||||
function createSession(database, accountId) {
|
||||
const token = randomBytes(32).toString("base64url");
|
||||
const expiresAt = new Date(Date.now() + SESSION_LIFETIME_MS).toISOString();
|
||||
database.prepare(`
|
||||
INSERT INTO sessions (account_id, token_hash, expires_at)
|
||||
VALUES (?, ?, ?)
|
||||
`).run(accountId, tokenHash(token), expiresAt);
|
||||
return token;
|
||||
}
|
||||
|
||||
function currentSession(database, request) {
|
||||
const token = bearerToken(request);
|
||||
if (!token) return null;
|
||||
return database.prepare(`
|
||||
SELECT accounts.id AS accountId, accounts.username
|
||||
FROM sessions
|
||||
JOIN accounts ON accounts.id = sessions.account_id
|
||||
WHERE sessions.token_hash = ? AND sessions.expires_at > CURRENT_TIMESTAMP
|
||||
`).get(tokenHash(token)) ?? null;
|
||||
}
|
||||
|
||||
function requireSession(database, request) {
|
||||
const session = currentSession(database, request);
|
||||
if (!session) throw apiError("Sign in required.", 401);
|
||||
return session;
|
||||
}
|
||||
|
||||
function clientAddress(request) {
|
||||
return request.socket?.remoteAddress ?? "unknown";
|
||||
}
|
||||
|
||||
function enforceAuthRateLimit(request) {
|
||||
const now = Date.now();
|
||||
const key = clientAddress(request);
|
||||
const existing = authAttempts.get(key);
|
||||
const bucket = existing && now - existing.startedAt < AUTH_WINDOW_MS
|
||||
? existing
|
||||
: { startedAt: now, count: 0 };
|
||||
bucket.count += 1;
|
||||
authAttempts.set(key, bucket);
|
||||
if (bucket.count > AUTH_ATTEMPTS_PER_WINDOW) {
|
||||
throw apiError("Too many authentication attempts. Try again later.", 429);
|
||||
}
|
||||
}
|
||||
|
||||
function register(database, payload) {
|
||||
const username = validateUsername(payload?.username);
|
||||
const password = validatePassword(payload?.password);
|
||||
const canonical = canonicalUsername(username);
|
||||
if (database.prepare("SELECT id FROM accounts WHERE canonical_username = ?").get(canonical)) {
|
||||
throw apiError("Account already exists.", 409);
|
||||
}
|
||||
const salt = randomBytes(16).toString("hex");
|
||||
const result = database.prepare(`
|
||||
INSERT INTO accounts (username, canonical_username, password_hash, password_salt)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run(username, canonical, passwordDigest(password, salt), salt);
|
||||
const accountId = Number(result.lastInsertRowid);
|
||||
return { account: { id: accountId, username }, token: createSession(database, accountId) };
|
||||
}
|
||||
|
||||
function login(database, payload) {
|
||||
const canonical = canonicalUsername(payload?.username);
|
||||
const password = String(payload?.password ?? "");
|
||||
const account = database.prepare(`
|
||||
SELECT id, username, password_hash AS passwordHash, password_salt AS passwordSalt
|
||||
FROM accounts WHERE canonical_username = ?
|
||||
`).get(canonical);
|
||||
if (!account || !verifyPassword(password, account)) {
|
||||
throw apiError("Username or password is incorrect.", 401);
|
||||
}
|
||||
return {
|
||||
account: { id: account.id, username: account.username },
|
||||
token: createSession(database, account.id),
|
||||
};
|
||||
}
|
||||
|
||||
function validateSlotId(value) {
|
||||
const slotId = Number(value);
|
||||
if (!Number.isInteger(slotId) || slotId < 1 || slotId > 3) throw apiError("Invalid save slot.");
|
||||
return slotId;
|
||||
}
|
||||
|
||||
function validateSave(value, slotId) {
|
||||
if (!value || typeof value !== "object" || Number(value.schemaVersion) !== 5) {
|
||||
throw apiError("Save snapshot is invalid.");
|
||||
}
|
||||
if (Number(value.slotId) !== slotId) throw apiError("Save slot does not match request.");
|
||||
if (typeof value.hunterName !== "string" || !value.hunterName.trim()) {
|
||||
throw apiError("Save snapshot has no hunter name.");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeNonNegativeInteger(value) {
|
||||
const number = Math.floor(Number(value));
|
||||
return Number.isFinite(number) ? Math.max(0, number) : 0;
|
||||
}
|
||||
|
||||
function syncLeaderboardStats(database, accountId, slotId, save) {
|
||||
database.prepare("DELETE FROM boss_kill_records WHERE account_id = ? AND slot_id = ?").run(accountId, slotId);
|
||||
const insertBoss = database.prepare(`
|
||||
INSERT INTO boss_kill_records (account_id, slot_id, boss_id, kills, updated_at)
|
||||
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
`);
|
||||
const bossKills = save.stats?.bossKills && typeof save.stats.bossKills === "object"
|
||||
? save.stats.bossKills
|
||||
: {};
|
||||
for (const [bossId, rawKills] of Object.entries(bossKills)) {
|
||||
if (!/^[a-z0-9-]{1,64}$/.test(bossId)) continue;
|
||||
const kills = normalizeNonNegativeInteger(rawKills);
|
||||
if (kills > 0) insertBoss.run(accountId, slotId, bossId, kills);
|
||||
}
|
||||
const highestRound = normalizeNonNegativeInteger(save.stats?.highestRoguelikeRound);
|
||||
database.prepare(`
|
||||
INSERT INTO roguelike_records (account_id, slot_id, highest_round, updated_at)
|
||||
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(account_id, slot_id) DO UPDATE SET
|
||||
highest_round = excluded.highest_round,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
`).run(accountId, slotId, highestRound);
|
||||
}
|
||||
|
||||
function writeSave(database, accountId, slotId, rawSave) {
|
||||
const save = validateSave(rawSave, slotId);
|
||||
const serialized = JSON.stringify(save);
|
||||
if (Buffer.byteLength(serialized) > MAX_JSON_BYTES) throw apiError("Save snapshot is too large.", 413);
|
||||
database.exec("BEGIN IMMEDIATE");
|
||||
try {
|
||||
database.prepare(`
|
||||
INSERT INTO hunter_saves (account_id, slot_id, hunter_name, save_json, updated_at)
|
||||
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(account_id, slot_id) DO UPDATE SET
|
||||
hunter_name = excluded.hunter_name,
|
||||
save_json = excluded.save_json,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
`).run(accountId, slotId, save.hunterName.trim().slice(0, 20), serialized);
|
||||
syncLeaderboardStats(database, accountId, slotId, save);
|
||||
database.exec("COMMIT");
|
||||
} catch (error) {
|
||||
database.exec("ROLLBACK");
|
||||
throw error;
|
||||
}
|
||||
return save;
|
||||
}
|
||||
|
||||
function readSave(database, accountId, slotId) {
|
||||
const row = database.prepare(`
|
||||
SELECT save_json AS saveJson FROM hunter_saves WHERE account_id = ? AND slot_id = ?
|
||||
`).get(accountId, slotId);
|
||||
if (!row) return null;
|
||||
try { return JSON.parse(row.saveJson); } catch { return null; }
|
||||
}
|
||||
|
||||
function listSaves(database, accountId) {
|
||||
return database.prepare(`
|
||||
SELECT slot_id AS slotId, save_json AS saveJson, updated_at AS updatedAt
|
||||
FROM hunter_saves WHERE account_id = ? ORDER BY slot_id
|
||||
`).all(accountId).flatMap((row) => {
|
||||
try { return [{ slotId: row.slotId, save: JSON.parse(row.saveJson), updatedAt: row.updatedAt }]; }
|
||||
catch { return []; }
|
||||
});
|
||||
}
|
||||
|
||||
function leaderboardEntry(row, valueKey) {
|
||||
return {
|
||||
rank: row.rank,
|
||||
username: row.username,
|
||||
hunterName: row.hunterName,
|
||||
slotId: row.slotId,
|
||||
value: row[valueKey],
|
||||
};
|
||||
}
|
||||
|
||||
function bossLeaderboard(database, accountId, slotId, bossId) {
|
||||
if (!/^[a-z0-9-]{1,64}$/.test(bossId)) throw apiError("Invalid boss.");
|
||||
const rows = database.prepare(`
|
||||
WITH ranked AS (
|
||||
SELECT
|
||||
RANK() OVER (ORDER BY records.kills DESC) AS rank,
|
||||
records.account_id AS accountId,
|
||||
records.slot_id AS slotId,
|
||||
records.kills,
|
||||
accounts.username,
|
||||
saves.hunter_name AS hunterName,
|
||||
records.updated_at AS updatedAt
|
||||
FROM boss_kill_records records
|
||||
JOIN accounts ON accounts.id = records.account_id
|
||||
JOIN hunter_saves saves ON saves.account_id = records.account_id AND saves.slot_id = records.slot_id
|
||||
WHERE records.boss_id = ?
|
||||
)
|
||||
SELECT * FROM ranked ORDER BY kills DESC, updatedAt ASC, accountId ASC, slotId ASC
|
||||
`).all(bossId);
|
||||
const current = rows.find((row) => row.accountId === accountId && row.slotId === slotId) ?? null;
|
||||
return {
|
||||
kind: "boss",
|
||||
bossId,
|
||||
top: rows.slice(0, 5).map((row) => leaderboardEntry(row, "kills")),
|
||||
current: current ? leaderboardEntry(current, "kills") : null,
|
||||
};
|
||||
}
|
||||
|
||||
function roguelikeLeaderboard(database, accountId, slotId) {
|
||||
const rows = database.prepare(`
|
||||
WITH ranked AS (
|
||||
SELECT
|
||||
RANK() OVER (ORDER BY records.highest_round DESC) AS rank,
|
||||
records.account_id AS accountId,
|
||||
records.slot_id AS slotId,
|
||||
records.highest_round AS highestRound,
|
||||
accounts.username,
|
||||
saves.hunter_name AS hunterName,
|
||||
records.updated_at AS updatedAt
|
||||
FROM roguelike_records records
|
||||
JOIN accounts ON accounts.id = records.account_id
|
||||
JOIN hunter_saves saves ON saves.account_id = records.account_id AND saves.slot_id = records.slot_id
|
||||
WHERE records.highest_round > 0
|
||||
)
|
||||
SELECT * FROM ranked ORDER BY highestRound DESC, updatedAt ASC, accountId ASC, slotId ASC
|
||||
`).all();
|
||||
const current = rows.find((row) => row.accountId === accountId && row.slotId === slotId) ?? null;
|
||||
return {
|
||||
kind: "roguelike",
|
||||
top: rows.slice(0, 5).map((row) => leaderboardEntry(row, "highestRound")),
|
||||
current: current ? leaderboardEntry(current, "highestRound") : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function createGameApiHandler(options = {}) {
|
||||
const dataDirectory = resolve(options.dataDirectory ?? process.env.DATA_DIR ?? "data");
|
||||
mkdirSync(dataDirectory, { recursive: true });
|
||||
const database = new DatabaseSync(resolve(dataDirectory, "game.db"));
|
||||
database.exec(readFileSync(new URL("../db/schema.sql", import.meta.url), "utf8"));
|
||||
|
||||
async function handle(request, response, next) {
|
||||
if (!request.url?.startsWith("/api/")) return next();
|
||||
setCorsHeaders(request, response);
|
||||
if (request.method === "OPTIONS") {
|
||||
response.statusCode = 204;
|
||||
return response.end();
|
||||
}
|
||||
try {
|
||||
database.prepare("DELETE FROM sessions WHERE expires_at <= CURRENT_TIMESTAMP").run();
|
||||
const url = new URL(request.url, "http://localhost");
|
||||
const path = url.pathname;
|
||||
|
||||
if (path === "/api/health" && request.method === "GET") {
|
||||
return sendJson(response, 200, { ok: true, database: "ready" });
|
||||
}
|
||||
if (path === "/api/auth/register" && request.method === "POST") {
|
||||
enforceAuthRateLimit(request);
|
||||
return sendJson(response, 201, register(database, await readJson(request)));
|
||||
}
|
||||
if (path === "/api/auth/login" && request.method === "POST") {
|
||||
enforceAuthRateLimit(request);
|
||||
return sendJson(response, 200, login(database, await readJson(request)));
|
||||
}
|
||||
if (path === "/api/auth/session" && request.method === "GET") {
|
||||
const session = currentSession(database, request);
|
||||
return sendJson(response, session ? 200 : 401, session
|
||||
? { account: { id: session.accountId, username: session.username } }
|
||||
: { error: "Sign in required." });
|
||||
}
|
||||
if (path === "/api/auth/logout" && request.method === "POST") {
|
||||
const token = bearerToken(request);
|
||||
if (token) database.prepare("DELETE FROM sessions WHERE token_hash = ?").run(tokenHash(token));
|
||||
return sendJson(response, 200, { ok: true });
|
||||
}
|
||||
|
||||
const session = requireSession(database, request);
|
||||
if (path === "/api/saves" && request.method === "GET") {
|
||||
return sendJson(response, 200, { slots: listSaves(database, session.accountId) });
|
||||
}
|
||||
const saveMatch = path.match(/^\/api\/saves\/([1-3])$/);
|
||||
if (saveMatch && request.method === "GET") {
|
||||
return sendJson(response, 200, { save: readSave(database, session.accountId, validateSlotId(saveMatch[1])) });
|
||||
}
|
||||
if (saveMatch && request.method === "PUT") {
|
||||
const slotId = validateSlotId(saveMatch[1]);
|
||||
const payload = await readJson(request);
|
||||
return sendJson(response, 200, { save: writeSave(database, session.accountId, slotId, payload?.save) });
|
||||
}
|
||||
const bossMatch = path.match(/^\/api\/leaderboards\/boss\/([a-z0-9-]+)$/);
|
||||
if (bossMatch && request.method === "GET") {
|
||||
const slotId = validateSlotId(url.searchParams.get("slot"));
|
||||
return sendJson(response, 200, bossLeaderboard(database, session.accountId, slotId, bossMatch[1]));
|
||||
}
|
||||
if (path === "/api/leaderboards/roguelike" && request.method === "GET") {
|
||||
const slotId = validateSlotId(url.searchParams.get("slot"));
|
||||
return sendJson(response, 200, roguelikeLeaderboard(database, session.accountId, slotId));
|
||||
}
|
||||
return sendJson(response, 404, { error: "API route not found." });
|
||||
} catch (error) {
|
||||
const status = Number(error?.status) || 500;
|
||||
const message = status >= 500 ? "Server error." : error.message;
|
||||
if (status >= 500) console.error(error);
|
||||
return sendJson(response, status, { error: message });
|
||||
}
|
||||
}
|
||||
|
||||
return { handle, close: () => database.close() };
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { createServer } from "node:http";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { after, before, test } from "node:test";
|
||||
import { createGameApiHandler } from "./game-api.mjs";
|
||||
|
||||
const dataDirectory = mkdtempSync(join(tmpdir(), "iwt-heal-api-"));
|
||||
const api = createGameApiHandler({ dataDirectory });
|
||||
const server = createServer((request, response) => {
|
||||
void api.handle(request, response, () => {
|
||||
response.statusCode = 404;
|
||||
response.end();
|
||||
});
|
||||
});
|
||||
let baseUrl;
|
||||
|
||||
before(async () => {
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
baseUrl = `http://127.0.0.1:${address.port}`;
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
api.close();
|
||||
rmSync(dataDirectory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function json(path, init = {}) {
|
||||
const response = await fetch(`${baseUrl}${path}`, init);
|
||||
const body = await response.json();
|
||||
return { response, body };
|
||||
}
|
||||
|
||||
function save(slotId, hunterName, bulldromeKills, highestRoguelikeRound) {
|
||||
return {
|
||||
schemaVersion: 5,
|
||||
slotId,
|
||||
hunterName,
|
||||
stats: { bossKills: { bulldrome: bulldromeKills }, highestRoguelikeRound },
|
||||
};
|
||||
}
|
||||
|
||||
test("health endpoint reports persistent database readiness", async () => {
|
||||
const { response, body } = await json("/api/health");
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(body, { ok: true, database: "ready" });
|
||||
});
|
||||
|
||||
test("accounts, server saves, and top-five plus current rankings work end to end", async () => {
|
||||
const players = [];
|
||||
for (let index = 0; index < 6; index += 1) {
|
||||
const username = `hunter_${index}`;
|
||||
const registration = await json("/api/auth/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password: `long-password-${index}` }),
|
||||
});
|
||||
assert.equal(registration.response.status, 201);
|
||||
const token = registration.body.token;
|
||||
const kills = 60 - index * 10;
|
||||
const highestRound = 30 - index * 4;
|
||||
const upload = await json("/api/saves/1", {
|
||||
method: "PUT",
|
||||
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ save: save(1, `Hero ${index}`, kills, highestRound) }),
|
||||
});
|
||||
assert.equal(upload.response.status, 200);
|
||||
players.push({ token, kills, highestRound });
|
||||
}
|
||||
|
||||
const current = players[5];
|
||||
const bossBoard = await json("/api/leaderboards/boss/bulldrome?slot=1", {
|
||||
headers: { Authorization: `Bearer ${current.token}` },
|
||||
});
|
||||
assert.equal(bossBoard.body.top.length, 5);
|
||||
assert.equal(bossBoard.body.top[0].value, 60);
|
||||
assert.equal(bossBoard.body.current.rank, 6);
|
||||
assert.equal(bossBoard.body.current.value, current.kills);
|
||||
|
||||
const rogueBoard = await json("/api/leaderboards/roguelike?slot=1", {
|
||||
headers: { Authorization: `Bearer ${current.token}` },
|
||||
});
|
||||
assert.equal(rogueBoard.body.top.length, 5);
|
||||
assert.equal(rogueBoard.body.current.rank, 6);
|
||||
assert.equal(rogueBoard.body.current.value, current.highestRound);
|
||||
|
||||
const download = await json("/api/saves/1", {
|
||||
headers: { Authorization: `Bearer ${current.token}` },
|
||||
});
|
||||
assert.equal(download.body.save.hunterName, "Hero 5");
|
||||
});
|
||||
|
||||
test("invalid credentials cannot access server saves", async () => {
|
||||
const login = await json("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username: "hunter_0", password: "incorrect-password" }),
|
||||
});
|
||||
assert.equal(login.response.status, 401);
|
||||
const saves = await json("/api/saves");
|
||||
assert.equal(saves.response.status, 401);
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { createReadStream, existsSync, statSync } from "node:fs";
|
||||
import { createServer } from "node:http";
|
||||
import { extname, resolve, sep } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createGameApiHandler } from "./game-api.mjs";
|
||||
|
||||
const distPath = fileURLToPath(new URL("../dist", import.meta.url));
|
||||
const indexPath = resolve(distPath, "index.html");
|
||||
const host = process.env.HOST ?? "127.0.0.1";
|
||||
const port = Number(process.env.PORT ?? 4173);
|
||||
const contentTypes = {
|
||||
".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",
|
||||
".png": "image/png",
|
||||
".svg": "image/svg+xml",
|
||||
".webp": "image/webp",
|
||||
};
|
||||
|
||||
function sendFile(response, filePath) {
|
||||
response.statusCode = 200;
|
||||
response.setHeader("Content-Type", contentTypes[extname(filePath).toLowerCase()] ?? "application/octet-stream");
|
||||
response.setHeader("X-Content-Type-Options", "nosniff");
|
||||
response.setHeader("Referrer-Policy", "same-origin");
|
||||
response.setHeader("X-Frame-Options", "DENY");
|
||||
createReadStream(filePath).pipe(response);
|
||||
}
|
||||
|
||||
function serveStatic(request, response) {
|
||||
let requestPath;
|
||||
try { requestPath = decodeURIComponent(new URL(request.url, "http://localhost").pathname); }
|
||||
catch { response.statusCode = 400; return response.end("Bad request"); }
|
||||
const candidate = resolve(distPath, `.${requestPath}`);
|
||||
const insideDist = candidate === distPath || candidate.startsWith(`${distPath}${sep}`);
|
||||
if (insideDist && existsSync(candidate) && statSync(candidate).isFile()) return sendFile(response, candidate);
|
||||
if (!existsSync(indexPath)) {
|
||||
response.statusCode = 503;
|
||||
return response.end("Build missing. Run pnpm build.");
|
||||
}
|
||||
return sendFile(response, indexPath);
|
||||
}
|
||||
|
||||
const api = createGameApiHandler();
|
||||
const server = createServer((request, response) => {
|
||||
void api.handle(request, response, () => serveStatic(request, response));
|
||||
});
|
||||
|
||||
server.listen(port, host, () => console.log(`I Want To Heal listening on http://${host}:${port}`));
|
||||
|
||||
function shutdown() {
|
||||
server.close(() => {
|
||||
api.close();
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
process.on("SIGINT", shutdown);
|
||||
process.on("SIGTERM", shutdown);
|
||||
Reference in New Issue
Block a user