Adds /__wrnexus/islands.js (the bootstrap) and the /__wrnexus/island/ prefix (mount runtime, island bundles, shared chunks) to all three serving paths. Dev reuses the browserArtifactPaths registry pattern from pipeline.ts. Prod mirrors the clientModulesDir handler, including its filename allowlist, so island names cannot escape the output directory. The bootstrap is inert without a data-wrn-island marker, so island-free pages still download nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
609 lines
25 KiB
TypeScript
609 lines
25 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 { getIslandRuntime } from "@wrnexus/react/runtime";
|
|
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;
|
|
islandsDir?: 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.startsWith("/__wrnexus/island/")) {
|
|
const name = pathname.slice("/__wrnexus/island/".length);
|
|
if (!opts.islandsDir || !/^[A-Za-z0-9._-]+\.js$/.test(name)) {
|
|
return new Response("Not Found", { status: 404 });
|
|
}
|
|
return serveFile(join(opts.islandsDir, name), JS_HEADERS);
|
|
}
|
|
if (pathname === "/__wrnexus/islands.js")
|
|
return new Response(getIslandRuntime(), { headers: 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;
|
|
}
|