Files
2026-07-12 15:55:18 +05:30

382 lines
25 KiB
Markdown

# @wrnexus/core
> The framework core: the request `Context`, middleware contract, and the security, session, caching, streaming, realtime, and JSX primitives every other WrNexus package builds on.
Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework.
## Overview
`@wrnexus/core` is the shared foundation of WrNexus. It defines the `Context`
object that flows through every middleware, page, and API route, plus the
`Middleware`/`Next` contract they implement. On top of that it ships the
building blocks a real app needs: cookie-backed sessions, password auth, CSRF
protection, rate limiting, request logging, HTTP + in-memory caching, file
uploads, streaming/SSE responses, WebSocket "rooms", security headers/CORS, and
a server-side JSX runtime that renders to HTML strings. Everything here is
**server-side** and Bun-native (it uses `Bun.password`, `Bun.write`, the
web-standard `Request`/`Response`, and `crypto`). You depend on it directly and
transitively through the rest of the framework.
## Installation
```bash
bun add @wrnexus/core
```
> Private package — the machine must be authenticated to the `wrnexus` npm org
> (a read token in `~/.npmrc`). Requires **Bun** (Node is not supported).
## API
### Context & middleware — `@wrnexus/core`
The `Context` (`ctx`) is the single value passed to middleware and handlers.
| Export | Kind | Description |
| ------------------------------ | ---- | ------------------------------------------------------------------------------------------------------------------------ |
| `Context` | type | Per-request object: `req`, `url`, `lang`, `t`, `params`, `locals`, `user?`, `ip?`, `cookies`, `session`, `localStorage`. |
| `Next` | type | `() => Promise<Response> \| Response` — invokes the next middleware/handler. |
| `Middleware` | type | `(ctx, next) => Promise<Response> \| Response`. Return `next()` to continue, or a `Response` to short-circuit. |
| `createContext(req, url)` | fn | Build a fresh `Context` for an incoming request (wires up cookies, session, localStorage snapshot). |
| `withContextHeaders(ctx, res)` | fn | Apply accumulated headers (e.g. `Set-Cookie`) from the context onto a response. |
| `PageComponent` | type | `(ctx) => string \| Promise<string>` — a page module's default export. |
| `PageMeta` / `SeoConfig` | type | `<head>` metadata: `title`, `description`, `canonical`, `robots`, `image`, `twitterCard`, `themeColor`, … |
| `TFunction` | type | `(key, params?) => string` — translate a key for `ctx.lang`, interpolating `{param}` placeholders. |
Key `Context` fields:
- `ctx.locals` — per-request scratch space for passing values between middleware.
- `ctx.user` — the authenticated user (populated by `sessionAuth`/`logIn`), or `null`.
- `ctx.ip` — the direct socket peer IP (not spoofable via headers).
- `ctx.cookies` / `ctx.session` / `ctx.localStorage` — see **Storage** below.
### Authentication — `@wrnexus/core`
Passwords are hashed with argon2id via `Bun.password`; sessions ride the
cookie-backed `SessionStore`.
| Export | Signature | Notes |
| -------------------------------- | -------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| `hashPassword(password)` | `(string) => Promise<string>` | argon2id hash to store. |
| `verifyPassword(password, hash)` | `(string, string) => Promise<boolean>` | Constant-safe; returns `false` on bad/empty hash. |
| `logIn(ctx, user)` | `(Context, U) => void` | Regenerates the session id (fixation defense), stores the user, sets `ctx.user`. |
| `logOut(ctx)` | `(Context) => void` | Clears the session and `ctx.user`. |
| `getUser(ctx)` | `(Context) => U \| null` | Current user from `ctx.user`, falling back to the session. |
| `sessionAuth()` | `() => Middleware` | Hydrates `ctx.user` from the session each request. Register early. |
| `requireAuth(options?)` | `(RequireAuthOptions?) => Middleware` | Guard: API/fetch requests get `401 JSON`, page navigations get `302` to `loginPath` (default `/login`) with `?next=`. |
| `SESSION_USER_KEY` | `"user"` | Session key holding the user. |
`RequireAuthOptions`: `{ loginPath?: string }`.
### CSRF — `@wrnexus/core`
Double-submit cookie pattern: a readable `wire-csrf` cookie is echoed in an
`x-csrf-token` header on unsafe requests.
| Export | Signature | Notes |
| ----------------------------- | -------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| `csrfToken(ctx)` | `(Context) => string` | Ensures the CSRF cookie exists and returns its token. |
| `verifyCsrf(ctx)` | `(Context) => boolean` | Safe methods (GET/HEAD/OPTIONS) pass; otherwise header/`ctx.locals._csrf` must match the cookie (constant-time). |
| `csrfProtection()` | `() => Middleware` | 403s unsafe requests with a missing/mismatched token. |
| `CSRF_COOKIE` / `CSRF_HEADER` | `"wire-csrf"` / `"x-csrf-token"` | Cookie & header names. |
### Rate limiting — `@wrnexus/core`
Fixed-window limiter that returns `429` with `Retry-After` and emits
`RateLimit-Limit`/`-Remaining`/`-Reset` headers.
| Export | Signature | Notes |
| --------------------- | ----------------------------------- | ---------------------------------------------------------------------- |
| `rateLimit(options?)` | `(RateLimitOptions?) => Middleware` | Main middleware. |
| `peerKey(ctx)` | `(Context) => string` | Non-spoofable key from `ctx.ip` (default). |
| `proxyKey(ctx)` | `(Context) => string` | Trusts `x-forwarded-for`/`x-real-ip`. Use only behind a trusted proxy. |
| `defaultKey` | — | **Deprecated** alias of `proxyKey`. |
`RateLimitOptions`: `windowMs` (default `60_000`), `max` (default `60`),
`key`, `trustProxy` (default `false` → keys on `peerKey`; `true``proxyKey`),
`message`, `headers` (default `true`), `store`.
`RateLimitStore` is pluggable — implement `hit(key, windowMs, now) => Bucket | Promise<Bucket>`
(a `Bucket` is `{ count, resetAt }`) to back limits with Redis/SQL across
instances. The default store is process-local memory.
### Request logging — `@wrnexus/core`
| Export | Signature | Notes |
| ------------------------- | --------------------------------------- | -------------------------------------------------------------------------------- |
| `requestLogger(options?)` | `(RequestLoggerOptions?) => Middleware` | One record per request with a request id (stored on `ctx.locals[requestIdKey]`). |
`RequestLoggerOptions`: `format` (`"pretty"` default \| `"json"`), `sink(line, record)`
(default `console.log`), `requestIdKey` (default `"requestId"`), `now`.
`RequestRecord` = `{ time, id, method, path, status, durationMs }`.
### Caching — `@wrnexus/core`
| Export | Kind | Notes |
| -------------------------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `TTLCache<V>` | class | In-memory TTL cache: `get`, `set`, `getOrLoad(key, loader, ttlMs?)`, `delete`, `clear`, `size`. Constructor takes a default `ttlMs` (60s). |
| `cacheControl(options)` | fn | Build a `Cache-Control` value from `CacheControlOptions`. |
| `withCacheControl(res, options)` | fn | Apply `Cache-Control` to a response. |
| `etag(body, weak?)` | fn | Stable quoted FNV-1a ETag (weak by default). |
| `notModified(req, tag)` | fn | `true` when `If-None-Match` matches — send a `304`. |
`CacheControlOptions`: `maxAge`, `sMaxAge`, `private`, `noStore`, `noCache`,
`staleWhileRevalidate`, `immutable`.
### File uploads — `@wrnexus/core`
Bun parses `multipart/form-data` via `Request.formData()`; these helpers
validate and persist the resulting `File`s.
| Export | Signature | Notes |
| --------------------------- | --------------------------------------------------- | -------------------------------------------------------------------------------------- |
| `collectUploads(form)` | `(FormData) => { field, file }[]` | Every non-empty `File` in a parsed form. |
| `saveUpload(file, options)` | `(File, SaveUploadOptions) => Promise<SavedUpload>` | Validates size/type, sanitizes the name, writes via `Bun.write`. Throws `UploadError`. |
| `sanitizeFilename(name)` | `(string) => string` | Strips separators, traversal, control/illegal chars; caps at 255. |
| `UploadError` | class | Thrown on rejected uploads. |
`SaveUploadOptions`: `dir` (required), `maxBytes`, `allowedTypes` (MIME types
like `"image/png"` and/or extensions like `".png"`), `filename(file)`.
`SavedUpload` = `{ path, filename, size, type }`.
### Streaming & SSE — `@wrnexus/core`
| Export | Signature | Notes |
| ------------------------------- | -------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
| `streamResponse(source, init?)` | `(Iterable\|AsyncIterable<string\|Uint8Array>, StreamResponseInit?) => Response` | Streaming `Response` from a chunk source (basis for streaming SSR). |
| `sse(source)` | `(Iterable\|AsyncIterable<ServerSentEvent>) => Response` | `text/event-stream` response. |
`StreamResponseInit`: `status`, `headers`, `contentType` (default
`"text/html; charset=utf-8"`). `ServerSentEvent`: `{ data, event?, id?, retry? }`.
### Realtime rooms — `@wrnexus/core`
WebSocket rooms. A file in `app/realtime/` exports
`default defineRoom({ ... })` and is served at `ws://host/realtime/<name>`.
| Export | Signature | Notes |
| --------------------------------------- | -------------------------------------------------------- | -------------------------------------------------------------------- |
| `defineRoom(handlers)` | `(RoomHandlers) => RoomDefinition` | Define a room. Export the result as `default`. |
| `isRoomDefinition(value)` | `(unknown) => boolean` | Type guard for a room definition. |
| `createRealtimeRegistry()` | `() => RealtimeRegistry` | Server-side connection manager mapping sockets ↔ rooms. |
| `bridgeRealtime(registry, bus, topic?)` | `(RealtimeRegistry, RealtimeBus, string?) => () => void` | Bridge broadcasts/`toUser` sends across processes via a pub/sub bus. |
`RoomHandlers`: `authorize(info) => boolean` (gate before accept — return
`false` to reject with 403), `onConnect(client)`, `onMessage(client, message)`
(JSON auto-parsed), `onLeave(client)`. A handler receives a `RoomClient` with
`id`, `user`, `query`, `data`, `room`, and `send` / `broadcast` /
`to(id)` / `toUser(user)` / `close`. The `Room` API adds `state`, `clients()`,
`count()`, and `broadcast`. `RealtimeBus` is structurally satisfied by
`@wrnexus/pubsub`. Legacy `RealtimeHandler`/`RealtimeSocket` raw handlers are
still exported. Connection-targeted sends (`send`, `to(id)`) stay local; room
broadcasts and `toUser` cross the bridge.
### Error pages — `@wrnexus/core`
| Export | Signature | Notes |
| ------------------------------ | -------------------------------- | ----------------------------------------------------- |
| `renderError(err, mode)` | `(unknown, Mode) => Response` | Dev page (with stack) or generic prod page by `mode`. |
| `renderDevError(err, status?)` | `(unknown, number?) => Response` | Readable HTML error page including the stack trace. |
| `renderProdError(status?)` | `(number?) => Response` | Generic page that never leaks file paths. |
| `renderNotFound()` | `() => Response` | Simple 404 page. |
`Mode` = `"development" | "production"`.
### Security headers & CORS — `@wrnexus/core`
| Export | Signature | Notes |
| -------------------------------------------------------- | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `withSecurityHeaders(req, res, mode, security?, nonce?)` | → `Response` | Applies CORS + CSP, HSTS, `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`, `Permissions-Policy`, COOP, Trusted Types, and `extraHeaders`. |
| `createCorsPreflightResponse(req, security?)` | → `Response \| null` | Builds a `204`/`403` preflight response for CORS `OPTIONS` requests. |
| `isWebSocketOriginAllowed(req, security?)` | → `boolean` | Guards WS upgrades against cross-site hijacking (allows same-origin, configured CORS origins, and non-browser clients). |
Config types: `SecurityConfig` (top-level), `CorsConfig`/`CorsOrigin`,
`ContentSecurityPolicyConfig`/`CspDirectiveValue`, `HstsConfig`,
`TrustedTypesConfig`, `PermissionsPolicyConfig`. WrNexus applies sensible
defaults (self-only CSP, `frame-ancestors 'none'`, restrictive Permissions-Policy,
HSTS in production, Trusted Types in production); each is individually
overridable or disable-able via `false`.
### Storage: cookies, sessions, localStorage — `@wrnexus/core`
These back the `ctx.cookies`, `ctx.session`, and `ctx.localStorage` fields.
| Export | Kind | Notes |
| --------------------------------------------------------- | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `setSessionBackend(backend)` | fn | Swap the **sync** session persistence backend (`SessionBackend`) — e.g. `bun:sqlite`. Default is process-local memory. Call once at startup. |
| `loadSession(backend, options?)` | fn → `Middleware` | Back `ctx.session` with an **async** store (`AsyncSessionBackend`: `load`/`save`/`destroy`) — loads before the request, saves after. `options.ttlMs` default 24h. |
| `CookieStore` | type | `get`/`getAll`/`has`/`set(name, value, opts?)`/`delete`/`headers`. |
| `SessionStore` | type | `id`/`get`/`getAll`/`set`/`delete`/`regenerate`/`clear`. |
| `LocalStorageSnapshot` | type | Read-only view of the browser's localStorage sent via header for CSR bindings. |
| `CookieOptions` | type | `path`, `domain`, `maxAge`, `expires`, `httpOnly`, `secure`, `sameSite`. |
| `SessionEntry` / `SessionBackend` / `AsyncSessionBackend` | types | Session persistence contracts. |
### Low-level security helpers — `@wrnexus/core`
| Export | Signature | Notes |
| ----------------------------- | --------------------- | --------------------------------------------------- |
| `escapeHtml(value)` | `(string) => string` | Escape for HTML text/attributes. |
| `isSafeIslandName(name)` | `(string) => boolean` | Allow only a conservative `[A-Za-z0-9_-]+` charset. |
| `isSafeRequestPath(pathname)` | `(string) => boolean` | Reject NULs, `..` traversal, and backslashes. |
### JSX runtime — `@wrnexus/core`, `@wrnexus/core/jsx-runtime`, `@wrnexus/core/jsx-dev-runtime`
A server-side JSX runtime that renders to HTML **strings** (no virtual DOM).
Point `tsconfig`'s `jsxImportSource` at `@wrnexus/core`.
| Export | Kind | Notes |
| ------------------------------------------ | ------ | --------------------------------------------------------------------------------------- |
| `jsx` / `jsxs` | fn | The runtime factory (TypeScript calls these automatically). Returns an `Html` instance. |
| `Fragment` | symbol | JSX fragment marker. |
| `Html` | class | Wraps a raw, already-safe HTML string (`toString()` returns it). |
| `mustache(expr)` | fn | Emit a `{{expr}}` placeholder (tagged-template or string form) for the client binder. |
| `JSXComponent` / `JSXProps` / `Renderable` | types | Component signature and renderable value types. |
Values interpolated as children are HTML-escaped unless they are an `Html`
instance; use `dangerouslySetInnerHTML={{ __html }}` for trusted markup. Void
elements render without a closing tag; `className``class`, `htmlFor``for`, and
`style` objects are serialized to CSS text.
The subpath exports map to the runtime TypeScript's JSX transform expects:
```jsonc
// tsconfig.json
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "@wrnexus/core",
},
}
```
## Usage
### A minimal middleware chain
```ts
import {
createContext,
withContextHeaders,
sessionAuth,
requireAuth,
requestLogger,
rateLimit,
csrfProtection,
type Middleware,
} from "@wrnexus/core";
const chain: Middleware[] = [
requestLogger({ format: "json" }),
rateLimit({ max: 100, windowMs: 60_000 }),
csrfProtection(),
sessionAuth(),
requireAuth({ loginPath: "/login" }),
];
```
### Password auth
```ts
import { hashPassword, verifyPassword, logIn, getUser } from "@wrnexus/core";
// Registration
const passwordHash = await hashPassword(form.password);
// Login
if (await verifyPassword(form.password, user.passwordHash)) {
logIn(ctx, { id: user.id, email: user.email });
}
const current = getUser<{ id: string }>(ctx); // or null
```
### HTTP caching with ETags
```ts
import { etag, notModified, withCacheControl } from "@wrnexus/core";
const body = JSON.stringify(data);
const tag = etag(body);
if (notModified(ctx.req, tag)) {
return new Response(null, { status: 304, headers: { ETag: tag } });
}
const res = new Response(body, { headers: { ETag: tag, "content-type": "application/json" } });
return withCacheControl(res, { maxAge: 60, staleWhileRevalidate: 300 });
```
### Streaming SSE
```ts
import { sse } from "@wrnexus/core";
async function* ticks() {
for (let n = 0; ; n++) {
yield { event: "tick", data: String(n) };
await Bun.sleep(1000);
}
}
export default (ctx) => sse(ticks());
```
### A realtime room
```ts
// app/realtime/chat.ts
import { defineRoom } from "@wrnexus/core";
export default defineRoom({
authorize: (info) => !!info.user, // require auth
onConnect(client) {
client.user = client.query.user;
client.room.broadcast({ type: "join", id: client.id });
},
onMessage(client, msg) {
client.broadcast({ type: "say", from: client.id, text: msg.text });
},
});
```
Scale it across processes:
```ts
import { createRealtimeRegistry, bridgeRealtime } from "@wrnexus/core";
import { createPubSub } from "@wrnexus/pubsub";
import { redisDriver } from "@wrnexus/pubsub/redis";
const registry = createRealtimeRegistry();
bridgeRealtime(registry, createPubSub(redisDriver(process.env.REDIS_URL)));
```
### JSX rendering
```tsx
import { Html } from "@wrnexus/core";
function Card({ title, body }: { title: string; body: string }) {
return (
<article class="card">
<h2>{title}</h2>
<p>{body}</p>
</article>
);
}
const html: Html = <Card title="Hi" body="<b>escaped</b> automatically" />;
return new Response(html.toString(), { headers: { "content-type": "text/html" } });
```
## Requirements / Notes
- **Bun-only.** Uses `Bun.password` (argon2id), `Bun.write`, web-standard
`Request`/`Response`/`FormData`/`ReadableStream`, and the global `crypto`.
Node is not supported.
- Session and rate-limit backends default to **process-local memory**. For
multi-instance deployments, swap in a shared backend: `setSessionBackend` (sync,
e.g. `bun:sqlite`) or `loadSession` (async, e.g. Redis) for sessions, a custom
`RateLimitStore` for limits, and `bridgeRealtime` for realtime.
- Works with the rest of the framework: realtime bridging is structurally
compatible with [`@wrnexus/pubsub`](../pubsub); the security, auth, and JSX
primitives here are consumed by the WrNexus server/router packages.
- Subpath exports: `@wrnexus/core/jsx-runtime` and `@wrnexus/core/jsx-dev-runtime`
for TypeScript's automatic JSX transform.