mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-29 18:52:11 +00:00
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:
@@ -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, "'");
|
||||
}
|
||||
|
||||
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})` : "";
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user