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