mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:21:10 +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.
451 lines
13 KiB
JavaScript
Executable file
451 lines
13 KiB
JavaScript
Executable file
#!/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);
|
|
});
|