first commit
This commit is contained in:
@@ -0,0 +1,287 @@
|
||||
# @wrnexus/dev-server
|
||||
|
||||
> The WrNexus HTTP + WebSocket server runtime — request dispatch, SSR document assembly, live-reload (HMR), and the portable production handler.
|
||||
|
||||
Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework.
|
||||
|
||||
## Overview
|
||||
|
||||
This package is the server runtime that powers a WrNexus app in both development and production. A single **request runtime** (`createHandlers`) owns HTTP/WebSocket dispatch and SSR document assembly; it knows nothing about _how_ modules and assets are produced, so the dev and prod entry points wire in different backends: dev uses dynamic module loading plus on-the-fly bundling and injects a live-reload client; prod uses a static, pre-built manifest with cache-immutable assets. The package also ships a multi-app **gateway** (route several apps by `Host` header behind one port) and a portable `node:http` adapter for WinterCG hosts. It is entirely server-side and Bun-native (`Bun.serve`, `Bun.file`, `Bun.gzipSync`).
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun add @wrnexus/dev-server
|
||||
```
|
||||
|
||||
> Private package — the machine must be authenticated to the `wrnexus` npm org
|
||||
> (a read token in `~/.npmrc`). Requires **Bun** (Node is not supported for the full server; the `node:http` adapter is for WinterCG embedding only).
|
||||
|
||||
## API
|
||||
|
||||
### Main entry (`@wrnexus/dev-server`)
|
||||
|
||||
| Export | Kind | Purpose |
|
||||
| --------------------------------------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `startServer(opts: ServeOptions)` | `Promise<RunningServer>` | Start the dev server on `Bun.serve`: builds the router, connects/migrates databases, wires assets + HMR, and starts the file watcher. |
|
||||
| `createHandlers(deps: RuntimeDeps)` | `Handlers` | The shared request runtime (fetch + websocket handlers). Re-exported from `runtime.ts`. |
|
||||
| `createProductionServer(manifest, opts)` | `Bun.Server` | Start the production server from a precompiled manifest. |
|
||||
| `createProductionHandlers(manifest, opts)` | `Handlers` | Build the portable prod fetch/websocket handlers with no server bound (the deployment-adapter seam). |
|
||||
| `startGateway(opts: GatewayOptions)` | `Promise<RunningGateway>` | Boot multiple apps as child processes and route by `Host`. |
|
||||
| `toRequest`, `writeResponse`, `nodeListener`, `serveNode` | functions | `node:http` ↔ WinterCG `Request`/`Response` adapter. |
|
||||
| `RESTART_EXIT_CODE` | `number` (`97`) | Exit code the dev child uses to ask the supervisor for a fresh process. |
|
||||
| `STYLES_HREF`, `HMR_CLIENT_JS` | constants | The global stylesheet URL and the inline HMR client script. |
|
||||
|
||||
Exported types: `ServeOptions`, `RunningServer`, `RuntimeDeps`, `AssetServer`, `WsData`, `GatewayApp`, `GatewayOptions`, `GatewayAuth`, `GatewaySecurity`, `RunningGateway`, `FetchHandler`.
|
||||
|
||||
### `startServer(opts)`
|
||||
|
||||
```ts
|
||||
interface ServeOptions {
|
||||
appDir: string; // absolute/relative path to the app/ dir
|
||||
port?: number; // default 3000
|
||||
hostname?: string; // default "localhost"
|
||||
mode?: Mode; // "development" | "production"; default "development"
|
||||
hmr?: boolean; // inject live-reload client; default (mode === "development")
|
||||
styleEntry?: string | null; // resolved absolute path to the global CSS entry
|
||||
stylesConfig?: StylesConfig; // custom styles processor (e.g. Tailwind/PostCSS)
|
||||
head?: string; // raw HTML appended to every page <head>
|
||||
seo?: SeoConfig; // global SEO defaults
|
||||
security?: SecurityConfig; // security headers + CORS policy
|
||||
theme?: ThemeConfig; // design-token theme (merged over built-in light/dark)
|
||||
i18n?: I18nConfig; // default language + supported locales
|
||||
db?: { driver: string; url: string }; // default db → getDb(); dev auto-migrates
|
||||
databases?: Record<string, { driver: string; url: string }>; // named dbs → getDb("<name>")
|
||||
realtime?: { scale?: boolean; redisUrl?: string }; // bridge rooms over Redis across processes
|
||||
}
|
||||
|
||||
interface RunningServer {
|
||||
port: number;
|
||||
hostname: string;
|
||||
url: string;
|
||||
router: Router;
|
||||
stop(): void;
|
||||
}
|
||||
```
|
||||
|
||||
In development, `startServer` also connects `app/db/migrations` (and `app/db/<name>/migrations`) and auto-applies migrations, then starts an in-process file watcher. CSS edits hot-swap live; any other server change triggers `process.exit(RESTART_EXIT_CODE)` so the dev supervisor (`@wrnexus/cli`) respawns the process with fresh modules.
|
||||
|
||||
### `createHandlers(deps)`
|
||||
|
||||
The core runtime shared by dev and prod. It handles CORS preflight, `/healthz` and `/__wrnexus/health`, request-body size limits (413), HMR socket upgrades (`/__wrnexus/hmr`), realtime WebSocket upgrades (`defineRoom` default export or a raw `websocket` export), the middleware pipeline, API routes (`/api/*`), framework assets (`/__wrnexus/*`), public assets, and full SSR page rendering (component mounts, layouts, slots, i18n markers, per-page script selection, ETag/304, gzip).
|
||||
|
||||
```ts
|
||||
interface RuntimeDeps {
|
||||
mode: Mode;
|
||||
hmr: boolean; // inject the live-reload client into pages
|
||||
router: Router;
|
||||
loadModule(file: string): Promise<Record<string, unknown>>;
|
||||
getMiddleware(): Promise<Middleware[]>;
|
||||
assets: AssetServer; // serves /__wrnexus/* (islands, reactive, hmr)
|
||||
hasStyles?: boolean; // inject the global stylesheet link
|
||||
hasUi?: boolean; // inject the Wire UI stylesheet (/__wrnexus/ui.css)
|
||||
theme?: ResolvedTheme; // enables /__wrnexus/theme.css + <html data-theme>
|
||||
i18n?: ResolvedI18n; // enables ctx.t, <html lang>, {t:key} markers
|
||||
inlineStyles?: string; // inline small prod stylesheets into <head>
|
||||
assetVersion?: string; // cache-busting ?v= on framework asset URLs
|
||||
head?: string; // raw HTML appended to every page <head>
|
||||
seo?: SeoConfig;
|
||||
security?: SecurityConfig;
|
||||
maxBodyBytes?: number; // 413 above this; default 10 MB
|
||||
hub?: HmrHub; // browser HMR sockets (dev only)
|
||||
realtimeBus?: RealtimeBus; // cross-process room bridge (Redis pub/sub)
|
||||
}
|
||||
|
||||
interface Handlers {
|
||||
fetch(req: Request, server: UpgradeServer): Promise<Response | undefined>;
|
||||
websocket: { open; message; close; drain };
|
||||
}
|
||||
```
|
||||
|
||||
`WsData` is the per-connection socket tag — a discriminated union of `{ kind: "realtime"; handler }`, `{ kind: "room"; meta }`, or `{ kind: "hmr" }`.
|
||||
|
||||
### `createProductionServer(manifest, opts)` / `createProductionHandlers(manifest, opts)`
|
||||
|
||||
Production runs the _same_ request runtime as dev, but with no filesystem scan and no runtime bundling. `wrnexus build` emits an entry that statically imports every route/component/layout module and passes them as a `ProdManifest`; the route-matching tables are rebuilt from the raw patterns.
|
||||
|
||||
```ts
|
||||
interface ProdManifest {
|
||||
pages: { raw: string; mod: RouteModule }[];
|
||||
api: { raw: string; mod: RouteModule }[];
|
||||
realtime: { raw: string; mod: RouteModule }[];
|
||||
middleware: Middleware[];
|
||||
components: { name: string; mod: RouteModule }[];
|
||||
layouts: { name: string; mod: RouteModule }[];
|
||||
}
|
||||
|
||||
interface ProdOptions {
|
||||
stylesPath?: string;
|
||||
inlineStyles?: string;
|
||||
reactivePath?: string;
|
||||
themePath?: string;
|
||||
themeJsPath?: string;
|
||||
theme?: ResolvedTheme;
|
||||
uiCssPath?: string;
|
||||
schemasJs?: string;
|
||||
i18n?: ResolvedI18n;
|
||||
db?: { driver: string; url: string };
|
||||
databases?: Record<string, { driver: string; url: string }>;
|
||||
realtime?: { scale?: boolean; redisUrl?: string };
|
||||
assetVersion?: string;
|
||||
publicDir?: string;
|
||||
head?: string;
|
||||
seo?: SeoConfig;
|
||||
security?: SecurityConfig;
|
||||
port?: number;
|
||||
hostname?: string;
|
||||
maxBodyBytes?: number;
|
||||
}
|
||||
```
|
||||
|
||||
`createProductionServer` also loads the `.env` cascade for the `production` profile, installs `SIGTERM`/`SIGINT` graceful shutdown, and binds `0.0.0.0` (port from `opts.port` or `$PORT`, default 3000). Migrations are **not** run here — apply them first (`wrnexus db migrate`). `createProductionHandlers` returns the bare handlers for edge/serverless/`node:http` deployment.
|
||||
|
||||
### `startGateway(opts)` — multi-app gateway
|
||||
|
||||
Serves several apps behind one port and routes each request to the right app by its `Host` header. Each app runs as its own child process (full isolation); the gateway is a thin host-based reverse proxy for HTTP and WebSocket. Apps communicate at runtime via `@wrnexus/pubsub` (use the Redis driver so messages cross processes).
|
||||
|
||||
```ts
|
||||
interface GatewayOptions {
|
||||
port?: number; // default 3000
|
||||
hostname?: string; // default "localhost"
|
||||
mode?: "development" | "production";
|
||||
apps: GatewayApp[];
|
||||
security?: GatewaySecurity;
|
||||
}
|
||||
|
||||
interface GatewayApp {
|
||||
name: string; // app id (for logs)
|
||||
dir: string; // app root (contains app/ + wrnexus.config.ts)
|
||||
domains: string[]; // host names routed here
|
||||
port?: number; // fixed internal port; else assigned
|
||||
auth?: GatewayAuth; // per-app edge access control
|
||||
}
|
||||
|
||||
interface GatewayAuth {
|
||||
basic?: { user: string; pass: string } | Array<{ user: string; pass: string }>;
|
||||
allowIps?: string[]; // exact-match IP allowlist
|
||||
forward?: { url: string }; // forward-auth (SSO): 2xx allows
|
||||
}
|
||||
|
||||
interface GatewaySecurity {
|
||||
trustedHostsOnly?: boolean; // 404 unknown hosts instead of first app
|
||||
rateLimit?: { max: number; windowMs?: number }; // global by client IP (429)
|
||||
headers?: boolean; // add baseline edge security headers
|
||||
forwardedHeaders?: boolean; // set X-Forwarded-* (default true)
|
||||
accessLog?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
The gateway exposes `/__gateway/health` (JSON list of routed apps) and returns a `RunningGateway` (`{ port, url, stop() }`).
|
||||
|
||||
### `node:http` adapter (from `./adapters/node.ts`)
|
||||
|
||||
For embedding the WinterCG handler behind an existing Node server or a WinterCG host. Note the full app still needs Bun-compatible globals (`Bun.file`, `bun:sqlite`, etc.); only the `Request`/`Response` conversion is fully portable.
|
||||
|
||||
```ts
|
||||
type FetchHandler = (req: Request) => Response | undefined | Promise<Response | undefined>;
|
||||
|
||||
toRequest(req: IncomingMessage, opts?): Promise<Request>
|
||||
writeResponse(res: ServerResponse, response: Response): Promise<void> // preserves multiple Set-Cookie
|
||||
nodeListener(handler: FetchHandler, opts?): (req, res) => Promise<void>
|
||||
serveNode(handler: FetchHandler, opts?): Promise<Server>
|
||||
```
|
||||
|
||||
### Subpath export: `@wrnexus/dev-server/serve-entry`
|
||||
|
||||
The child process the dev supervisor launches:
|
||||
|
||||
```bash
|
||||
bun run serve-entry.ts <appDir> <port> <mode>
|
||||
```
|
||||
|
||||
It loads the optional `wrnexus.config.ts`, resolves the style entry, calls `startServer`, and prints the route table (Pages / API / Realtime / Components). 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. `startGateway` resolves this entry via `import.meta.resolve("@wrnexus/dev-server/serve-entry")` to spawn each dev app.
|
||||
|
||||
## Usage
|
||||
|
||||
### Programmatic dev server
|
||||
|
||||
```ts
|
||||
import { startServer } from "@wrnexus/dev-server";
|
||||
|
||||
const server = await startServer({
|
||||
appDir: "./app",
|
||||
port: 3000,
|
||||
mode: "development",
|
||||
theme: {/* design tokens */},
|
||||
db: { driver: "sqlite", url: "file:./data/app.db" },
|
||||
});
|
||||
|
||||
console.log(`Running at ${server.url}`);
|
||||
// server.stop();
|
||||
```
|
||||
|
||||
### Production server from a build manifest
|
||||
|
||||
```ts
|
||||
import { createProductionServer } from "@wrnexus/dev-server";
|
||||
import { manifest } from "./dist/manifest.js"; // generated by `wrnexus build`
|
||||
|
||||
createProductionServer(manifest, {
|
||||
stylesPath: "./dist/styles.css",
|
||||
reactivePath: "./dist/reactive.js",
|
||||
assetVersion: process.env.BUILD_ID,
|
||||
db: { driver: "postgres", url: process.env.DATABASE_URL! },
|
||||
port: Number(process.env.PORT) || 3000,
|
||||
});
|
||||
```
|
||||
|
||||
### Embedding the handler on `node:http`
|
||||
|
||||
```ts
|
||||
import { createProductionHandlers, serveNode } from "@wrnexus/dev-server";
|
||||
|
||||
const handlers = createProductionHandlers(manifest, opts);
|
||||
await serveNode(handlers.fetch, { port: 8080 });
|
||||
```
|
||||
|
||||
### Multi-app gateway
|
||||
|
||||
```ts
|
||||
import { startGateway } from "@wrnexus/dev-server";
|
||||
|
||||
await startGateway({
|
||||
port: 3000,
|
||||
apps: [
|
||||
{ name: "web", dir: "./apps/web", domains: ["localhost", "web.localhost"] },
|
||||
{
|
||||
name: "admin",
|
||||
dir: "./apps/admin",
|
||||
domains: ["admin.localhost"],
|
||||
auth: { basic: { user: "root", pass: "s3cret" } },
|
||||
},
|
||||
],
|
||||
security: { trustedHostsOnly: true, rateLimit: { max: 600 } },
|
||||
});
|
||||
```
|
||||
|
||||
## Framework asset routes
|
||||
|
||||
The runtime serves these framework-owned paths (dev builds them live; prod serves pre-built/immutable versions):
|
||||
|
||||
- `/__wrnexus/nav.js`, `/__wrnexus/reactive.js`, `/__wrnexus/realtime.js` — client runtimes
|
||||
- `/__wrnexus/validate.js`, `/__wrnexus/schemas.js`, `/__wrnexus/i18n.js` — validation + i18n runtimes
|
||||
- `/__wrnexus/theme.css`, `/__wrnexus/theme.js`, `/__wrnexus/ui.css`, `/__wrnexus/styles.css` — styles
|
||||
- `/__wrnexus/hmr` — dev-only HMR WebSocket
|
||||
- `/__wrnexus/csr` — server-evaluated CSR bindings for browser-side API fetches
|
||||
|
||||
Pages get only the scripts they use: `nav.js` always, `reactive.js` when a page has a `data-scope`/CSR fetch, plus theme/validation/i18n/realtime runtimes when the relevant markup is present.
|
||||
|
||||
## Requirements / Notes
|
||||
|
||||
- **Bun-only.** Uses `Bun.serve` (HTTP + WebSocket), `Bun.file`, and `Bun.gzipSync`. The full app also relies on `bun:sqlite` / `Bun.SQL` via `@wrnexus/db`.
|
||||
- Orchestrates the whole framework: `@wrnexus/core` (context, security, realtime registry), `@wrnexus/router`, `@wrnexus/ssr` (`renderDocument`), `@wrnexus/csr` (client runtimes), `@wrnexus/compiler` (`.wrn` → TS), `@wrnexus/styles`, `@wrnexus/ui`, `@wrnexus/validation`, `@wrnexus/i18n`, `@wrnexus/db`, and `@wrnexus/pubsub` (Redis-backed cross-process realtime).
|
||||
- `.wrn` files are compiled to TypeScript into a hidden sibling `.wrnexus/` cache dir and dynamically imported; the module cache means each edited server module needs a fresh process (dev) — hence the restart-on-change model.
|
||||
- Responses are gzipped when the client accepts it and the body is a buffered, compressible payload ≥ 1 KB; streaming/SSE responses opt out via `Cache-Control: no-transform`.
|
||||
</content>
|
||||
|
||||
</invoke>
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@wrnexus/dev-server",
|
||||
"version": "0.2.12",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./serve-entry": "./src/serve-entry.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "workspace:*",
|
||||
"@wrnexus/router": "workspace:*",
|
||||
"@wrnexus/ssr": "workspace:*",
|
||||
"@wrnexus/csr": "workspace:*",
|
||||
"@wrnexus/compiler": "workspace:*",
|
||||
"@wrnexus/styles": "workspace:*",
|
||||
"@wrnexus/ui": "workspace:*",
|
||||
"@wrnexus/validation": "workspace:*",
|
||||
"@wrnexus/i18n": "workspace:*",
|
||||
"@wrnexus/db": "workspace:*",
|
||||
"@wrnexus/pubsub": "workspace:*",
|
||||
"@wrnexus/uploader": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}`));
|
||||
}
|
||||
@@ -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
@@ -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("");
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { HMR_CLIENT_JS } from "../src/runtime.ts";
|
||||
|
||||
test("HMR client syncs fresh HTML over the websocket", () => {
|
||||
expect(HMR_CLIENT_JS).toContain('send({ type: "sync"');
|
||||
expect(HMR_CLIENT_JS).toContain('msg.type === "html"');
|
||||
expect(HMR_CLIENT_JS).toContain("applyHtml(msg.html)");
|
||||
expect(HMR_CLIENT_JS).not.toContain("fetch(location.href");
|
||||
expect(HMR_CLIENT_JS).not.toContain("location.reload()");
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { MOBILE_CLIENT } from "../src/runtime.ts";
|
||||
|
||||
test("native client is valid JavaScript and relays platform-specific event types", () => {
|
||||
expect(() => new Bun.Transpiler({ loader: "js" }).transformSync(MOBILE_CLIENT)).not.toThrow();
|
||||
expect(MOBILE_CLIENT).toContain("installNativeEvents(document)");
|
||||
expect(MOBILE_CLIENT).toContain("/^data-on-wrnexus-(?:browser|mobile)-(.+)$/");
|
||||
expect(MOBILE_CLIENT).toContain("document.addEventListener(type, handleNativeEvent, true)");
|
||||
expect(MOBILE_CLIENT).toContain('"wrnexus-" + nativeTarget + "-" + event.type');
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { toRequest, writeResponse, nodeListener } from "../src/adapters/node.ts";
|
||||
|
||||
function mockReq(opts: {
|
||||
method?: string;
|
||||
url?: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: string;
|
||||
}): any {
|
||||
const em = new EventEmitter() as any;
|
||||
em.method = opts.method ?? "GET";
|
||||
em.url = opts.url ?? "/";
|
||||
em.headers = opts.headers ?? { host: "localhost" };
|
||||
setTimeout(() => {
|
||||
if (opts.body != null) em.emit("data", Buffer.from(opts.body));
|
||||
em.emit("end");
|
||||
}, 0);
|
||||
return em;
|
||||
}
|
||||
|
||||
function mockRes(): any {
|
||||
const chunks: Buffer[] = [];
|
||||
return {
|
||||
statusCode: 0,
|
||||
headers: null as any,
|
||||
headersSent: false,
|
||||
ended: false,
|
||||
writeHead(status: number, headers: any) {
|
||||
this.statusCode = status;
|
||||
this.headers = headers;
|
||||
this.headersSent = true;
|
||||
},
|
||||
write(chunk: any) {
|
||||
chunks.push(Buffer.from(chunk));
|
||||
},
|
||||
end(chunk?: any) {
|
||||
if (chunk) chunks.push(Buffer.from(chunk));
|
||||
this.ended = true;
|
||||
},
|
||||
body() {
|
||||
return Buffer.concat(chunks).toString();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("toRequest converts method, URL, headers and body", async () => {
|
||||
const req = mockReq({
|
||||
method: "POST",
|
||||
url: "/api/x?a=1",
|
||||
headers: { host: "ex.test", "content-type": "application/json" },
|
||||
body: '{"k":1}',
|
||||
});
|
||||
const r = await toRequest(req);
|
||||
expect(r.method).toBe("POST");
|
||||
expect(r.url).toBe("http://ex.test/api/x?a=1");
|
||||
expect(r.headers.get("content-type")).toBe("application/json");
|
||||
expect(await r.json()).toEqual({ k: 1 });
|
||||
});
|
||||
|
||||
test("toRequest honours x-forwarded-proto for the origin", async () => {
|
||||
const req = mockReq({ url: "/p", headers: { host: "ex.test", "x-forwarded-proto": "https" } });
|
||||
const r = await toRequest(req);
|
||||
expect(r.url).toBe("https://ex.test/p");
|
||||
});
|
||||
|
||||
test("writeResponse writes status, body and keeps multiple Set-Cookie separate", async () => {
|
||||
const res = mockRes();
|
||||
const response = new Response("hello", {
|
||||
status: 201,
|
||||
headers: { "content-type": "text/plain" },
|
||||
});
|
||||
response.headers.append("set-cookie", "a=1");
|
||||
response.headers.append("set-cookie", "b=2");
|
||||
await writeResponse(res, response);
|
||||
expect(res.statusCode).toBe(201);
|
||||
expect(res.headers["content-type"]).toBe("text/plain");
|
||||
expect(res.headers["set-cookie"]).toEqual(["a=1", "b=2"]);
|
||||
expect(res.body()).toBe("hello");
|
||||
});
|
||||
|
||||
test("nodeListener dispatches a request through a fetch handler", async () => {
|
||||
const handler = (req: Request) => Response.json({ path: new URL(req.url).pathname });
|
||||
const res = mockRes();
|
||||
await nodeListener(handler)(mockReq({ url: "/hi", headers: { host: "x" } }), res);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(JSON.parse(res.body())).toEqual({ path: "/hi" });
|
||||
});
|
||||
|
||||
test("nodeListener returns 500 when the handler throws", async () => {
|
||||
const handler = () => {
|
||||
throw new Error("boom");
|
||||
};
|
||||
const res = mockRes();
|
||||
await nodeListener(handler)(mockReq({ url: "/x", headers: { host: "x" } }), res);
|
||||
expect(res.statusCode).toBe(500);
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { parseComponentProps, resolveTProps } from "../src/runtime.ts";
|
||||
|
||||
test("parseComponentProps extracts quoted attrs, skips data-component", () => {
|
||||
const props = parseComponentProps('data-component="badge" label="New" count="3"');
|
||||
expect(props).toEqual({ label: "New", count: "3" });
|
||||
});
|
||||
|
||||
test("resolveTProps translates {t:key} markers via the active language", () => {
|
||||
const t = (key: string) => ({ "status.new": "Nuevo", greeting: "Hola" })[key] ?? key;
|
||||
const props = resolveTProps({ label: "{t:status.new}", plain: "as-is", hi: "{t:greeting}!" }, t);
|
||||
expect(props).toEqual({ label: "Nuevo", plain: "as-is", hi: "Hola!" });
|
||||
});
|
||||
|
||||
test("resolveTProps leaves values without markers untouched", () => {
|
||||
const props = resolveTProps({ a: "1", b: "hello" }, (k) => `T(${k})`);
|
||||
expect(props).toEqual({ a: "1", b: "hello" });
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { servePublicAsset } from "../src/public.ts";
|
||||
|
||||
test("production HTML revalidates while fingerprinted assets are immutable", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-public-"));
|
||||
mkdirSync(join(root, "assets"));
|
||||
writeFileSync(join(root, "index.html"), "home");
|
||||
writeFileSync(join(root, "assets", "app.abcdef1234.js"), "app");
|
||||
const html = await servePublicAsset(root, "/index.html", "production");
|
||||
const asset = await servePublicAsset(root, "/assets/app.abcdef1234.js", "production");
|
||||
expect(html?.headers.get("cache-control")).toBe("public, max-age=0, must-revalidate");
|
||||
expect(asset?.headers.get("cache-control")).toBe("public, max-age=31536000, immutable");
|
||||
expect(asset?.headers.get("x-content-type-options")).toBe("nosniff");
|
||||
});
|
||||
|
||||
test("public server rejects encoded traversal", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-public-"));
|
||||
expect(await servePublicAsset(root, "/%2e%2e/secret", "production")).toBeNull();
|
||||
});
|
||||
Reference in New Issue
Block a user