304 lines
16 KiB
Markdown
304 lines
16 KiB
Markdown
# @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; // dev: "127.0.0.1"; production: "0.0.0.0"
|
|
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;
|
|
}
|
|
```
|
|
|
|
Forward auth is a verification hook, not a login page. Configure `forward.url` with a
|
|
dedicated endpoint such as `http://sso.localhost:3000/api/verify`. The gateway forwards
|
|
the request's `Cookie` and `Authorization` headers plus `X-Forwarded-Host`,
|
|
`X-Forwarded-Proto`, `X-Original-Method`, and `X-Original-Uri` (including its query
|
|
string). The verifier must return 2xx only for an authenticated session and 401/403
|
|
otherwise. Pointing forward auth at an SSO home page that always returns 200 allows
|
|
every request and does not implement SSO.
|
|
|
|
For browser SSO, the verifier may return a `302`/`303`/`307`/`308` with a `Location`
|
|
header pointing to its login page. The gateway passes that redirect to the browser. The
|
|
login flow should validate a signed `returnTo` value before redirecting back; API clients
|
|
should receive `401`/`403` instead of an HTML login redirect.
|
|
|
|
Open the gateway URL (normally `http://127.0.0.1:3000`), not an app's internal
|
|
port. The gateway exposes `/__gateway/health` (JSON list of routed apps) and returns a
|
|
`RunningGateway` (`{ port, url, stop() }`). Use `--host=0.0.0.0` when other devices need
|
|
to reach a development gateway.
|
|
|
|
### `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>
|