# Runtime Configuration Pattern This document describes the **runtime configuration** pattern used for SvelteKit web apps in the monorepo. This pattern implements the **12-factor app methodology** ("Config in environment") and enables **build once, deploy anywhere**. ## Why Runtime Configuration? ### The Problem Build-time environment variables (`import.meta.env.PUBLIC_*`) are baked into the JavaScript bundle at build time. This creates several issues: 1. **Separate builds per environment** - Need different builds for dev, staging, and production 2. **Cannot reuse Docker images** - Each environment needs its own image 3. **Cannot change config without rebuild** - URL changes require new deployment 4. **Brittle deployments** - Single Docker image can't adapt to different environments ### The Solution **Runtime configuration** loads config from `/config.json` when the app starts in the browser: 1. **Build once** - Single Docker image works everywhere 2. **Configure at deployment** - Environment variables injected at container startup 3. **Fast config changes** - Just restart containers, no rebuild 4. **Better DX** - Local dev still uses static file fallback ## Implementation Guide ### Step 1: Create Runtime Config Loader Create `src/lib/config/runtime.ts`: ```typescript /** * Runtime Configuration Loader * * Implements the 12-factor "config in environment" principle. * Loads configuration from /config.json at runtime, allowing the same * Docker image to be deployed across dev/staging/prod environments. */ import { browser } from '$app/environment'; import { z } from 'zod'; // Define your config schema const ConfigSchema = z.object({ BACKEND_URL: z.string().url(), AUTH_URL: z.string().url(), // Add other URLs as needed }); export type RuntimeConfig = z.infer; // Development fallbacks (only used in local dev, not in Docker) const DEV_CONFIG: RuntimeConfig = { BACKEND_URL: 'http://localhost:3000', AUTH_URL: 'http://localhost:3001', }; let cachedConfig: RuntimeConfig | null = null; let configPromise: Promise | null = null; /** * Load runtime configuration from /config.json * This file is generated by the Docker entrypoint script from environment variables */ async function loadConfig(): Promise { // Server-side: use dev config (SSR doesn't need runtime config) if (!browser) { return DEV_CONFIG; } // Return cached config if available if (cachedConfig) { return cachedConfig; } // Return existing promise if loading if (configPromise) { return configPromise; } // Load config from /config.json configPromise = fetch('/config.json') .then((res) => { if (!res.ok) { console.warn('Failed to load /config.json, using dev config'); return DEV_CONFIG; } return res.json(); }) .then((config: unknown) => { // Validate config with Zod const result = ConfigSchema.safeParse(config); if (!result.success) { console.error('Invalid runtime config:', result.error); return DEV_CONFIG; } cachedConfig = result.data; return result.data; }) .catch((error) => { console.warn('Error loading runtime config:', error); return DEV_CONFIG; }); return configPromise; } /** * Get runtime configuration * Must be called after app initialization */ export async function getConfig(): Promise { return loadConfig(); } /** * Initialize runtime config on app start * Call this in your root +layout.svelte */ export async function initializeConfig(): Promise { await loadConfig(); } /** * Helper to get backend URL (most commonly used) */ export async function getBackendUrl(): Promise { const config = await getConfig(); return config.BACKEND_URL; } /** * Helper to get auth URL */ export async function getAuthUrl(): Promise { const config = await getConfig(); return config.AUTH_URL; } ``` ### Step 2: Disable SSR Create `src/routes/+layout.ts`: ```typescript export const ssr = false; ``` This ensures the app runs as a client-side SPA, allowing us to use browser APIs like `fetch()`. ### Step 3: Initialize Config in Root Layout Update `src/routes/+layout.svelte`: ```svelte ``` ### Step 4: Use Async Config in Stores Update stores to use async config: ```typescript // BEFORE - Build-time env ❌ import { PUBLIC_BACKEND_URL } from '$env/static/public'; const API_URL = PUBLIC_BACKEND_URL; // AFTER - Runtime config ✅ import { getBackendUrl } from '$lib/config/runtime'; async function getApiClient() { const backendUrl = await getBackendUrl(); return createClient({ baseUrl: backendUrl }); } ``` **CRITICAL**: Always use `await` when calling async config functions! ```typescript // WRONG ❌ - This creates "[object Promise]" in URLs fetch(`${getAuthUrl()}/api/login`); // CORRECT ✅ const authUrl = await getAuthUrl(); fetch(`${authUrl}/api/login`); ``` ### Step 5: Create Development Fallback Config Create `static/config.json` for local development: ```json { "BACKEND_URL": "http://localhost:3000", "AUTH_URL": "http://localhost:3001" } ``` This file is served by Vite during `pnpm dev` and provides config when not running in Docker. ### Step 6: Create Docker Entrypoint Script Create `docker-entrypoint.sh`: ```bash #!/bin/sh set -e # Docker Entrypoint # Generates runtime config from environment variables # Implements "build once, configure at runtime" pattern echo "🔧 Generating runtime configuration..." # Default values for local development BACKEND_URL=${BACKEND_URL:-"http://localhost:3000"} AUTH_URL=${AUTH_URL:-"http://localhost:3001"} # Ensure the directory exists (it should from the build, but be safe) mkdir -p build/client # Generate config.json from template cat > build/client/config.json < | null = null; export async function getApiClient() { if (!apiClient) { const backendUrl = await getBackendUrl(); apiClient = createApiClient({ baseUrl: backendUrl }); } return apiClient; } // Usage const client = await getApiClient(); const data = await client.get('/users'); ``` ### Auth Store ```typescript import { getAuthUrl } from '$lib/config/runtime'; async function getAuthService() { if (!_authService) { const authUrl = await getAuthUrl(); const auth = initializeWebAuth({ baseUrl: authUrl }); _authService = auth.authService; } return _authService; } export const authStore = { async signIn(email: string, password: string) { const authService = await getAuthService(); return await authService.signIn(email, password); }, }; ``` ## Anti-Patterns to Avoid ### ❌ Using Build-Time Env in Stores ```typescript // WRONG - This is baked into bundle at build time import { PUBLIC_BACKEND_URL } from '$env/static/public'; const API_URL = PUBLIC_BACKEND_URL; ``` ### ❌ Window Injection ```typescript // WRONG - This pattern is deprecated const authUrl = (window as any).__PUBLIC_MANA_CORE_AUTH_URL__; ``` ### ❌ Missing Await on Async Config ```typescript // WRONG - Returns Promise not string fetch(`${getAuthUrl()}/api`); // ❌ "[object Promise]/api" // CORRECT const authUrl = await getAuthUrl(); fetch(`${authUrl}/api`); // ✅ "https://auth.example.com/api" ``` ### ❌ Absolute Paths in Docker Entrypoint ```bash # WRONG - Breaks with WORKDIR cat > /app/build/client/config.json < build/client/config.json <