716 lines
25 KiB
TypeScript
716 lines
25 KiB
TypeScript
/**
|
|
* App configuration loader (`wrnexus.config.ts`).
|
|
*
|
|
* The config is optional. It lets an app inject arbitrary `<head>` 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<string>;
|
|
/** 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<string, unknown>;
|
|
/** Advanced CapacitorConfig fields merged into generated capacitor.config.ts. */
|
|
capacitor?: Record<string, unknown>;
|
|
}
|
|
|
|
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<Record<string, boolean>>;
|
|
severity?: Partial<Record<string, "error" | "warning" | "info" | "suggestion">>;
|
|
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<string, string>;
|
|
}
|
|
|
|
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<string, PluginPermission[]>;
|
|
};
|
|
/** 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 `<head>` (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<string, string>;
|
|
fallbacks?: Record<string, string[]>;
|
|
direction?: Record<string, "ltr" | "rtl">;
|
|
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("<name>")`. Each has its own
|
|
* migrations/queries under `app/db/<name>/`. Connect to as many as you like and
|
|
* read/write to any of them per request.
|
|
*
|
|
* databases: { analytics: { driver: "postgres", url: "…" } }
|
|
*/
|
|
databases?: Record<string, { driver: "sqlite" | "postgres" | "mysql" | "mongo"; url: string }>;
|
|
/**
|
|
* 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=<name>` or the `WRNEXUS_PROFILE` env var.
|
|
*/
|
|
profiles?: Record<string, Partial<Omit<AppConfig, "profiles">>>;
|
|
}
|
|
|
|
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<string, string | undefined> } }).process
|
|
?.env;
|
|
return (
|
|
options.explicit ||
|
|
env?.WRNEXUS_PROFILE ||
|
|
(options.mode === "production" ? "production" : "development")
|
|
);
|
|
}
|
|
|
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
}
|
|
|
|
/** Deep-merge `override` onto `base` (objects merge; arrays/scalars replace). */
|
|
function deepMerge<T>(base: T, override: unknown, path = ""): T {
|
|
if (!isPlainObject(base) || !isPlainObject(override)) return (override ?? base) as T;
|
|
const out: Record<string, unknown> = { ...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<AppConfig> {
|
|
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<ResolvedConfigLayers> {
|
|
const sources: string[] = [];
|
|
const visiting: string[] = [];
|
|
const resolvedFiles = new Set<string>();
|
|
const visit = async (config: AppConfig, declaringRoot: string): Promise<AppConfig> => {
|
|
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<AppConfig> {
|
|
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<AppConfig> {
|
|
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.<profile>` < `.env.local` < `.env.<profile>.local`.
|
|
* Returns the variables it loaded.
|
|
*/
|
|
export function loadEnv(appRoot: string, profile: string): Record<string, string> {
|
|
const proc = (globalThis as { process?: { env: Record<string, string | undefined> } }).process;
|
|
const env = proc?.env ?? {};
|
|
const realKeys = new Set(Object.keys(env));
|
|
const loaded: Record<string, string> = {};
|
|
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<string, string> {
|
|
const out: Record<string, string> = {};
|
|
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<ExplainedConfig> {
|
|
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;
|
|
}
|