413 lines
26 KiB
Markdown
413 lines
26 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 }`.
|
|
|
|
### Resilience — `@wrnexus/core`
|
|
|
|
`resilientCall` standardizes cancellation-aware timeouts, controlled retries,
|
|
fixed or exponential backoff, fallback responses, circuit breaking, and bounded
|
|
concurrency. Reuse a declarative circuit/bulkhead options object, or an explicit
|
|
`CircuitBreaker`/`Bulkhead` instance, wherever calls must share health and
|
|
capacity state.
|
|
|
|
```ts
|
|
import { resilientCall } from "@wrnexus/core";
|
|
|
|
const paymentCircuit = { failures: 5, resetAfter: "30s" } as const;
|
|
|
|
const status = await resilientCall({
|
|
timeout: "5s",
|
|
retries: 3,
|
|
retryDelay: "100ms",
|
|
backoff: "exponential",
|
|
circuitBreaker: paymentCircuit,
|
|
bulkhead: { concurrency: 20, queue: 100 },
|
|
run: (signal) => paymentProvider.checkStatus({ signal }),
|
|
fallback: () => ({ state: "unavailable" }),
|
|
});
|
|
```
|
|
|
|
`CircuitBreaker.snapshot()` reports `closed`, `open`, or `half-open`, failure
|
|
and success counts, and the remaining retry delay for health endpoints and
|
|
development tooling. Fail-fast conditions use stable `WRN-RESILIENCE-*` codes.
|
|
Core's existing `HealthRegistry`, `withIdempotency`, and pluggable stores/locks
|
|
cover health reporting, idempotent requests, and distributed coordination.
|
|
|
|
### 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.
|