From aab8c73a9c4d09ffe96180d039505b71e9b37e46 Mon Sep 17 00:00:00 2001 From: Wuesteon Date: Tue, 16 Dec 2025 00:28:57 +0100 Subject: [PATCH] feat: add multi-layered runtime config protection system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add comprehensive defense system to prevent runtime config bugs across all projects: ## 1. Enhanced ESLint Rules - Added @typescript-eslint/no-floating-promises (error) Catches: fetch(`${getAuthUrl()}/api`) without await - Added @typescript-eslint/no-misused-promises (error) Catches: Promises in conditionals and logical expressions - Added @typescript-eslint/require-await (warn) Ensures async functions actually use await ## 2. Validation Script (scripts/validate-runtime-config.mjs) Automated checker that scans all web apps for: - ✅ Required files (runtime.ts, docker-entrypoint.sh, Dockerfile) - ❌ Window injection patterns (window.__PUBLIC_*) - ❌ Build-time env usage in stores/api (import.meta.env.PUBLIC_*) - ❌ Missing await on async config functions - ⚠️ Docker entrypoint best practices Usage: pnpm validate:runtime-config ## 3. Comprehensive Documentation (docs/RUNTIME_CONFIG.md) Complete implementation guide covering: - Why runtime configuration is needed - Step-by-step implementation guide - Common patterns (API clients, auth stores) - Anti-patterns to avoid - Migration checklist - ESLint protection details ## Benefits - Prevents "[object Promise]" in API URLs (staging bug) - Catches missing await at lint time - Validates all apps automatically - Clear documentation for new projects - Can run in CI/CD ## Future Work - Add to pre-push hook (optional) - Create project generator/template - Shared runtime config package This prevents the class of bugs we just fixed in manacore-web where getAuthUrl() was called without await, causing ERR_CONNECTION_REFUSED on staging. --- docs/RUNTIME_CONFIG.md | 464 +++++++++++++++++++++++++++ package.json | 1 + packages/eslint-config/typescript.js | 29 ++ scripts/validate-runtime-config.mjs | 451 ++++++++++++++++++++++++++ 4 files changed, 945 insertions(+) create mode 100644 docs/RUNTIME_CONFIG.md create mode 100755 scripts/validate-runtime-config.mjs diff --git a/docs/RUNTIME_CONFIG.md b/docs/RUNTIME_CONFIG.md new file mode 100644 index 000000000..33718ca91 --- /dev/null +++ b/docs/RUNTIME_CONFIG.md @@ -0,0 +1,464 @@ +# 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 < awaitMatches.length) { + const relativePath = relative(webApp.path, fullPath); + errors.push( + `${relativePath}: Missing 'await' on async function ${funcName}(). This causes "[object Promise]" in URLs.` + ); + } + } + } + } + } + } catch { + // Directory doesn't exist or can't be read + } + } + + await scanDirectory(srcPath); + return { errors, warnings: [] }; +} + +/** + * Validate Docker entrypoint generates config.json correctly + */ +async function validateDockerEntrypoint(webApp) { + const errors = []; + const warnings = []; + + const entrypointPath = join(webApp.path, 'docker-entrypoint.sh'); + const content = await readFileSafe(entrypointPath); + + if (!content) { + // Already caught in validateRequiredFiles + return { errors, warnings }; + } + + // Check that it generates config.json + if (!content.includes('config.json')) { + errors.push('docker-entrypoint.sh does not generate config.json'); + } + + // Check that it uses relative paths (not absolute like /app/build/client/config.json) + if (content.includes('> /app/build/client/config.json')) { + errors.push( + 'docker-entrypoint.sh uses absolute path for config.json. Use relative path (build/client/config.json) instead.' + ); + } + + // Check that it has mkdir -p for config directory + if (!content.includes('mkdir -p')) { + warnings.push('docker-entrypoint.sh should include "mkdir -p build/client" for safety'); + } + + // Check that it executes the CMD with exec "$@" + if (!content.includes('exec "$@"')) { + warnings.push('docker-entrypoint.sh should end with: exec "$@"'); + } + + return { errors, warnings }; +} + +/** + * Validate a single web app + */ +async function validateWebApp(webApp) { + console.log(`\n${blue}${bold}Checking:${reset} ${webApp.name}`); + + const checks = [ + { name: 'Required files', fn: validateRequiredFiles }, + { name: 'No window injection', fn: validateNoWindowInjection }, + { name: 'No build-time env in stores/api', fn: validateNoBuildTimeEnv }, + { name: 'Async function usage', fn: validateAsyncUsage }, + { name: 'Docker entrypoint', fn: validateDockerEntrypoint }, + ]; + + const allErrors = []; + const allWarnings = []; + + for (const check of checks) { + const { errors, warnings } = await check.fn(webApp); + if (errors.length > 0) { + allErrors.push(...errors); + } + if (warnings.length > 0) { + allWarnings.push(...warnings); + } + } + + // Print results + if (allErrors.length === 0 && allWarnings.length === 0) { + console.log(`${green}✓${reset} All checks passed`); + results.passed.push(webApp.name); + } else { + if (allErrors.length > 0) { + console.log(`${red}✗${reset} ${allErrors.length} error(s):`); + allErrors.forEach((error) => console.log(` ${red}•${reset} ${error}`)); + results.failed.push({ name: webApp.name, errors: allErrors }); + } + + if (allWarnings.length > 0) { + console.log(`${yellow}⚠${reset} ${allWarnings.length} warning(s):`); + allWarnings.forEach((warning) => console.log(` ${yellow}•${reset} ${warning}`)); + results.warnings.push({ name: webApp.name, warnings: allWarnings }); + } + } +} + +/** + * Main validation function + */ +async function main() { + console.log(`${bold}Runtime Configuration Validator${reset}\n`); + console.log('Scanning for web apps...\n'); + + const webApps = await findWebApps(); + + if (webApps.length === 0) { + console.log(`${yellow}No web apps found${reset}`); + return; + } + + console.log(`Found ${webApps.length} web app(s)\n`); + + // Validate each web app + for (const webApp of webApps) { + await validateWebApp(webApp); + } + + // Print summary + console.log(`\n${bold}═══════════════════════════════════════${reset}`); + console.log(`${bold}Summary${reset}\n`); + console.log(`${green}✓${reset} Passed: ${results.passed.length}`); + console.log(`${yellow}⚠${reset} Warnings: ${results.warnings.length}`); + console.log(`${red}✗${reset} Failed: ${results.failed.length}`); + + if (results.failed.length > 0) { + console.log(`\n${red}${bold}Failed apps:${reset}`); + results.failed.forEach(({ name }) => console.log(` ${red}•${reset} ${name}`)); + } + + // Exit with error if any validations failed + if (results.failed.length > 0) { + console.log(`\n${red}${bold}Validation failed!${reset}`); + console.log('\nFix the errors above to ensure runtime configuration is implemented correctly.'); + console.log('See docs/RUNTIME_CONFIG.md for implementation guide.\n'); + process.exit(1); + } + + console.log(`\n${green}${bold}All validations passed!${reset}\n`); +} + +main().catch((error) => { + console.error(`${red}${bold}Validation script error:${reset}`, error); + process.exit(1); +});