598 lines
24 KiB
TypeScript
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;
|
|
}
|