first commit

This commit is contained in:
2026-07-12 15:55:18 +05:30
commit ee98026cc5
404 changed files with 44522 additions and 0 deletions
+113
View File
@@ -0,0 +1,113 @@
/**
* node:http adapter — bridge a WinterCG `fetch(request) => Response` handler
* onto a Node HTTP server, with no external dependencies. Converts a Node
* `IncomingMessage` into a web `Request` and writes a web `Response` back into a
* `ServerResponse` (preserving multiple `Set-Cookie` headers).
*
* Caveat: the production handler uses Bun-native APIs (Bun.file for assets,
* Bun.serve for websockets, Bun.SQL / bun:sqlite for the database), so running
* the FULL app under plain Node needs Bun-compatible globals. This adapter is
* for WinterCG hosts and for embedding the handler behind an existing
* `node:http` server; the Request/Response conversion itself is fully portable.
*/
import type { IncomingMessage, ServerResponse, Server } from "node:http";
export type FetchHandler = (req: Request) => Response | undefined | Promise<Response | undefined>;
/** Convert a Node IncomingMessage into a web Request (buffers the body). */
export async function toRequest(
req: IncomingMessage,
opts: { origin?: string } = {},
): Promise<Request> {
const method = req.method ?? "GET";
const host = req.headers.host ?? "localhost";
const proto = (asString(req.headers["x-forwarded-proto"]) ?? "http").split(",")[0]!.trim();
const origin = opts.origin ?? `${proto}://${host}`;
const url = new URL(req.url ?? "/", origin);
const headers = new Headers();
for (const [key, value] of Object.entries(req.headers)) {
if (value === undefined) continue;
if (Array.isArray(value)) for (const v of value) headers.append(key, v);
else headers.set(key, value);
}
const hasBody = method !== "GET" && method !== "HEAD";
const body = hasBody ? ((await readBody(req)) as BodyInit) : undefined;
return new Request(url, { method, headers, body });
}
function asString(value: string | string[] | undefined): string | undefined {
return Array.isArray(value) ? value[0] : value;
}
function readBody(req: IncomingMessage): Promise<Uint8Array> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
req.on("data", (c: Buffer) => chunks.push(c));
req.on("end", () => resolve(new Uint8Array(Buffer.concat(chunks))));
req.on("error", reject);
});
}
/** Write a web Response into a Node ServerResponse. */
export async function writeResponse(res: ServerResponse, response: Response): Promise<void> {
const headers: Record<string, string | string[]> = {};
response.headers.forEach((value, key) => {
if (key.toLowerCase() !== "set-cookie") headers[key] = value;
});
// Multiple Set-Cookie headers must stay separate (Headers.forEach joins them).
const getSetCookie = (response.headers as { getSetCookie?: () => string[] }).getSetCookie;
const cookies = typeof getSetCookie === "function" ? getSetCookie.call(response.headers) : [];
if (cookies.length) headers["set-cookie"] = cookies;
res.writeHead(response.status, headers);
if (response.body) {
const reader = response.body.getReader();
for (;;) {
const { done, value } = await reader.read();
if (done) break;
res.write(value);
}
} else {
const buf = new Uint8Array(await response.arrayBuffer());
if (buf.length) res.write(buf);
}
res.end();
}
/** A `node:http` request listener that dispatches to a fetch handler. */
export function nodeListener(handler: FetchHandler, opts: { origin?: string } = {}) {
return async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
try {
const response = await handler(await toRequest(req, opts));
if (!response) {
// A missing response means the handler expected a protocol upgrade
// (e.g. a WebSocket), which this HTTP adapter does not perform.
res.writeHead(426, { "content-type": "text/plain" });
res.end("Upgrade Required");
return;
}
await writeResponse(res, response);
} catch (err) {
if (!res.headersSent) res.writeHead(500, { "content-type": "text/plain" });
res.end("Internal Server Error");
console.error("[wrnexus] node adapter error:", err);
}
};
}
/** Create and start a `node:http` server for a fetch handler. */
export async function serveNode(
handler: FetchHandler,
opts: { port?: number; hostname?: string } = {},
): Promise<Server> {
const { createServer } = await import("node:http");
const server = createServer(nodeListener(handler, {}));
const port = opts.port ?? 3000;
server.listen(port, opts.hostname ?? "0.0.0.0");
console.log(`WrNexus (node adapter) listening on http://localhost:${port}`);
return server;
}
+118
View File
@@ -0,0 +1,118 @@
/**
* Dev-mode asset server for `/__wrnexus/*`:
* /__wrnexus/reactive.js the reactive runtime
* /__wrnexus/theme.css design-token themes (per resolved theme config)
* /__wrnexus/theme.js client theme switcher
* /__wrnexus/styles.css bundled global stylesheet (cached)
*
* Components are `.wrn` files rendered on the server (see runtime.ts), so there
* are no per-component browser chunks to build or serve here. The CSS cache is
* invalidated in-process by the file watcher so edits show without a restart.
*/
import { getReactiveRuntime, getNavRuntime, getRealtimeRuntime } from "@wrnexus/csr";
import {
renderStyles,
renderThemeCss,
renderThemeRuntime,
type ResolvedTheme,
type StylesConfig,
} from "@wrnexus/styles";
import { VALIDATE_RUNTIME } from "@wrnexus/validation";
import { I18N_RUNTIME } from "@wrnexus/i18n";
import { UPLOAD_RUNTIME, UPLOAD_JS_HREF, UPLOADS_PREFIX, serveStoredFile } from "@wrnexus/uploader";
import type { Mode } from "@wrnexus/core";
import type { AssetServer } from "./runtime.ts";
import { servePublicAsset } from "./public.ts";
/** Style inputs the dev asset server needs to build `/__wrnexus/styles.css`. */
export interface DevStyles {
entry: string | null;
config?: StylesConfig;
appRoot: string;
publicDir?: string;
}
/** A dev asset server also supports invalidating its caches in-process. */
export interface DevAssetServer extends AssetServer {
invalidateCss(): void;
}
function jsResponse(code: string): Response {
return new Response(code, {
headers: {
"content-type": "text/javascript; charset=utf-8",
"cache-control": "no-cache",
},
});
}
function cssResponse(code: string): Response {
return new Response(code, {
headers: {
"content-type": "text/css; charset=utf-8",
"cache-control": "no-cache",
},
});
}
export function createDevAssetServer(
appDir: string,
mode: Mode,
styles?: DevStyles,
theme?: ResolvedTheme,
uiCss?: string,
schemasJs?: string,
): DevAssetServer {
let cssCache: string | null = null;
return {
invalidateCss() {
cssCache = null;
},
async serve(pathname: string): Promise<Response | null> {
if (pathname === "/__wrnexus/reactive.js") return jsResponse(getReactiveRuntime());
if (pathname === "/__wrnexus/nav.js") return jsResponse(getNavRuntime());
if (pathname === "/__wrnexus/realtime.js") return jsResponse(getRealtimeRuntime());
if (pathname === "/__wrnexus/validate.js") return jsResponse(VALIDATE_RUNTIME);
if (pathname === "/__wrnexus/i18n.js") return jsResponse(I18N_RUNTIME);
if (pathname === UPLOAD_JS_HREF) return jsResponse(UPLOAD_RUNTIME);
// Public local uploads served at /__wrnexus/uploads/<store>/<key>.
if (pathname.startsWith(UPLOADS_PREFIX)) {
return (await serveStoredFile(pathname)) ?? new Response("Not Found", { status: 404 });
}
if (pathname === "/__wrnexus/schemas.js")
return jsResponse(schemasJs ?? "window.__wireSchemas={};");
if (pathname === "/__wrnexus/ui.css") {
return uiCss ? cssResponse(uiCss) : new Response("Not Found", { status: 404 });
}
if (pathname === "/__wrnexus/theme.css") {
return theme
? cssResponse(renderThemeCss(theme))
: new Response("Not Found", { status: 404 });
}
if (pathname === "/__wrnexus/theme.js") {
return theme
? jsResponse(renderThemeRuntime(theme))
: new Response("Not Found", { status: 404 });
}
if (pathname === "/__wrnexus/styles.css") {
if (!styles?.entry) return new Response("Not Found", { status: 404 });
if (cssCache === null) {
cssCache = await renderStyles(
{ entryPath: styles.entry, appDir, appRoot: styles.appRoot, mode },
styles.config,
);
}
return cssResponse(cssCache);
}
return servePublicAsset(styles?.publicDir, pathname, mode);
},
};
}
+340
View File
@@ -0,0 +1,340 @@
/**
* The multi-app **gateway** — serves several WrNexus apps behind one port and
* routes each request to the right app by its `Host` header (domain). This is how
* a monorepo becomes a multi-domain SaaS: `app-a.com` → apps/a, `app-b.com` → apps/b.
*
* Each app runs as its own **process** (full isolation — its own database
* registry, pubsub, in-memory state), and the gateway is a thin host-based
* reverse proxy for both HTTP and WebSocket. Apps talk to each other at runtime
* via @wrnexus/pubsub (use the Redis driver so messages cross processes).
*/
import { spawn, type ChildProcess } from "node:child_process";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
/** Per-app access control, enforced at the gateway before proxying. */
export interface GatewayAuth {
/** HTTP Basic auth — one or more allowed user/password pairs. */
basic?: { user: string; pass: string } | Array<{ user: string; pass: string }>;
/** Allow only these client IPs (exact match; others get 403). */
allowIps?: string[];
/**
* Forward-auth (SSO): the gateway GETs `url` forwarding the request's cookies +
* Authorization; a 2xx allows the request, anything else blocks it (its status
* is returned). Point it at your own verify endpoint.
*/
forward?: { url: string };
}
export interface GatewayApp {
/** App id (for logs). */
name: string;
/** Path to the app root (the dir containing `app/` and wrnexus.config.ts). */
dir: string;
/** Host names routed to this app (e.g. ["localhost", "web.localhost"]). */
domains: string[];
/** Optional fixed internal port; otherwise assigned from the gateway port. */
port?: number;
/** Access control enforced at the edge for this app. */
auth?: GatewayAuth;
}
/** Gateway-wide security controls, enforced for every app. */
export interface GatewaySecurity {
/** Reject requests whose Host matches no app (404) instead of routing to the first. */
trustedHostsOnly?: boolean;
/** Global rate limit by client IP (429 over the limit). */
rateLimit?: { max: number; windowMs?: number };
/** Add baseline security headers to responses (only where the app didn't set them). */
headers?: boolean;
/** Set X-Forwarded-For/Host/Proto so apps see the real client. Default true. */
forwardedHeaders?: boolean;
/** Log each request (host → app, method, path, status). */
accessLog?: boolean;
}
export interface GatewayOptions {
port?: number;
hostname?: string;
mode?: "development" | "production";
apps: GatewayApp[];
security?: GatewaySecurity;
}
export interface RunningGateway {
port: number;
url: string;
stop(): void;
}
interface Target extends GatewayApp {
port: number;
origin: string;
child: ChildProcess;
}
interface WsBridge {
origin: string;
path: string;
backend?: WebSocket;
queue: (string | ArrayBufferLike | ArrayBufferView)[];
}
/** Fixed-window rate limiter keyed by client IP. */
function makeRateLimiter(max: number, windowMs: number) {
const hits = new Map<string, { count: number; reset: number }>();
return (ip: string, now: number): boolean => {
const b = hits.get(ip);
if (!b || now >= b.reset) {
hits.set(ip, { count: 1, reset: now + windowMs });
return true;
}
b.count++;
return b.count <= max;
};
}
/** Constant-time-ish string compare. */
function timingSafeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
return diff === 0;
}
/**
* Enforce a per-app auth policy. Returns a Response to block, or null to allow.
* `ip` is the client address (for the IP allowlist).
*/
async function checkAuth(
auth: GatewayAuth | undefined,
req: Request,
ip: string,
): Promise<Response | null> {
if (!auth) return null;
if (auth.allowIps && !auth.allowIps.includes(ip)) {
return new Response("Forbidden", { status: 403 });
}
if (auth.basic) {
const pairs = Array.isArray(auth.basic) ? auth.basic : [auth.basic];
const header = req.headers.get("authorization") ?? "";
const ok =
header.startsWith("Basic ") &&
(() => {
const [user, pass] = atob(header.slice(6)).split(":", 2);
return pairs.some(
(p) => timingSafeEqual(user ?? "", p.user) && timingSafeEqual(pass ?? "", p.pass),
);
})();
if (!ok) {
return new Response("Authentication required", {
status: 401,
headers: { "www-authenticate": 'Basic realm="Restricted"' },
});
}
}
if (auth.forward) {
try {
const res = await fetch(auth.forward.url, {
headers: {
cookie: req.headers.get("cookie") ?? "",
authorization: req.headers.get("authorization") ?? "",
"x-forwarded-host": req.headers.get("host") ?? "",
"x-original-uri": new URL(req.url).pathname,
},
redirect: "manual",
});
if (!res.ok)
return new Response("Unauthorized", { status: res.status === 200 ? 401 : res.status });
} catch {
return new Response("Auth service unavailable", { status: 503 });
}
}
return null;
}
/** Baseline edge security headers, only where the app didn't already set them. */
function applyEdgeHeaders(res: Response): Response {
const defaults: Record<string, string> = {
"x-content-type-options": "nosniff",
"x-frame-options": "SAMEORIGIN",
"referrer-policy": "strict-origin-when-cross-origin",
};
const headers = new Headers(res.headers);
for (const [k, v] of Object.entries(defaults)) if (!headers.has(k)) headers.set(k, v);
return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
}
async function waitReady(origin: string, timeoutMs = 15000): Promise<void> {
const deadline = Date.now() + timeoutMs;
for (;;) {
try {
await fetch(origin + "/__wrnexus/health-probe", { method: "HEAD" });
return; // any HTTP response (incl. 404) means the server is up
} catch {
if (Date.now() > deadline) throw new Error(`app at ${origin} did not start in time`);
await new Promise((r) => setTimeout(r, 150));
}
}
}
/** Boot every app as a child process, then route by Host on one gateway port. */
export async function startGateway(opts: GatewayOptions): Promise<RunningGateway> {
const port = opts.port ?? 3000;
const hostname = opts.hostname ?? "::";
const displayHost = hostname === "0.0.0.0" || hostname === "::" ? "localhost" : hostname;
const mode = opts.mode ?? "development";
const serveEntry = fileURLToPath(import.meta.resolve("@wrnexus/dev-server/serve-entry"));
const targets: Target[] = opts.apps.map((app, i) => {
const appPort = app.port ?? port + 1 + i;
const dir = resolve(app.dir);
const child =
mode === "production"
? spawn(process.execPath, [join(dir, "dist", "server.js")], {
stdio: "inherit",
env: { ...process.env, PORT: String(appPort) },
})
: spawn(
process.execPath,
[serveEntry, join(dir, "app"), String(appPort), mode, "127.0.0.1"],
{
stdio: "inherit",
},
);
return { ...app, port: appPort, origin: `http://localhost:${appPort}`, child };
});
await Promise.all(targets.map((t) => waitReady(t.origin)));
const byHost = new Map<string, Target>();
for (const t of targets) for (const d of t.domains) byHost.set(d.toLowerCase(), t);
const pick = (host: string): Target | null =>
byHost.get((host.split(":")[0] ?? "").toLowerCase()) ?? null;
const sec = opts.security ?? {};
const forwardedHeaders = sec.forwardedHeaders !== false; // default on
const rateLimit = sec.rateLimit
? makeRateLimiter(sec.rateLimit.max, sec.rateLimit.windowMs ?? 60_000)
: null;
const now = () => Date.now();
const server = Bun.serve<WsBridge>({
port,
hostname,
development: mode === "development",
maxRequestBodySize: 50 * 1024 * 1024,
async fetch(req, srv) {
const url = new URL(req.url);
const ip = srv.requestIP(req)?.address ?? "";
// Health/status endpoint (not proxied).
if (url.pathname === "/__gateway/health") {
return Response.json({
ok: true,
apps: targets.map((t) => ({ name: t.name, domains: t.domains, origin: t.origin })),
});
}
// Edge rate limit (global, by client IP).
if (rateLimit && !rateLimit(ip, now())) {
return new Response("Too Many Requests", { status: 429, headers: { "retry-after": "60" } });
}
// Route by Host. Unknown host → 404 when trustedHostsOnly, else first app.
const target =
pick(req.headers.get("host") ?? "") ?? (sec.trustedHostsOnly ? null : targets[0]!);
if (!target) {
return new Response("Unknown host", { status: 404 });
}
// Per-app access control (basic auth / IP allowlist / forward-auth).
const denied = await checkAuth(target.auth, req, ip);
if (denied) {
if (sec.accessLog)
console.log(
`${req.headers.get("host")} ${req.method} ${url.pathname}${denied.status} (${target.name})`,
);
return denied;
}
// WebSocket upgrade → proxy the socket to the app's realtime server.
if (req.headers.get("upgrade")?.toLowerCase() === "websocket") {
const ok = srv.upgrade(req, {
data: { origin: target.origin, path: url.pathname + url.search, queue: [] },
});
return ok ? undefined : new Response("WebSocket upgrade failed", { status: 400 });
}
// HTTP → reverse-proxy to the app, preserving method/headers/body.
const headers = new Headers(req.headers);
if (forwardedHeaders) {
headers.set("x-forwarded-host", req.headers.get("host") ?? "");
headers.set("x-forwarded-proto", url.protocol.replace(":", ""));
if (ip) headers.set("x-forwarded-for", ip);
}
const body =
req.method === "GET" || req.method === "HEAD" ? undefined : await req.arrayBuffer();
let res: Response;
try {
res = await fetch(target.origin + url.pathname + url.search, {
method: req.method,
headers,
body,
redirect: "manual",
});
} catch {
res = new Response(`Gateway: app '${target.name}' is unavailable.`, { status: 502 });
}
if (sec.headers) res = applyEdgeHeaders(res);
if (sec.accessLog)
console.log(
` ${req.headers.get("host")} ${req.method} ${url.pathname}${res.status} (${target.name})`,
);
return res;
},
websocket: {
open(ws) {
const backendUrl = ws.data.origin.replace(/^http/, "ws") + ws.data.path;
const backend = new WebSocket(backendUrl);
ws.data.backend = backend;
backend.addEventListener("open", () => {
for (const m of ws.data.queue) backend.send(m);
ws.data.queue = [];
});
backend.addEventListener("message", (e) => ws.send(e.data as string | ArrayBufferLike));
backend.addEventListener("close", () => ws.close());
backend.addEventListener("error", () => ws.close());
},
message(ws, message) {
const backend = ws.data.backend;
if (backend && backend.readyState === WebSocket.OPEN) backend.send(message);
else ws.data.queue.push(message);
},
close(ws) {
ws.data.backend?.close();
},
},
});
const stop = () => {
server.stop();
for (const t of targets) t.child.kill();
};
process.on("SIGINT", stop);
process.on("SIGTERM", stop);
const url = `http://${displayHost}:${port}`;
console.log(`\n ⚡ WrNexus gateway — ${url}`);
for (const t of targets) {
console.log(` ${t.domains.join(", ")}${t.name} (${t.origin})`);
}
console.log("");
return { port, url, stop };
}
+56
View File
@@ -0,0 +1,56 @@
/**
* HMR hub — tracks connected browser HMR sockets and broadcasts update events.
*
* Each open page holds one WebSocket to `/__wrnexus/hmr`. The in-process file
* watcher (see index.ts) classifies a change and broadcasts a typed message:
*
* { type: "css" } -> the browser hot-swaps the stylesheet (no reload)
* { type: "reload" } -> the browser asks for fresh HTML over the HMR socket
*
* Server-logic changes (pages/api/middleware/realtime) are NOT broadcast here:
* they require a fresh process, so the child exits and the supervisor respawns
* it. The browser then reconnects and performs a soft DOM morph automatically.
*/
export type HmrMessage = { type: "css"; version: number } | { type: "reload"; version: number };
/** Minimal shape of a Bun ServerWebSocket we rely on. */
interface Socket {
send(data: string): unknown;
}
export class HmrHub {
private sockets = new Set<Socket>();
private version = 0;
add(ws: Socket): void {
this.sockets.add(ws);
}
remove(ws: Socket): void {
this.sockets.delete(ws);
}
broadcast(message: HmrMessage): void {
const payload = JSON.stringify(message);
for (const ws of this.sockets) {
try {
ws.send(payload);
} catch {
this.sockets.delete(ws);
}
}
}
get size(): number {
return this.sockets.size;
}
css(): void {
this.broadcast({ type: "css", version: ++this.version });
}
reload(): void {
this.broadcast({ type: "reload", version: ++this.version });
}
}
+259
View File
@@ -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";
+111
View File
@@ -0,0 +1,111 @@
/**
* Request pipeline helpers: middleware execution and safe module loading.
* These are deliberately runtime-agnostic (no Bun APIs) so they could run on
* Node too.
*/
import { pathToFileURL } from "node:url";
import { readFileSync, writeFileSync, mkdirSync, statSync } from "node:fs";
import { dirname, join, basename } from "node:path";
import { compileWireFile } from "@wrnexus/compiler";
import type { Context, Middleware } from "@wrnexus/core";
/**
* Run an onion-style middleware chain, ending in `final` (the route handler).
* Each middleware receives `next`; calling it advances the chain. A middleware
* may short-circuit by returning a Response without calling `next`.
*/
export function runMiddleware(
middlewares: Middleware[],
ctx: Context,
final: () => Promise<Response> | Response,
): Promise<Response> {
let lastIndex = -1;
const dispatch = (index: number): Promise<Response> => {
if (index <= lastIndex) {
return Promise.reject(new Error("next() called multiple times"));
}
lastIndex = index;
const mw = middlewares[index];
if (!mw) return Promise.resolve(final());
return Promise.resolve(mw(ctx, () => dispatch(index + 1)));
};
return dispatch(0);
}
/**
* Cache of imported route modules. Modules are only ever loaded from absolute
* paths discovered during the startup scan — never from request input.
*/
const moduleCache = new Map<string, Promise<Record<string, unknown>>>();
export function loadModule(file: string): Promise<Record<string, unknown>> {
let mod = moduleCache.get(file);
if (!mod) {
// `.wrn` files are compiled to TypeScript first, then imported.
const target = file.endsWith(".wrn") ? compileWireToTs(file) : file;
// pathToFileURL handles Windows drive letters and spaces correctly.
mod = import(pathToFileURL(target).href) as Promise<Record<string, unknown>>;
moduleCache.set(file, mod);
}
return mod;
}
/**
* A single cache dir for ALL `.wrn` compilation (set once at server start).
* When unset, compilation falls back to a sibling `.wrnexus/` next to each file.
*/
let compileCacheDir: string | null = null;
/**
* Point all `.wrn` compilation at ONE cache dir (typically `<appRoot>/.wrnexus`)
* instead of scattering a `.wrnexus/` folder next to every `.wrn` source. Called
* once by the dev server at startup.
*/
export function setCompileCacheDir(dir: string): void {
compileCacheDir = dir;
}
/** FNV-1a hash of a string → short base36, to make unique flat cache filenames. */
function hashPath(s: string): string {
let h = 0x811c9dc5;
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
h = Math.imul(h, 0x01000193);
}
return (h >>> 0).toString(36);
}
/**
* Compile a `.wrn` file to a `.ts` file inside the shared `.wrnexus/` cache dir and
* return the generated path. The cache dir is hidden, so the router never re-scans
* it and the dev watcher ignores it. Output names are flat + hash-suffixed by the
* absolute source path, so `.wrn` files from anywhere (the app AND node_modules UI
* components) share one cache dir without colliding. Generated modules are
* self-contained (no relative imports), so the cache location doesn't affect them.
*/
function compileWireToTs(file: string): string {
const cacheDir = compileCacheDir ?? join(dirname(file), ".wrnexus");
const name = basename(file).replace(/\.wrn$/, "");
const out = join(cacheDir, `${name}-${hashPath(file)}.wrn.ts`);
// Skip recompiling when the on-disk cache is already newer than the source
// (e.g. reused across dev restarts) — avoids a read + compile + write.
try {
if (statSync(out).mtimeMs >= statSync(file).mtimeMs) return out;
} catch {
/* cache missing → compile below */
}
const code = compileWireFile(readFileSync(file, "utf8"));
mkdirSync(cacheDir, { recursive: true });
writeFileSync(out, code, "utf8");
return out;
}
/** Forget cached modules (used by build/dev tooling if needed). */
export function clearModuleCache(): void {
moduleCache.clear();
}
+369
View File
@@ -0,0 +1,369 @@
/**
* @wrnexus/dev-server/prod — the production server (Point 4).
*
* Unlike dev, there is NO filesystem scan and NO on-the-fly bundling at runtime.
* `wrnexus build` generates an entry that statically imports every route and
* component module and hands them here as a manifest. We rebuild the (cheap)
* route-matching tables from the raw patterns and run the exact same request
* runtime as dev — just with production error pages and no live-reload client.
*/
import type { Middleware, Mode, SecurityConfig, SeoConfig } from "@wrnexus/core";
import {
compileRoutePattern,
matchRoute,
sortRoutes,
type Route,
type Router,
} from "@wrnexus/router";
import { getReactiveRuntime, getNavRuntime, getRealtimeRuntime } from "@wrnexus/csr";
import {
loadEnv,
resolveProfile,
type ResolvedTheme,
type MobileConfig,
type PwaConfig,
} from "@wrnexus/styles";
import { VALIDATE_RUNTIME } from "@wrnexus/validation";
import { I18N_RUNTIME, type ResolvedI18n } from "@wrnexus/i18n";
import { setDb, registerDb, getDb, hasDb, migrate } from "@wrnexus/db";
import { connectFromConfig } from "@wrnexus/db/connect";
import {
configureStorage,
serveStoredFile,
UPLOAD_RUNTIME,
UPLOAD_JS_HREF,
UPLOADS_PREFIX,
type StorageConfig,
} from "@wrnexus/uploader";
import { realtimeBusFromConfig } from "./realtime-bus.ts";
import { createHandlers, type AssetServer, type WsData } from "./runtime.ts";
import { servePublicAsset } from "./public.ts";
type RouteModule = Record<string, unknown>;
export interface ManifestRoute {
/** URL pattern, e.g. `/users/[id]`. */
raw: string;
/** The statically-imported route module. */
mod: RouteModule;
}
export interface ProdManifest {
pages: ManifestRoute[];
api: ManifestRoute[];
realtime: ManifestRoute[];
middleware: Middleware[];
/** Server-rendered components, statically imported and keyed by name. */
components: { name: string; mod: RouteModule }[];
/** Named page layouts (from app/layouts/*.wrn). */
layouts: { name: string; mod: RouteModule }[];
}
export interface ProdOptions {
/** Absolute path to the pre-built global stylesheet, if any. */
stylesPath?: string;
/** Small production stylesheet inlined into the document head. */
inlineStyles?: string;
/** Absolute path to the pre-built reactive runtime. */
reactivePath?: string;
/** Absolute path to the pre-built theme stylesheet (`theme.css`). */
themePath?: string;
/** Absolute path to the pre-built theme runtime (`theme.js`). */
themeJsPath?: string;
/** Resolved theme config: enables `<html data-theme>` + `theme.css` link. */
theme?: ResolvedTheme;
/** Absolute path to the pre-built Wire UI stylesheet (`ui.css`). */
uiCssPath?: string;
/** Pre-built `window.__wireSchemas = {...}` script for client validation. */
schemasJs?: string;
/** Resolved i18n bundle (default lang + locale messages). */
i18n?: ResolvedI18n;
/** Default database connection (driver + url); enables `getDb()`. */
db?: { driver: string; url: string };
/** Named databases, reached with `getDb("<name>")`. */
databases?: Record<string, { driver: string; url: string }>;
/**
* Absolute path to the default db's migrations bundled into the build
* (`dist/migrations`). When set, they are applied on startup — like dev.
*/
migrationsDir?: string;
/** Bundled migrations dirs for named dbs (name → `dist/db/<name>/migrations`). */
databaseMigrationDirs?: Record<string, string>;
/**
* Auto-apply bundled migrations on server startup (default: true). Set false
* for deploys that migrate in a separate release step (e.g. multiple instances
* behind a load balancer, where you migrate once before rolling out).
*/
autoMigrate?: boolean;
/** Realtime scaling: bridge room broadcasts over Redis across app processes. */
realtime?: { scale?: boolean; redisUrl?: string };
/** File-upload storage: named stores (local dir / S3). Local dirs resolve against cwd. */
storage?: StorageConfig;
/** Cache-busting version appended to framework asset URLs. */
assetVersion?: string;
/** Absolute path to copied public assets, if any. */
publicDir?: string;
/** Raw HTML appended to every page head. */
head?: string;
/** Global SEO defaults. */
seo?: SeoConfig;
mobile?: MobileConfig;
pwa?: PwaConfig | false;
/** Framework security headers and CORS policy. */
security?: SecurityConfig;
port?: number;
hostname?: string;
maxBodyBytes?: number;
}
const MODE: Mode = "production";
const JS_HEADERS = {
"content-type": "text/javascript; charset=utf-8",
"cache-control": "public, max-age=31536000, immutable",
};
const CSS_HEADERS = {
"content-type": "text/css; charset=utf-8",
"cache-control": "public, max-age=31536000, immutable",
};
function resolvePort(explicit?: number): number {
if (typeof explicit === "number" && Number.isFinite(explicit)) return explicit;
const envPort = process.env.PORT;
if (!envPort) return 3000;
const parsed = Number(envPort);
return Number.isFinite(parsed) ? parsed : 3000;
}
/** Build the route-matching tables + a module map from the manifest. */
function buildProdRouter(manifest: ProdManifest): {
router: Router;
modules: Map<string, RouteModule>;
} {
const modules = new Map<string, RouteModule>();
const toRoutes = (entries: ManifestRoute[]): Route[] => {
const routes = entries.map((e): Route => {
const { regex, paramNames } = compileRoutePattern(e.raw);
// Use the raw pattern as a stable module key.
modules.set(e.raw, e.mod);
return { raw: e.raw, file: e.raw, regex, paramNames };
});
return sortRoutes(routes);
};
const pages = toRoutes(manifest.pages);
const api = toRoutes(manifest.api);
const realtime = toRoutes(manifest.realtime);
// Components are keyed by name; the runtime resolves them via loadModule(name).
for (const c of manifest.components) modules.set(c.name, c.mod);
// Layouts share the module map under a `layout:` prefix (no name collisions).
for (const l of manifest.layouts) modules.set(`layout:${l.name}`, l.mod);
const router: Router = {
pages,
api,
realtime,
middlewareFiles: [],
components: manifest.components.map((c) => ({ name: c.name, file: c.name })),
layouts: manifest.layouts.map((l) => ({ name: l.name, file: `layout:${l.name}` })),
schemas: [], // descriptors are pre-baked into schemasJs; not needed at runtime
matchPage: (p) => matchRoute(pages, p),
matchApi: (p) => matchRoute(api, p),
matchRealtime: (p) => matchRoute(realtime, p),
};
return { router, modules };
}
/** Serve a pre-built asset file from disk, or 404 if it is absent. */
async function serveFile(path: string | undefined, headers: Record<string, string>) {
if (!path) return new Response("Not Found", { status: 404 });
const file = Bun.file(path);
if (!(await file.exists())) return new Response("Not Found", { status: 404 });
return new Response(file, { headers });
}
/** Production asset server: pre-built files from disk, reactive runtime inlined. */
function createProdAssetServer(opts: ProdOptions): AssetServer {
return {
async serve(pathname: string): Promise<Response | null> {
if (pathname === "/__wrnexus/reactive.js") {
if (opts.reactivePath) {
const file = Bun.file(opts.reactivePath);
if (await file.exists()) return new Response(file, { headers: JS_HEADERS });
}
return new Response(getReactiveRuntime(), { headers: JS_HEADERS });
}
if (pathname === "/__wrnexus/nav.js")
return new Response(getNavRuntime(), { headers: JS_HEADERS });
if (pathname === "/__wrnexus/realtime.js")
return new Response(getRealtimeRuntime(), { headers: JS_HEADERS });
if (pathname === "/__wrnexus/validate.js")
return new Response(VALIDATE_RUNTIME, { headers: JS_HEADERS });
if (pathname === "/__wrnexus/i18n.js")
return new Response(I18N_RUNTIME, { headers: JS_HEADERS });
if (pathname === UPLOAD_JS_HREF) return new Response(UPLOAD_RUNTIME, { headers: JS_HEADERS });
if (pathname.startsWith(UPLOADS_PREFIX)) {
return (await serveStoredFile(pathname)) ?? new Response("Not Found", { status: 404 });
}
if (pathname === "/__wrnexus/schemas.js") {
return new Response(opts.schemasJs ?? "window.__wireSchemas={};", { headers: JS_HEADERS });
}
if (pathname === "/__wrnexus/theme.css") return serveFile(opts.themePath, CSS_HEADERS);
if (pathname === "/__wrnexus/theme.js") return serveFile(opts.themeJsPath, JS_HEADERS);
if (pathname === "/__wrnexus/ui.css") return serveFile(opts.uiCssPath, CSS_HEADERS);
if (pathname === "/__wrnexus/styles.css") return serveFile(opts.stylesPath, CSS_HEADERS);
return servePublicAsset(opts.publicDir, pathname, MODE);
},
};
}
/**
* Build the portable request handler from a precompiled manifest — a
* WinterCG-style `fetch(request) => Response` plus the websocket handlers, with
* NO server bound. This is the deployment-adapter seam: `createProductionServer`
* wraps it in `Bun.serve`, `serveNode` bridges it onto `node:http`, and edge or
* serverless targets can call `fetch` directly.
*/
export function createProductionHandlers(
manifest: ProdManifest,
opts: ProdOptions,
): ReturnType<typeof createHandlers> {
const { router, modules } = buildProdRouter(manifest);
const assets = createProdAssetServer(opts);
// Configure the default + named databases. Migrations must already be applied
// (`wrnexus db migrate [--db=<name>]` against the production databases).
if (opts.db) {
try {
setDb(connectFromConfig(opts.db));
} catch (err) {
console.warn("[wrnexus] database setup failed:", err instanceof Error ? err.message : err);
}
}
for (const [name, cfg] of Object.entries(opts.databases ?? {})) {
try {
registerDb(name, connectFromConfig(cfg));
} catch (err) {
console.warn(
`[wrnexus] database '${name}' setup failed:`,
err instanceof Error ? err.message : err,
);
}
}
// File-upload storage. Relative local dirs resolve against the deployment cwd
// (NOT dist/, which is rebuilt) so uploads persist across deploys.
configureStorage(opts.storage, process.cwd());
// Middleware is already an ordered array of functions.
const getMiddleware = async (): Promise<Middleware[]> => manifest.middleware;
// In prod, modules are pre-imported; "loading" is a map lookup.
const loadModule = async (key: string): Promise<RouteModule> => {
const mod = modules.get(key);
if (!mod) throw new Error(`No module registered for route ${key}`);
return mod;
};
const handlers = createHandlers({
mode: MODE,
hmr: false,
router,
loadModule,
getMiddleware,
assets,
hasStyles: !!opts.stylesPath,
hasUi: !!opts.uiCssPath,
theme: opts.theme,
i18n: opts.i18n,
inlineStyles: opts.inlineStyles,
assetVersion: opts.assetVersion,
head: opts.head,
seo: opts.seo,
mobile: opts.mobile,
pwa: opts.pwa,
security: opts.security,
maxBodyBytes: opts.maxBodyBytes,
realtimeBus: realtimeBusFromConfig(opts.realtime),
});
return handlers;
}
/**
* Apply migrations bundled into the build before the server accepts traffic, so
* a fresh deploy always runs on the latest schema — exactly like the dev server
* auto-migrates on startup. Applied migrations are tracked in `_wire_migrations`,
* so this is idempotent and safe to run on every boot. Opt out with
* `autoMigrate: false` (e.g. multi-instance deploys that migrate in a release
* step). A failed migration is logged but does not crash the server: each
* migration runs in a transaction, so the DB is left at the last good state.
*/
async function runStartupMigrations(opts: ProdOptions): Promise<void> {
if (opts.autoMigrate === false) return;
const targets: { name?: string; dir: string }[] = [];
if (opts.db && opts.migrationsDir) targets.push({ dir: opts.migrationsDir });
for (const [name, dir] of Object.entries(opts.databaseMigrationDirs ?? {})) {
targets.push({ name, dir });
}
for (const { name, dir } of targets) {
const label = name ? ` (db: ${name})` : "";
if (!hasDb(name)) continue;
try {
const applied = await migrate(getDb(name), dir);
if (applied.length) {
console.log(
`WrNexus: applied ${applied.length} migration(s)${label}: ${applied.join(", ")}`,
);
}
} catch (err) {
console.error(
`WrNexus: migration failed${label}`,
err instanceof Error ? err.message : err,
);
}
}
}
/** Start the production server on Bun from a precompiled manifest. */
export async function createProductionServer(manifest: ProdManifest, opts: ProdOptions) {
// Load the deployment's .env cascade for the active profile (real env wins),
// so runtime secrets are available even though config was baked at build time.
loadEnv(process.cwd(), resolveProfile({ mode: "production" }));
const handlers = createProductionHandlers(manifest, opts);
// Bring the schema up to date before listening (opt out with autoMigrate:false).
await runStartupMigrations(opts);
const server = Bun.serve<WsData>({
port: resolvePort(opts.port),
hostname: opts.hostname ?? "0.0.0.0",
development: false,
maxRequestBodySize: opts.maxBodyBytes ?? 10 * 1024 * 1024,
fetch: handlers.fetch,
websocket: handlers.websocket,
});
// Graceful shutdown: stop accepting connections, then exit.
let shuttingDown = false;
const shutdown = () => {
if (shuttingDown) return;
shuttingDown = true;
console.log("WrNexus: shutting down…");
server.stop();
process.exit(0);
};
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
console.log(`WrNexus (production) listening on http://${server.hostname}:${server.port}`);
return server;
}
+127
View File
@@ -0,0 +1,127 @@
import { existsSync } from "node:fs";
import { readFile, stat } from "node:fs/promises";
import { extname, join, relative, resolve, sep } from "node:path";
import { isSafeRequestPath } from "@wrnexus/core";
import type { Mode } from "@wrnexus/core";
const CONTENT_TYPES: Record<string, string> = {
".avif": "image/avif",
".css": "text/css; charset=utf-8",
".gif": "image/gif",
".html": "text/html; charset=utf-8",
".ico": "image/x-icon",
".jpeg": "image/jpeg",
".jpg": "image/jpeg",
".js": "text/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".mjs": "text/javascript; charset=utf-8",
".pdf": "application/pdf",
".png": "image/png",
".svg": "image/svg+xml",
".txt": "text/plain; charset=utf-8",
".wasm": "application/wasm",
".webp": "image/webp",
".woff": "font/woff",
".woff2": "font/woff2",
".xml": "application/xml; charset=utf-8",
};
const IMMUTABLE_CACHE = "public, max-age=31536000, immutable";
const REVALIDATE_CACHE = "public, max-age=0, must-revalidate";
function cachePolicy(filePath: string, mode: Mode): string {
if (mode !== "production") return "no-cache";
const name = filePath.split(/[\\/]/).pop() ?? "";
if (/\.html?$/i.test(name)) return REVALIDATE_CACHE;
return /(?:^|[.-])[a-f0-9]{8,}(?:[.-]|$)/i.test(name) ? IMMUTABLE_CACHE : "public, max-age=3600";
}
/** Cache the public-dir existence check so it isn't a sync stat on every request. */
const publicDirExistsCache = new Map<string, boolean>();
function publicDirExists(dir: string): boolean {
let exists = publicDirExistsCache.get(dir);
if (exists === undefined) {
exists = existsSync(dir);
publicDirExistsCache.set(dir, exists);
}
return exists;
}
const DEFAULT_FAVICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="14" fill="#0f172a"/>
<path d="M15 18h8l5 22 7-22h7l7 22 5-22h8L53 50h-8l-7-21-7 21h-8L15 18z" fill="#6c8cff"/>
</svg>`;
export async function servePublicAsset(
publicDir: string | undefined,
pathname: string,
mode: Mode,
): Promise<Response | null> {
if (pathname === "/" || pathname.startsWith("/__wrnexus/")) return null;
if (!isSafeRequestPath(pathname)) return null;
const fallback = pathname === "/favicon.ico" ? defaultFaviconResponse(mode) : null;
if (!publicDir || !publicDirExists(publicDir)) return fallback;
const rel = safePublicRelativePath(pathname);
if (!rel) return fallback;
const base = resolve(publicDir);
let filePath = resolve(base, rel);
if (!isInside(base, filePath)) return fallback;
try {
const info = await stat(filePath);
if (info.isDirectory()) {
filePath = resolve(filePath, "index.html");
if (!isInside(base, filePath)) return null;
}
const fileInfo = await stat(filePath);
if (!fileInfo.isFile()) return null;
const body = await readFile(filePath);
const contentType =
CONTENT_TYPES[extname(filePath).toLowerCase()] ?? "application/octet-stream";
return new Response(body, {
headers: {
"content-type": contentType,
"cache-control": cachePolicy(filePath, mode),
"x-content-type-options": "nosniff",
},
});
} catch {
return fallback;
}
}
function defaultFaviconResponse(mode: Mode): Response {
return new Response(DEFAULT_FAVICON, {
headers: {
"content-type": "image/svg+xml; charset=utf-8",
"cache-control": mode === "production" ? "public, max-age=86400" : "no-cache",
"x-content-type-options": "nosniff",
},
});
}
function safePublicRelativePath(pathname: string): string | null {
let decoded: string;
try {
decoded = decodeURIComponent(pathname);
} catch {
return null;
}
const segments = decoded.split("/").filter(Boolean);
if (segments.some((segment) => segment.startsWith(".") || segment.includes("\\"))) {
return null;
}
return segments.length ? join(...segments) : null;
}
function isInside(base: string, target: string): boolean {
const rel = relative(base, target);
return rel === "" || (!!rel && !rel.startsWith("..") && !rel.includes(`..${sep}`));
}
+18
View File
@@ -0,0 +1,18 @@
/**
* Build the cross-process realtime bus from config. When realtime scaling is
* enabled, room broadcasts are bridged over Redis pub/sub so they reach clients
* on every app process/instance (multiple runs, or multiple apps behind the
* gateway). Returns undefined when scaling is off (single-process realtime).
*/
import type { RealtimeBus } from "@wrnexus/core";
import { createPubSub } from "@wrnexus/pubsub";
import { redisDriver } from "@wrnexus/pubsub/redis";
export function realtimeBusFromConfig(cfg?: {
scale?: boolean;
redisUrl?: string;
}): RealtimeBus | undefined {
if (!cfg?.scale && !cfg?.redisUrl) return undefined;
return createPubSub(redisDriver(cfg.redisUrl));
}
File diff suppressed because it is too large Load Diff
+61
View File
@@ -0,0 +1,61 @@
/**
* The child process launched by the dev supervisor.
*
* bun run serve-entry.ts <appDir> <port> <mode> [hostname]
*
* It starts the dev server and prints the route table. Because it runs in its
* own process, every restart re-imports all route modules fresh — that is how
* the supervisor delivers live reload of edited server code.
*/
import { dirname } from "node:path";
import { startServer } from "./index.ts";
import { loadAppConfig, headToString, findStyleEntry, renderFontHead } from "@wrnexus/styles";
import type { Mode } from "@wrnexus/core";
const [appDir, portStr, modeStr, hostname] = process.argv.slice(2);
const mode = (modeStr as Mode) || "development";
const port = Number(portStr) || 3000;
// Load optional wrnexus.config.ts (sits next to the app/ dir) + resolve styles.
const appRoot = dirname(appDir!);
const config = await loadAppConfig(appRoot);
const styleEntry = findStyleEntry(appDir!, appRoot, config.styles?.entry);
const server = await startServer({
appDir: appDir!,
port,
hostname,
mode,
styleEntry,
stylesConfig: config.styles,
head: [renderFontHead(config.fonts), headToString(config.head)].filter(Boolean).join("\n "),
seo: config.seo,
security: config.security,
theme: config.theme,
i18n: config.i18n,
db: config.db,
databases: config.databases,
realtime: config.realtime,
storage: config.storage,
mobile: config.mobile,
pwa: config.pwa,
});
const r = server.router;
const group = (label: string, items: { raw: string }[]) => {
if (!items.length) return;
console.log(` ${label}`);
for (const it of items) console.log(` ${it.raw}`);
};
console.log(`\n ⚡ WrNexus — ${server.url}\n`);
group("Pages", r.pages);
group("API", r.api);
group("Realtime", r.realtime);
if (r.components.length) {
console.log(" Components");
for (const c of r.components) console.log(` ${c.name}`);
}
console.log("");
+75
View File
@@ -0,0 +1,75 @@
/**
* In-process file watcher (dev). Classifies each change and chooses the
* cheapest update that still shows the latest page:
*
* *.css / styles/ -> invalidate CSS cache, push { type: "css" } (instant swap)
* anything else -> a server module changed (pages, components, api, …):
* it can't be re-imported in process, so request a
* restart (the supervisor respawns us; the browser then
* morphs in the new HTML).
*/
import { watch, type FSWatcher } from "node:fs";
import type { HmrHub } from "./hmr.ts";
import type { DevAssetServer } from "./assets.ts";
export interface WatchOptions {
appDir: string;
hub: HmrHub;
assets: DevAssetServer;
/** Called when a change requires a fresh process. */
onServerChange: () => void;
}
function isIgnored(rel: string): boolean {
return (
rel.includes("node_modules/") ||
rel.includes(".wrnexus/") ||
rel.startsWith("dist/") ||
rel.includes("/dist/")
);
}
type Kind = "css" | "server";
function classify(rel: string): Kind {
if (rel.endsWith(".css") || rel.startsWith("styles/") || rel.includes("/styles/")) return "css";
return "server";
}
/** Returns the watcher so the caller can close it before a restart (important on
* Windows, where a live recursive fs.watch handle can block `process.exit`). */
export function startWatcher(opts: WatchOptions): FSWatcher | undefined {
const { appDir, hub, assets, onServerChange } = opts;
const pending = new Set<Kind>();
let timer: ReturnType<typeof setTimeout> | null = null;
const flush = (): void => {
timer = null;
// A server change always wins (needs a restart).
if (pending.has("server")) {
pending.clear();
onServerChange();
return;
}
if (pending.has("css")) {
assets.invalidateCss();
hub.css();
}
pending.clear();
};
try {
return watch(appDir, { recursive: true }, (_event, filename) => {
if (!filename) return;
const rel = filename.toString().replace(/\\/g, "/");
if (isIgnored(rel)) return;
pending.add(classify(rel));
if (timer) clearTimeout(timer);
timer = setTimeout(flush, 60); // debounce editor write bursts
});
} catch (err) {
console.warn("[wrnexus] file watching unavailable; HMR disabled", err);
return undefined;
}
}