Files
WRNexusJS/packages/styles/src/config.ts
T
Clintchiz 5de792f359
Quality / quality (ubuntu-latest) (push) Failing after 9m46s
Quality / quality (windows-latest) (push) Canceled after 0s
feat(config): add shared browser cookie policy
2026-08-12 21:53:07 +05:30

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;
}