diff --git a/nginx.conf b/nginx.conf index 8466fd86c..081481739 100644 --- a/nginx.conf +++ b/nginx.conf @@ -17,6 +17,99 @@ server { access_log /var/log/nginx/access.log; error_log /var/log/nginx/error.log; + location ^~ /assets/ { + proxy_pass http://127.0.0.1:3000; + proxy_cache STATIC; + proxy_cache_valid 200 302 24h; + proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504; + proxy_cache_lock on; + add_header X-Cache-Status $upstream_cache_status; + add_header Cache-Control "public, max-age=31536000, immutable"; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location ^~ /_assets/ { + proxy_pass http://127.0.0.1:3000; + proxy_cache STATIC; + proxy_cache_valid 200 302 24h; + proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504; + proxy_cache_lock on; + add_header X-Cache-Status $upstream_cache_status; + add_header Cache-Control "public, max-age=31536000, immutable"; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location ~* ^/w(\d+)(/(?:assets|_assets)/.*)$ { + set $worker $1; + set $worker_port 3001; + + if ($worker = "0") { set $worker_port 3001; } + if ($worker = "1") { set $worker_port 3002; } + if ($worker = "2") { set $worker_port 3003; } + if ($worker = "3") { set $worker_port 3004; } + if ($worker = "4") { set $worker_port 3005; } + if ($worker = "5") { set $worker_port 3006; } + if ($worker = "6") { set $worker_port 3007; } + if ($worker = "7") { set $worker_port 3008; } + if ($worker = "8") { set $worker_port 3009; } + if ($worker = "9") { set $worker_port 3010; } + if ($worker = "10") { set $worker_port 3011; } + if ($worker = "11") { set $worker_port 3012; } + if ($worker = "12") { set $worker_port 3013; } + if ($worker = "13") { set $worker_port 3014; } + if ($worker = "14") { set $worker_port 3015; } + if ($worker = "15") { set $worker_port 3016; } + if ($worker = "16") { set $worker_port 3017; } + if ($worker = "17") { set $worker_port 3018; } + if ($worker = "18") { set $worker_port 3019; } + if ($worker = "19") { set $worker_port 3020; } + if ($worker = "20") { set $worker_port 3021; } + if ($worker = "21") { set $worker_port 3022; } + if ($worker = "22") { set $worker_port 3023; } + if ($worker = "23") { set $worker_port 3024; } + if ($worker = "24") { set $worker_port 3025; } + if ($worker = "25") { set $worker_port 3026; } + if ($worker = "26") { set $worker_port 3027; } + if ($worker = "27") { set $worker_port 3028; } + if ($worker = "28") { set $worker_port 3029; } + if ($worker = "29") { set $worker_port 3030; } + if ($worker = "30") { set $worker_port 3031; } + if ($worker = "31") { set $worker_port 3032; } + if ($worker = "32") { set $worker_port 3033; } + if ($worker = "33") { set $worker_port 3034; } + if ($worker = "34") { set $worker_port 3035; } + if ($worker = "35") { set $worker_port 3036; } + if ($worker = "36") { set $worker_port 3037; } + if ($worker = "37") { set $worker_port 3038; } + if ($worker = "38") { set $worker_port 3039; } + if ($worker = "39") { set $worker_port 3040; } + if ($worker = "40") { set $worker_port 3041; } + + proxy_pass http://127.0.0.1:$worker_port$2$is_args$args; + proxy_cache STATIC; + proxy_cache_valid 200 302 24h; + proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504; + proxy_cache_lock on; + add_header X-Cache-Status $upstream_cache_status; + add_header Cache-Control "public, max-age=31536000, immutable"; + + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + # Worker locations - Processing this first so worker-specific requests are handled by workers # This prevents static file regexes from capturing worker requests location ~* ^/w(\d+)(/.*)?$ { diff --git a/src/server/Master.ts b/src/server/Master.ts index 90b9cf48f..7592ed25a 100644 --- a/src/server/Master.ts +++ b/src/server/Master.ts @@ -11,6 +11,7 @@ import { logger } from "./Logger"; import { MapPlaylist } from "./MapPlaylist"; import { MasterLobbyService } from "./MasterLobbyService"; import { renderHtml } from "./RenderHtml"; +import { applyStaticAssetCacheControl } from "./StaticAssetCache"; const config = getServerConfigFromServer(); const playlist = new MapPlaylist(); @@ -43,16 +44,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, + ); }, }), ); diff --git a/src/server/StaticAssetCache.ts b/src/server/StaticAssetCache.ts new file mode 100644 index 000000000..abf3d50b7 --- /dev/null +++ b/src/server/StaticAssetCache.ts @@ -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); + } +} diff --git a/src/server/Worker.ts b/src/server/Worker.ts index e0a16d3b6..1ec8c0281 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -29,6 +29,7 @@ import { GameEnv } from "../core/configuration/Config"; import { MapPlaylist } from "./MapPlaylist"; 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 +111,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"), { diff --git a/tests/server/StaticAssetCache.test.ts b/tests/server/StaticAssetCache.test.ts new file mode 100644 index 000000000..6c92e63ac --- /dev/null +++ b/tests/server/StaticAssetCache.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from "vitest"; +import { getStaticAssetCacheControl } from "../../src/server/StaticAssetCache"; + +describe("StaticAssetCache", () => { + test("marks Vite asset namespace as immutable", () => { + expect(getStaticAssetCacheControl("/assets/index-abc123.js")).toBe( + "public, max-age=31536000, immutable", + ); + }); + + test("marks custom hashed asset namespace as immutable", () => { + expect( + getStaticAssetCacheControl("/_assets/maps/world/manifest.hash.json"), + ).toBe("public, max-age=31536000, immutable"); + }); + + test("does not mark other paths as immutable", () => { + expect(getStaticAssetCacheControl("/manifest.json")).toBeUndefined(); + expect(getStaticAssetCacheControl("/api/health")).toBeUndefined(); + }); +});