Improve cacheability with content-hashed public assets and a cacheable app shell (#3494)

## Description:

This reworks asset delivery and cacheability across the app and moves
non-bundled public resources onto immutable, content-hashed URLs.

Vite bundle outputs continue to live under `/assets/**` and remain
content-hashed by Vite. Public resources that were previously fetched
from stable paths in `resources/` now go through a custom hashed
namespace under `/_assets/**`, backed by a generated asset manifest that
is available to the server, browser, and worker runtime.

In parallel, the root app shell is now cacheable shared HTML instead of
request-time `no-store` HTML. Dynamic and live routes remain explicitly
uncached.

## Why
- Improve browser and Cloudflare cacheability for static assets.
- Remove query-string and release-version cache busting for
runtime-fetched assets.
- Allow unchanged public assets to keep the same URL across releases.
- Reduce avoidable work on `/` by serving a shared app shell instead of
rendering HTML on every request.
- Make cache behavior explicit instead of relying on mixed framework
defaults and file-extension heuristics.

## What Changed

### 1. Content-hashed public asset pipeline
- Added a build-time public asset manifest and hashing pipeline for
non-Vite resources.
- Production now emits hashed public assets under `/_assets/**`.
- Added runtime manifest loading for Node so server-rendered paths
resolve against built hashed files instead of rebuilding from source at
runtime.
- Emitted the runtime asset manifest as an ESM module for server
consumption.

Result:
- `/assets/**` = Vite-managed hashed bundle outputs
- `/_assets/**` = custom content-hashed public resources

### 2. Runtime asset URL migration
- Added a shared `assetUrl(...)` resolution path.
- Migrated runtime references away from query-string versioning and
stable source paths.
- Updated browser, worker, and server-side rendering paths to resolve
through the asset manifest.
- Moved map manifests, map binaries, thumbnails, sprites, sounds, fonts,
flags, icons, screenshots, and other runtime-fetched resources onto
hashed URLs.

### 3. Map and preview fixes
- Fixed directory and per-file map asset resolution so map manifest and
binary fetches resolve to the correct hashed URLs.
- Updated preview metadata and map thumbnail paths to use the hashed
asset namespace.
- Fixed runtime manifest loading in prod after deployment.

### 4. Explicit cache policies
- Added explicit immutable cache headers for:
  - `/assets/**`
  - `/_assets/**`
  - worker-prefixed equivalents under `/wN/...`
- Added explicit `no-store` headers for live and dynamic APIs.
- Removed the old `/api/env` bootstrap request and baked `gameEnv` into
the HTML bootstrap instead.

### 5. Cacheable root app shell
- Refactored the root HTML path to serve a shared app shell with:
- `Cache-Control: public, max-age=0, s-maxage=300,
stale-while-revalidate=86400`
- `/` and the SPA fallback now serve shared cacheable HTML instead of
request-time `no-store` rendering.
- `/game/:id` remains dynamic and `no-store`, but now reuses the shared
shell before injecting preview tags.

### 6. Matchmaking instance handling
- Because the app shell is now cacheable, `INSTANCE_ID` was removed from
shared HTML.
- Added `/api/instance` as a temporary `no-store` runtime lookup used
only by matchmaking.
- This preserves correctness with the current random-per-boot
`INSTANCE_ID` model while keeping `/` cacheable, but it is not the
intended long-term design.

## Behavior Changes

### Asset URL contract
Production URLs for non-Vite public resources now change from stable
paths such as:
- `/maps/...`
- `/images/...`
- `/manifest.json`

to content-hashed paths under:
- `/_assets/...`

Examples:
- `/_assets/maps/<map>/manifest.<hash>.json`
- `/_assets/images/Favicon.<hash>.svg`

### Bootstrap/config
- `/api/env` is removed.
- `gameEnv` is now bootstrapped from HTML.

### HTML caching
- `/` and the SPA fallback are now cacheable shared HTML.
- `/game/:id` remains dynamic.

## Cache Matrix After This Branch
- `/_assets/**`: `public, max-age=31536000, immutable`
- `/assets/**`: `public, max-age=31536000, immutable`
- live `/api/**`: explicit `no-store`
- `/api/health`: explicit `no-store`
- `/api/instance`: explicit `no-store`
- `/game/:id`: explicit `no-store`
- `/` and SPA fallback: `public, max-age=0, s-maxage=300,
stale-while-revalidate=86400`

## Notes / Tradeoffs
- `/api/instance` is a temporary compromise. It exists because
`INSTANCE_ID` is currently random per boot, which is not safe to embed
into cacheable shared HTML.
- The current matchmaking flow still asks the client to provide
`instance_id` during `matchmaking/join`. That is functional, but it is
the wrong ownership boundary: instance selection should be handled by
the matchmaking service, not by the browser.
- The cleaner end-state would be:
- make `matchmaking/join` stop requiring `instance_id` from the client,
and let the matchmaking service select a healthy instance from worker
check-ins
- This branch makes the origin behavior edge-cache-friendly, but
Cloudflare still needs matching cache rules if HTML itself should be
cached at the edge.

## Validation
Verified during development with:
- `npx tsc --noEmit`
- `node node_modules\\vite\\bin\\vite.js build`
- `node node_modules\\vitest\\vitest.mjs run
tests/server/RenderHtml.test.ts tests/server/NoStoreHeaders.test.ts
tests/server/StaticAssetCache.test.ts
tests/core/configuration/ConfigLoader.test.ts`

Additional targeted tests added:
- `tests/AssetUrls.test.ts`
- `tests/core/game/FetchGameMapLoader.test.ts`
- `tests/core/configuration/ConfigLoader.test.ts`
- `tests/server/NoStoreHeaders.test.ts`
- `tests/server/StaticAssetCache.test.ts`
- `tests/server/RenderHtml.test.ts`

## Known Existing Warnings
The production build still reports pre-existing warnings that are not
addressed by this branch:
- inconsistent JSON import attributes for `resources/countries.json`
- inconsistent JSON import attributes for `resources/QuickChat.json`
- large chunk warnings from Vite

## Rollout Notes
- Cache rules should treat `/_assets/**` and `/assets/**` as immutable.
- Cloudflare will still classify HTML as dynamic after deploy unless
matching edge cache rules are configured for it.

## Follow-ups
- Remove `/api/instance` by changing `matchmaking/join` so the server
selects the target instance, or by making `INSTANCE_ID` deploy-stable if
the current contract must remain.


## Please complete the following:

- [ ] I have added screenshots for all UI updates
- [ ] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [ ] I have added relevant tests to the test directory
- [ ] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

## Please put your Discord username so you can be contacted if a bug or
regression is found:

DISCORD_USERNAME
This commit is contained in:
scamiv
2026-03-23 19:36:52 +01:00
committed by GitHub
parent e3a14671ab
commit 05e2bc9f0a
65 changed files with 1214 additions and 331 deletions
+12 -4
View File
@@ -1,7 +1,9 @@
import { z } from "zod";
import { buildAssetUrl } from "../core/AssetUrls";
import { ClanTagSchema, GameInfo, UsernameSchema } from "../core/Schemas";
import { formatPlayerDisplayName } from "../core/Util";
import { GameMode } from "../core/game/Game";
import { getRuntimeAssetManifest } from "./RuntimeAssetManifest";
export const PlayerInfoSchema = z.object({
clientID: z.string().optional(),
@@ -131,13 +133,16 @@ export function escapeHtml(value: string): string {
.replace(/'/g, "&#39;");
}
export function buildPreview(
export async function buildPreview(
gameID: string,
origin: string,
workerPath: string,
lobby: GameInfo | null,
publicInfo: ExternalGameInfo | null,
): PreviewMeta {
): Promise<PreviewMeta> {
const assetManifest = await getRuntimeAssetManifest();
const buildAbsoluteAssetUrl = (path: string) =>
new URL(buildAssetUrl(path, assetManifest), origin).toString();
const isFinished = !!publicInfo?.info?.end;
const isPrivate = lobby?.gameConfig?.gameType === "Private";
@@ -188,9 +193,12 @@ export function buildPreview(
const normalizedMap = map ? map.toLowerCase().replace(/[\s.()]+/g, "") : null;
const mapThumbnail = normalizedMap
? `${origin}/maps/${encodeURIComponent(normalizedMap)}/thumbnail.webp`
? buildAbsoluteAssetUrl(
`maps/${encodeURIComponent(normalizedMap)}/thumbnail.webp`,
)
: null;
const image = mapThumbnail ?? `${origin}/images/GameplayScreenshot.png`;
const image =
mapThumbnail ?? buildAbsoluteAssetUrl("images/GameplayScreenshot.png");
const gameType = lobby?.gameConfig?.gameType ?? config.gameType;
const gameTypeLabel = gameType ? ` (${gameType})` : "";
+5 -3
View File
@@ -14,7 +14,8 @@ import {
ExternalGameInfo,
ExternalGameInfoSchema,
} from "./GamePreviewBuilder";
import { renderHtmlContent, setHtmlNoCacheHeaders } from "./RenderHtml";
import { setNoStoreHeaders } from "./NoStoreHeaders";
import { getAppShellContent, setHtmlNoCacheHeaders } from "./RenderHtml";
const requestOrigin = (req: Request, config: ServerConfig): string => {
const protoHeader = (req.headers["x-forwarded-proto"] as string) ?? "";
@@ -96,7 +97,7 @@ export function registerGamePreviewRoute(opts: {
}
const origin = requestOrigin(req, config);
const meta = buildPreview(
const meta = await buildPreview(
gameID,
origin,
config.workerPath(gameID),
@@ -122,7 +123,7 @@ export function registerGamePreviewRoute(opts: {
}
if (filePath) {
const html = await renderHtmlContent(filePath);
const html = await getAppShellContent(filePath);
const root = parse(html);
const head = root.querySelector("head");
if (head) {
@@ -151,6 +152,7 @@ export function registerGamePreviewRoute(opts: {
}
// Fallback to JSON if HTML file not found
setNoStoreHeaders(res);
res.setHeader("Content-Type", "application/json");
return res.send(JSON.stringify(lobby ?? publicInfo, replacer));
} catch (error) {
+25 -22
View File
@@ -10,7 +10,9 @@ import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
import { logger } from "./Logger";
import { MapPlaylist } from "./MapPlaylist";
import { MasterLobbyService } from "./MasterLobbyService";
import { renderHtml } from "./RenderHtml";
import { setNoStoreHeaders } from "./NoStoreHeaders";
import { renderAppShell } from "./RenderHtml";
import { applyStaticAssetCacheControl } from "./StaticAssetCache";
const config = getServerConfigFromServer();
const playlist = new MapPlaylist();
@@ -26,11 +28,14 @@ const __dirname = path.dirname(__filename);
app.use(express.json());
// Middleware to handle HTML files with EJS templating
// Serve the shared app shell for the root document.
app.use(async (req, res, next) => {
if (req.path === "/") {
try {
await renderHtml(res, path.join(__dirname, "../../static/index.html"));
await renderAppShell(
res,
path.join(__dirname, "../../static/index.html"),
);
} catch (error) {
log.error("Error rendering index.html:", error);
res.status(500).send("Internal Server Error");
@@ -43,16 +48,11 @@ app.use(async (req, res, next) => {
app.use(
express.static(path.join(__dirname, "../../static"), {
maxAge: "1y", // Set max-age to 1 year for all static assets
setHeaders: (res, path) => {
// You can conditionally set different cache times based on file types
if (path.match(/\.(js|css|svg)$/)) {
// JS, CSS, SVG get long cache with immutable
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
} else if (path.match(/\.(bin|dat|exe|dll|so|dylib)$/)) {
// Binary files also get long cache with immutable
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
}
// Other file types use the default maxAge setting
setHeaders: (res) => {
applyStaticAssetCacheControl(
res.setHeader.bind(res),
res.req.originalUrl,
);
},
}),
);
@@ -65,6 +65,11 @@ app.use(
}),
);
app.use("/api", (_req, res, next) => {
setNoStoreHeaders(res);
next();
});
// Start the master process
export async function startMaster() {
if (!cluster.isPrimary) {
@@ -137,14 +142,6 @@ export async function startMaster() {
});
}
app.get("/api/env", async (req, res) => {
const envConfig = {
game_env: process.env.GAME_ENV,
};
if (!envConfig.game_env) return res.sendStatus(500);
res.json(envConfig);
});
app.get("/api/health", (_req, res) => {
const ready = lobbyService?.isHealthy() ?? false;
if (ready) {
@@ -154,11 +151,17 @@ app.get("/api/health", (_req, res) => {
}
});
app.get("/api/instance", (_req, res) => {
res.json({
instanceId: process.env.INSTANCE_ID ?? "undefined",
});
});
// SPA fallback route
app.get("*", async function (_req, res) {
try {
const htmlPath = path.join(__dirname, "../../static/index.html");
await renderHtml(res, htmlPath);
await renderAppShell(res, htmlPath);
} catch (error) {
log.error("Error rendering SPA fallback:", error);
res.status(500).send("Internal Server Error");
+10
View File
@@ -0,0 +1,10 @@
import type { Response } from "express";
export function setNoStoreHeaders(res: Response): void {
res.setHeader(
"Cache-Control",
"no-store, no-cache, must-revalidate, proxy-revalidate",
);
res.setHeader("Pragma", "no-cache");
res.setHeader("Expires", "0");
}
+187
View File
@@ -0,0 +1,187 @@
import { createHash } from "crypto";
import fs from "fs";
import { globSync } from "glob";
import path from "path";
import {
type AssetManifest,
buildAssetUrl,
encodeAssetPath,
normalizeAssetPath,
} from "../core/AssetUrls";
const HASHED_PUBLIC_ASSET_GLOBS = [
"changelog.md",
"manifest.json",
"cosmetics/**/*",
"flags/**/*",
"fonts/**/*",
"icons/**/*",
"images/**/*",
"lang/**/*",
"maps/**/*",
"sounds/**/*",
"sprites/**/*",
] as const;
const ROOT_PUBLIC_FILES = new Set([
"LICENSE",
"ads.txt",
"privacy-policy.html",
"robots.txt",
"terms-of-service.html",
"version.txt",
]);
const manifestCache = new Map<string, AssetManifest>();
function toPosixPath(filePath: string): string {
return filePath.split(path.sep).join(path.posix.sep);
}
function createContentHash(filePath: string): string {
const content = fs.readFileSync(filePath);
return createHash("sha256").update(content).digest("hex").slice(0, 12);
}
function createStringHash(content: string): string {
return createHash("sha256").update(content).digest("hex").slice(0, 12);
}
function createHashedAssetUrl(relativePath: string, hash: string): string {
const parsed = path.posix.parse(toPosixPath(relativePath));
const hashedFileName = `${parsed.name}.${hash}${parsed.ext}`;
const hashedRelativePath = path.posix.join(
"_assets",
parsed.dir,
hashedFileName,
);
return `/${encodeAssetPath(hashedRelativePath)}`;
}
function renderWebManifest(
resourcesDir: string,
assetManifest: AssetManifest,
): string {
const sourcePath = path.join(resourcesDir, "manifest.json");
const manifest = JSON.parse(fs.readFileSync(sourcePath, "utf8")) as {
icons?: Array<{ src?: string }>;
};
manifest.icons = manifest.icons?.map((icon) => ({
...icon,
src: buildAssetUrl(icon.src ?? "", assetManifest),
}));
return `${JSON.stringify(manifest, null, 2)}\n`;
}
export function getResourcesDir(rootDir: string = process.cwd()): string {
return path.join(rootDir, "resources");
}
export function shouldKeepRootPublicFile(relativePath: string): boolean {
return ROOT_PUBLIC_FILES.has(normalizeAssetPath(relativePath));
}
export function listHashedPublicAssetPaths(resourcesDir: string): string[] {
const files = new Set<string>();
for (const pattern of HASHED_PUBLIC_ASSET_GLOBS) {
for (const file of globSync(pattern, {
cwd: resourcesDir,
nodir: true,
dot: false,
posix: true,
})) {
files.add(normalizeAssetPath(file));
}
}
return [...files].sort();
}
export function listRootPublicFiles(resourcesDir: string): string[] {
return globSync("**/*", {
cwd: resourcesDir,
nodir: true,
dot: false,
posix: true,
})
.map((file) => normalizeAssetPath(file))
.filter((file) => shouldKeepRootPublicFile(file))
.sort();
}
export function buildPublicAssetManifest(resourcesDir: string): AssetManifest {
const cached = manifestCache.get(resourcesDir);
if (cached) {
return cached;
}
const manifest: AssetManifest = {};
for (const relativePath of listHashedPublicAssetPaths(resourcesDir)) {
if (relativePath === "manifest.json") {
continue;
}
const absolutePath = path.join(resourcesDir, relativePath);
const hash = createContentHash(absolutePath);
manifest[relativePath] = createHashedAssetUrl(relativePath, hash);
}
const renderedWebManifest = renderWebManifest(resourcesDir, manifest);
manifest["manifest.json"] = createHashedAssetUrl(
"manifest.json",
createStringHash(renderedWebManifest),
);
manifestCache.set(resourcesDir, manifest);
return manifest;
}
export function clearPublicAssetManifestCache(): void {
manifestCache.clear();
}
export function createHashedPublicAssetFiles(
resourcesDir: string,
outDir: string,
assetManifest: AssetManifest,
): void {
for (const [relativePath, hashedUrl] of Object.entries(assetManifest)) {
const sourcePath = path.join(resourcesDir, relativePath);
const outputPath = path.join(outDir, normalizeAssetPath(hashedUrl));
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
if (relativePath === "manifest.json") {
fs.writeFileSync(
outputPath,
renderWebManifest(resourcesDir, assetManifest),
);
continue;
}
fs.copyFileSync(sourcePath, outputPath);
}
}
export function copyRootPublicFiles(
resourcesDir: string,
outDir: string,
): void {
for (const relativePath of listRootPublicFiles(resourcesDir)) {
const sourcePath = path.join(resourcesDir, relativePath);
const outputPath = path.join(outDir, relativePath);
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
fs.copyFileSync(sourcePath, outputPath);
}
}
export function writePublicAssetManifestModule(
outDir: string,
assetManifest: AssetManifest,
): void {
const manifestPath = path.join(outDir, "_assets", "asset-manifest.mjs");
fs.mkdirSync(path.dirname(manifestPath), { recursive: true });
const serializedManifest = JSON.stringify(assetManifest, null, 2);
fs.writeFileSync(
manifestPath,
`const assetManifest = ${serializedManifest};\nexport { assetManifest };\nexport default assetManifest;\n`,
);
}
+45 -10
View File
@@ -1,31 +1,66 @@
import ejs from "ejs";
import type { Response } from "express";
import fs from "fs/promises";
import { buildAssetUrl } from "../core/AssetUrls";
import { setNoStoreHeaders } from "./NoStoreHeaders";
import { getRuntimeAssetManifest } from "./RuntimeAssetManifest";
const APP_SHELL_CACHE_CONTROL =
"public, max-age=0, s-maxage=300, stale-while-revalidate=86400";
const appShellContentCache = new Map<string, Promise<string>>();
export async function renderHtmlContent(htmlPath: string): Promise<string> {
const htmlContent = await fs.readFile(htmlPath, "utf-8");
const assetManifest = await getRuntimeAssetManifest();
return ejs.render(htmlContent, {
gitCommit: JSON.stringify(process.env.GIT_COMMIT ?? "undefined"),
instanceId: JSON.stringify(process.env.INSTANCE_ID ?? "undefined"),
assetManifest: JSON.stringify(assetManifest),
gameEnv: JSON.stringify(process.env.GAME_ENV ?? "dev"),
manifestHref: buildAssetUrl("manifest.json", assetManifest),
faviconHref: buildAssetUrl("images/Favicon.svg", assetManifest),
gameplayScreenshotUrl: buildAssetUrl(
"images/GameplayScreenshot.png",
assetManifest,
),
backgroundImageUrl: buildAssetUrl("images/background.webp", assetManifest),
desktopLogoImageUrl: buildAssetUrl("images/OpenFront.webp", assetManifest),
mobileLogoImageUrl: buildAssetUrl("images/OF.webp", assetManifest),
});
}
export async function getAppShellContent(htmlPath: string): Promise<string> {
let cachedContent = appShellContentCache.get(htmlPath);
if (!cachedContent) {
cachedContent = renderHtmlContent(htmlPath).catch((error: unknown) => {
appShellContentCache.delete(htmlPath);
throw error;
});
appShellContentCache.set(htmlPath, cachedContent);
}
return cachedContent;
}
export function clearAppShellContentCache(): void {
appShellContentCache.clear();
}
export function setAppShellCacheHeaders(res: Response): void {
res.setHeader("Cache-Control", APP_SHELL_CACHE_CONTROL);
res.setHeader("Content-Type", "text/html");
}
export function setHtmlNoCacheHeaders(res: Response): void {
res.setHeader(
"Cache-Control",
"no-store, no-cache, must-revalidate, proxy-revalidate",
);
res.setHeader("Pragma", "no-cache");
res.setHeader("Expires", "0");
setNoStoreHeaders(res);
res.setHeader("ETag", "");
res.setHeader("Content-Type", "text/html");
}
export async function renderHtml(
export async function renderAppShell(
res: Response,
htmlPath: string,
): Promise<void> {
const rendered = await renderHtmlContent(htmlPath);
setHtmlNoCacheHeaders(res);
const rendered = await getAppShellContent(htmlPath);
setAppShellCacheHeaders(res);
res.send(rendered);
}
+40
View File
@@ -0,0 +1,40 @@
import fs from "fs";
import path from "path";
import { fileURLToPath, pathToFileURL } from "url";
import type { AssetManifest } from "../core/AssetUrls";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const staticDir = path.join(__dirname, "../../static");
const manifestPath = path.join(staticDir, "_assets", "asset-manifest.mjs");
let manifestPromise: Promise<AssetManifest> | null = null;
let manifestVersion = 0;
async function importRuntimeAssetManifest(
version: number,
): Promise<AssetManifest> {
const manifestModule = (await import(
`${pathToFileURL(manifestPath).href}?v=${version}`
)) as {
assetManifest?: AssetManifest;
default?: AssetManifest;
};
return manifestModule.assetManifest ?? manifestModule.default ?? {};
}
export async function getRuntimeAssetManifest(): Promise<AssetManifest> {
if (!fs.existsSync(manifestPath)) {
return {};
}
manifestPromise ??= importRuntimeAssetManifest(manifestVersion).catch(
() => ({}),
);
return manifestPromise;
}
export function clearRuntimeAssetManifestCache(): void {
manifestVersion++;
manifestPromise = null;
}
+33
View File
@@ -0,0 +1,33 @@
const IMMUTABLE_CACHE_CONTROL = "public, max-age=31536000, immutable";
function stripQueryString(urlPath: string): string {
return urlPath.split("?", 1)[0];
}
export function getStaticAssetCacheControl(
urlPath: string | undefined,
): string | undefined {
if (!urlPath) {
return undefined;
}
const normalizedPath = stripQueryString(urlPath);
if (
normalizedPath.startsWith("/assets/") ||
normalizedPath.startsWith("/_assets/")
) {
return IMMUTABLE_CACHE_CONTROL;
}
return undefined;
}
export function applyStaticAssetCacheControl(
setHeader: (name: string, value: string) => void,
urlPath: string | undefined,
): void {
const cacheControl = getStaticAssetCacheControl(urlPath);
if (cacheControl) {
setHeader("Cache-Control", cacheControl);
}
}
+17 -1
View File
@@ -27,8 +27,10 @@ import { logger } from "./Logger";
import { GameEnv } from "../core/configuration/Config";
import { MapPlaylist } from "./MapPlaylist";
import { setNoStoreHeaders } from "./NoStoreHeaders";
import { startPolling } from "./PollingLoop";
import { PrivilegeRefresher } from "./PrivilegeRefresher";
import { applyStaticAssetCacheControl } from "./StaticAssetCache";
import { verifyTurnstileToken } from "./Turnstile";
import { WorkerLobbyService } from "./WorkerLobbyService";
import { initWorkerMetrics } from "./WorkerMetrics";
@@ -110,7 +112,16 @@ export async function startWorker() {
// Configure MIME types for webp files
express.static.mime.define({ "image/webp": ["webp"] });
app.use(express.static(path.join(__dirname, "../../out")));
app.use(
express.static(path.join(__dirname, "../../out"), {
setHeaders: (res) => {
applyStaticAssetCacheControl(
res.setHeader.bind(res),
res.req.originalUrl,
);
},
}),
);
app.use(
"/maps",
express.static(path.join(__dirname, "../../static/maps"), {
@@ -129,6 +140,11 @@ export async function startWorker() {
}),
);
app.use("/api", (_req, res, next) => {
setNoStoreHeaders(res);
next();
});
app.post("/api/create_game/:id", async (req, res) => {
const id = req.params.id;