managarten/docs/RUNTIME_CONFIG.md
Wuesteon aab8c73a9c feat: add multi-layered runtime config protection system
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.
2025-12-16 00:28:57 +01:00

11 KiB

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:

/**
 * 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<typeof ConfigSchema>;

// 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<RuntimeConfig> | null = null;

/**
 * Load runtime configuration from /config.json
 * This file is generated by the Docker entrypoint script from environment variables
 */
async function loadConfig(): Promise<RuntimeConfig> {
	// 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<RuntimeConfig> {
	return loadConfig();
}

/**
 * Initialize runtime config on app start
 * Call this in your root +layout.svelte
 */
export async function initializeConfig(): Promise<void> {
	await loadConfig();
}

/**
 * Helper to get backend URL (most commonly used)
 */
export async function getBackendUrl(): Promise<string> {
	const config = await getConfig();
	return config.BACKEND_URL;
}

/**
 * Helper to get auth URL
 */
export async function getAuthUrl(): Promise<string> {
	const config = await getConfig();
	return config.AUTH_URL;
}

Step 2: Disable SSR

Create src/routes/+layout.ts:

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:

<script lang="ts">
	import { onMount } from 'svelte';
	import { initializeConfig } from '$lib/config/runtime';
	import { authStore } from '$lib/stores/auth.svelte';
	import { theme } from '$lib/stores/theme';

	onMount(() => {
		initializeConfig().then(() => {
			// Initialize stores after config is loaded
			const cleanupTheme = theme.initialize();
			authStore.initialize();

			return () => {
				cleanupTheme();
			};
		});
	});
</script>

<slot />

Step 4: Use Async Config in Stores

Update stores to use async config:

// 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!

// 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:

{
	"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:

#!/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 <<EOF
{
  "BACKEND_URL": "${BACKEND_URL}",
  "AUTH_URL": "${AUTH_URL}"
}
EOF

echo "✅ Runtime configuration generated:"
cat build/client/config.json

echo ""
echo "🚀 Starting Node server..."

# Execute the CMD (node build)
exec "$@"

Make it executable:

chmod +x docker-entrypoint.sh

Step 7: Update Dockerfile

FROM node:20-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable

FROM base AS build
WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY apps/your-app/apps/web/package.json ./apps/your-app/apps/web/
RUN pnpm install --frozen-lockfile
COPY apps/your-app/apps/web ./apps/your-app/apps/web
RUN pnpm --filter @your-app/web build

FROM base
WORKDIR /app/apps/your-app/apps/web
COPY --from=build /app/apps/your-app/apps/web/build ./build
COPY --from=build /app/apps/your-app/apps/web/package.json ./package.json
COPY --from=build /app/node_modules ./node_modules

# Copy the entrypoint script
COPY apps/your-app/apps/web/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh

EXPOSE 3000
ENV NODE_ENV=production

# Use entrypoint to generate config.json
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
CMD ["node", "build"]

Step 8: Deploy with Environment Variables

# docker-compose.yml
services:
  your-app-web:
    image: your-app-web:latest
    environment:
      - BACKEND_URL=https://api.staging.yourdomain.com
      - AUTH_URL=https://auth.staging.yourdomain.com
    ports:
      - "3000:3000"

Common Patterns

API Client Singleton

import { getBackendUrl } from '$lib/config/runtime';

let apiClient: ReturnType<typeof createApiClient> | 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

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

// 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

// WRONG - This pattern is deprecated
const authUrl = (window as any).__PUBLIC_MANA_CORE_AUTH_URL__;

Missing Await on Async Config

// WRONG - Returns Promise<string> 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

# WRONG - Breaks with WORKDIR
cat > /app/build/client/config.json <<EOF

# CORRECT - Use relative paths
mkdir -p build/client
cat > build/client/config.json <<EOF

Validation

Run the validation script to check for anti-patterns:

pnpm validate:runtime-config

This checks for:

  • Required files exist (runtime.ts, docker-entrypoint.sh)
  • Window injection patterns
  • Build-time env usage in stores/api
  • Missing await on async config functions
  • ⚠️ Docker entrypoint best practices

Migration Checklist

When migrating an existing app:

  • Create src/lib/config/runtime.ts
  • Create src/routes/+layout.ts with export const ssr = false
  • Initialize config in src/routes/+layout.svelte
  • Update all stores/API clients to use async config
  • Add zod dependency for config validation
  • Create static/config.json for local dev
  • Create docker-entrypoint.sh
  • Update Dockerfile with ENTRYPOINT
  • Run pnpm validate:runtime-config
  • Test locally: pnpm dev should still work
  • Test in Docker: Build image and run with env vars

ESLint Protection

The monorepo ESLint config includes rules to catch these bugs:

// Prevents calling async functions without await
'@typescript-eslint/no-floating-promises': 'error',

// Prevents misused promises in conditionals
'@typescript-eslint/no-misused-promises': 'error',

// Requires await in async functions
'@typescript-eslint/require-await': 'warn',

These rules will catch patterns like:

fetch(`${getAuthUrl()}/api`);  // ❌ ESLint error: floating promise

Benefits

  1. Single Docker Image - Build once, deploy anywhere
  2. Fast Config Updates - Just restart, no rebuild
  3. Environment Parity - Same image in dev/staging/prod
  4. Better Security - Secrets injected at runtime, not in bundle
  5. Easier Testing - Can override config per environment
  6. CI/CD Friendly - Single artifact for all environments

References