From 4aa726cfd82c5df5cac34cedd1e0a5d64bed9f93 Mon Sep 17 00:00:00 2001 From: Evan Date: Mon, 27 Apr 2026 11:27:54 -0600 Subject: [PATCH] Serve hashed assets from R2 via CDN_BASE (#3773) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: Add an optional CDN_BASE env var that prefixes hashed asset URLs from asset-manifest.json, so the app can serve static assets from R2/CDN instead of the app origin. The value is determined at runtime via the EJS template (window.CDN_BASE) — empty string means "same origin," matching today's behavior. A hack to load the worker bundle: A same-origin Blob script that dynamic-import()s the cross-origin worker module and buffers early postMessage calls until the imported module's handler attaches, sidestepping the browser's refusal to construct a Worker directly from a cross-origin URL. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] 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: evan --- .github/workflows/deploy.yml | 1 + .github/workflows/release.yml | 4 ++ deploy.sh | 1 + docs/Architecture.md | 10 +++ example.env | 5 ++ index.html | 1 + src/core/AssetUrls.ts | 35 +++++++++- src/core/worker/Worker.worker.ts | 3 + src/core/worker/WorkerClient.ts | 64 ++++++++++++++++-- src/core/worker/WorkerMessages.ts | 16 ++++- src/server/GamePreviewBuilder.ts | 3 +- src/server/RenderHtml.ts | 26 +++++-- tests/AssetUrls.test.ts | 108 +++++++++++++++++++++++++++++- vite.config.ts | 43 ++++++++++-- 14 files changed, 296 insertions(+), 24 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 1684f8c9b..b0882f2dd 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -135,6 +135,7 @@ jobs: GHCR_USERNAME: ${{ vars.GHCR_USERNAME }} ENV: ${{ inputs.target_domain == 'openfront.io' && 'prod' || 'staging' }} HOST: ${{ github.event_name == 'workflow_dispatch' && inputs.target_host || 'staging' }} # schedule and push both use staging + CDN_BASE: ${{ vars.CDN_BASE }} OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }} OTEL_AUTH_HEADER: ${{ secrets.OTEL_AUTH_HEADER }} TURNSTILE_SECRET_KEY: ${{ secrets.TURNSTILE_SECRET_KEY }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c843b1aaf..71cbfe364 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -67,6 +67,7 @@ jobs: GHCR_REPO: openfront-prod GHCR_USERNAME: ${{ vars.GHCR_USERNAME }} DOMAIN: ${{ vars.DOMAIN }} + CDN_BASE: ${{ vars.CDN_BASE }} IMAGE_ID: ${{ needs.build.outputs.IMAGE_ID }} OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }} OTEL_AUTH_HEADER: ${{ secrets.OTEL_AUTH_HEADER }} @@ -117,6 +118,7 @@ jobs: GHCR_REPO: ${{ vars.GHCR_REPO }} GHCR_USERNAME: ${{ vars.GHCR_USERNAME }} DOMAIN: ${{ vars.DOMAIN }} + CDN_BASE: ${{ vars.CDN_BASE }} IMAGE_ID: ${{ needs.build.outputs.IMAGE_ID }} OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }} OTEL_AUTH_HEADER: ${{ secrets.OTEL_AUTH_HEADER }} @@ -167,6 +169,7 @@ jobs: GHCR_REPO: ${{ vars.GHCR_REPO }} GHCR_USERNAME: ${{ vars.GHCR_USERNAME }} DOMAIN: ${{ vars.DOMAIN }} + CDN_BASE: ${{ vars.CDN_BASE }} IMAGE_ID: ${{ needs.build.outputs.IMAGE_ID }} OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }} OTEL_AUTH_HEADER: ${{ secrets.OTEL_AUTH_HEADER }} @@ -217,6 +220,7 @@ jobs: GHCR_REPO: ${{ vars.GHCR_REPO }} GHCR_USERNAME: ${{ vars.GHCR_USERNAME }} DOMAIN: ${{ vars.DOMAIN }} + CDN_BASE: ${{ vars.CDN_BASE }} IMAGE_ID: ${{ needs.build.outputs.IMAGE_ID }} OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }} OTEL_AUTH_HEADER: ${{ secrets.OTEL_AUTH_HEADER }} diff --git a/deploy.sh b/deploy.sh index e033b5693..3797d1554 100755 --- a/deploy.sh +++ b/deploy.sh @@ -138,6 +138,7 @@ TURNSTILE_SECRET_KEY=$TURNSTILE_SECRET_KEY API_KEY=$API_KEY DOMAIN=$DOMAIN SUBDOMAIN=$SUBDOMAIN +CDN_BASE=$CDN_BASE OTEL_EXPORTER_OTLP_ENDPOINT=$OTEL_EXPORTER_OTLP_ENDPOINT OTEL_AUTH_HEADER=$OTEL_AUTH_HEADER EOL diff --git a/docs/Architecture.md b/docs/Architecture.md index e01bd624e..95b07c476 100644 --- a/docs/Architecture.md +++ b/docs/Architecture.md @@ -28,3 +28,13 @@ When a user performs an action, it creates an "Intent" which is sent to the serv 6. All executions run 7. At the end of the tick core sends updates to client 8. Client renders the updates + +## Static Assets / CDN + +The game server only renders `index.html` and serves the websocket. Every other asset (the Vite JS/CSS bundle, images, map binaries, the worker module) is served from a CDN bucket. Setting `CDN_BASE` to an empty string falls back to same-origin and is the dev default. + +### `CDN_BASE` format + +- Full origin, no path, no trailing slash: `https://cdn.example.com` +- Set as a build-time variable in `vite.config.ts` (so the manifest is built with absolute URLs) and as a runtime env var on the server (so `RenderHtml.ts` can prefix Vite's emitted `/assets/...` refs at request time). +- Configured in CI via `vars.CDN_BASE` in `.github/workflows/{deploy,release}.yml`. diff --git a/example.env b/example.env index 68226d211..7cf72c6c2 100644 --- a/example.env +++ b/example.env @@ -11,6 +11,11 @@ DOMAIN=your-domain.com # API Key API_KEY=your_api_key_here +# Optional CDN origin (e.g. https://cdn.example.com) prepended to hashed asset +# URLs from asset-manifest.json. Leave empty to serve assets from the same +# origin as the app. No trailing slash. +CDN_BASE= + # Server Hosts SERVER_HOST_STAGING=123.456.78.90 SERVER_HOST_NBG1=123.456.78.92 diff --git a/index.html b/index.html index 1b81cdd16..629d7a1f6 100644 --- a/index.html +++ b/index.html @@ -62,6 +62,7 @@ `, + ); + expect(out).toBe( + ``, + ); + }); + + test("rewrites href=/assets/ for modulepreload and stylesheet links", () => { + const out = rewriteAssetsForCdn( + `\n`, + ); + expect(out).toBe( + `/assets/vendor-XXX.js">\n/assets/index-XXX.css">`, + ); + }); + + test("supports single-quoted attribute values", () => { + expect(rewriteAssetsForCdn(``)).toBe( + ``, + ); + }); + + test("does not rewrite /_assets/ (underscore manifest paths)", () => { + const html = ``; + expect(rewriteAssetsForCdn(html)).toBe(html); + }); + + test("does not rewrite already-absolute asset URLs", () => { + const html = ``; + expect(rewriteAssetsForCdn(html)).toBe(html); + }); + + // Inline scripts containing the literal "/assets/..." string must survive + // unrewrite — the regex requires whitespace before src=/href=, and inside a + // JS string literal there's no preceding `src=`/`href=` token at all. + test("does not mangle /assets/ inside inline script string literals", () => { + const html = ``; + expect(rewriteAssetsForCdn(html)).toBe(html); + }); + + test("does not match data-src or other custom attributes", () => { + const html = ``; + expect(rewriteAssetsForCdn(html)).toBe(html); + }); }); diff --git a/vite.config.ts b/vite.config.ts index 9cfbdd148..e0fb13f7e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -6,7 +6,11 @@ import { fileURLToPath } from "url"; import { defineConfig, loadEnv, type Plugin } from "vite"; import { createHtmlPlugin } from "vite-plugin-html"; import tsconfigPaths from "vite-tsconfig-paths"; -import { type AssetManifest, buildAssetUrl } from "./src/core/AssetUrls"; +import { + type AssetManifest, + buildAssetUrl, + rewriteAssetsForCdn, +} from "./src/core/AssetUrls"; import { buildPublicAssetManifest, copyRootPublicFiles, @@ -57,20 +61,43 @@ export default defineConfig(({ mode }) => { const assetManifest: AssetManifest = isProduction ? buildPublicAssetManifest(sourceDirs) : {}; + const cdnBase = env.CDN_BASE ?? ""; const htmlAssetData = { assetManifest: JSON.stringify(assetManifest), + cdnBase: JSON.stringify(cdnBase), gameEnv: JSON.stringify(env.GAME_ENV ?? "dev"), - manifestHref: buildAssetUrl("manifest.json", assetManifest), - faviconHref: buildAssetUrl("images/Favicon.svg", assetManifest), + manifestHref: buildAssetUrl("manifest.json", assetManifest, cdnBase), + faviconHref: buildAssetUrl("images/Favicon.svg", assetManifest, cdnBase), gameplayScreenshotUrl: buildAssetUrl( "images/GameplayScreenshot.png", assetManifest, + cdnBase, ), - backgroundImageUrl: buildAssetUrl("images/background.webp", assetManifest), - desktopLogoImageUrl: buildAssetUrl("images/OpenFront.png", assetManifest), - mobileLogoImageUrl: buildAssetUrl("images/OF.png", assetManifest), + backgroundImageUrl: buildAssetUrl( + "images/background.webp", + assetManifest, + cdnBase, + ), + desktopLogoImageUrl: buildAssetUrl( + "images/OpenFront.png", + assetManifest, + cdnBase, + ), + mobileLogoImageUrl: buildAssetUrl("images/OF.png", assetManifest, cdnBase), }; + // Vite's HTML transform replaces the source