Files
WRNexusJS/packages/dev-server/src/prod.ts
T
Clintchiz 2c960fc1dc
Quality / quality (ubuntu-latest) (push) Failing after 9m49s
Quality / quality (windows-latest) (push) Canceled after 0s
refactor: migrate legacy wire namespace to wrn
2026-08-12 18:51:15 +05:30

598 lines
24 KiB
TypeScript

/**
* @wrnexus/dev-server/prod — the production server (Point 4).
*
* Unlike dev, there is NO filesystem scan and NO on-the-fly bundling at runtime.
* `wrnexus build` generates an entry that statically imports every route and
* component module and hands them here as a manifest. We rebuild the (cheap)
* route-matching tables from the raw patterns and run the exact same request
* runtime as dev. Normal preview/deploy output has no live-reload client; the
* supervised `dev --production-runtime` mode can explicitly enable it.
*/
import { join } from "node:path";
import type { Middleware, Mode, SecurityConfig, SeoConfig } from "@wrnexus/core";
import {
compileRoutePattern,
matchRoute,
sortRoutes,
type Route,
type Router,
} from "@wrnexus/router";
import {
getActionRuntime,
getComponentControllerRuntime,
getReactiveRuntime,
getNavRuntime,
getRealtimeRuntime,
} from "@wrnexus/csr";
import {
loadEnv,
resolveProfile,
type ResolvedTheme,
type MobileConfig,
type PwaConfig,
type ObservabilityConfig,
type TenancyConfig,
type NavigationConfig,
} from "@wrnexus/styles";
import { VALIDATE_RUNTIME } from "@wrnexus/validation";
import { I18N_RUNTIME, type ResolvedI18n } from "@wrnexus/i18n";
import { setDb, registerLazyDb, getDb, hasDb, migrate } from "@wrnexus/db";
import { connectFromConfig } from "@wrnexus/db/connect";
import { hasAuthzCatalog, mergeCatalogs, setAuthzCatalog, type AuthzModule } from "@wrnexus/authz";
import {
configureStorage,
serveStoredFile,
UPLOAD_RUNTIME,
UPLOAD_JS_HREF,
UPLOADS_PREFIX,
type StorageConfig,
} from "@wrnexus/uploader";
import { realtimeBusFromConfig } from "./realtime-bus.ts";
import { createHandlers, type AssetServer, type WsData } from "./runtime.ts";
import { servePublicAsset } from "./public.ts";
import type { ClientRuntimeDefinition } from "@wrnexus/plugin";
import { HmrHub } from "./hmr.ts";
type RouteModule = Record<string, unknown>;
export interface ManifestRoute {
/** URL pattern, e.g. `/users/[id]`. */
raw: string;
/** The statically-imported route module. */
mod: RouteModule;
/** Body shell precomputed by `wrnexus build` for a partial-static page. */
staticShell?: string;
}
export interface ProdManifest {
pages: ManifestRoute[];
api: ManifestRoute[];
realtime: ManifestRoute[];
middleware: Middleware[];
/** Server-rendered components, statically imported and keyed by name. */
components: { name: string; mod: RouteModule }[];
/** Named page layouts (from app/layouts/*.wrn). */
layouts: { name: string; mod: RouteModule }[];
/** RPC service implementations (from app/services/*.ts). */
services?: { name: string; mod: RouteModule }[];
}
export interface ProductionPluginAsset {
path: string;
contentType: string;
immutable?: boolean;
}
export interface ProdOptions {
/** Absolute path to the pre-built global stylesheet, if any. */
stylesPath?: string;
/** Small production stylesheet inlined into the document head. */
inlineStyles?: string;
/** `stylesPath` contains theme + UI + app CSS in cascade order. */
stylesIncludeFramework?: boolean;
/** `stylesPath` already contains the shared WrNexus UI stylesheet. */
stylesIncludeUi?: boolean;
/** Absolute path to the pre-built reactive runtime. */
reactivePath?: string;
/** Absolute path to the on-demand component controller runtime. */
controllersPath?: string;
/** Absolute directory containing bundled per-WRN browser modules. */
clientModulesDir?: string;
/** Absolute path to the pre-built theme stylesheet (`theme.css`). */
themePath?: string;
/** Pre-built active theme/accent stylesheets, loaded on demand. */
themeAssetsDir?: string;
/** Absolute path to the pre-built theme runtime (`theme.js`). */
themeJsPath?: string;
/** Resolved theme config: enables `<html data-theme>` + `theme.css` link. */
theme?: ResolvedTheme;
/** Absolute path to the pre-built WrNexus UI stylesheet (`ui.css`). */
uiCssPath?: string;
/** Combined production theme + WrNexus UI stylesheet. */
frameworkCssPath?: string;
/** Pre-built `window.__wrnSchemas = {...}` script for client validation. */
schemasJs?: string;
/**
* Authorization declarations discovered by `wrnexus build` from
* `app/authz/*.ts`, statically imported into the generated entry (the
* catalog holds policy FUNCTIONS, so — unlike `schemasJs` — it cannot be
* JSON-serialised). `module` is `undefined` for a file with no default
* export. In the NORMAL generated-entry build, the catalog is already set
* by the generated `.authz-setup.ts` module before this ever runs (see
* `applyAuthzManifestEarly` below); `createProductionHandlers` merges this
* same list again as an idempotent second pass — with its warnings — so a
* caller that bypasses the generated entry and calls it directly still gets
* a correctly merged catalog.
*/
authz?: AuthzManifestEntry[];
/** Resolved i18n bundle (default lang + locale messages). */
i18n?: ResolvedI18n;
/** Default database connection (driver + url); enables `getDb()`. */
db?: { driver: string; url: string };
/** Named databases, reached with `getDb("<name>")`. */
databases?: Record<string, { driver: string; url: string }>;
/**
* Absolute path to the default db's migrations bundled into the build
* (`dist/migrations`). When set, they are applied on startup — like dev.
*/
migrationsDir?: string;
/** Bundled migrations dirs for named dbs (name → `dist/db/<name>/migrations`). */
databaseMigrationDirs?: Record<string, string>;
/**
* Auto-apply bundled migrations on server startup (default: true). Set false
* for deploys that migrate in a separate release step (e.g. multiple instances
* behind a load balancer, where you migrate once before rolling out).
*/
autoMigrate?: boolean;
/** Realtime scaling: bridge room broadcasts over Redis across app processes. */
realtime?: { scale?: boolean; redisUrl?: string };
/** File-upload storage: named stores (local dir / S3). Local dirs resolve against cwd. */
storage?: StorageConfig;
/** Cache-busting version appended to framework asset URLs. */
assetVersion?: string;
/** Package browser runtimes already emitted by the production build. */
clientRuntimes?: ClientRuntimeDefinition[];
/** Public URL to emitted package asset metadata. */
pluginAssets?: Record<string, ProductionPluginAsset>;
/** Absolute path to copied public assets, if any. */
publicDir?: string;
/** Raw HTML appended to every page head. */
head?: string;
/** Global SEO defaults. */
seo?: SeoConfig;
mobile?: MobileConfig;
pwa?: PwaConfig | false;
/** Framework security headers and CORS policy. */
security?: SecurityConfig;
/** Built-in request tracing and Server-Timing policy. */
observability?: ObservabilityConfig;
/** Built-in tenant identity resolution. */
tenancy?: TenancyConfig;
/** Page navigation strategy. */
navigation?: NavigationConfig;
port?: number;
hostname?: string;
maxBodyBytes?: number;
/** Keep-alive idle timeout in seconds. Defaults to 30. */
idleTimeout?: number;
/** Allow multiple Bun workers to share the listening port. */
reusePort?: boolean;
/** Enable only for the CLI's supervised exact-production development mode. */
developmentRuntime?: boolean;
}
const MODE: Mode = "production";
const JS_HEADERS = {
"content-type": "text/javascript; charset=utf-8",
"cache-control": "public, max-age=31536000, immutable",
};
const CSS_HEADERS = {
"content-type": "text/css; charset=utf-8",
"cache-control": "public, max-age=31536000, immutable",
};
function resolvePort(explicit?: number): number {
if (typeof explicit === "number" && Number.isFinite(explicit)) return explicit;
const envPort = process.env.PORT;
if (!envPort) return 3000;
const parsed = Number(envPort);
return Number.isFinite(parsed) ? parsed : 3000;
}
/** Resolve the production bind address. Gateway-managed app servers override
* this with loopback so only the gateway is externally reachable. */
export function resolveProductionHostname(
explicit?: string,
environmentHostname = process.env.WRNEXUS_HOSTNAME,
): string {
return environmentHostname?.trim() || explicit || "0.0.0.0";
}
/** One `app/authz/*.ts` declaration as passed through `ProdOptions.authz`. */
export interface AuthzManifestEntry {
source: string;
/** Undefined when the declaration file has no default export. */
module?: AuthzModule;
}
function resolveAuthzSources(
entries: AuthzManifestEntry[],
): { source: string; module: AuthzModule }[] {
return entries.flatMap((entry) =>
entry.module ? [{ source: entry.source, module: entry.module }] : [],
);
}
/**
* Merge + `setAuthzCatalog` as EARLY as possible, deliberately silently (no
* missing-default-export warnings). Called ONLY from the generated
* `.authz-setup.ts` module that `wrnexus build` imports FIRST in the
* production entry — before any other static import, including app
* middleware — so that a middleware module reading `getAuthzCatalog()` at its
* own module scope (the same eager shape `authzMiddleware({ catalog, ... })`
* itself requires) sees a populated catalog. `createProductionHandlers` below
* performs the exact same merge again, WITH its warnings, as the canonical,
* always-warns second pass — this function stays silent specifically so the
* normal boot path does not print the same "no default export" warning
* twice. A genuine conflict still throws here (via `mergeCatalogs`), which
* fails the boot at import time — before the entry body, and thus
* `createProductionHandlers`, ever runs.
*/
export function applyAuthzManifestEarly(entries: AuthzManifestEntry[]): void {
setAuthzCatalog(mergeCatalogs(resolveAuthzSources(entries)));
}
/** Build the route-matching tables + a module map from the manifest. */
function buildProdRouter(manifest: ProdManifest): {
router: Router;
modules: Map<string, RouteModule>;
} {
const modules = new Map<string, RouteModule>();
const toRoutes = (entries: ManifestRoute[]): Route[] => {
const routes = entries.map((e): Route => {
const { regex, paramNames } = compileRoutePattern(e.raw);
// Use the raw pattern as a stable module key.
modules.set(
e.raw,
e.staticShell === undefined ? e.mod : { ...e.mod, __wrnexusStaticShell: e.staticShell },
);
return { raw: e.raw, file: e.raw, regex, paramNames };
});
return sortRoutes(routes);
};
const pages = toRoutes(manifest.pages);
const api = toRoutes(manifest.api);
const realtime = toRoutes(manifest.realtime);
const optimizedMatcher = (routes: Route[]) => {
const exact = new Map<string, Route>();
const dynamic: Route[] = [];
for (const route of routes) {
if (route.paramNames.length === 0) exact.set(route.raw, route);
else dynamic.push(route);
}
return (pathname: string) => {
const route = exact.get(pathname);
return route ? { route, params: {} } : matchRoute(dynamic, pathname);
};
};
// Components are keyed by name; the runtime resolves them via loadModule(name).
for (const c of manifest.components) modules.set(c.name, c.mod);
// Layouts share the module map under a `layout:` prefix (no name collisions).
for (const l of manifest.layouts) modules.set(`layout:${l.name}`, l.mod);
for (const service of manifest.services ?? [])
modules.set(`service:${service.name}`, service.mod);
const router: Router = {
pages,
api,
realtime,
middlewareFiles: [],
components: manifest.components.map((c) => ({ name: c.name, file: c.name })),
layouts: manifest.layouts.map((l) => ({ name: l.name, file: `layout:${l.name}` })),
stores: [],
schemas: [], // descriptors are pre-baked into schemasJs; not needed at runtime
authz: [], // authz declarations are not needed at runtime in production
services: (manifest.services ?? []).map((service) => ({
name: service.name,
file: `service:${service.name}`,
})),
matchPage: optimizedMatcher(pages),
matchApi: optimizedMatcher(api),
matchRealtime: optimizedMatcher(realtime),
};
return { router, modules };
}
const productionFileCache = new Map<string, ReturnType<typeof Bun.file>>();
const missingProductionFiles = new Set<string>();
/** Serve a pre-built asset file from disk, caching stable build file handles. */
async function serveFile(path: string | undefined, headers: Record<string, string>) {
if (!path) return new Response("Not Found", { status: 404 });
if (missingProductionFiles.has(path)) return new Response("Not Found", { status: 404 });
let file = productionFileCache.get(path);
if (!file) {
file = Bun.file(path);
if (!(await file.exists())) {
missingProductionFiles.add(path);
return new Response("Not Found", { status: 404 });
}
productionFileCache.set(path, file);
}
return new Response(file, { headers });
}
/** Production asset server: pre-built files from disk, reactive runtime inlined. */
function createProdAssetServer(opts: ProdOptions): AssetServer {
return {
async serve(pathname: string): Promise<Response | null> {
if (pathname.startsWith("/__wrnexus/client/")) {
const name = pathname.slice("/__wrnexus/client/".length);
if (!opts.clientModulesDir || !/^[A-Za-z0-9._-]+\.mjs$/.test(name)) {
return new Response("Not Found", { status: 404 });
}
return serveFile(join(opts.clientModulesDir, name), JS_HEADERS);
}
if (pathname === "/__wrnexus/reactive.js") {
if (opts.reactivePath) {
const file = Bun.file(opts.reactivePath);
if (await file.exists()) return new Response(file, { headers: JS_HEADERS });
}
return new Response(getReactiveRuntime(), { headers: JS_HEADERS });
}
if (pathname === "/__wrnexus/controllers.js") {
if (opts.controllersPath) return serveFile(opts.controllersPath, JS_HEADERS);
return new Response(getComponentControllerRuntime(), { headers: JS_HEADERS });
}
if (pathname === "/__wrnexus/nav.js")
return new Response(getNavRuntime(), { headers: JS_HEADERS });
if (pathname === "/__wrnexus/realtime.js")
return new Response(getRealtimeRuntime(), { headers: JS_HEADERS });
if (pathname === "/__wrnexus/actions.js")
return new Response(getActionRuntime(), { headers: JS_HEADERS });
if (pathname === "/__wrnexus/validate.js")
return new Response(VALIDATE_RUNTIME, { headers: JS_HEADERS });
if (pathname === "/__wrnexus/i18n.js")
return new Response(I18N_RUNTIME, { headers: JS_HEADERS });
if (pathname === UPLOAD_JS_HREF) return new Response(UPLOAD_RUNTIME, { headers: JS_HEADERS });
if (pathname.startsWith(UPLOADS_PREFIX)) {
return (await serveStoredFile(pathname)) ?? new Response("Not Found", { status: 404 });
}
if (pathname === "/__wrnexus/schemas.js") {
return new Response(opts.schemasJs ?? "window.__wrnSchemas={};", { headers: JS_HEADERS });
}
if (pathname === "/__wrnexus/theme.css") return serveFile(opts.themePath, CSS_HEADERS);
const activeThemeMatch = /^\/__wrnexus\/theme\/([^/]+)\/([^/]+)\.css$/.exec(pathname);
if (activeThemeMatch && opts.theme && opts.themeAssetsDir) {
try {
const themeName = decodeURIComponent(activeThemeMatch[1]!);
const accentPart = decodeURIComponent(activeThemeMatch[2]!);
if (!opts.theme.names.includes(themeName))
return new Response("Not Found", { status: 404 });
if (accentPart !== "_" && !opts.theme.accentNames.some((name) => name === accentPart)) {
return new Response("Not Found", { status: 404 });
}
return serveFile(
join(
opts.themeAssetsDir,
encodeURIComponent(themeName),
`${accentPart === "_" ? "_" : encodeURIComponent(accentPart)}.css`,
),
CSS_HEADERS,
);
} catch {
return new Response("Not Found", { status: 404 });
}
}
if (pathname === "/__wrnexus/theme.js") return serveFile(opts.themeJsPath, JS_HEADERS);
if (pathname === "/__wrnexus/ui.css") return serveFile(opts.uiCssPath, CSS_HEADERS);
if (pathname === "/__wrnexus/framework.css")
return serveFile(opts.frameworkCssPath, CSS_HEADERS);
if (pathname === "/__wrnexus/styles.css") return serveFile(opts.stylesPath, CSS_HEADERS);
const pluginAsset = opts.pluginAssets?.[pathname];
if (pluginAsset) {
return serveFile(pluginAsset.path, {
"content-type": pluginAsset.contentType,
"cache-control":
pluginAsset.immutable === false ? "no-cache" : "public, max-age=31536000, immutable",
"x-content-type-options": "nosniff",
});
}
return servePublicAsset(opts.publicDir, pathname, MODE);
},
};
}
/**
* Build the portable request handler from a precompiled manifest — a
* WinterCG-style `fetch(request) => Response` plus the websocket handlers, with
* NO server bound. This is the deployment-adapter seam: `createProductionServer`
* wraps it in `Bun.serve`, `serveNode` bridges it onto `node:http`, and edge or
* serverless targets can call `fetch` directly.
*/
export function createProductionHandlers(
manifest: ProdManifest,
opts: ProdOptions,
): ReturnType<typeof createHandlers> {
const { router, modules } = buildProdRouter(manifest);
const assets = createProdAssetServer(opts);
// Configure the default + named databases. Migrations must already be applied
// (`wrnexus db migrate [--db=<name>]` against the production databases).
if (opts.db) {
try {
setDb(connectFromConfig(opts.db));
} catch (err) {
console.warn("[wrnexus] database setup failed:", err instanceof Error ? err.message : err);
}
}
for (const [name, cfg] of Object.entries(opts.databases ?? {})) {
registerLazyDb(name, () => connectFromConfig(cfg));
}
// File-upload storage. Relative local dirs resolve against the deployment cwd
// (NOT dist/, which is rebuilt) so uploads persist across deploys.
configureStorage(opts.storage, process.cwd());
// Authorization: merge the build's statically-imported app/authz/*.ts
// declarations into the process-wide catalog BEFORE the handlers (and thus
// any request) exist, so a conflicting pair of declarations fails the boot
// loudly instead of surfacing on the first request. This runs for every
// deployment adapter that calls createProductionHandlers, not only the
// Bun.serve path in createProductionServer below. The app still registers
// authzMiddleware itself with its own store; this only makes the merged
// catalog reachable. No declarations -> an empty catalog, no error.
//
// In the NORMAL generated-entry build, this is a deliberately redundant
// SECOND pass: the generated `.authz-setup.ts` module already ran this
// exact merge (silently, via applyAuthzManifestEarly above) before this
// function was ever called, specifically so a middleware module that reads
// getAuthzCatalog() at its own module scope sees a populated catalog — this
// function's body runs too late for that (it is reached only once every
// OTHER static import, including middleware, has already evaluated).
//
// The merge+validation of opts.authz always runs (a genuine conflict must
// still fail the boot loudly, no matter which pass discovers it). But
// setAuthzCatalog is only called when this pass actually has something to
// contribute, OR when nothing has been set yet: client.ts documents an
// escape hatch where a direct caller of createProductionHandlers may call
// setAuthzCatalog(catalog) itself before importing anything that reads it,
// specifically for a custom entry that never ran the generated
// `.authz-setup.ts` pass. Calling setAuthzCatalog unconditionally here would
// clobber that caller's catalog with an empty one whenever opts.authz is
// omitted — silently deleting every permission the app declared.
for (const missing of (opts.authz ?? []).filter((entry) => !entry.module)) {
console.warn(`[wrnexus] authz declaration ${missing.source} has no default export; skipping.`);
}
const mergedAuthzCatalog = mergeCatalogs(resolveAuthzSources(opts.authz ?? []));
if ((opts.authz?.length ?? 0) > 0 || !hasAuthzCatalog()) {
setAuthzCatalog(mergedAuthzCatalog);
}
// Middleware is already an ordered array of functions.
const getMiddleware = async (): Promise<Middleware[]> => manifest.middleware;
// In prod, modules are pre-imported; "loading" is a map lookup.
const loadModule = async (key: string): Promise<RouteModule> => {
const mod = modules.get(key);
if (!mod) throw new Error(`No module registered for route ${key}`);
return mod;
};
const productionHmr = opts.developmentRuntime === true;
const handlers = createHandlers({
mode: MODE,
hmr: productionHmr,
hub: productionHmr ? new HmrHub() : undefined,
router,
loadModule,
getMiddleware,
assets,
hasStyles: !!opts.stylesPath,
hasUi: !!opts.uiCssPath,
hasFrameworkStyles: !!opts.frameworkCssPath,
theme: opts.theme,
i18n: opts.i18n,
inlineStyles: opts.inlineStyles,
stylesIncludeFramework: opts.stylesIncludeFramework,
stylesIncludeUi: opts.stylesIncludeUi,
assetVersion: opts.assetVersion,
clientRuntimes: opts.clientRuntimes,
head: opts.head,
seo: opts.seo,
mobile: opts.mobile,
pwa: opts.pwa,
security: opts.security,
observability: opts.observability,
tenancy: opts.tenancy,
navigation: opts.navigation,
maxBodyBytes: opts.maxBodyBytes,
realtimeBus: realtimeBusFromConfig(opts.realtime),
});
return handlers;
}
/**
* Apply migrations bundled into the build before the server accepts traffic, so
* a fresh deploy always runs on the latest schema — exactly like the dev server
* auto-migrates on startup. Applied migrations are tracked in `_wrn_migrations`,
* so this is idempotent and safe to run on every boot. Opt out with
* `autoMigrate: false` (e.g. multi-instance deploys that migrate in a release
* step). A failed migration is logged but does not crash the server: each
* migration runs in a transaction, so the DB is left at the last good state.
*/
async function runStartupMigrations(opts: ProdOptions): Promise<void> {
if (opts.autoMigrate === false) return;
const targets: { name?: string; dir: string }[] = [];
if (opts.db && opts.migrationsDir) targets.push({ dir: opts.migrationsDir });
for (const [name, dir] of Object.entries(opts.databaseMigrationDirs ?? {})) {
targets.push({ name, dir });
}
for (const { name, dir } of targets) {
const label = name ? ` (db: ${name})` : "";
if (!hasDb(name)) continue;
try {
const applied = await migrate(getDb(name), dir);
if (applied.length) {
console.log(
`WrNexus: applied ${applied.length} migration(s)${label}: ${applied.join(", ")}`,
);
}
} catch (err) {
console.error(
`WrNexus: migration failed${label} —`,
err instanceof Error ? err.message : err,
);
}
}
}
/** Start the production server on Bun from a precompiled manifest. */
export async function createProductionServer(manifest: ProdManifest, opts: ProdOptions) {
// Load the deployment's .env cascade for the active profile (real env wins),
// so runtime secrets are available even though config was baked at build time.
loadEnv(process.cwd(), resolveProfile({ mode: "production" }));
const handlers = createProductionHandlers(manifest, opts);
// Bring the schema up to date before listening (opt out with autoMigrate:false).
await runStartupMigrations(opts);
const server = Bun.serve<WsData>({
port: resolvePort(opts.port),
hostname: resolveProductionHostname(opts.hostname),
development: false,
maxRequestBodySize: opts.maxBodyBytes ?? 10 * 1024 * 1024,
idleTimeout: opts.idleTimeout ?? 30,
reusePort: opts.reusePort ?? false,
fetch: handlers.fetch,
websocket: handlers.websocket,
});
// Graceful shutdown: stop accepting connections, then exit.
let shuttingDown = false;
const shutdown = () => {
if (shuttingDown) return;
shuttingDown = true;
console.log("WrNexus: shutting down…");
server.stop();
process.exit(0);
};
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
console.log(`WrNexus (production) listening on http://${server.hostname}:${server.port}`);
return server;
}