Release Healer Man 0.1.32
This commit is contained in:
1 parent
d3d6aaebdb
commit
6dcb1034e5
102 files changed
+10772
-788
No files matched your search
+57
-7
@@ -35,6 +35,19 @@ function parseAllowedOrigins(value) {
|
||||
return new Set(String(value ?? "").split(",").map((entry) => entry.trim()).filter(Boolean));
|
||||
}
|
||||
|
||||
function booleanFlag(value) {
|
||||
return ["1", "true", "yes", "on"].includes(String(value ?? "").trim().toLowerCase());
|
||||
}
|
||||
|
||||
function acceptsEncoding(value, requested) {
|
||||
const encodings = new Map(String(value ?? "").split(",").map((entry) => {
|
||||
const [name, ...parameters] = entry.trim().toLowerCase().split(";");
|
||||
const quality = parameters.find((part) => part.trim().startsWith("q="));
|
||||
return [name, quality ? Number(quality.trim().slice(2)) : 1];
|
||||
}));
|
||||
return (encodings.get(requested) ?? encodings.get("*") ?? 0) > 0;
|
||||
}
|
||||
|
||||
function bearerToken(request) {
|
||||
return /^Bearer\s+(.+)$/i.exec(String(request.headers.authorization ?? ""))?.[1] ?? "";
|
||||
}
|
||||
@@ -92,15 +105,25 @@ function serveStatic(request, response, staticDir, pathname, options = {}) {
|
||||
const stats = statSync(filePath);
|
||||
const extension = path.extname(filePath).toLowerCase();
|
||||
const range = request.headers.range ? parseRange(request.headers.range, stats.size) : null;
|
||||
const canEncode = !range && !request.headers.range;
|
||||
const encoding = canEncode && existsSync(`${filePath}.br`) && acceptsEncoding(request.headers["accept-encoding"], "br")
|
||||
? "br"
|
||||
: canEncode && existsSync(`${filePath}.gz`) && acceptsEncoding(request.headers["accept-encoding"], "gzip")
|
||||
? "gzip"
|
||||
: null;
|
||||
const encodedPath = encoding ? `${filePath}.${encoding === "br" ? "br" : "gz"}` : null;
|
||||
const servedPath = encodedPath ?? filePath;
|
||||
const servedStats = encodedPath ? statSync(encodedPath) : stats;
|
||||
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
|
||||
: ["index.html", "content-cache-sw.js"].includes(path.basename(filePath)) || options.noCache
|
||||
? "no-cache"
|
||||
: "public, max-age=3600",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
...(encodedPath ? { "Content-Encoding": encoding, Vary: "Accept-Encoding" } : {}),
|
||||
};
|
||||
if (request.headers.range && !range) {
|
||||
response.writeHead(416, { ...headers, "Content-Range": `bytes */${stats.size}` });
|
||||
@@ -117,9 +140,9 @@ function serveStatic(request, response, staticDir, pathname, options = {}) {
|
||||
else createReadStream(filePath, range).pipe(response);
|
||||
return true;
|
||||
}
|
||||
response.writeHead(200, { ...headers, "Content-Length": stats.size });
|
||||
response.writeHead(200, { ...headers, "Content-Length": servedStats.size });
|
||||
if (request.method === "HEAD") response.end();
|
||||
else createReadStream(filePath).pipe(response);
|
||||
else createReadStream(servedPath).pipe(response);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -153,9 +176,10 @@ export function createGameServer(options = {}) {
|
||||
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 enableShelvedModes = options.enableShelvedModes ?? booleanFlag(process.env.HEALER_MAN_ENABLE_SHELVED_MODES);
|
||||
const gameDatabase = options.database ?? openGameDatabase(options);
|
||||
const onlineSessions = createOnlineSessionManager();
|
||||
const arenaService = createArenaService({ database: gameDatabase });
|
||||
const arenaService = createArenaService({ database: gameDatabase, autoPump: enableShelvedModes });
|
||||
const allowAuthRequest = createAuthLimiter();
|
||||
gameDatabase.pruneExpiredSessions();
|
||||
|
||||
@@ -185,6 +209,15 @@ export function createGameServer(options = {}) {
|
||||
json(response, 200, { ok: true, service: "healer-man", time: new Date().toISOString() });
|
||||
return;
|
||||
}
|
||||
if (!enableShelvedModes && (
|
||||
url.pathname === "/api/manastorm-config"
|
||||
|| url.pathname.startsWith("/api/gm/manastorm-config")
|
||||
|| url.pathname === "/api/arena"
|
||||
|| url.pathname.startsWith("/api/arena/")
|
||||
)) {
|
||||
json(response, 503, { error: "This feature is temporarily unavailable.", code: "feature_disabled" });
|
||||
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")) {
|
||||
@@ -254,6 +287,10 @@ export function createGameServer(options = {}) {
|
||||
if (url.pathname === "/api/online-group/activity" && request.method === "POST") {
|
||||
const account = gameDatabase.authenticate(bearerToken(request));
|
||||
const body = await readJson(request, 64 * 1024);
|
||||
if (!enableShelvedModes && body.type === "manastorm") {
|
||||
json(response, 503, { error: "Manastorm groups are temporarily unavailable.", code: "feature_disabled" });
|
||||
return;
|
||||
}
|
||||
json(response, 200, gameDatabase.startOnlineGroupActivity(account.id, body));
|
||||
return;
|
||||
}
|
||||
@@ -348,7 +385,11 @@ export function createGameServer(options = {}) {
|
||||
}
|
||||
if (serveBundledContent(request, response, url.pathname)) return;
|
||||
if (url.pathname === "/content/bootstrap-manifest.json" || url.pathname.startsWith("/content/bundled/")) {
|
||||
if (serveStatic(request, response, staticDir, url.pathname, { spaFallback: false, noCache: true })) return;
|
||||
if (serveStatic(request, response, staticDir, url.pathname, {
|
||||
spaFallback: false,
|
||||
immutable: url.pathname.startsWith("/content/bundled/"),
|
||||
noCache: url.pathname === "/content/bootstrap-manifest.json",
|
||||
})) return;
|
||||
}
|
||||
if (url.pathname === "/content" || url.pathname.startsWith("/content/")) {
|
||||
const contentPath = url.pathname.slice("/content".length) || "/manifest.json";
|
||||
@@ -361,7 +402,8 @@ export function createGameServer(options = {}) {
|
||||
json(response, 404, { error: "Content file not found.", code: "content_not_found" });
|
||||
return;
|
||||
}
|
||||
if (serveStatic(request, response, staticDir, url.pathname)) return;
|
||||
const immutableViteAsset = /^\/assets\/.+-[A-Za-z0-9_-]{8,}\.[A-Za-z0-9]+$/.test(url.pathname);
|
||||
if (serveStatic(request, response, staticDir, url.pathname, { immutable: immutableViteAsset })) return;
|
||||
json(response, 404, { error: "Not found.", code: "not_found" });
|
||||
} catch (error) {
|
||||
const known = error instanceof GameDatabaseError;
|
||||
@@ -376,6 +418,14 @@ export function createGameServer(options = {}) {
|
||||
});
|
||||
|
||||
server.on("upgrade", (request, socket, head) => {
|
||||
if (!enableShelvedModes && new URL(request.url ?? "/", "http://localhost").pathname === "/ws/arena") {
|
||||
const body = Buffer.from(JSON.stringify({
|
||||
error: "This feature is temporarily unavailable.",
|
||||
code: "feature_disabled",
|
||||
}));
|
||||
socket.end(`HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\nContent-Type: application/json\r\nContent-Length: ${body.length}\r\n\r\n${body}`);
|
||||
return;
|
||||
}
|
||||
if (!upgradeArenaWebSocket(request, socket, head, arenaService)) {
|
||||
socket.end("HTTP/1.1 404 Not Found\r\nConnection: close\r\nContent-Length: 0\r\n\r\n");
|
||||
}
|
||||
@@ -386,7 +436,7 @@ export function createGameServer(options = {}) {
|
||||
arenaService.close();
|
||||
gameDatabase.close();
|
||||
});
|
||||
return { server, database: gameDatabase, arenaService, staticDir, contentDir };
|
||||
return { server, database: gameDatabase, arenaService, staticDir, contentDir, enableShelvedModes };
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
|
||||
+61
-1
@@ -3,6 +3,7 @@ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { brotliCompressSync } from "node:zlib";
|
||||
import { createGameServer } from "./server.mjs";
|
||||
|
||||
test("serves the game and authenticated cloud-save API", async () => {
|
||||
@@ -11,18 +12,36 @@ test("serves the game and authenticated cloud-save API", async () => {
|
||||
const dataDir = path.join(directory, "data");
|
||||
const contentDir = path.join(directory, "content");
|
||||
mkdirSync(staticDir, { recursive: true });
|
||||
mkdirSync(path.join(staticDir, "assets"), { recursive: true });
|
||||
mkdirSync(path.join(staticDir, "content", "bundled"), { recursive: true });
|
||||
mkdirSync(path.join(contentDir, "objects"), { recursive: true });
|
||||
writeFileSync(path.join(staticDir, "index.html"), "<!doctype html><title>Healer Man</title>");
|
||||
const hashedScript = "console.log('compressed delivery');";
|
||||
writeFileSync(path.join(staticDir, "assets", "app-12345678.js"), hashedScript);
|
||||
writeFileSync(path.join(staticDir, "assets", "app-12345678.js.br"), brotliCompressSync(hashedScript));
|
||||
writeFileSync(path.join(staticDir, "content", "bootstrap-manifest.json"), JSON.stringify({ schemaVersion: 1, files: {} }));
|
||||
writeFileSync(path.join(staticDir, "content", "bundled", `${"a".repeat(64)}.json`), "{}");
|
||||
writeFileSync(path.join(staticDir, "sample.glb"), Buffer.from("0123456789"));
|
||||
writeFileSync(path.join(contentDir, "manifest.json"), JSON.stringify({ schemaVersion: 1 }));
|
||||
writeFileSync(path.join(contentDir, "objects", "asset.glb"), Buffer.from("abcdefghij"));
|
||||
const runtime = createGameServer({ staticDir, contentDir, dataDir, corsOrigins: "https://iwanttoheal.phenomrom.com" });
|
||||
const runtime = createGameServer({
|
||||
staticDir, contentDir, dataDir, corsOrigins: "https://iwanttoheal.phenomrom.com", enableShelvedModes: true,
|
||||
});
|
||||
await new Promise((resolve) => runtime.server.listen(0, "127.0.0.1", resolve));
|
||||
const address = runtime.server.address();
|
||||
const origin = `http://127.0.0.1:${address.port}`;
|
||||
|
||||
try {
|
||||
assert.equal((await fetch(`${origin}/`)).status, 200);
|
||||
const compressedScript = await fetch(`${origin}/assets/app-12345678.js`, {
|
||||
headers: { "Accept-Encoding": "br" },
|
||||
});
|
||||
assert.equal(compressedScript.headers.get("content-encoding"), "br");
|
||||
assert.equal(compressedScript.headers.get("cache-control"), "public, max-age=31536000, immutable");
|
||||
assert.equal(await compressedScript.text(), hashedScript);
|
||||
const bundledObject = await fetch(`${origin}/content/bundled/${"a".repeat(64)}.json`);
|
||||
assert.equal(bundledObject.headers.get("cache-control"), "public, max-age=31536000, immutable");
|
||||
assert.equal((await fetch(`${origin}/content/bootstrap-manifest.json`)).headers.get("cache-control"), "no-cache");
|
||||
const range = await fetch(`${origin}/sample.glb`, { headers: { Range: "bytes=2-5" } });
|
||||
assert.equal(range.status, 206);
|
||||
assert.equal(await range.text(), "2345");
|
||||
@@ -250,3 +269,44 @@ test("serves the game and authenticated cloud-save API", async () => {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("disables shelved APIs and Manastorm group creation by default", async () => {
|
||||
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-shelved-server-test-"));
|
||||
const staticDir = path.join(directory, "dist");
|
||||
const dataDir = path.join(directory, "data");
|
||||
const contentDir = path.join(directory, "content");
|
||||
mkdirSync(staticDir, { recursive: true });
|
||||
mkdirSync(contentDir, { recursive: true });
|
||||
writeFileSync(path.join(staticDir, "index.html"), "<!doctype html><title>Healer Man</title>");
|
||||
const runtime = createGameServer({ staticDir, contentDir, dataDir, enableShelvedModes: false });
|
||||
await new Promise((resolve) => runtime.server.listen(0, "127.0.0.1", resolve));
|
||||
const address = runtime.server.address();
|
||||
const origin = `http://127.0.0.1:${address.port}`;
|
||||
|
||||
try {
|
||||
assert.equal(runtime.enableShelvedModes, false);
|
||||
for (const route of ["/api/manastorm-config", "/api/gm/manastorm-config", "/api/arena/state"]) {
|
||||
const response = await fetch(`${origin}${route}`);
|
||||
assert.equal(response.status, 503);
|
||||
assert.equal((await response.json()).code, "feature_disabled");
|
||||
}
|
||||
|
||||
const registration = await fetch(`${origin}/api/auth/register`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username: "ShelvedHealer", password: "healing-pass" }),
|
||||
});
|
||||
const auth = await registration.json();
|
||||
const activity = await fetch(`${origin}/api/online-group/activity`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${auth.token}` },
|
||||
body: JSON.stringify({ type: "manastorm", contentId: "deadmines", partySize: 1, fillWithAi: true }),
|
||||
});
|
||||
assert.equal(activity.status, 503);
|
||||
assert.equal((await activity.json()).code, "feature_disabled");
|
||||
assert.equal(runtime.arenaService.inspect().tickets.size, 0);
|
||||
} finally {
|
||||
await new Promise((resolve) => runtime.server.close(resolve));
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user