first commit
This commit is contained in:
@@ -0,0 +1,369 @@
|
||||
/**
|
||||
* @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;
|
||||
/** 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;
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/** 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/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,
|
||||
theme: opts.theme,
|
||||
i18n: opts.i18n,
|
||||
inlineStyles: opts.inlineStyles,
|
||||
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: opts.hostname ?? "0.0.0.0",
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user