/** * @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, isAbsolute, 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 { applyMigrations, 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 { pluginAssetsFromContributions } from "./plugin-assets.ts"; import { resolvePackageMigrations } from "./plugin-migrations.ts"; import { HmrHub } from "./hmr.ts"; import { startWatcher } from "./watch.ts"; export { RESTART_EXIT_CODE } from "./restart.ts"; import { resetDevCache } from "./cache.ts"; import type { DevToolbarConfig } from "@wrnexus/dev-toolbar/types"; import { createPluginRunner, discoverPlugins, type PluginInput } from "@wrnexus/plugin"; import type { ObservabilityConfig, TenancyConfig } from "@wrnexus/styles"; import { createDevToolbarCollector, type DevToolbarCollector } from "@wrnexus/dev-toolbar/server"; 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; devToolbar?: boolean | DevToolbarConfig; plugins?: PluginInput; observability?: ObservabilityConfig; tenancy?: TenancyConfig; } 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); } function resolveDevToolbarConfig( mode: string, value: ServeOptions["devToolbar"], ): DevToolbarConfig | null { if (mode !== "development" || value === false || value === undefined) { return null; } if (value === true) { return { enabled: true, position: "bottom-center", defaultOpen: false, scanOnNavigation: true, scanOnHmr: true, openEditor: true, }; } if (value.enabled === false) { return null; } return { enabled: true, position: "bottom-center", defaultOpen: false, scanOnNavigation: true, scanOnHmr: true, openEditor: true, ...value, }; } export async function startServer(opts: ServeOptions): Promise { const appDir = resolve(opts.appDir); const appRoot = dirname(appDir); const mode: Mode = opts.mode ?? "development"; const discoveredPlugins = await discoverPlugins(appRoot, opts.plugins, { includeDevDependencies: true, strict: true, warn: (message) => console.warn(`[wrnexus:plugin] ${message}`), }); const pluginRunner = createPluginRunner(discoveredPlugins, { root: appRoot, mode, command: "dev", metadata: new Map(), warn: (message) => console.warn(`[wrnexus:plugin] ${message}`), }); await pluginRunner.configure(opts as unknown as Record); await pluginRunner.configResolved( Object.freeze({ ...opts }) as Readonly>, ); const pluginContributions = await pluginRunner.contributions(); const pluginToolbarPanels = await pluginRunner.devToolbarPanels(); const componentDirs = [uiComponentsDir(), ...pluginContributions.componentDirs]; 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, externalRoutes: pluginContributions.routes, middlewareFiles: pluginContributions.middleware, }); const styleEntry = opts.styleEntry ?? null; const devToolbarConfig = resolveDevToolbarConfig(mode, opts.devToolbar); const devToolbarCollector: DevToolbarCollector | undefined = devToolbarConfig ? createDevToolbarCollector() : undefined; 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 appApplied = await migrate(db, dir); const packageApplied = await applyMigrations( db, resolvePackageMigrations(pluginContributions.migrations, name ?? undefined), ); const applied = [...appApplied, ...packageApplied]; 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"), sources: [ ...componentDirs, ...pluginContributions.styles.flatMap((style) => (style.source ? [style.source] : [])), ], entries: pluginContributions.styles.flatMap((style) => (style.entry ? [style.entry] : [])), }, theme, uiStyles, schemasJs, pluginAssetsFromContributions(pluginContributions), ); const hub = hmr ? new HmrHub() : undefined; const unsubscribeDevToolbar = devToolbarCollector && hub ? devToolbarCollector.subscribe((issues) => { hub.broadcastJson({ channel: "toolbar", type: "toolbar:issues", issues }); }) : undefined; const middleware = middlewareLoader(router); const runtimeDeps = { mode, hmr, router, loadModule, getMiddleware: middleware.load, assets, hasStyles: !!styleEntry || pluginContributions.styles.some((style) => !!style.entry), hasUi: true, theme, i18n, head: opts.head, seo: opts.seo, mobile: opts.mobile, pwa: opts.pwa, security: opts.security, observability: opts.observability, tenancy: opts.tenancy, clientRuntimes: pluginContributions.clientRuntimes, hub, realtimeBus: realtimeBusFromConfig(opts.realtime), devToolbar: devToolbarConfig && devToolbarCollector ? { config: devToolbarConfig, collector: devToolbarCollector, root: appRoot, panels: pluginToolbarPanels, platform: { plugins: pluginRunner.plugins.map((plugin) => ({ name: plugin.name, version: plugin.version, })), runtimes: pluginContributions.clientRuntimes.map((runtime) => ({ id: runtime.id, publicPath: runtime.publicPath, type: runtime.type, load: runtime.load, })), assets: pluginContributions.assets.map((asset) => ({ id: asset.id, publicPath: asset.publicPath, contentType: asset.contentType, })), componentDirs, styles: pluginContributions.styles, routes: { pages: router.pages.length, api: router.api.length, realtime: router.realtime.length, }, }, } : undefined, }; const handlers = createHandlers(runtimeDeps); const server = Bun.serve({ port, hostname, development: mode === "development", maxRequestBodySize: 10 * 1024 * 1024, fetch: handlers.fetch, websocket: handlers.websocket, }); try { await pluginRunner.hook("configureServer", { server, router, handlers, assets, devToolbarCollector, pluginContributions, }); } catch (error) { server.stop(); throw error; } 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 file of files) { invalidateModule(isAbsolute(file) ? file : resolve(appDir, file)); } if (files.some((file) => file.endsWith(".wrn"))) assets.invalidateCss(); Object.assign( router, buildRouter(appDir, { componentDirs, externalRoutes: pluginContributions.routes, middlewareFiles: pluginContributions.middleware, }), ); middleware.invalidate(); const appFiles = files.filter((file) => !isAbsolute(file)); if (appFiles.some((file) => file === "schemas" || file.startsWith("schemas/"))) { assets.updateSchemas(await schemaRuntime(router)); } if (appFiles.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(); hub.broadcastJson({ channel: "toolbar", type: "toolbar:scan", reason: "source-change", files, }); }; const packageWatchDirs = [ ...componentDirs, ...pluginContributions.clientRuntimes.flatMap((runtime) => runtime.entry ? [dirname(runtime.entry)] : [], ), ...pluginContributions.assets.flatMap((asset) => (asset.entry ? [dirname(asset.entry)] : [])), ...pluginContributions.styles.flatMap((style) => [style.source, style.entry ? dirname(style.entry) : undefined].filter( (value): value is string => !!value, ), ), ...pluginContributions.routes.map((route) => dirname(route.entry)), ...pluginContributions.middleware.map((file) => dirname(file)), ]; watcher = startWatcher({ appDir, extraDirs: packageWatchDirs, hub, assets, devToolbarCollector, onHotChange: hotUpdate, }); } const boundPort = server.port ?? port; return { port: boundPort, hostname, url: `http://${displayHost}:${boundPort}`, router, stop: () => { watcher?.close(); unsubscribeDevToolbar?.(); 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";