Cache the shared app shell HTML

This commit is contained in:
scamiv
2026-03-22 21:00:11 +01:00
parent 0635daa742
commit fd19295fea
9 changed files with 138 additions and 23 deletions
-1
View File
@@ -56,7 +56,6 @@
<!-- Injected from Server env -->
<script>
window.GIT_COMMIT = <%- gitCommit %>;
window.INSTANCE_ID = <%- instanceId %>;
window.ASSET_MANIFEST = <%- assetManifest %>;
window.BOOTSTRAP_CONFIG = {
gameEnv: <%- gameEnv %>,
+5 -9
View File
@@ -304,18 +304,14 @@ server {
}
# Root location with short Nginx cache but no browser cache
# Root location with short shared cache for the app shell
location = / {
proxy_pass http://127.0.0.1:3000;
# Tell browsers not to cache
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
# But let Nginx cache for 1 second to reduce load
# Cache the shared app shell briefly at the proxy; browser/CDN policy
# comes from the upstream Cache-Control header.
proxy_cache STATIC;
proxy_cache_valid 200 302 1s;
proxy_cache_valid 200 302 300s;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
proxy_cache_lock on;
-1
View File
@@ -169,7 +169,6 @@ function updateAccountNavButton(userMeResponse: UserMeResponse | false) {
declare global {
interface Window {
GIT_COMMIT: string;
INSTANCE_ID: string;
turnstile: any;
adsEnabled: boolean;
PageOS: {
+29 -1
View File
@@ -13,6 +13,7 @@ import { translateText } from "./Utils";
@customElement("matchmaking-modal")
export class MatchmakingModal extends BaseModal {
private static instanceIdPromise: Promise<string> | null = null;
private gameCheckInterval: ReturnType<typeof setInterval> | null = null;
private connectTimeout: ReturnType<typeof setTimeout> | null = null;
@state() private connected = false;
@@ -87,9 +88,10 @@ export class MatchmakingModal extends BaseModal {
private async connect() {
const config = await getServerConfigFromClient();
const instanceId = await MatchmakingModal.getInstanceId();
this.socket = new WebSocket(
`${config.jwtIssuer()}/matchmaking/join?instance_id=${window.INSTANCE_ID}`,
`${config.jwtIssuer()}/matchmaking/join?instance_id=${encodeURIComponent(instanceId)}`,
);
this.socket.onopen = async () => {
console.log("Connected to matchmaking server");
@@ -130,6 +132,32 @@ export class MatchmakingModal extends BaseModal {
};
}
private static async getInstanceId(): Promise<string> {
MatchmakingModal.instanceIdPromise ??= fetch("/api/instance", {
cache: "no-store",
})
.then(async (response) => {
if (!response.ok) {
throw new Error(
`Failed to load instance id: ${response.status} ${response.statusText}`,
);
}
const data = (await response.json()) as { instanceId?: string };
if (!data.instanceId) {
throw new Error("Missing instance id");
}
return data.instanceId;
})
.catch((error: unknown) => {
MatchmakingModal.instanceIdPromise = null;
throw error;
});
return MatchmakingModal.instanceIdPromise;
}
protected async onOpen(): Promise<void> {
const userMe = await getUserMe();
// Early return if modal was closed during async operation
+2 -2
View File
@@ -15,7 +15,7 @@ import {
ExternalGameInfoSchema,
} from "./GamePreviewBuilder";
import { setNoStoreHeaders } from "./NoStoreHeaders";
import { renderHtmlContent, setHtmlNoCacheHeaders } from "./RenderHtml";
import { getAppShellContent, setHtmlNoCacheHeaders } from "./RenderHtml";
const requestOrigin = (req: Request, config: ServerConfig): string => {
const protoHeader = (req.headers["x-forwarded-proto"] as string) ?? "";
@@ -123,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) {
+13 -4
View File
@@ -11,7 +11,7 @@ import { logger } from "./Logger";
import { MapPlaylist } from "./MapPlaylist";
import { MasterLobbyService } from "./MasterLobbyService";
import { setNoStoreHeaders } from "./NoStoreHeaders";
import { renderHtml } from "./RenderHtml";
import { renderAppShell } from "./RenderHtml";
import { applyStaticAssetCacheControl } from "./StaticAssetCache";
const config = getServerConfigFromServer();
@@ -28,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");
@@ -148,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");
+29 -4
View File
@@ -5,12 +5,16 @@ 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),
@@ -25,17 +29,38 @@ export async function renderHtmlContent(htmlPath: string): Promise<string> {
});
}
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 {
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);
}
+60
View File
@@ -0,0 +1,60 @@
import fs from "fs/promises";
import os from "os";
import path from "path";
import { afterEach, describe, expect, test } from "vitest";
import {
clearAppShellContentCache,
getAppShellContent,
setAppShellCacheHeaders,
} from "../../src/server/RenderHtml";
describe("RenderHtml", () => {
const originalGitCommit = process.env.GIT_COMMIT;
let tempDir: string | null = null;
afterEach(async () => {
process.env.GIT_COMMIT = originalGitCommit;
clearAppShellContentCache();
if (tempDir) {
await fs.rm(tempDir, { recursive: true, force: true });
tempDir = null;
}
});
test("reuses cached app shell content", async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "render-html-"));
const htmlPath = path.join(tempDir, "index.html");
await fs.writeFile(
htmlPath,
"<script>window.GIT_COMMIT = <%- gitCommit %>;</script>",
"utf8",
);
process.env.GIT_COMMIT = "first";
const first = await getAppShellContent(htmlPath);
process.env.GIT_COMMIT = "second";
const second = await getAppShellContent(htmlPath);
expect(first).toContain('"first"');
expect(second).toBe(first);
expect(second).not.toContain('"second"');
});
test("sets shared-cache headers for the app shell", () => {
const headers = new Map<string, string>();
const response = {
setHeader(name: string, value: string) {
headers.set(name, value);
},
} as any;
setAppShellCacheHeaders(response);
expect(headers.get("Cache-Control")).toBe(
"public, max-age=0, s-maxage=300, stale-while-revalidate=86400",
);
expect(headers.get("Content-Type")).toBe("text/html");
});
});
-1
View File
@@ -101,7 +101,6 @@ export default defineConfig(({ mode }) => {
inject: {
data: {
gitCommit: JSON.stringify("DEV"),
instanceId: JSON.stringify("DEV_ID"),
...htmlAssetData,
},
},