This commit is contained in:
183
apps/server/src/index.ts
Normal file
183
apps/server/src/index.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import {
|
||||
API_PATHS,
|
||||
FLUX_SOCKET_PATH,
|
||||
GAME_SOCKET_PATH,
|
||||
ROYALE_SOCKET_PATH,
|
||||
createShooterGame,
|
||||
fluxGame,
|
||||
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, FLUX_SOCKET_PATH, fluxGame),
|
||||
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 ".ico": return "image/x-icon";
|
||||
case ".woff2": return "font/woff2";
|
||||
default: return "application/octet-stream";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user