Files
WRNexusJS/packages/dev-server/src/prod.ts
T
2026-07-22 17:29:08 +05:30

395 lines
15 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 — just with production error pages and no live-reload client.
*/
import type { Middleware, Mode, SecurityConfig, SeoConfig } from "@wrnexus/core";
import {
compileRoutePattern,
matchRoute,
sortRoutes,
type Route,
type Router,
} from "@wrnexus/router";
import { getReactiveRuntime, getNavRuntime, getRealtimeRuntime } from "@wrnexus/csr";
import {
loadEnv,
resolveProfile,
type ResolvedTheme,
type MobileConfig,
type PwaConfig,
type ObservabilityConfig,
type TenancyConfig,
} from "@wrnexus/styles";
import { VALIDATE_RUNTIME } from "@wrnexus/validation";
import { I18N_RUNTIME, type ResolvedI18n } from "@wrnexus/i18n";
import { setDb, registerDb, getDb, hasDb, migrate } from "@wrnexus/db";
import { connectFromConfig } from "@wrnexus/db/connect";
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";
type RouteModule = Record<string, unknown>;
export interface ManifestRoute {
/** URL pattern, e.g. `/users/[id]`. */
raw: string;
/** The statically-imported route module. */
mod: RouteModule;
}
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 }[];
}
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;
/** Absolute path to the pre-built reactive runtime. */
reactivePath?: string;
/** Absolute path to the pre-built theme stylesheet (`theme.css`). */
themePath?: 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 Wire UI stylesheet (`ui.css`). */
uiCssPath?: string;
/** Combined production theme + Wire UI stylesheet. */
frameworkCssPath?: string;
/** Pre-built `window.__wireSchemas = {...}` script for client validation. */
schemasJs?: string;
/** 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;
/** 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;
port?: number;
hostname?: string;
maxBodyBytes?: number;
}
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";
}
/** 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.mod);
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);
// 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);
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}` })),
schemas: [], // descriptors are pre-baked into schemasJs; not needed at runtime
matchPage: (p) => matchRoute(pages, p),
matchApi: (p) => matchRoute(api, p),
matchRealtime: (p) => matchRoute(realtime, p),
};
return { router, modules };
}
/** Serve a pre-built asset file from disk, or 404 if it is absent. */
async function serveFile(path: string | undefined, headers: Record<string, string>) {
if (!path) return new Response("Not Found", { status: 404 });
const file = Bun.file(path);
if (!(await file.exists())) return new Response("Not Found", { status: 404 });
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 === "/__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/nav.js")
return new Response(getNavRuntime(), { headers: JS_HEADERS });
if (pathname === "/__wrnexus/realtime.js")
return new Response(getRealtimeRuntime(), { 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.__wireSchemas={};", { headers: JS_HEADERS });
}
if (pathname === "/__wrnexus/theme.css") return serveFile(opts.themePath, CSS_HEADERS);
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);
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 ?? {})) {
try {
registerDb(name, connectFromConfig(cfg));
} catch (err) {
console.warn(
`[wrnexus] database '${name}' setup failed:`,
err instanceof Error ? err.message : err,
);
}
}
// 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());
// 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 handlers = createHandlers({
mode: MODE,
hmr: false,
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,
assetVersion: opts.assetVersion,
head: opts.head,
seo: opts.seo,
mobile: opts.mobile,
pwa: opts.pwa,
security: opts.security,
observability: opts.observability,
tenancy: opts.tenancy,
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 `_wire_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,
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;
}