first commit

This commit is contained in:
2026-07-12 15:55:18 +05:30
commit ee98026cc5
404 changed files with 44522 additions and 0 deletions
+291
View File
@@ -0,0 +1,291 @@
/**
* 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 { join } from "node:path";
import { pathToFileURL } from "node:url";
import type { SecurityConfig, SeoConfig } from "@wrnexus/core";
import type { StorageConfig } from "@wrnexus/uploader";
import type { ThemeConfig } from "./theme.ts";
import type { FontConfig } from "./fonts.ts";
import { fontCspSources } from "./fonts.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;
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;
/**
* 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>;
}
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;
}
export interface AppConfig {
/** 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;
/** i18n: default language + supported locales (strings live in app/locales/*.json). */
i18n?: { default?: string; locales?: string[] };
/** 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): 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)) {
out[key] = key in out ? deepMerge(out[key], value) : value;
}
return out as T;
}
/** 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 base = await loadRawConfig(appRoot);
const active = profile ?? resolveProfile();
const override = base.profiles?.[active];
const merged: AppConfig = override ? deepMerge(base, override) : { ...base };
delete merged.profiles;
applyFontCsp(merged);
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`). 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);
}
/**
* 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;
}
/** 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;
}