first commit
This commit is contained in:
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* @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("<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 a cached middleware loader for a router. */
|
||||
function middlewareLoader(router: Router): () => Promise<Middleware[]> {
|
||||
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<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);
|
||||
// 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<string, SchemaDescriptor> = {};
|
||||
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("<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 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<WsData>({
|
||||
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";
|
||||
Reference in New Issue
Block a user