🔧 refactor: implement 12-factor runtime config for all web apps

Replace window injection and build-time env vars with runtime config
loaded from /config.json (generated by Docker entrypoint). This fixes
the staging deployment issue where apps were requesting localhost URLs
instead of production URLs.

Changes:
- Add runtime.ts config loader with Zod validation (fail-hard in prod)
- Disable SSR via +layout.ts (apps are client-only SPAs)
- Update API clients and auth stores to use async config getters
- Add docker-entrypoint.sh scripts to generate config.json at startup
- Update Dockerfiles with ENTRYPOINT for config generation
- Simplify docker-compose.staging.yml env vars (12-factor pattern)
- Add static/config.json as dev fallback (localhost defaults)
- Fix onMount return type (Svelte 5 compatibility)
- Add zod dependency to Picture app
- Add backward compat exports for Contacts app

Apps updated:
- Clock (port 3017)
- Chat (port 3002)
- Picture (port 3006)
- Contacts (port 3015)
- Calendar (port 3016)
- Manacore (multi-app platform)

Benefits:
- Build once, deploy anywhere (same Docker image for all envs)
- Configuration in environment, not code (12-factor compliance)
- Fail-hard on missing/invalid config in production
- No accidental SSR localhost fallbacks
- Schema validation ensures all required URLs are present
This commit is contained in:
Wuesteon 2025-12-15 21:33:50 +01:00
parent f414aecda1
commit 2c30867251
55 changed files with 1596 additions and 610 deletions

View file

@ -68,6 +68,10 @@ COPY --from=builder /app/apps/clock/apps/web/node_modules ./node_modules
COPY --from=builder /app/apps/clock/apps/web/build ./build
COPY --from=builder /app/apps/clock/apps/web/package.json ./
# Copy entrypoint script for runtime config generation
COPY apps/clock/apps/web/docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# Expose port
EXPOSE 5187
@ -80,5 +84,8 @@ ENV HOST=0.0.0.0
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:5187/health || exit 1
# Use entrypoint to generate runtime config
ENTRYPOINT ["docker-entrypoint.sh"]
# Run the app
CMD ["node", "build"]

View file

@ -0,0 +1,26 @@
#!/bin/sh
set -e
echo "🔧 Generating runtime configuration..."
# Environment variables with development defaults
API_BASE_URL=${API_BASE_URL:-"http://localhost:3017"}
AUTH_URL=${AUTH_URL:-"http://localhost:3001"}
echo "📝 Config values:"
echo " API_BASE_URL: $API_BASE_URL"
echo " AUTH_URL: $AUTH_URL"
# Generate config.json from environment variables
cat > /app/apps/clock/apps/web/build/client/config.json <<EOF
{
"API_BASE_URL": "${API_BASE_URL}",
"AUTH_URL": "${AUTH_URL}"
}
EOF
echo "✅ Configuration generated at /app/apps/clock/apps/web/build/client/config.json"
cat /app/apps/clock/apps/web/build/client/config.json
echo "🚀 Starting Clock web app..."
exec "$@"

View file

@ -1,10 +1,10 @@
/**
* API Client for Clock backend
* Uses runtime configuration for 12-factor compliance
*/
import { authStore } from '$lib/stores/auth.svelte';
const API_URL = 'http://localhost:3017/api/v1';
import { getApiBaseUrl } from '$lib/config/runtime';
export interface ApiResponse<T> {
data?: T;
@ -17,6 +17,7 @@ export async function fetchApi<T>(
): Promise<ApiResponse<T>> {
try {
const token = await authStore.getAccessToken();
const apiBaseUrl = await getApiBaseUrl();
const headers: HeadersInit = {
'Content-Type': 'application/json',
@ -27,7 +28,7 @@ export async function fetchApi<T>(
(headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(`${API_URL}${endpoint}`, {
const response = await fetch(`${apiBaseUrl}/api/v1${endpoint}`, {
...options,
headers,
});

View file

@ -0,0 +1,123 @@
/**
* Runtime Configuration Loader
*
* Implements 12-factor app "Config in Environment" principle.
* Configuration is loaded at runtime from /config.json generated by Docker entrypoint,
* allowing the same Docker image to work across all environments.
*
* Pattern: Client-only SPA (SSR disabled via +layout.ts)
* - Browser: Fetches /config.json (generated by docker-entrypoint.sh)
* - Validation: Enforces schema in production (fail hard on misconfiguration)
* - Dev fallback: Only when dev=true, never in staging/prod
*/
import { browser, dev } from '$app/environment';
import { z } from 'zod';
export interface RuntimeConfig {
API_BASE_URL: string;
AUTH_URL: string;
}
const ConfigSchema = z.object({
API_BASE_URL: z.string().url().min(1, 'API_BASE_URL must be a valid URL'),
AUTH_URL: z.string().url().min(1, 'AUTH_URL must be a valid URL'),
});
// Development fallback configuration (only used when dev=true)
const DEV_CONFIG: RuntimeConfig = {
API_BASE_URL: 'http://localhost:3017',
AUTH_URL: 'http://localhost:3001',
};
let cachedConfig: RuntimeConfig | null = null;
let configPromise: Promise<RuntimeConfig> | null = null;
/**
* Load runtime configuration from /config.json
* Uses caching to avoid multiple fetches
*/
async function loadConfig(): Promise<RuntimeConfig> {
// Guard: SSR should never happen (we disabled it in +layout.ts)
if (!browser) {
if (dev) {
console.warn('[Clock] Config accessed during SSR in dev mode, using fallback');
return DEV_CONFIG;
}
throw new Error('[Clock] Runtime config called on server - SSR should be disabled');
}
// Return cached config if available
if (cachedConfig) {
return cachedConfig;
}
// If already loading, return the existing promise
if (configPromise) {
return configPromise;
}
// Fetch config from /config.json (generated by docker-entrypoint.sh)
configPromise = fetch('/config.json')
.then((res) => {
if (!res.ok) {
if (dev) {
console.warn(
`[Clock] Failed to load /config.json (HTTP ${res.status}), using dev defaults`
);
return DEV_CONFIG;
}
throw new Error(
`[Clock] Failed to load /config.json (HTTP ${res.status}) - check Docker entrypoint script`
);
}
return res.json();
})
.then((config) => {
// Validate schema in production (fail hard on misconfiguration)
if (!dev) {
const result = ConfigSchema.safeParse(config);
if (!result.success) {
throw new Error(
`[Clock] Invalid config.json schema: ${result.error.errors.map((e) => `${e.path.join('.')}: ${e.message}`).join(', ')}`
);
}
}
cachedConfig = config as RuntimeConfig;
return cachedConfig;
});
return configPromise;
}
/**
* Get the full runtime configuration
*/
export async function getConfig(): Promise<RuntimeConfig> {
return loadConfig();
}
/**
* Get the Auth service URL
*/
export async function getAuthUrl(): Promise<string> {
const config = await getConfig();
return config.AUTH_URL;
}
/**
* Get the API base URL
*/
export async function getApiBaseUrl(): Promise<string> {
const config = await getConfig();
return config.API_BASE_URL;
}
/**
* Initialize runtime configuration
* Call this early in app lifecycle (e.g., +layout.svelte onMount)
*/
export async function initializeConfig(): Promise<void> {
await loadConfig();
}

View file

@ -1,44 +1,24 @@
/**
* Auth Store - Manages authentication state using Svelte 5 runes
* Uses Mana Core Auth
* Uses Mana Core Auth with runtime configuration
*/
import { browser } from '$app/environment';
import { initializeWebAuth, type UserData } from '@manacore/shared-auth';
// Get auth URL dynamically at runtime - fallback for SSR and client
function getAuthUrl(): string {
if (browser && typeof window !== 'undefined') {
// Client-side: use injected window variable (set by hooks.server.ts)
// Falls back to localhost for local development
const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string })
.__PUBLIC_MANA_CORE_AUTH_URL__;
return injectedUrl || 'http://localhost:3001';
}
// Server-side (SSR): use Docker internal URL for container-to-container communication
return process.env.PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
}
// Get backend URL dynamically at runtime
function getBackendUrl(): string {
if (browser && typeof window !== 'undefined') {
const injectedUrl = (window as unknown as { __PUBLIC_BACKEND_URL__?: string })
.__PUBLIC_BACKEND_URL__;
return injectedUrl || 'http://localhost:3017';
}
return process.env.PUBLIC_BACKEND_URL || 'http://localhost:3017';
}
import { getAuthUrl, getApiBaseUrl } from '$lib/config/runtime';
// Lazy initialization to avoid SSR issues with localStorage
let _authService: ReturnType<typeof initializeWebAuth>['authService'] | null = null;
let _tokenManager: ReturnType<typeof initializeWebAuth>['tokenManager'] | null = null;
function getAuthService() {
async function getAuthService() {
if (!browser) return null;
if (!_authService) {
const authUrl = await getAuthUrl();
const backendUrl = await getApiBaseUrl();
const auth = initializeWebAuth({
baseUrl: getAuthUrl(),
backendUrl: getBackendUrl(), // Enables automatic token refresh on 401 responses
baseUrl: authUrl,
backendUrl: backendUrl, // Enables automatic token refresh on 401 responses
});
_authService = auth.authService;
_tokenManager = auth.tokenManager;
@ -46,10 +26,10 @@ function getAuthService() {
return _authService;
}
function getTokenManager() {
async function getTokenManager() {
if (!browser) return null;
// Ensure auth service is initialized first
getAuthService();
await getAuthService();
return _tokenManager;
}
@ -79,7 +59,7 @@ export const authStore = {
async initialize() {
if (initialized) return;
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
initialized = true;
loading = false;
@ -106,7 +86,7 @@ export const authStore = {
* Sign in with email and password
*/
async signIn(email: string, password: string) {
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server' };
}
@ -133,7 +113,7 @@ export const authStore = {
* Sign up with email and password
*/
async signUp(email: string, password: string) {
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server', needsVerification: false };
}
@ -163,7 +143,7 @@ export const authStore = {
* Sign out
*/
async signOut() {
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
user = null;
return;
@ -183,7 +163,7 @@ export const authStore = {
* Send password reset email
*/
async resetPassword(email: string) {
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server' };
}
@ -207,7 +187,7 @@ export const authStore = {
* @deprecated Use getValidToken() instead for automatic refresh
*/
async getAccessToken() {
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
return null;
}
@ -219,7 +199,7 @@ export const authStore = {
* Automatically refreshes if the token is expired or about to expire
*/
async getValidToken(): Promise<string | null> {
const tokenManager = getTokenManager();
const tokenManager = await getTokenManager();
if (!tokenManager) {
return null;
}

View file

@ -5,6 +5,7 @@
import { theme } from '$lib/stores/theme.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { waitLocale } from '$lib/i18n';
import { initializeConfig } from '$lib/config/runtime';
import ToastContainer from '$lib/components/ToastContainer.svelte';
import { AppLoadingSkeleton } from '$lib/components/skeletons';
@ -13,6 +14,9 @@
let loading = $state(true);
onMount(async () => {
// Initialize runtime config first (12-factor pattern)
await initializeConfig();
// Wait for locale to be loaded
await waitLocale();

View file

@ -0,0 +1,10 @@
/**
* Layout Configuration
*
* Disable SSR - this is a client-only SPA that:
* - Requires authentication (no SEO benefit)
* - Fetches all data client-side via authenticated APIs
* - Loads runtime config from /config.json (browser-only)
*/
export const ssr = false;

View file

@ -0,0 +1,4 @@
{
"API_BASE_URL": "http://localhost:3017",
"AUTH_URL": "http://localhost:3001"
}