191 lines
6.1 KiB
TypeScript
191 lines
6.1 KiB
TypeScript
import {
|
|
API_PATHS,
|
|
DEAD_AIR_SOCKET_PATH,
|
|
FLUX_SOCKET_PATH,
|
|
GAME_SOCKET_PATH,
|
|
MOVERS_SOCKET_PATH,
|
|
ROYALE_SOCKET_PATH,
|
|
createShooterGame,
|
|
deadAirGame,
|
|
fluxGame,
|
|
moversGame,
|
|
royaleGame,
|
|
type ApiMessage,
|
|
} from "@syncer/shared";
|
|
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
import { extname, join, relative } from "node:path";
|
|
import { App, type HttpResponse } from "uWebSockets.js";
|
|
import { ClientProfileStore } from "./client-profile-store.js";
|
|
import { hostNetworkedGame } from "./host-networked-game.js";
|
|
import { ServerProcessProfiler } from "./server-profiler.js";
|
|
|
|
const port = Number(process.env.PORT ?? 3001);
|
|
const app = App();
|
|
const processProfiler = new ServerProcessProfiler();
|
|
const clientProfiles = new ClientProfileStore();
|
|
const staticSite = loadStaticSite(process.env.STATIC_ROOT);
|
|
const gameLoops = [
|
|
hostNetworkedGame(app, GAME_SOCKET_PATH, createShooterGame({ botCount: 0 })),
|
|
hostNetworkedGame(app, DEAD_AIR_SOCKET_PATH, deadAirGame),
|
|
hostNetworkedGame(app, FLUX_SOCKET_PATH, fluxGame),
|
|
hostNetworkedGame(app, MOVERS_SOCKET_PATH, moversGame),
|
|
hostNetworkedGame(app, ROYALE_SOCKET_PATH, royaleGame),
|
|
];
|
|
|
|
app
|
|
.post("/api/client-profile", (response) => {
|
|
const chunks: Buffer[] = [];
|
|
let bytes = 0;
|
|
let finished = false;
|
|
response.onAborted(() => {
|
|
finished = true;
|
|
});
|
|
response.onData((chunk, isLast) => {
|
|
if (finished) return;
|
|
bytes += chunk.byteLength;
|
|
if (bytes > 64 * 1_024) {
|
|
finished = true;
|
|
response.writeStatus("413 Payload Too Large").end();
|
|
return;
|
|
}
|
|
chunks.push(Buffer.from(new Uint8Array(chunk)));
|
|
if (!isLast) return;
|
|
finished = true;
|
|
try {
|
|
const sample: unknown = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
if (!clientProfiles.record(sample, readRemoteAddress(response))) {
|
|
response.writeStatus("400 Bad Request").end("Invalid profile sample");
|
|
return;
|
|
}
|
|
response
|
|
.writeStatus("204 No Content")
|
|
.writeHeader("Access-Control-Allow-Origin", "*")
|
|
.end();
|
|
} catch {
|
|
response.writeStatus("400 Bad Request").end("Invalid JSON");
|
|
}
|
|
});
|
|
})
|
|
.get("/api/profile", (response) => {
|
|
const now = performance.now();
|
|
response
|
|
.writeHeader("Content-Type", "application/json; charset=utf-8")
|
|
.writeHeader("Access-Control-Allow-Origin", "*")
|
|
.end(JSON.stringify({
|
|
timestamp: new Date().toISOString(),
|
|
process: processProfiler.snapshot(),
|
|
clients: clientProfiles.snapshot(now),
|
|
games: gameLoops.map((game) => game.profile(now)),
|
|
}, null, 2));
|
|
})
|
|
.get(API_PATHS.message, (response) => {
|
|
const body: ApiMessage = {
|
|
message: "Authoritative multiplayer simulations online",
|
|
timestamp: new Date().toISOString(),
|
|
};
|
|
response
|
|
.writeHeader("Content-Type", "application/json; charset=utf-8")
|
|
.end(JSON.stringify(body));
|
|
})
|
|
.any("/*", (response, request) => {
|
|
const url = request.getUrl();
|
|
const method = request.getMethod().toUpperCase();
|
|
const asset = staticSite?.files.get(url);
|
|
const fallback =
|
|
staticSite &&
|
|
(method === "GET" || method === "HEAD") &&
|
|
!url.startsWith("/api/") &&
|
|
!url.startsWith("/ws/")
|
|
? staticSite.index
|
|
: undefined;
|
|
const file = asset ?? fallback;
|
|
if (!file || (method !== "GET" && method !== "HEAD")) {
|
|
response.writeStatus("404 Not Found").end("Not found");
|
|
return;
|
|
}
|
|
response
|
|
.writeHeader("Content-Type", file.contentType)
|
|
.writeHeader(
|
|
"Cache-Control",
|
|
asset && url.startsWith("/assets/")
|
|
? "public, max-age=31536000, immutable"
|
|
: "no-cache",
|
|
);
|
|
response.end(method === "HEAD" ? undefined : file.body);
|
|
})
|
|
.listen(port, (listenSocket) => {
|
|
if (!listenSocket) {
|
|
console.error(`Could not listen on port ${port}`);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
console.log(
|
|
`Authoritative server listening on http://localhost:${port} (${gameLoops
|
|
.map(({ path }) => path)
|
|
.join(", ")}); profiler: /api/profile`,
|
|
);
|
|
const timer = setInterval(() => {
|
|
const now = performance.now();
|
|
for (const game of gameLoops) game.advance(now);
|
|
}, 4);
|
|
timer.unref();
|
|
});
|
|
|
|
function readRemoteAddress(response: HttpResponse): string {
|
|
const port = response.getRemotePort();
|
|
const text = new TextDecoder().decode(response.getRemoteAddressAsText());
|
|
return text ? `${text}:${port}` : port > 0 ? `unknown:${port}` : "unknown";
|
|
}
|
|
|
|
interface StaticAsset {
|
|
body: Buffer;
|
|
contentType: string;
|
|
}
|
|
|
|
interface StaticSite {
|
|
files: Map<string, StaticAsset>;
|
|
index: StaticAsset;
|
|
}
|
|
|
|
function loadStaticSite(root: string | undefined): StaticSite | null {
|
|
if (!root || !existsSync(root)) return null;
|
|
const files = new Map<string, StaticAsset>();
|
|
for (const filePath of walkFiles(root)) {
|
|
const url = `/${relative(root, filePath).split("\\").join("/")}`;
|
|
files.set(url, {
|
|
body: readFileSync(filePath),
|
|
contentType: contentType(filePath),
|
|
});
|
|
}
|
|
const index = files.get("/index.html");
|
|
if (!index) throw new Error(`STATIC_ROOT has no index.html: ${root}`);
|
|
console.log(`Serving ${files.size} static files from ${root}`);
|
|
return { files, index };
|
|
}
|
|
|
|
function walkFiles(directory: string): string[] {
|
|
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
|
const path = join(directory, entry.name);
|
|
return entry.isDirectory() ? walkFiles(path) : [path];
|
|
});
|
|
}
|
|
|
|
function contentType(filePath: string): string {
|
|
switch (extname(filePath)) {
|
|
case ".html": return "text/html; charset=utf-8";
|
|
case ".js": return "text/javascript; charset=utf-8";
|
|
case ".css": return "text/css; charset=utf-8";
|
|
case ".json": return "application/json; charset=utf-8";
|
|
case ".svg": return "image/svg+xml";
|
|
case ".png": return "image/png";
|
|
case ".jpg":
|
|
case ".jpeg": return "image/jpeg";
|
|
case ".webp": return "image/webp";
|
|
case ".wasm": return "application/wasm";
|
|
case ".ico": return "image/x-icon";
|
|
case ".woff2": return "font/woff2";
|
|
default: return "application/octet-stream";
|
|
}
|
|
}
|