/** * App configuration loader (`wrnexus.config.ts`). * * The config is optional. It lets an app inject arbitrary `` HTML (ideal * for CDN-delivered CSS frameworks like Bootstrap or the Tailwind Play CDN) and * customise the global stylesheet pipeline (entry file or a custom processor for * Tailwind / PostCSS / Sass). */ import { existsSync, readFileSync } from "node:fs"; import { dirname, extname, join, resolve } from "node:path"; import { createRequire } from "node:module"; import { pathToFileURL } from "node:url"; import type { PerformanceBudgets, SecurityConfig, SeoConfig } from "@wrnexus/core"; import type { PluginInput, PluginPermission } from "@wrnexus/plugin"; import type { StorageConfig } from "@wrnexus/uploader"; import type { BrowserCookiesConfig, ThemeConfig } from "./theme.ts"; import type { FontConfig } from "./fonts.ts"; import { fontCspSources } from "./fonts.ts"; import { isCompatibilityDate, resolveCompatibility, type CompatibilityPolicy, } from "./compatibility.ts"; export type Mode = "development" | "production"; export interface StyleProcessContext { /** Resolved absolute path to the CSS entry, or null if there is none. */ entryPath: string | null; /** Original application entry when a package-aware wrapper was generated. */ originalEntryPath?: string | null; /** Package component/style directories that processors should scan. */ sources?: string[]; /** Package-owned CSS entries automatically imported into the application bundle. */ entries?: string[]; appDir: string; appRoot: string; mode: Mode; } export interface StylesConfig { /** Path to the CSS entry, relative to the app root. Default: app/styles/global.css */ entry?: string; /** Include the complete @wrnexus/ui component catalog stylesheet. Default true. */ includeUi?: boolean; /** * Optional custom processor. Return the final CSS string. Use this to run * Tailwind, PostCSS, Sass, etc. When omitted, the built-in Bun CSS bundler is * used (which already resolves `@import`, including from node_modules). */ process?: (ctx: StyleProcessContext) => string | Promise; /** Automatically append package scan sources to custom processor input. Default true. */ includePackageSources?: boolean; /** Production defaults to throw; development defaults to best-effort fallback. */ failureMode?: "throw" | "fallback"; } export interface MobileConfig { enabled?: boolean; /** Mobile renderer. `webview` uses Capacitor; `native` scaffolds an Expo/React Native app. */ mode?: "webview" | "native"; appId?: string; appName?: string; serverUrl?: string; userAgent?: string; layout?: string; backgroundColor?: string; icon?: string; errorTitle?: string; errorMessage?: string; /** Base URL used by a fully native client for WrNexus API and realtime requests. */ apiUrl?: string; /** URL scheme used for native deep links (defaults to a slug of appName). */ scheme?: string; /** Advanced Expo app config fields merged into generated app.config.ts. */ expo?: Record; /** Advanced CapacitorConfig fields merged into generated capacitor.config.ts. */ capacitor?: Record; } export interface PwaScreenshot { src: string; sizes: string; type?: string; formFactor?: "wide" | "narrow"; label?: string; } export interface PwaShortcut { name: string; shortName?: string; description?: string; url: string; icons?: Array<{ src: string; sizes: string; type?: string; purpose?: string }>; } export interface PwaConfig { enabled?: boolean; id?: string; name?: string; shortName?: string; description?: string; startUrl?: string; scope?: string; lang?: string; display?: "standalone" | "fullscreen" | "minimal-ui" | "browser"; orientation?: | "any" | "natural" | "landscape" | "landscape-primary" | "landscape-secondary" | "portrait" | "portrait-primary" | "portrait-secondary"; themeColor?: string; backgroundColor?: string; icons?: Array<{ src: string; sizes: string; type?: string; purpose?: string }>; categories?: string[]; screenshots?: PwaScreenshot[]; shortcuts?: PwaShortcut[]; /** Disable service-worker registration while keeping the web manifest. */ serviceWorker?: boolean; /** Navigation shown when both the network and requested page cache are unavailable. */ offlineUrl?: string; /** Additional same-origin URLs precached during service-worker installation. */ cacheUrls?: string[]; /** Service-worker cache key. Change it to invalidate existing PWA caches. */ cacheName?: string; /** Ordered URL rules for runtime caching. Patterns are regular-expression source strings. */ runtimeCaching?: Array<{ pattern: string; strategy: "network-first" | "cache-first" | "stale-while-revalidate"; cacheName?: string; methods?: string[]; }>; /** Background Sync tag used by the offline mutation queue. */ backgroundSyncTag?: string; } export type DevToolbarPosition = "bottom-center" | "bottom-left" | "bottom-right"; export interface DevToolbarConfig { enabled?: boolean; position?: DevToolbarPosition; defaultOpen?: boolean; keyboardShortcut?: string; scanOnNavigation?: boolean; scanOnHmr?: boolean; openEditor?: boolean; editor?: string; rules?: Partial>; severity?: Partial>; ignoredRules?: string[]; ignoredPaths?: string[]; slowRequestMs?: number; largeImageBytes?: number; veryLargeImageBytes?: number; } export interface ExperimentalConfig { serverComponents?: boolean; streaming?: boolean; partialHydration?: boolean; typedRpc?: boolean; pluginTransforms?: boolean; [feature: string]: boolean | undefined; } export interface PerformanceConfig { budgets?: PerformanceBudgets; /** `warn` reports budget violations; `error` fails production builds. */ enforcement?: "off" | "warn" | "error"; analyze?: boolean; } export interface ObservabilityConfig { enabled?: boolean; serviceName?: string; serverTiming?: boolean; sampleRate?: number; exporter?: "console" | "otlp" | "none"; endpoint?: string; /** Collect privacy-preserving Core Web Vitals from real browsers. */ webVitals?: boolean; /** Same-origin endpoint receiving Web Vitals. */ webVitalsEndpoint?: string; } export interface TenancyConfig { mode?: "subdomain" | "domain" | "path" | "custom"; required?: boolean; rootDomains?: string[]; pathPrefix?: string; } export interface BuildConfig { cache?: boolean; cacheDir?: string; sourceMaps?: boolean; report?: boolean; adapter?: "bun" | "node" | "static" | "serverless" | "edge" | string; } export interface NavigationConfig { /** * `auto` (default) omits navigation JavaScript from fully static pages and * progressively enhances routes that already need browser behavior. * `client` always enhances same-origin links with in-place page swaps. * `document` keeps normal browser navigation so every route performs a fresh * server-rendered document request. */ mode?: "auto" | "client" | "document"; } export interface ImportsConfig { mode?: "legacy" | "compatible" | "explicit"; autoImport?: boolean; aliases?: Record; } export interface TypesConfig { strict?: boolean; noImplicitAny?: boolean; strictNullChecks?: boolean; checkTemplates?: boolean; checkComponentProps?: boolean; generateDeclarations?: boolean; globalTypes?: string; } export interface FunctionsConfig { legacyDefaultRuntime?: "current" | "client" | "server" | "shared"; } export interface StoresConfig { strictMutations?: boolean; persistence?: boolean; } export interface CompatibilityConfig { legacyEmit?: boolean; legacyEventProps?: boolean; legacyComponentDiscovery?: boolean; stringLayouts?: boolean; } export interface AppConfig extends CompatibilityPolicy { /** Ordered reusable configuration layers; the application always has final precedence. */ extends?: string | string[]; /** Compiler/dev/build plugins, resolved in deterministic pre/normal/post order. */ plugins?: PluginInput; /** Optional least-privilege enforcement for automatically discovered packages. */ pluginPermissions?: { enforce?: boolean; grants?: Record; }; /** WRN v0.6 explicit import and compatibility resolution. */ imports?: ImportsConfig; /** TypeScript-backed .wrn type checking and declaration generation. */ types?: TypesConfig; /** Legacy function runtime behavior for existing applications. */ functions?: FunctionsConfig; /** Typed global/page store behavior. */ stores?: StoresConfig; /** Temporary v0.5 syntax compatibility switches. */ compatibility?: CompatibilityConfig; /** Opt-in APIs that are not yet covered by stable compatibility guarantees. */ experimental?: ExperimentalConfig; /** Route and asset budgets plus build analyzer behavior. */ performance?: PerformanceConfig; /** Request tracing, Server-Timing, and exporter configuration. */ observability?: ObservabilityConfig; /** First-class tenant resolution defaults. */ tenancy?: TenancyConfig; /** Build cache, source map, report, and deployment adapter settings. */ build?: BuildConfig; /** Page navigation strategy. Defaults to progressive client navigation. */ navigation?: NavigationConfig; /** Development-only page diagnostics toolbar. Enabled by default in development. */ devToolbar?: boolean | DevToolbarConfig; /** Raw HTML appended to every page's `` (e.g. CDN stylesheet links). */ head?: string | string[]; /** Global SEO defaults merged with every page's exported `meta`. */ seo?: SeoConfig; /** Framework security headers and optional CORS policy. */ security?: SecurityConfig; styles?: StylesConfig; /** Capacitor/native shell defaults and mobile-only page rendering. */ mobile?: MobileConfig; /** Progressive Web App metadata. Enabled by default unless set to false. */ pwa?: PwaConfig | false; /** * Fonts. Declare Google Fonts (subsetted + preconnect + `font-display`) and/or * self-hosted `@font-face` (with preload), and set `sans`/`mono`/`serif` family * stacks. Google Fonts auto-extend the CSP so they load under the default policy. */ fonts?: FontConfig; /** Design-token themes (deep-merged over the built-in light/dark). */ theme?: ThemeConfig; /** Shared client cookie policy used by theme, accent, language, and window.wrnCookies. */ cookies?: BrowserCookiesConfig; /** i18n: default language + supported locales (strings live in app/locales/*.json). */ i18n?: { default?: string; locales?: string[]; labels?: Record; fallbacks?: Record; direction?: Record; cookie?: { name?: string; maxAge?: number; domain?: string; path?: string; sameSite?: "Strict" | "Lax" | "None"; secure?: boolean; }; strict?: boolean; }; /** Default database connection (driver + url); reached with `getDb()`. */ db?: { driver: "sqlite" | "postgres" | "mysql" | "mongo"; url: string }; /** * File-upload storage. Declare named stores (local dir or S3-compatible), * upload with `handleUpload`/`upload` from `@wrnexus/uploader`, and serve * files back. Each store is `access: "public" | "private"`. */ storage?: StorageConfig; /** * Additional named databases, reached with `getDb("")`. Each has its own * migrations/queries under `app/db//`. Connect to as many as you like and * read/write to any of them per request. * * databases: { analytics: { driver: "postgres", url: "…" } } */ databases?: Record; /** * Realtime scaling. When `scale` is true (or `redisUrl` is set), room * broadcasts are bridged over Redis pub/sub so they reach clients on **every** * app process/instance — realtime that works with multiple running apps. */ realtime?: { scale?: boolean; redisUrl?: string }; /** Default server port. */ port?: number; /** * Named config profiles (dev, prod, uat, test, …). When a profile is active * its overrides are DEEP-MERGED over the base config. Select with * `--profile=` or the `WRNEXUS_PROFILE` env var. */ profiles?: Record>>; } const CONFIG_NAMES = ["wrnexus.config.ts", "wrnexus.config.js", "wrnexus.config.mjs"]; /** * Resolve the active profile name: explicit argument > `WRNEXUS_PROFILE` env var * > a mode-based default ("production" in prod, else "development"). */ export function resolveProfile(options: { explicit?: string; mode?: Mode } = {}): string { const env = (globalThis as { process?: { env?: Record } }).process ?.env; return ( options.explicit || env?.WRNEXUS_PROFILE || (options.mode === "production" ? "production" : "development") ); } function isPlainObject(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); } /** Deep-merge `override` onto `base` (objects merge; arrays/scalars replace). */ function deepMerge(base: T, override: unknown, path = ""): T { if (!isPlainObject(base) || !isPlainObject(override)) return (override ?? base) as T; const out: Record = { ...base }; for (const [key, value] of Object.entries(override)) { const currentPath = path ? `${path}.${key}` : key; if ( key in out && ["plugins", "head"].includes(currentPath) && (Array.isArray(out[key]) || Array.isArray(value)) ) { const before = Array.isArray(out[key]) ? out[key] : out[key] == null ? [] : [out[key]]; const after = Array.isArray(value) ? value : value == null ? [] : [value]; out[key] = [...before, ...after]; } else { out[key] = key in out ? deepMerge(out[key], value, currentPath) : value; } } return out as T; } const LAYER_CONFIG_NAMES = [ "wrnexus.layer.ts", "wrnexus.layer.js", "wrnexus.layer.mjs", ...CONFIG_NAMES, ]; function layerSpecifiers(config: AppConfig): string[] { return config.extends ? (Array.isArray(config.extends) ? config.extends : [config.extends]) : []; } function resolveLayerFile(specifier: string, declaringRoot: string, appRoot: string): string { const local = specifier.startsWith(".") || specifier.startsWith("/") || /^[A-Za-z]:[\\/]/.test(specifier); if (local) { const candidate = resolve(declaringRoot, specifier); if (extname(candidate) && existsSync(candidate)) return candidate; for (const name of LAYER_CONFIG_NAMES) { const file = join(candidate, name); if (existsSync(file)) return file; } throw new Error(`WRN-CONFIG-LAYER-NOT-FOUND: ${specifier} from ${declaringRoot}.`); } const require = createRequire(join(appRoot, "package.json")); try { const packageFile = require.resolve(`${specifier}/package.json`); const packageRoot = dirname(packageFile); const pkg = JSON.parse(readFileSync(packageFile, "utf8")) as { wrnexus?: { layer?: string }; }; if (pkg.wrnexus?.layer) { const file = resolve(packageRoot, pkg.wrnexus.layer); if (existsSync(file)) return file; } for (const name of LAYER_CONFIG_NAMES) { const file = join(packageRoot, name); if (existsSync(file)) return file; } } catch (error) { throw new Error(`WRN-CONFIG-LAYER-NOT-FOUND: package ${specifier} from ${appRoot}.`, { cause: error, }); } throw new Error(`WRN-CONFIG-LAYER-ENTRY: ${specifier} has no wrnexus layer entry.`); } async function importConfigFile(file: string): Promise { const mod = (await import(pathToFileURL(file).href)) as { default?: AppConfig }; if (!mod.default || !isPlainObject(mod.default)) { throw new Error(`WRN-CONFIG-LAYER-SHAPE: ${file} must export a configuration object.`); } return mod.default; } export interface ResolvedConfigLayers { config: AppConfig; sources: string[]; } export async function resolveConfigLayers( appRoot: string, application: AppConfig, ): Promise { const sources: string[] = []; const visiting: string[] = []; const resolvedFiles = new Set(); const visit = async (config: AppConfig, declaringRoot: string): Promise => { let merged: AppConfig = {}; for (const specifier of layerSpecifiers(config)) { const file = resolveLayerFile(specifier, declaringRoot, appRoot); if (visiting.includes(file)) { throw new Error(`WRN-CONFIG-LAYER-CYCLE: ${[...visiting, file].join(" -> ")}`); } visiting.push(file); const layer = await importConfigFile(file); const resolvedLayer = await visit(layer, dirname(file)); visiting.pop(); merged = deepMerge(merged, resolvedLayer); if (!resolvedFiles.has(file)) { resolvedFiles.add(file); sources.push(file); } } const own = { ...config }; delete own.extends; return deepMerge(merged, own); }; return { config: await visit(application, appRoot), sources }; } /** Load the raw `wrnexus.config.*` (with the `profiles` map intact), or `{}`. */ export async function loadRawConfig(appRoot: string): Promise { for (const name of CONFIG_NAMES) { const file = join(appRoot, name); if (existsSync(file)) { const mod = (await import(pathToFileURL(file).href)) as { default?: AppConfig }; return mod.default ?? {}; } } return {}; } /** Load `wrnexus.config.*`, applying the active profile's overrides. */ export async function loadAppConfig(appRoot: string, profile?: string): Promise { const active = profile ?? resolveProfile(); // Configuration modules commonly read DATABASE_URL and other settings during // module evaluation. Load the profile cascade before importing the module so // development and production behave consistently. loadEnv(appRoot, active); const raw = await loadRawConfig(appRoot); const { config: base } = await resolveConfigLayers(appRoot, raw); const override = base.profiles?.[active]; const merged: AppConfig = override ? deepMerge(base, override) : { ...base }; delete merged.profiles; delete merged.extends; applyFontCsp(merged); const issues = validateAppConfig(merged); const errors = issues.filter((issue) => issue.severity === "error"); if (errors.length) { throw new Error( `Invalid wrnexus.config: ${errors.map((issue) => `${issue.path}: ${issue.message}`).join("; ")}`, ); } return merged; } /** * Auto-extend the CSP so configured Google Fonts load under the default policy * (their CSS host into `style-src`, the static host into `font-src`, and the * static host into `connect-src` for service-worker fetch interception). No-op when * the app disabled CSP (`security.contentSecurityPolicy: false`) or uses no * Google Fonts. Local (self-hosted) fonts are served from `'self'` and need nothing. */ function applyFontCsp(config: AppConfig): void { const add = fontCspSources(config.fonts); if (!add.style.length && !add.font.length) return; const security = (config.security ??= {}); if (security.contentSecurityPolicy === false) return; const csp = security.contentSecurityPolicy ?? {}; security.contentSecurityPolicy = csp; const dirs = csp.directives ?? {}; csp.directives = dirs; const extend = (name: string, base: string[], adds: string[]) => { const cur = Array.isArray(dirs[name]) ? (dirs[name] as string[]) : base; dirs[name] = Array.from(new Set([...cur, ...adds])); }; if (add.style.length) extend("style-src", ["'self'", "'unsafe-inline'"], add.style); if (add.font.length) { extend("font-src", ["'self'", "data:"], add.font); extend("connect-src", ["'self'", "ws:", "wss:"], add.font); } } /** * Load the `.env` cascade for a profile into `process.env`, WITHOUT clobbering * variables already set in the real environment (which always win). Order, low * → high precedence: `.env` < `.env.` < `.env.local` < `.env..local`. * Returns the variables it loaded. */ export function loadEnv(appRoot: string, profile: string): Record { const proc = (globalThis as { process?: { env: Record } }).process; const env = proc?.env ?? {}; const realKeys = new Set(Object.keys(env)); const loaded: Record = {}; for (const name of [".env", `.env.${profile}`, ".env.local", `.env.${profile}.local`]) { const file = join(appRoot, name); if (!existsSync(file)) continue; for (const [key, value] of Object.entries(parseDotenv(readFileSync(file, "utf8")))) { loaded[key] = value; if (!realKeys.has(key)) env[key] = value; // never override real env } } return loaded; } function parseDotenv(content: string): Record { const out: Record = {}; for (const line of content.split(/\r?\n/)) { const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line); if (!m) continue; let value = m[2]!.trim(); if ( (value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")) ) { const quoted = value.slice(1, -1); value = value[0] === '"' ? quoted.replace(/\\n/g, "\n").replace(/\\t/g, "\t") : quoted; } else { const comment = value.indexOf(" #"); // strip trailing comments on unquoted values if (comment >= 0) value = value.slice(0, comment).trim(); } out[m[1]!] = value; } return out; } export interface ConfigIssue { path: string; severity: "error" | "warning"; message: string; } export function defineConfig(config: AppConfig): AppConfig { return config; } export function validateAppConfig(config: AppConfig): ConfigIssue[] { const issues: ConfigIssue[] = []; if (config.compatibilityDate !== undefined && !isCompatibilityDate(config.compatibilityDate)) { issues.push({ path: "compatibilityDate", severity: "error", message: "must be a real ISO calendar date in YYYY-MM-DD format", }); } if ( config.frameworkBehaviour !== undefined && (!Number.isInteger(config.frameworkBehaviour) || config.frameworkBehaviour < 1) ) { issues.push({ path: "frameworkBehaviour", severity: "error", message: "must be a positive integer", }); } const compatibility = resolveCompatibility(config); if (compatibility.future) { issues.push({ path: "compatibilityDate", severity: "error", message: "targets framework behavior newer than this version supports", }); } const sampleRate = config.observability?.sampleRate; if ( sampleRate !== undefined && (!Number.isFinite(sampleRate) || sampleRate < 0 || sampleRate > 1) ) { issues.push({ path: "observability.sampleRate", severity: "error", message: "must be between 0 and 1", }); } if (config.observability?.exporter === "otlp") { const endpoint = config.observability.endpoint; const validEndpoint = (() => { try { return Boolean(endpoint && ["http:", "https:"].includes(new URL(endpoint).protocol)); } catch { return false; } })(); if (!validEndpoint) { issues.push({ path: "observability.endpoint", severity: "error", message: "must be an absolute HTTP(S) URL when exporter is otlp", }); } } if (config.observability?.serviceName !== undefined && !config.observability.serviceName.trim()) { issues.push({ path: "observability.serviceName", severity: "error", message: "must not be empty", }); } const budgets = config.performance?.budgets; if (budgets) { for (const [name, value] of Object.entries(budgets)) { if (value !== undefined && (!Number.isFinite(value) || value < 0)) { issues.push({ path: `performance.budgets.${name}`, severity: "error", message: "must be a non-negative finite number", }); } } } if (config.tenancy?.mode === "path" && !config.tenancy.pathPrefix) { issues.push({ path: "tenancy.pathPrefix", severity: "warning", message: "is recommended when tenancy.mode is path", }); } return issues; } export interface ExplainedConfig { profile: string; config: AppConfig; issues: ConfigIssue[]; sources: string[]; } export async function explainAppConfig( appRoot: string, profile?: string, ): Promise { const active = profile ?? resolveProfile(); const config = await loadAppConfig(appRoot, active); const appSources = CONFIG_NAMES.filter((name) => existsSync(join(appRoot, name))); const raw = await loadRawConfig(appRoot); const layerSources = (await resolveConfigLayers(appRoot, raw)).sources; const envSources = [".env", ".env.local", `.env.${active}`, `.env.${active}.local`].filter( (name) => existsSync(join(appRoot, name)), ); return { profile: active, config, issues: validateAppConfig(config), sources: [...layerSources, ...appSources, ...envSources], }; } /** Flatten a head config into a single HTML string. */ export function headToString(head?: string | string[]): string { if (!head) return ""; return Array.isArray(head) ? head.filter(Boolean).join("\n ") : head; }