/** * @wrnexus/dev-server — the development HTTP + WebSocket server. * * Thin Bun.serve wrapper around the shared runtime (runtime.ts). Dynamic module * loading makes it fast to iterate; the dev supervisor (see @wrnexus/cli) * restarts this process on file changes. */ import { resolve, dirname, join } from "node:path"; import type { Middleware, Mode, SecurityConfig, SeoConfig } from "@wrnexus/core"; import { buildRouter, type Router } from "@wrnexus/router"; import { resolveThemeConfig, type StylesConfig, type ThemeConfig, type MobileConfig, type PwaConfig, } from "@wrnexus/styles"; import { uiComponentsDir, uiCss } from "@wrnexus/ui"; import { renderSchemasScript, type ObjectSchema, type SchemaDescriptor } from "@wrnexus/validation"; import { loadLocales, resolveI18n, type I18nConfig } from "@wrnexus/i18n"; import { migrate, setDb, registerDb } from "@wrnexus/db"; import { connectFromConfig } from "@wrnexus/db/connect"; import { configureStorage, type StorageConfig } from "@wrnexus/uploader"; import { realtimeBusFromConfig } from "./realtime-bus.ts"; import { loadModule, setCompileCacheDir } from "./pipeline.ts"; import { createHandlers, type WsData } from "./runtime.ts"; import { createDevAssetServer } from "./assets.ts"; import { HmrHub } from "./hmr.ts"; import { startWatcher } from "./watch.ts"; /** Exit code the child uses to ask the dev supervisor for a fresh process. */ export const RESTART_EXIT_CODE = 97; export interface ServeOptions { appDir: string; port?: number; hostname?: string; mode?: Mode; /** Inject the live-reload client (defaults to true in development). */ hmr?: boolean; /** Resolved absolute path to the global CSS entry, or null. */ styleEntry?: string | null; /** Custom styles config (e.g. a Tailwind/PostCSS processor). */ stylesConfig?: StylesConfig; /** Raw HTML appended to every page head (from wrnexus.config.ts). */ head?: string; /** Global SEO defaults. */ seo?: SeoConfig; /** Framework security headers and CORS policy. */ security?: SecurityConfig; /** Design-token theme config (merged over the built-in light/dark). */ theme?: ThemeConfig; /** i18n config (default language + supported locales). */ i18n?: I18nConfig; /** Default database connection (driver + url). Enables `getDb()` and dev auto-migrate. */ db?: { driver: string; url: string }; /** Named databases, reached with `getDb("")`; migrations under app/db//. */ databases?: Record; /** Realtime scaling: bridge room broadcasts over Redis across app processes. */ realtime?: { scale?: boolean; redisUrl?: string }; /** File-upload storage: named stores (local dir / S3), reached with `getStore()`. */ storage?: StorageConfig; mobile?: MobileConfig; pwa?: PwaConfig | false; } export interface RunningServer { port: number; hostname: string; url: string; router: Router; stop(): void; } /** Build a cached middleware loader for a router. */ function middlewareLoader(router: Router): () => Promise { let cache: Middleware[] | null = null; return async () => { if (cache) return cache; const out: Middleware[] = []; for (const file of router.middlewareFiles) { const mod = await loadModule(file); if (typeof mod.default === "function") out.push(mod.default as Middleware); else console.warn(`[wrnexus] middleware ${file} has no default export; skipped`); } cache = out; return out; }; } export async function startServer(opts: ServeOptions): Promise { const appDir = resolve(opts.appDir); const mode: Mode = opts.mode ?? "development"; const hmr = opts.hmr ?? mode === "development"; const port = opts.port ?? 3000; const hostname = opts.hostname ?? "::"; const displayHost = hostname === "0.0.0.0" || hostname === "::" ? "localhost" : hostname; const router = buildRouter(appDir, { componentDirs: [uiComponentsDir()] }); const styleEntry = opts.styleEntry ?? null; const appRoot = dirname(appDir); // Compile every `.wrn` into ONE cache dir at the project root, instead of a // `.wrnexus/` next to each source file (and inside node_modules UI dirs). setCompileCacheDir(join(appRoot, ".wrnexus")); const theme = resolveThemeConfig(opts.theme); const uiStyles = uiCss(); // Load validation schemas once at startup and bake their descriptors into the // client script (schemas change → the dev supervisor restarts this process). const descriptors: Record = {}; for (const s of router.schemas) { try { const mod = await loadModule(s.file); const schema = mod.default as ObjectSchema | undefined; if (schema && typeof schema.describe === "function") descriptors[s.name] = schema.describe(); } catch (err) { console.warn(`[wrnexus] schema '${s.name}' failed to load`, err); } } const schemasJs = renderSchemasScript(descriptors); // i18n is opt-in by the presence of app/locales/*.json. const localeMessages = loadLocales(join(appDir, "locales")); const i18n = Object.keys(localeMessages).length ? resolveI18n(localeMessages, opts.i18n) : undefined; // Databases: configure the default (getDb()) + each named one (getDb("")), // and auto-migrate in dev so schemas are ready. The default's migrations live in // app/db/migrations; a named db's in app/db//migrations. Prod runs // migrations explicitly (files aren't in the bundle). const connectAndMigrate = async (name: string | null, cfg: { driver: string; url: string }) => { try { const db = name ? registerDb(name, connectFromConfig(cfg, appRoot)) : setDb(connectFromConfig(cfg, appRoot)); const dir = name ? join(appDir, "db", name, "migrations") : join(appDir, "db", "migrations"); const applied = await migrate(db, dir); if (applied.length) { console.log( `[wrnexus] applied ${applied.length} migration(s)${name ? ` to '${name}'` : ""}`, ); } } catch (err) { const label = name ? `database '${name}'` : "database"; console.warn(`[wrnexus] ${label} setup failed:`, err instanceof Error ? err.message : err); } }; if (opts.db) await connectAndMigrate(null, opts.db); for (const [name, cfg] of Object.entries(opts.databases ?? {})) await connectAndMigrate(name, cfg); // File-upload storage: build a driver per configured store (local dir / S3). // Relative local dirs resolve against the app root; served/served-back below. configureStorage(opts.storage, appRoot); const assets = createDevAssetServer( appDir, mode, { entry: styleEntry, config: opts.stylesConfig, appRoot, publicDir: join(appRoot, "public"), }, theme, uiStyles, schemasJs, ); const hub = hmr ? new HmrHub() : undefined; const handlers = createHandlers({ mode, hmr, router, loadModule, getMiddleware: middlewareLoader(router), assets, hasStyles: !!styleEntry, hasUi: true, theme, i18n, head: opts.head, seo: opts.seo, mobile: opts.mobile, pwa: opts.pwa, security: opts.security, hub, realtimeBus: realtimeBusFromConfig(opts.realtime), }); const server = Bun.serve({ port, hostname, development: mode === "development", maxRequestBodySize: 10 * 1024 * 1024, fetch: handlers.fetch, websocket: handlers.websocket, }); // In-process HMR: CSS edits update live; server edits (pages/components/api) // request a restart. if (hmr && hub) { let restarting = false; const requestRestart = (): void => { if (restarting) return; restarting = true; console.log("[wrnexus] server change — restarting…"); // Close the watcher and stop the server FIRST. On Windows a live recursive // fs.watch handle can hang `process.exit`, and stopping the server frees the // port so the freshly-spawned child can rebind immediately (no EADDRINUSE). // Without this the child would print "restarting…" but never actually exit. try { watcher?.close(); } catch { /* already closed */ } try { server.stop(true); // true = close active connections now, release the socket } catch { /* already stopping */ } // Let close callbacks and stdio flush, then force the exit if any handle // remains alive. This is especially important on Windows file watching. process.exitCode = RESTART_EXIT_CODE; setTimeout(() => process.exit(RESTART_EXIT_CODE), 250).unref(); }; const watcher = startWatcher({ appDir, hub, assets, onServerChange: requestRestart }); } const boundPort = server.port ?? port; return { port: boundPort, hostname, url: `http://${displayHost}:${boundPort}`, router, stop: () => server.stop(), }; } export { createHandlers } from "./runtime.ts"; export type { RuntimeDeps, AssetServer, WsData } from "./runtime.ts"; // Multi-app gateway: route multiple apps by domain behind one port. export { startGateway } from "./gateway.ts"; export type { GatewayApp, GatewayOptions, GatewayAuth, GatewaySecurity, RunningGateway, } from "./gateway.ts"; // Deployment: the portable production handler + the node:http adapter. export { createProductionServer, createProductionHandlers } from "./prod.ts"; export { toRequest, writeResponse, nodeListener, serveNode } from "./adapters/node.ts"; export type { FetchHandler } from "./adapters/node.ts";