387 lines
14 KiB
TypeScript
387 lines
14 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,
|
|
} 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;
|
|
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,
|
|
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;
|
|
}
|