mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:01:08 +02:00
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.
464 lines
11 KiB
Markdown
464 lines
11 KiB
Markdown
# 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<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`:
|
|
|
|
```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
|
|
<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:
|
|
|
|
```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 <<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:
|
|
|
|
```bash
|
|
chmod +x docker-entrypoint.sh
|
|
```
|
|
|
|
### Step 7: Update Dockerfile
|
|
|
|
```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
|
|
|
|
```yaml
|
|
# 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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```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<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
|
|
|
|
```bash
|
|
# 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:
|
|
|
|
```bash
|
|
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:
|
|
|
|
```javascript
|
|
// 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:
|
|
|
|
```typescript
|
|
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
|
|
|
|
- [12-Factor App: Config](https://12factor.net/config)
|
|
- [SvelteKit Adapters](https://kit.svelte.dev/docs/adapters)
|
|
- [Docker ENTRYPOINT Best Practices](https://docs.docker.com/engine/reference/builder/#entrypoint)
|