/** * @wrnexus/dev-server — the development HTTP + WebSocket server. * * Thin Bun.serve wrapper around the shared runtime (runtime.ts). Dynamic module * loading and targeted cache invalidation keep page/component/API edits inside * the running process while the HMR socket morphs fresh HTML into the browser. */ 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 { invalidateModule, 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"; export { RESTART_EXIT_CODE } from "./restart.ts"; import { resetDevCache } from "./cache.ts"; 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 an invalidatable middleware loader for in-process hot updates. */ function middlewareLoader(router: Router): { load: () => Promise; invalidate: () => void; } { let cache: Middleware[] | null = null; return { async load() { 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; }, invalidate() { cache = null; }, }; } async function schemaRuntime(router: Router): Promise { const descriptors: Record = {}; for (const schemaRef of router.schemas) { try { const mod = await loadModule(schemaRef.file); const schema = mod.default as ObjectSchema | undefined; if (schema && typeof schema.describe === "function") { descriptors[schemaRef.name] = schema.describe(); } } catch (error) { console.warn(`[wrnexus] schema '${schemaRef.name}' failed to load`, error); } } return renderSchemasScript(descriptors); } 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); resetDevCache({ rootDir: appRoot, enabled: process.env.WRNEXUS_PRESERVE_CACHE !== "1", }); // 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(); const schemasJs = await schemaRuntime(router); // 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 middleware = middlewareLoader(router); const runtimeDeps = { mode, hmr, router, loadModule, getMiddleware: middleware.load, 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 handlers = createHandlers(runtimeDeps); const server = Bun.serve({ port, hostname, development: mode === "development", maxRequestBodySize: 10 * 1024 * 1024, fetch: handlers.fetch, websocket: handlers.websocket, }); let watcher: ReturnType; // In-process HMR: keep the server and socket alive, invalidate only changed // modules, rescan file routes, and ask browsers to morph in fresh HTML. if (hmr && hub) { const hotUpdate = async (files: string[]): Promise => { // Allow VS Code/Bun to finish writing pasted content. await new Promise((resolvePromise) => setTimeout(resolvePromise, 50)); for (const relative of files) { invalidateModule(resolve(appDir, relative)); } Object.assign( router, buildRouter(appDir, { componentDirs: [uiComponentsDir()], }), ); middleware.invalidate(); if (files.some((file) => file === "schemas" || file.startsWith("schemas/"))) { assets.updateSchemas(await schemaRuntime(router)); } if (files.some((file) => file === "locales" || file.startsWith("locales/"))) { const messages = loadLocales(join(appDir, "locales")); runtimeDeps.i18n = Object.keys(messages).length ? resolveI18n(messages, opts.i18n) : undefined; } console.log(`[wrnexus] hot update — ${files.join(", ")}`); hub.reload(); }; watcher = startWatcher({ appDir, hub, assets, onHotChange: hotUpdate }); } const boundPort = server.port ?? port; return { port: boundPort, hostname, url: `http://${displayHost}:${boundPort}`, router, stop: () => { watcher?.close(); 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";