/** * @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 { mkdirSync, readFileSync, writeFileSync } from "node:fs"; 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, type NavigationConfig, } from "@wrnexus/styles"; import { uiComponentsDir, uiCssPath } from "@wrnexus/ui"; import { renderSchemasScript, type ObjectSchema, type SchemaDescriptor } from "@wrnexus/validation"; import { loadLocales, resolveI18n, type I18nConfig } from "@wrnexus/i18n"; import { applyMigrations, loadMigrations, migrate, setDb, registerDb, registerLazyDb, getDbPerformanceSnapshot, } 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, loadWrnServerModule, setCompileCacheDir, setCompileImportOptions, setDevCompilerPipeline, wrnBrowserArtifactUrlAsync, } from "./pipeline.ts"; import { createRpcHandler } from "@wrnexus/ssr/rpc"; 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"; export { expandStaticComponents, precomputePartialStaticShell } from "./partial-build.ts"; export { getWrnCompileMetrics, resetWrnCompileMetrics } from "./pipeline.ts"; export type { WrnCompileMetrics } from "./pipeline.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 { builtinDevToolbarPanels, createDevToolbarCollector, type DevToolbarCollector, } from "@wrnexus/dev-toolbar/server"; export interface ServeOptions { appDir: string; port?: number; hostname?: string; /** Development TLS material. Production TLS is normally terminated by the deployment proxy. */ tls?: { cert: string; key: string }; mode?: Mode; /** Inject the live-reload client (defaults to true in development). */ hmr?: boolean; appConfig?: Record; /** 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; navigation?: NavigationConfig; } 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 importConfig = (opts.appConfig?.imports ?? {}) as { mode?: "legacy" | "compatible" | "explicit"; autoImport?: boolean; aliases?: Record; }; setCompileImportOptions(appRoot, importConfig); const configuredPlugins = opts.plugins ?? (opts.appConfig?.plugins as PluginInput | undefined); const discoveredPlugins = await discoverPlugins(appRoot, configuredPlugins, { includeDevDependencies: true, strict: true, warn: (message) => console.warn(`[wrnexus:plugin] ${message}`), enforcePermissions: (opts.appConfig?.pluginPermissions as { enforce?: boolean } | undefined) ?.enforce, grantedPermissions: ( opts.appConfig?.pluginPermissions as { grants?: Record } | undefined )?.grants, }); const pluginRunner = createPluginRunner(discoveredPlugins, { root: appRoot, mode, command: "dev", metadata: new Map(), warn: (message) => console.warn(`[wrnexus:plugin] ${message}`), }); /** * Plugins must receive the complete * wrnexus.config.ts object. * * ServeOptions only contains framework-known * properties. Package-specific configuration, * such as `auth`, is stored in appConfig. */ const pluginConfig: Record = { ...(opts.appConfig ?? {}), // Resolved runtime values take priority. appDir, port: opts.port, hostname: opts.hostname, mode, hmr: opts.hmr, styleEntry: opts.styleEntry, styles: opts.stylesConfig ?? (opts.appConfig?.styles as StylesConfig | undefined), head: opts.head, seo: opts.seo, security: opts.security, theme: opts.theme, i18n: opts.i18n, db: opts.db, databases: opts.databases, realtime: opts.realtime, storage: opts.storage, mobile: opts.mobile, pwa: opts.pwa, devToolbar: opts.devToolbar, plugins: configuredPlugins, observability: opts.observability, tenancy: opts.tenancy, navigation: opts.navigation, }; await pluginRunner.configure(pluginConfig); await pluginRunner.configResolved( Object.freeze({ ...pluginConfig, }), ); const pluginContributions = await pluginRunner.contributions(); const virtualModules = new Map(); const virtualDir = join(appRoot, ".wrnexus", "virtual"); mkdirSync(virtualDir, { recursive: true }); for (const [index, module] of pluginContributions.virtualModules.entries()) { const output = join(virtualDir, `plugin-${index}.ts`); writeFileSync( output, await module.load({ root: appRoot, mode, command: "dev", profile: process.env.WRNEXUS_PROFILE, metadata: new Map(), warn: (message) => console.warn(`[wrnexus:plugin] ${message}`), }), "utf8", ); virtualModules.set(module.id, output); } setDevCompilerPipeline({ transformAst: (ast, file) => pluginRunner.transformAst(ast, file), transformCode: (code, file) => pluginRunner.transformCode(code, file), virtualModules, }); console.log( `[wrnexus:plugin] discovered: ${ pluginRunner.plugins.map((plugin) => plugin.name).join(", ") || "none" }`, ); console.log(`[wrnexus:plugin] contributed routes: ${pluginContributions.routes.length}`); 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 uiStylesPath = uiCssPath(); const schemasJs = await schemaRuntime(router); // i18n is opt-in by the presence of app/locales/*.json. const localeMessages = loadLocales(join(appDir, "locales"), { strict: opts.i18n?.strict }); 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 ?? {})) { const migrationDir = join(appDir, "db", name, "migrations"); const hasMigrations = loadMigrations(migrationDir).length > 0 || resolvePackageMigrations(pluginContributions.migrations, name).length > 0; if (hasMigrations) await connectAndMigrate(name, cfg); else registerLazyDb(name, () => connectFromConfig(cfg, appRoot)); } // 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, () => readFileSync(uiStylesPath, "utf8"), 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 toolbarPlatform = { wrnexus060: { clientFunctions: true, serverFunctions: true, sharedFunctions: true, typedOutputs: true, requestScopedStores: true, }, 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, }, }; 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, navigation: opts.navigation, clientRuntimes: pluginContributions.clientRuntimes, hub, realtimeBus: realtimeBusFromConfig(opts.realtime), renderHtml: (html: string) => pluginRunner.render(html), devToolbar: devToolbarConfig && devToolbarCollector ? { config: devToolbarConfig, collector: devToolbarCollector, root: appRoot, panels: () => [ { id: "runtime", title: "Runtime", icon: "cpu", description: "Client, server, and shared functions, hydration modules, and runtime-boundary diagnostics.", order: 10, }, { id: "stores", title: "Stores", icon: "database", description: "Global/page stores, safe client state, computed values, actions, persistence, and hydration.", order: 20, }, { id: "cache", title: "Cache", icon: "layers", description: "Request, data, component, and page cache entries plus hit/miss history.", order: 30, }, ...builtinDevToolbarPanels({ root: appRoot, platform: toolbarPlatform, database: getDbPerformanceSnapshot(), version: { current: "0.8.0" }, }), ...pluginToolbarPanels, ], platform: toolbarPlatform, } : undefined, }; const handlers = createHandlers(runtimeDeps); const rpcHandler = createRpcHandler({ async resolve(rawComponent) { const componentName = rawComponent.split(":", 1)[0] ?? rawComponent; const preferred = router.components.find( (entry) => entry.name.toLowerCase() === componentName.toLowerCase(), ); const candidates = [ ...(preferred ? [preferred.file] : []), ...router.pages.filter((route) => route.file.endsWith(".wrn")).map((route) => route.file), ...router.layouts.map((entry) => entry.file), ...router.stores.map((entry) => entry.file), ...router.components.filter((entry) => entry !== preferred).map((entry) => entry.file), ]; for (const file of new Set(candidates)) { const module = await loadWrnServerModule(file); const functions = module.__wrnexusServerFunctions; const manifest = module.__wrnexusRpcManifest; if (!functions || typeof functions !== "object" || !Array.isArray(manifest)) continue; const ownsComponent = manifest.some( (entry: any) => String(entry?.component ?? "").toLowerCase() === componentName.toLowerCase(), ); if (!ownsComponent) continue; return { functions: functions as Record any>, manifest: manifest as any, }; } return null; }, validateCsrf(request) { const url = new URL(request.url); const origin = request.headers.get("origin"); if (origin && origin !== url.origin) return false; const cookieHeader = request.headers.get("cookie") ?? ""; const cookieToken = /(?:^|;\s*)wire-csrf=([^;]+)/.exec(cookieHeader)?.[1] ?? /(?:^|;\s*)wrnexus_csrf=([^;]+)/.exec(cookieHeader)?.[1]; const headerToken = request.headers.get("x-csrf-token") ?? request.headers.get("x-wrnexus-csrf") ?? ""; return !cookieToken || decodeURIComponent(cookieToken) === headerToken; }, }); const server = Bun.serve({ port, hostname, development: mode === "development", maxRequestBodySize: 10 * 1024 * 1024, ...(opts.tls ? { tls: opts.tls } : {}), fetch(request, server) { if (new URL(request.url).pathname === "/__wrnexus/rpc") return rpcHandler(request); return handlers.fetch(request, server); }, 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/"))) { try { const messages = loadLocales(join(appDir, "locales"), { strict: opts.i18n?.strict }); runtimeDeps.i18n = Object.keys(messages).length ? resolveI18n(messages, opts.i18n) : undefined; } catch (error) { console.warn( "[wrnexus] locale hot update was incomplete; keeping the last valid bundle", error, ); } } console.log(`[wrnexus] hot update — ${files.join(", ")}`); await pluginRunner.hook("hmrUpdate", files); const storeUpdates: Array<{ name: string; url: string; kind: string }> = []; for (const changed of files) { const absolute = isAbsolute(changed) ? changed : resolve(appDir, changed); if (!absolute.endsWith(".wrn")) continue; try { const source = readFileSync(absolute, "utf8"); const declaration = /\b(global|page)\s+store\s+([A-Za-z_$][\w$]*)\s*\{/.exec(source); if (!declaration) continue; storeUpdates.push({ name: declaration[2]!, kind: declaration[1]!, url: await wrnBrowserArtifactUrlAsync(absolute), }); } catch (error) { console.warn(`[wrnexus] failed to prepare store HMR for ${absolute}`, error); } } if (storeUpdates.length) { hub.broadcastJson({ type: "store-update", version: Date.now(), stores: storeUpdates }); } const onlyStores = storeUpdates.length > 0 && files.every((changed) => { const absolute = isAbsolute(changed) ? changed : resolve(appDir, changed); if (!absolute.endsWith(".wrn")) return false; try { return /\b(?:global|page)\s+store\s+[A-Za-z_$][\w$]*\s*\{/.test( readFileSync(absolute, "utf8"), ); } catch { return false; } }); if (!onlyStores) hub.reload(); hub.broadcastJson({ channel: "toolbar", type: "toolbar:scan", reason: "source-change", files, }); }; const packageWatchDirs = [ dirname(uiStylesPath), ...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(); void pluginRunner.hook("shutdown"); }, }; } 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";