Files
WRNexusJS/packages/dev-server/src/index.ts
T
2026-07-15 14:02:27 +05:30

289 lines
9.8 KiB
TypeScript

/**
* @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("<name>")`; migrations under app/db/<name>/. */
databases?: Record<string, { driver: string; url: string }>;
/** 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<Middleware[]>;
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<string> {
const descriptors: Record<string, SchemaDescriptor> = {};
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<RunningServer> {
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("<name>")),
// 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/<name>/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<WsData>({
port,
hostname,
development: mode === "development",
maxRequestBodySize: 10 * 1024 * 1024,
fetch: handlers.fetch,
websocket: handlers.websocket,
});
let watcher: ReturnType<typeof startWatcher>;
// 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<void> => {
// 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";