mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:10:42 +00:00
Cache the shared app shell HTML
This commit is contained in:
@@ -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
@@ -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;
|
||||
|
||||
|
||||
@@ -169,7 +169,6 @@ function updateAccountNavButton(userMeResponse: UserMeResponse | false) {
|
||||
declare global {
|
||||
interface Window {
|
||||
GIT_COMMIT: string;
|
||||
INSTANCE_ID: string;
|
||||
turnstile: any;
|
||||
adsEnabled: boolean;
|
||||
PageOS: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -101,7 +101,6 @@ export default defineConfig(({ mode }) => {
|
||||
inject: {
|
||||
data: {
|
||||
gitCommit: JSON.stringify("DEV"),
|
||||
instanceId: JSON.stringify("DEV_ID"),
|
||||
...htmlAssetData,
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user