diff --git a/dockerfiles/30-runtime-config.sh b/dockerfiles/30-runtime-config.sh new file mode 100755 index 0000000..d2985b4 --- /dev/null +++ b/dockerfiles/30-runtime-config.sh @@ -0,0 +1,16 @@ +#!/bin/sh +# Write the SPA's runtime operator config at container start. +# +# The nginx base image runs every /docker-entrypoint.d/*.sh before launching +# nginx, so this overwrites the build-time public/config.js stub with the value +# of $PUBLIC_API_BASE_URL. That lets one prebuilt image target any backend +# origin without rebuilding. Resolution + precedence live in src/config/env.ts. +set -eu + +: "${PUBLIC_API_BASE_URL:=/api/v1}" +ROOT="${NGINX_HTML_ROOT:-/usr/share/nginx/html}" + +printf 'window.__APP_CONFIG__={"apiBaseUrl":"%s"};\n' "$PUBLIC_API_BASE_URL" \ + >"$ROOT/config.js" + +echo "runtime-config: wrote apiBaseUrl=$PUBLIC_API_BASE_URL to $ROOT/config.js" diff --git a/dockerfiles/Dockerfile.prod b/dockerfiles/Dockerfile.prod index e79f572..6af4384 100644 --- a/dockerfiles/Dockerfile.prod +++ b/dockerfiles/Dockerfile.prod @@ -13,8 +13,9 @@ RUN npm ci COPY . . -# Bake the API base URL at build time (rsbuild inlines PUBLIC_* vars). -# Same-origin default ('/api/v1') works behind any reverse proxy. +# Build-time default for the API base URL (rsbuild inlines PUBLIC_* vars). This +# is only the *fallback* now — the real value is injected at container start by +# 30-runtime-config.sh, so the image can target any backend without a rebuild. ARG PUBLIC_API_BASE_URL=/api/v1 ENV PUBLIC_API_BASE_URL=$PUBLIC_API_BASE_URL RUN npm run build @@ -25,5 +26,10 @@ FROM nginx:1.27-alpine AS runtime COPY dockerfiles/nginx.conf /etc/nginx/conf.d/default.conf COPY --from=build /app/dist /usr/share/nginx/html +# Runtime config injection: the nginx image runs /docker-entrypoint.d/*.sh +# before starting, regenerating /config.js from $PUBLIC_API_BASE_URL. +COPY dockerfiles/30-runtime-config.sh /docker-entrypoint.d/30-runtime-config.sh +RUN chmod +x /docker-entrypoint.d/30-runtime-config.sh + EXPOSE 80 CMD ["nginx", "-g", "daemon off;"] diff --git a/dockerfiles/nginx.conf b/dockerfiles/nginx.conf index 9652299..0818b56 100644 --- a/dockerfiles/nginx.conf +++ b/dockerfiles/nginx.conf @@ -14,6 +14,13 @@ server { try_files $uri =404; } + # Runtime operator config — regenerated per container start, so it must + # never be cached or a redeployed backend URL would be ignored. + location = /config.js { + add_header Cache-Control "no-store"; + try_files $uri =404; + } + # SPA: every unknown path falls back to index.html (client-side router). location / { try_files $uri $uri/ /index.html; diff --git a/public/config.js b/public/config.js new file mode 100644 index 0000000..f7c694e --- /dev/null +++ b/public/config.js @@ -0,0 +1,8 @@ +// Runtime operator configuration, read by the app before the bundle loads +// (see src/config/env.ts). In the PROD image this file is OVERWRITTEN at +// container start from $PUBLIC_API_BASE_URL (dockerfiles/30-runtime-config.sh), +// so one prebuilt image can target any backend origin without a rebuild. +// +// This committed stub is the local-dev / build-time default: it leaves the +// config empty so base-URL resolution falls back to the build-time env var. +window.__APP_CONFIG__ = {}; diff --git a/rsbuild.config.ts b/rsbuild.config.ts index 794272d..96aa071 100644 --- a/rsbuild.config.ts +++ b/rsbuild.config.ts @@ -36,6 +36,16 @@ export default defineConfig({ // "Install app". The service worker (audio offline cache) is registered // from src/index.tsx, not here. tags: [ + // Runtime operator config. A classic (non-deferred) head script, so it + // runs before the deferred app bundle and window.__APP_CONFIG__ is set by + // the time src/config/env.ts reads it. Served from public/ in dev and + // overwritten from $PUBLIC_API_BASE_URL at container start in prod. + { + tag: 'script', + attrs: { src: '/config.js' }, + head: true, + append: false, + }, { tag: 'link', attrs: { rel: 'manifest', href: '/manifest.webmanifest' }, diff --git a/src/config/env.ts b/src/config/env.ts index 3ffddef..3e0393b 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -1,2 +1,22 @@ +/** + * Default backend base URL — the operator-set fallback used when no specific + * instance is active. Resolution order: + * + * 1. window.__APP_CONFIG__.apiBaseUrl — runtime, injected by the container + * at start from $PUBLIC_API_BASE_URL (see public/config.js). Lets one + * prebuilt image point at any backend origin without rebuilding. + * 2. import.meta.env.PUBLIC_API_BASE_URL — build-time default (rsbuild inlines + * PUBLIC_* vars). Used in local dev and as a baked fallback. + * 3. '/api/v1' — same-origin relative path (works behind a reverse proxy). + * + * The user's chosen instance still wins over all of these — see + * runtime-config.ts / instances.ts. + */ +function runtimeApiBaseUrl(): string | undefined { + if (typeof window === 'undefined') return undefined; + const value = window.__APP_CONFIG__?.apiBaseUrl; + return value ? value : undefined; +} + export const DEFAULT_API_BASE_URL = - import.meta.env.PUBLIC_API_BASE_URL ?? '/api/v1'; + runtimeApiBaseUrl() ?? import.meta.env.PUBLIC_API_BASE_URL ?? '/api/v1'; diff --git a/src/env.d.ts b/src/env.d.ts index 9a350ff..86c7db9 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -5,3 +5,11 @@ interface ImportMetaEnv { interface ImportMeta { readonly env: ImportMetaEnv; } + +// Runtime operator config injected by /config.js before the app bundle loads +// (written from $PUBLIC_API_BASE_URL at container start). See src/config/env.ts. +interface Window { + __APP_CONFIG__?: { + apiBaseUrl?: string; + }; +}