mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 18:41:08 +02:00
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.
This commit is contained in:
parent
b2a8ffa6d9
commit
aab8c73a9c
4 changed files with 945 additions and 0 deletions
464
docs/RUNTIME_CONFIG.md
Normal file
464
docs/RUNTIME_CONFIG.md
Normal file
|
|
@ -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<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)
|
||||
|
|
@ -16,6 +16,7 @@
|
|||
"clean": "turbo run clean",
|
||||
"format": "prettier --config .prettierrc.json --write \"**/*.{ts,tsx,js,jsx,json,md,svelte,astro}\"",
|
||||
"format:check": "prettier --config .prettierrc.json --check \"**/*.{ts,tsx,js,jsx,json,md,svelte,astro}\"",
|
||||
"validate:runtime-config": "node scripts/validate-runtime-config.mjs",
|
||||
"svelte-check": "./scripts/svelte-check-staged.sh",
|
||||
"build:check": "./scripts/build-check-staged.sh",
|
||||
"build:check:all": "./scripts/build-check-staged.sh --all",
|
||||
|
|
|
|||
|
|
@ -47,6 +47,35 @@ export const typescriptConfig = [
|
|||
// Enforce proper typing
|
||||
'@typescript-eslint/no-non-null-assertion': 'warn',
|
||||
|
||||
// ============================================
|
||||
// ASYNC/AWAIT - Prevent runtime config bugs
|
||||
// ============================================
|
||||
|
||||
// CRITICAL: Prevent calling async functions without await
|
||||
// This catches bugs like: `fetch(\`\${getAuthUrl()}/api\`)` instead of `fetch(\`\${await getAuthUrl()}/api\`)`
|
||||
'@typescript-eslint/no-floating-promises': [
|
||||
'error',
|
||||
{
|
||||
ignoreVoid: true,
|
||||
ignoreIIFE: true,
|
||||
},
|
||||
],
|
||||
|
||||
// Prevent misused promises in conditionals/logical expressions
|
||||
'@typescript-eslint/no-misused-promises': [
|
||||
'error',
|
||||
{
|
||||
checksVoidReturn: false, // Allow async functions in event handlers
|
||||
checksConditionals: true,
|
||||
},
|
||||
],
|
||||
|
||||
// Require await in async functions (otherwise why is it async?)
|
||||
'@typescript-eslint/require-await': 'warn',
|
||||
|
||||
// Prevent returning values from Promise executor
|
||||
'@typescript-eslint/no-misused-new': 'error',
|
||||
|
||||
// ============================================
|
||||
// WARNINGS - Best practices, fix when convenient
|
||||
// ============================================
|
||||
|
|
|
|||
451
scripts/validate-runtime-config.mjs
Executable file
451
scripts/validate-runtime-config.mjs
Executable file
|
|
@ -0,0 +1,451 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* Runtime Configuration Validator
|
||||
*
|
||||
* Validates that all web apps follow the runtime configuration pattern correctly.
|
||||
* This prevents bugs where apps use build-time env vars or window injection instead
|
||||
* of the proper runtime config loader.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/validate-runtime-config.mjs
|
||||
* pnpm validate:runtime-config
|
||||
*
|
||||
* What it checks:
|
||||
* 1. Required files exist (runtime.ts, docker-entrypoint.sh, Dockerfile)
|
||||
* 2. No window injection patterns (window.__PUBLIC_*)
|
||||
* 3. No direct build-time env imports in stores/api (import.meta.env.PUBLIC_*)
|
||||
* 4. Correct async patterns (no missing await on getAuthUrl(), etc.)
|
||||
* 5. Docker entrypoint generates config.json correctly
|
||||
*/
|
||||
|
||||
import { readdir, readFile, stat } from 'node:fs/promises';
|
||||
import { join, relative } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||
const monorepoRoot = join(__dirname, '..');
|
||||
|
||||
// Colors for terminal output
|
||||
const colors = {
|
||||
reset: '\x1b[0m',
|
||||
red: '\x1b[31m',
|
||||
green: '\x1b[32m',
|
||||
yellow: '\x1b[33m',
|
||||
blue: '\x1b[34m',
|
||||
bold: '\x1b[1m',
|
||||
};
|
||||
|
||||
const { reset, red, green, yellow, blue, bold } = colors;
|
||||
|
||||
// Validation results
|
||||
const results = {
|
||||
passed: [],
|
||||
failed: [],
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Find all web apps in the monorepo
|
||||
*/
|
||||
async function findWebApps() {
|
||||
const webApps = [];
|
||||
const appsDirs = [join(monorepoRoot, 'apps'), join(monorepoRoot, 'games')];
|
||||
|
||||
for (const appsDir of appsDirs) {
|
||||
try {
|
||||
const projects = await readdir(appsDir);
|
||||
for (const project of projects) {
|
||||
const webAppPath = join(appsDir, project, 'apps', 'web');
|
||||
try {
|
||||
const stats = await stat(webAppPath);
|
||||
if (stats.isDirectory()) {
|
||||
webApps.push({
|
||||
path: webAppPath,
|
||||
name: `${relative(monorepoRoot, appsDir)}/${project}/apps/web`,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// No web app in this project, skip
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Apps directory doesn't exist, skip
|
||||
}
|
||||
}
|
||||
|
||||
return webApps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file exists
|
||||
*/
|
||||
async function fileExists(filePath) {
|
||||
try {
|
||||
await stat(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read file content safely
|
||||
*/
|
||||
async function readFileSafe(filePath) {
|
||||
try {
|
||||
return await readFile(filePath, 'utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate required files exist
|
||||
*/
|
||||
async function validateRequiredFiles(webApp) {
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
|
||||
// Check for runtime.ts
|
||||
const runtimeTsPath = join(webApp.path, 'src/lib/config/runtime.ts');
|
||||
if (!(await fileExists(runtimeTsPath))) {
|
||||
errors.push('Missing src/lib/config/runtime.ts');
|
||||
}
|
||||
|
||||
// Check for docker-entrypoint.sh
|
||||
const entrypointPath = join(webApp.path, 'docker-entrypoint.sh');
|
||||
if (!(await fileExists(entrypointPath))) {
|
||||
warnings.push('Missing docker-entrypoint.sh (required for Docker deployment)');
|
||||
}
|
||||
|
||||
// Check for Dockerfile
|
||||
const dockerfilePath = join(webApp.path, 'Dockerfile');
|
||||
if (!(await fileExists(dockerfilePath))) {
|
||||
warnings.push('Missing Dockerfile (required for Docker deployment)');
|
||||
}
|
||||
|
||||
// Check for static/config.json (dev fallback)
|
||||
const configJsonPath = join(webApp.path, 'static/config.json');
|
||||
if (!(await fileExists(configJsonPath))) {
|
||||
warnings.push('Missing static/config.json (dev fallback)');
|
||||
}
|
||||
|
||||
return { errors, warnings };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for window injection anti-pattern
|
||||
*/
|
||||
async function validateNoWindowInjection(webApp) {
|
||||
const errors = [];
|
||||
const srcPath = join(webApp.path, 'src');
|
||||
|
||||
async function scanDirectory(dir) {
|
||||
try {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
// Skip node_modules, build artifacts
|
||||
if (!['node_modules', 'build', '.svelte-kit', 'dist'].includes(entry.name)) {
|
||||
await scanDirectory(fullPath);
|
||||
}
|
||||
} else if (entry.name.match(/\.(ts|js|svelte)$/)) {
|
||||
const content = await readFileSafe(fullPath);
|
||||
if (!content) continue;
|
||||
|
||||
// Check for window.__PUBLIC_* pattern
|
||||
const windowInjectionPattern = /window\.__PUBLIC_[A-Z_]+__/g;
|
||||
const matches = content.match(windowInjectionPattern);
|
||||
|
||||
if (matches) {
|
||||
const relativePath = relative(webApp.path, fullPath);
|
||||
errors.push(
|
||||
`${relativePath}: Found window injection pattern (${matches.join(', ')}). Use runtime config instead.`
|
||||
);
|
||||
}
|
||||
|
||||
// Check for (window as any).__PUBLIC_* pattern
|
||||
const windowAsAnyPattern = /\(window as (?:any|unknown)\)\.__PUBLIC_[A-Z_]+__/g;
|
||||
const asAnyMatches = content.match(windowAsAnyPattern);
|
||||
|
||||
if (asAnyMatches) {
|
||||
const relativePath = relative(webApp.path, fullPath);
|
||||
errors.push(
|
||||
`${relativePath}: Found window injection with type assertion (${asAnyMatches.join(', ')}). Use runtime config instead.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Directory doesn't exist or can't be read
|
||||
}
|
||||
}
|
||||
|
||||
await scanDirectory(srcPath);
|
||||
return { errors, warnings: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for direct build-time env usage in stores/api files
|
||||
*/
|
||||
async function validateNoBuildTimeEnv(webApp) {
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
|
||||
const criticalPaths = [
|
||||
join(webApp.path, 'src/lib/stores'),
|
||||
join(webApp.path, 'src/lib/api'),
|
||||
join(webApp.path, 'src/lib/config'),
|
||||
];
|
||||
|
||||
for (const criticalPath of criticalPaths) {
|
||||
if (!(await fileExists(criticalPath))) continue;
|
||||
|
||||
async function scanDirectory(dir) {
|
||||
try {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
await scanDirectory(fullPath);
|
||||
} else if (entry.name.match(/\.(ts|js)$/)) {
|
||||
const content = await readFileSafe(fullPath);
|
||||
if (!content) continue;
|
||||
|
||||
// Check for import.meta.env.PUBLIC_* in stores/api
|
||||
const envPattern = /import\.meta\.env\.PUBLIC_[A-Z_]+/g;
|
||||
const matches = content.match(envPattern);
|
||||
|
||||
if (matches) {
|
||||
const relativePath = relative(webApp.path, fullPath);
|
||||
// Allow in config files if they have backward compat exports
|
||||
if (
|
||||
relativePath.includes('config/api.ts') ||
|
||||
relativePath.includes('config/env.ts')
|
||||
) {
|
||||
warnings.push(
|
||||
`${relativePath}: Uses build-time env vars (${matches.join(', ')}). Consider migrating to runtime config.`
|
||||
);
|
||||
} else {
|
||||
errors.push(
|
||||
`${relativePath}: Uses build-time env vars (${matches.join(', ')}). Use runtime config instead.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Directory doesn't exist or can't be read
|
||||
}
|
||||
}
|
||||
|
||||
await scanDirectory(criticalPath);
|
||||
}
|
||||
|
||||
return { errors, warnings };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for missing await on async config functions
|
||||
*/
|
||||
async function validateAsyncUsage(webApp) {
|
||||
const errors = [];
|
||||
const srcPath = join(webApp.path, 'src');
|
||||
|
||||
async function scanDirectory(dir) {
|
||||
try {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
if (!['node_modules', 'build', '.svelte-kit', 'dist'].includes(entry.name)) {
|
||||
await scanDirectory(fullPath);
|
||||
}
|
||||
} else if (entry.name.match(/\.(ts|js|svelte)$/)) {
|
||||
const content = await readFileSafe(fullPath);
|
||||
if (!content) continue;
|
||||
|
||||
// Check for common async config functions called without await
|
||||
const asyncFunctions = [
|
||||
'getAuthUrl',
|
||||
'getBackendUrl',
|
||||
'getApiBaseUrl',
|
||||
'getTodoApiUrl',
|
||||
'getCalendarApiUrl',
|
||||
'getClockApiUrl',
|
||||
'getContactsApiUrl',
|
||||
'getConfig',
|
||||
];
|
||||
|
||||
for (const funcName of asyncFunctions) {
|
||||
// Pattern: using function in template literal without await
|
||||
// e.g., `${getAuthUrl()}/api` instead of `${await getAuthUrl()}/api`
|
||||
const templateLiteralPattern = new RegExp(`\\$\\{\\s*${funcName}\\(\\)\\s*\\}`, 'g');
|
||||
const matches = content.match(templateLiteralPattern);
|
||||
|
||||
if (matches) {
|
||||
// Check if there's await before it
|
||||
const fullPattern = new RegExp(`await\\s+${funcName}\\(\\)`, 'g');
|
||||
const awaitMatches = content.match(fullPattern);
|
||||
|
||||
// If we found calls without await
|
||||
if (!awaitMatches || matches.length > 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);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue