first commit

This commit is contained in:
2026-07-12 15:55:18 +05:30
commit ee98026cc5
404 changed files with 44522 additions and 0 deletions
+381
View File
@@ -0,0 +1,381 @@
# @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.
+11
View File
@@ -0,0 +1,11 @@
{
"name": "@wrnexus/core",
"version": "0.2.12",
"type": "module",
"main": "src/index.ts",
"exports": {
".": "./src/index.ts",
"./jsx-runtime": "./src/jsx-runtime.ts",
"./jsx-dev-runtime": "./src/jsx-dev-runtime.ts"
}
}
+95
View File
@@ -0,0 +1,95 @@
/**
* Authentication primitives.
*
* Passwords are hashed with argon2id via `Bun.password`. Sessions ride on the
* existing cookie-backed `SessionStore`: logging a user in stores a serializable
* user object under the "user" key, and `sessionAuth` hydrates `ctx.user` from
* it on every request. `requireAuth` is a guard middleware for protected routes.
*/
import type { Context, Middleware } from "./context.ts";
/** Session key under which the authenticated user is stored. */
export const SESSION_USER_KEY = "user";
/** Hash a plaintext password (argon2id). Store the returned string. */
export function hashPassword(password: string): Promise<string> {
return Bun.password.hash(password);
}
/** Verify a plaintext password against a stored hash. Safe against bad hashes. */
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
if (!hash) return false;
try {
return await Bun.password.verify(password, hash);
} catch {
return false;
}
}
/** Persist the authenticated user in the session and on the context. */
export function logIn<U = unknown>(ctx: Context, user: U): void {
// Regenerate the session id first so a pre-login (possibly attacker-planted)
// id can't be reused post-login — defends against session fixation.
ctx.session.regenerate();
ctx.session.set(SESSION_USER_KEY, user);
ctx.user = user;
}
/** Clear the session and forget the current user. */
export function logOut(ctx: Context): void {
ctx.session.clear();
ctx.user = null;
}
/**
* The currently-authenticated user, or null. Reads `ctx.user` first (set by
* `sessionAuth`/`logIn`), falling back to the session store.
*/
export function getUser<U = unknown>(ctx: Context): U | null {
if (ctx.user != null) return ctx.user as U;
const fromSession = ctx.session.get<U>(SESSION_USER_KEY);
return fromSession ?? null;
}
/**
* Hydrate `ctx.user` from the session for every request. Register this early in
* the middleware chain so downstream pages and API routes can read `ctx.user`.
*/
export function sessionAuth(): Middleware {
return (ctx, next) => {
ctx.user = ctx.session.get(SESSION_USER_KEY) ?? null;
return next();
};
}
export interface RequireAuthOptions {
/** Where to redirect unauthenticated page requests. Default "/login". */
loginPath?: string;
}
/**
* Guard that requires an authenticated user. Unauthenticated requests that look
* like an API/fetch call get a 401 JSON response; page navigations get a 302
* redirect to the login page with the original target preserved as `?next=`.
*/
export function requireAuth(options: RequireAuthOptions = {}): Middleware {
const loginPath = options.loginPath ?? "/login";
return (ctx, next) => {
if (getUser(ctx) != null) return next();
if (wantsJson(ctx)) {
return Response.json({ ok: false, error: "Unauthorized" }, { status: 401 });
}
const target = encodeURIComponent(ctx.url.pathname + ctx.url.search);
return new Response(null, {
status: 302,
headers: { Location: `${loginPath}?next=${target}` },
});
};
}
function wantsJson(ctx: Context): boolean {
if (ctx.url.pathname.startsWith("/api/")) return true;
const accept = ctx.req.headers.get("accept") ?? "";
return accept.includes("application/json") && !accept.includes("text/html");
}
+145
View File
@@ -0,0 +1,145 @@
/**
* Caching primitives:
* - `TTLCache` — a small in-memory time-to-live cache with `getOrLoad`, for
* memoising expensive data (query results, computed pages).
* - HTTP helpers — `cacheControl` to build a directive, `withCacheControl` to
* apply it, and `etag` / `notModified` for conditional requests (304s).
*/
// --- In-memory TTL cache ---------------------------------------------------
interface Entry<V> {
value: V;
expiresAt: number;
}
export class TTLCache<V = unknown> {
private store = new Map<string, Entry<V>>();
private loading = new Map<string, Promise<V>>();
private revisions = new Map<string, number>();
private generation = 0;
constructor(private readonly ttlMs = 60_000) {}
get(key: string): V | undefined {
const entry = this.store.get(key);
if (!entry) return undefined;
if (entry.expiresAt <= Date.now()) {
this.store.delete(key);
return undefined;
}
return entry.value;
}
set(key: string, value: V, ttlMs = this.ttlMs): void {
this.revisions.set(key, (this.revisions.get(key) ?? 0) + 1);
this.store.set(key, { value, expiresAt: Date.now() + ttlMs });
}
/** Return the cached value or compute, cache, and return it. */
async getOrLoad(key: string, loader: () => Promise<V> | V, ttlMs = this.ttlMs): Promise<V> {
const hit = this.get(key);
if (hit !== undefined) return hit;
const pending = this.loading.get(key);
if (pending) return pending;
const revision = this.revisions.get(key) ?? 0;
const generation = this.generation;
const promise = Promise.resolve().then(loader);
this.loading.set(key, promise);
try {
const value = await promise;
if (this.generation === generation && (this.revisions.get(key) ?? 0) === revision) {
this.store.set(key, { value, expiresAt: Date.now() + ttlMs });
}
return value;
} finally {
if (this.loading.get(key) === promise) this.loading.delete(key);
}
}
delete(key: string): void {
this.store.delete(key);
this.loading.delete(key);
this.revisions.set(key, (this.revisions.get(key) ?? 0) + 1);
}
clear(): void {
this.store.clear();
this.loading.clear();
this.revisions.clear();
this.generation++;
}
get size(): number {
return this.store.size;
}
}
// --- HTTP caching ----------------------------------------------------------
export interface CacheControlOptions {
/** max-age in seconds. */
maxAge?: number;
/** s-maxage (shared/CDN cache) in seconds. */
sMaxAge?: number;
/** Mark private (per-user) rather than public. */
private?: boolean;
/** no-store: never cache. Overrides other directives. */
noStore?: boolean;
/** no-cache: revalidate before use. */
noCache?: boolean;
/** stale-while-revalidate window in seconds. */
staleWhileRevalidate?: number;
immutable?: boolean;
}
/** Build a Cache-Control header value from options. */
export function cacheControl(options: CacheControlOptions): string {
if (options.noStore) return "no-store";
const parts: string[] = [options.private ? "private" : "public"];
if (options.noCache) parts.push("no-cache");
if (options.maxAge !== undefined)
parts.push(`max-age=${Math.max(0, Math.floor(options.maxAge))}`);
if (options.sMaxAge !== undefined)
parts.push(`s-maxage=${Math.max(0, Math.floor(options.sMaxAge))}`);
if (options.staleWhileRevalidate !== undefined) {
parts.push(`stale-while-revalidate=${Math.max(0, Math.floor(options.staleWhileRevalidate))}`);
}
if (options.immutable) parts.push("immutable");
return parts.join(", ");
}
/** Apply a Cache-Control header to a response (returns the same response). */
export function withCacheControl(res: Response, options: CacheControlOptions): Response {
try {
res.headers.set("Cache-Control", cacheControl(options));
} catch {
/* immutable response — skip */
}
return res;
}
/** A stable, quoted ETag for a string/bytes body (FNV-1a, weak by default). */
export function etag(body: string | ArrayBuffer | Uint8Array, weak = true): string {
const bytes =
typeof body === "string"
? new TextEncoder().encode(body)
: body instanceof Uint8Array
? body
: new Uint8Array(body);
let hash = 0x811c9dc5;
for (let i = 0; i < bytes.length; i++) {
hash ^= bytes[i]!;
hash = Math.imul(hash, 0x01000193);
}
const tag = `"${(hash >>> 0).toString(16)}-${bytes.length.toString(16)}"`;
return weak ? `W/${tag}` : tag;
}
/** True when the request's If-None-Match matches the given ETag (send a 304). */
export function notModified(req: Request, tag: string): boolean {
const inm = req.headers.get("if-none-match");
if (!inm) return false;
const normalize = (t: string) => t.trim().replace(/^W\//, "");
const target = normalize(tag);
return inm.split(",").some((candidate) => normalize(candidate) === target);
}
+119
View File
@@ -0,0 +1,119 @@
/**
* Core request context and middleware contracts.
*
* The `Context` object is the single value that flows through middleware,
* pages and API routes. It is intentionally small and framework-agnostic so
* it can later be reused by the `.wrn` compiler output.
*/
import {
applyCookieHeaders,
createCookieStore,
createLocalStorageSnapshot,
createSessionStore,
type CookieStore,
type LocalStorageSnapshot,
type SessionStore,
} from "./storage.ts";
/** Translate a key for the active language, interpolating `{param}` placeholders. */
export type TFunction = (key: string, params?: Record<string, string | number>) => string;
export type Context = {
/** The raw incoming web-standard Request. */
req: Request;
/** Parsed URL of the request (pathname, query, etc.). */
url: URL;
/** Active language for this request (resolved by the runtime); "" if i18n is unused. */
lang: string;
/** Translate a key for the active language (identity until the runtime sets it). */
t: TFunction;
/** Dynamic route params, e.g. `/users/[id]` -> `{ id: "42" }`. */
params: Record<string, string>;
/**
* Per-request scratch space. Middleware can attach values here
* (e.g. the authenticated user) and downstream handlers can read them.
*/
locals: Record<string, unknown>;
/**
* The authenticated user for this request, or null when anonymous. Populated
* by the `sessionAuth` middleware (or `logIn`); read via `getUser(ctx)`.
*/
user?: unknown;
/**
* The direct socket peer IP, set by the server from `server.requestIP`. This
* is NOT spoofable by request headers — prefer it over `x-forwarded-for` for
* rate limiting unless you run behind a trusted proxy.
*/
ip?: string;
/** Read/write HTTP cookies for the current response. */
cookies: CookieStore;
/** In-memory cookie-backed session store. */
session: SessionStore;
/** Read-only localStorage snapshot sent by the browser for CSR data bindings. */
localStorage: LocalStorageSnapshot;
};
/** Calls the next middleware in the chain (or the final route handler). */
export type Next = () => Promise<Response> | Response;
/**
* Middleware runs before pages and API routes. It can:
* - inspect/modify `ctx`
* - short-circuit by returning a `Response` without calling `next()`
* - continue by returning `await next()`
*/
export type Middleware = (ctx: Context, next: Next) => Promise<Response> | Response;
/** SEO metadata rendered into the document `<head>`. */
export type SeoConfig = {
title?: string;
titleTemplate?: string;
description?: string;
canonical?: string;
canonicalBase?: string;
robots?: string;
keywords?: string | string[];
image?: string;
siteName?: string;
type?: string;
locale?: string;
twitterCard?: string;
twitterSite?: string;
themeColor?: string;
};
/** Page metadata rendered into the document `<head>`. */
export type PageMeta = SeoConfig;
/** A page module's default export. Returns an HTML string for the body. */
export type PageComponent = (ctx: Context) => string | Promise<string>;
/** Create a fresh context for an incoming request. */
export function createContext(req: Request, url: URL): Context {
const cookies = createCookieStore(req);
return {
req,
url,
params: {},
locals: {},
lang: "",
t: (key) => key,
cookies,
// `url` already reflects X-Forwarded-Proto when trustProxy is on, so session
// cookies get `Secure` behind a TLS-terminating proxy (matches CSRF cookies).
session: createSessionStore(cookies, req, undefined, url.protocol === "https:"),
localStorage: createLocalStorageSnapshot(req),
};
}
/** Apply headers accumulated on the context, such as Set-Cookie. */
export function withContextHeaders(ctx: Context, res: Response): Response {
const headers = new Headers(res.headers);
applyCookieHeaders(ctx, headers);
return new Response(res.body, {
status: res.status,
statusText: res.statusText,
headers,
});
}
+62
View File
@@ -0,0 +1,62 @@
/**
* CSRF protection via the double-submit cookie pattern.
*
* The framework sets a readable `wire-csrf` cookie on page loads; the client
* echoes it in an `x-csrf-token` header on unsafe requests (the Wire UI form
* runtime does this automatically). The server checks header === cookie. A
* cross-site attacker can't read the cookie to forge the header, so the request
* is rejected — while same-origin requests pass.
*/
import type { Context, Middleware } from "./context.ts";
export const CSRF_COOKIE = "wire-csrf";
export const CSRF_HEADER = "x-csrf-token";
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
/** Ensure the CSRF cookie exists (readable by JS) and return its token. */
export function csrfToken(ctx: Context): string {
let token = ctx.cookies.get(CSRF_COOKIE);
if (!token) {
token = crypto.randomUUID().replace(/-/g, "");
// Readable by JS (double-submit needs it) but Secure on HTTPS.
ctx.cookies.set(CSRF_COOKIE, token, {
sameSite: "Lax",
path: "/",
secure: ctx.url.protocol === "https:",
});
}
return token;
}
/**
* Verify an unsafe request's CSRF token against the cookie. Safe methods
* (GET/HEAD/OPTIONS) always pass. The token may arrive in the `x-csrf-token`
* header or a `_csrf` field already parsed onto `ctx.locals`.
*/
export function verifyCsrf(ctx: Context): boolean {
if (SAFE_METHODS.has(ctx.req.method.toUpperCase())) return true;
const cookie = ctx.cookies.get(CSRF_COOKIE);
const sent = ctx.req.headers.get(CSRF_HEADER) ?? (ctx.locals._csrf as string | undefined);
return !!cookie && !!sent && timingSafeEqual(cookie, sent);
}
/**
* Constant-time string comparison — the running time does not depend on where
* the first differing byte is, so an attacker can't time-probe the token.
*/
function timingSafeEqual(a: string, b: string): boolean {
let diff = a.length ^ b.length;
const max = Math.max(a.length, b.length);
for (let i = 0; i < max; i++) {
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
}
return diff === 0;
}
/** Middleware that 403s unsafe requests with a missing/mismatched CSRF token. */
export function csrfProtection(): Middleware {
return (ctx, next) =>
verifyCsrf(ctx) ? next() : new Response("Invalid CSRF token", { status: 403 });
}
+220
View File
@@ -0,0 +1,220 @@
/**
* Error + status pages. Every page here is a self-contained HTML document —
* inline CSS only, no external stylesheet, no JavaScript (so it renders under the
* strict CSP, even when the app's assets are what failed). Theme-aware via
* `prefers-color-scheme`, styled in the WrNexus design language (ink-navy,
* azure, a faint blueprint grid + glow). Development shows the stack trace;
* production never leaks internal paths.
*/
import { escapeHtml } from "./security.ts";
export type Mode = "development" | "production";
interface ErrorPageOptions {
status: number;
/** Big display code, e.g. "404" / "500". */
code: string;
title: string;
message: string;
/** Monospace eyebrow, e.g. "ERROR 404". */
eyebrow?: string;
/** Optional dev-only detail (error name + stack), rendered in a code panel. */
detail?: { heading: string; body: string };
/** Show a "Back home" action (default true). */
home?: boolean;
}
/** Shared, self-contained, theme-aware error document. */
function errorDocument(o: ErrorPageOptions): string {
const eyebrow = escapeHtml(o.eyebrow ?? `ERROR ${o.status}`);
const title = escapeHtml(o.title);
const message = escapeHtml(o.message);
const detail = o.detail
? `
<section class="detail">
<div class="detail-head">${escapeHtml(o.detail.heading)}</div>
<pre class="detail-body">${escapeHtml(o.detail.body)}</pre>
</section>`
: "";
const home = o.home === false ? "" : `<a class="btn btn-primary" href="/">Back to home</a>`;
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex" />
<title>${title}</title>
<style>
:root {
--bg: #0a0e17; --bg2: #070a12; --text: #e7ecf5; --muted: #93a1b8;
--brand: #6ea0ff; --brand-2: #3f7dff; --border: rgba(255,255,255,.10);
--card: rgba(255,255,255,.03); --grid: rgba(110,160,255,.10);
}
@media (prefers-color-scheme: light) {
:root {
--bg: #f7f9fc; --bg2: #eef2f8; --text: #0f172a; --muted: #5a6b85;
--brand: #2b62f0; --brand-2: #2b62f0; --border: rgba(15,23,42,.10);
--card: rgba(15,23,42,.02); --grid: rgba(43,98,240,.09);
}
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0; background: var(--bg); color: var(--text);
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility;
display: grid; place-items: center; min-height: 100%;
padding: clamp(1.5rem, 5vw, 4rem); position: relative; overflow-x: hidden;
}
/* Blueprint grid + radial glow backdrop. */
body::before {
content: ""; position: fixed; inset: 0; z-index: 0; pointer-events: none;
background-image:
linear-gradient(to right, var(--grid) 1px, transparent 1px),
linear-gradient(to bottom, var(--grid) 1px, transparent 1px);
background-size: 56px 56px;
-webkit-mask-image: radial-gradient(ellipse 75% 60% at 50% 30%, #000 10%, transparent 72%);
mask-image: radial-gradient(ellipse 75% 60% at 50% 30%, #000 10%, transparent 72%);
}
body::after {
content: ""; position: fixed; left: 50%; top: -10%; z-index: 0; pointer-events: none;
width: min(680px, 90vw); height: 420px; transform: translateX(-50%);
background: radial-gradient(circle at center, color-mix(in oklab, var(--brand-2) 34%, transparent), transparent 68%);
filter: blur(8px); opacity: .55;
}
main {
position: relative; z-index: 1; width: 100%; max-width: 640px; text-align: center;
animation: rise .6s cubic-bezier(.16,1,.3,1) both;
}
@keyframes rise { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: none; } }
@media (prefers-reduced-motion: reduce) { main { animation: none; } }
.eyebrow {
font: 600 .72rem/1 ui-monospace, "SFMono-Regular", Menlo, monospace;
letter-spacing: .22em; color: var(--brand); text-transform: uppercase;
}
.code {
margin: .5rem 0 0; font-weight: 800; line-height: .9;
font-size: clamp(5rem, 22vw, 11rem); letter-spacing: -.04em;
background: linear-gradient(180deg, var(--text), color-mix(in oklab, var(--brand) 60%, var(--text)));
-webkit-background-clip: text; background-clip: text; color: transparent;
}
h1 { margin: .25rem 0 0; font-size: clamp(1.4rem, 4vw, 2rem); font-weight: 700; letter-spacing: -.02em; }
.msg { margin: .9rem auto 0; max-width: 30rem; color: var(--muted); line-height: 1.65; font-size: 1rem; }
.actions { margin-top: 2rem; display: flex; flex-wrap: wrap; gap: .75rem; justify-content: center; }
.btn {
display: inline-flex; align-items: center; gap: .5rem; text-decoration: none;
padding: .7rem 1.25rem; border-radius: 10px; font-weight: 600; font-size: .9rem;
transition: transform .12s ease, filter .12s ease, border-color .12s ease;
}
.btn:active { transform: translateY(1px); }
.btn-primary {
color: #fff; background: linear-gradient(180deg, var(--brand), var(--brand-2));
box-shadow: 0 8px 24px -10px color-mix(in oklab, var(--brand-2) 80%, transparent);
}
.btn-primary:hover { filter: brightness(1.08); }
.detail {
margin: 2.25rem auto 0; text-align: left; max-width: 100%;
border: 1px solid var(--border); border-radius: 12px; background: var(--card); overflow: hidden;
}
.detail-head {
padding: .7rem 1rem; font: 600 .75rem/1 ui-monospace, "SFMono-Regular", Menlo, monospace;
color: var(--brand); border-bottom: 1px solid var(--border);
white-space: pre-wrap; word-break: break-word;
}
.detail-body {
margin: 0; padding: 1rem; max-height: 40vh; overflow: auto;
font: .8rem/1.6 ui-monospace, "SFMono-Regular", Menlo, monospace;
color: var(--muted); white-space: pre-wrap; word-break: break-word;
}
</style>
</head>
<body>
<main>
<p class="eyebrow">${eyebrow}</p>
<div class="code" aria-hidden="true">${escapeHtml(o.code)}</div>
<h1>${title}</h1>
<p class="msg">${message}</p>
<div class="actions">${home}</div>
${detail}
</main>
</body>
</html>`;
}
/** Common status → friendly copy, for a generic HTML status page. */
const STATUS_COPY: Record<number, { title: string; message: string }> = {
400: {
title: "Bad request",
message: "The request couldn't be understood. Check the URL and try again.",
},
401: { title: "Sign in required", message: "You need to be signed in to view this page." },
403: { title: "Access denied", message: "You don't have permission to view this page." },
404: {
title: "Page not found",
message: "The page you're looking for doesn't exist or has moved.",
},
413: { title: "Too large", message: "The request was larger than the server allows." },
429: {
title: "Slow down",
message: "You've made too many requests. Please wait a moment and try again.",
},
500: {
title: "Something went wrong",
message: "The server hit an unexpected error. Please try again in a moment.",
},
502: {
title: "Bad gateway",
message: "We couldn't reach an upstream service. Please try again shortly.",
},
503: {
title: "Temporarily unavailable",
message: "The service is down for a moment. Please try again shortly.",
},
};
/** A beautiful, self-contained HTML page for any 4xx/5xx status. */
export function renderStatusPage(status: number): Response {
const copy = STATUS_COPY[status] ?? {
title: status >= 500 ? "Something went wrong" : "Something's not right",
message: "An unexpected response was returned. Please try again.",
};
return new Response(
errorDocument({ status, code: String(status), title: copy.title, message: copy.message }),
{ status, headers: { "content-type": "text/html; charset=utf-8" } },
);
}
/** Readable, styled development error page — includes the stack trace. */
export function renderDevError(err: unknown, status = 500): Response {
const error = err instanceof Error ? err : new Error(String(err));
const name = error.name || "Error";
const message = error.message || "Unknown error";
return new Response(
errorDocument({
status,
code: String(status),
eyebrow: "DEVELOPMENT ERROR",
title: name,
message,
detail: { heading: `${name}: ${message}`, body: error.stack || "(no stack available)" },
}),
{ status, headers: { "content-type": "text/html; charset=utf-8" } },
);
}
/** Generic production error page — no stack, no file paths. */
export function renderProdError(status = 500): Response {
return renderStatusPage(status);
}
/** Pick the right error page for the current mode. */
export function renderError(err: unknown, mode: Mode): Response {
return mode === "development" ? renderDevError(err) : renderProdError();
}
/** Beautiful 404 page. */
export function renderNotFound(): Response {
return renderStatusPage(404);
}
+420
View File
@@ -0,0 +1,420 @@
import type { Mode } from "./errors.ts";
export type CorsOrigin = "*" | string | string[];
export interface CorsConfig {
/** Enable CORS headers and preflight handling. Defaults to false. */
enabled?: boolean;
/** Allowed origins. Use "*" for public APIs. Defaults to "*". */
origin?: CorsOrigin;
/** Allowed methods for preflight responses. */
methods?: string[];
/** Allowed request headers. Defaults to the browser's requested headers. */
allowedHeaders?: string[];
/** Response headers exposed to browser JavaScript. */
exposedHeaders?: string[];
/** Whether to send Access-Control-Allow-Credentials. */
credentials?: boolean;
/** Access-Control-Max-Age, in seconds. */
maxAge?: number;
}
export type CspDirectiveValue = string | string[] | false | null | undefined;
export interface ContentSecurityPolicyConfig {
/** Defaults to true. */
enabled?: boolean;
/** Use Content-Security-Policy-Report-Only instead of enforcing. */
reportOnly?: boolean;
/** Merge or remove directives. Set a directive to false/null to remove it. */
directives?: Record<string, CspDirectiveValue>;
/** Set false to start from an empty policy instead of WrNexus defaults. */
useDefaults?: boolean;
}
export interface HstsConfig {
/** Defaults to true in production, false in development. */
enabled?: boolean;
/** Defaults to 31536000 seconds (1 year). */
maxAge?: number;
/** Defaults to true. */
includeSubDomains?: boolean;
/** Defaults to true. */
preload?: boolean;
}
export interface TrustedTypesConfig {
/** Defaults to true in production, false in development. */
enabled?: boolean;
/**
* Defaults to ["*"] in production so browser extensions and dev tooling can
* create their own policies without noisy console errors. Set this to a
* concrete list, e.g. ["wrnexus", "default"], for stricter deployments.
*/
policyNames?: string[];
/** Defaults to true. */
requireForScript?: boolean;
/** Adds "allow-duplicates" to the trusted-types directive. */
allowDuplicates?: boolean;
}
export type PermissionsPolicyConfig = Record<string, string | string[] | false | null | undefined>;
export interface SecurityConfig {
/** Set false to skip all framework security headers except explicitly enabled CORS. */
headers?: boolean;
/**
* Trust `X-Forwarded-Proto` / `X-Forwarded-Host` when building `ctx.url` — set
* this when the app runs behind a TLS-terminating reverse proxy (nginx, the
* WrNexus gateway, a load balancer). Without it, a proxied app sees the internal
* `http://` request and marks cookies (e.g. CSRF/session) non-`Secure`. Default
* false; enable ONLY when a trusted proxy actually sets these headers.
*/
trustProxy?: boolean;
cors?: boolean | CorsConfig;
contentSecurityPolicy?: false | ContentSecurityPolicyConfig;
hsts?: false | HstsConfig;
trustedTypes?: false | TrustedTypesConfig;
/** Defaults to "same-origin". */
crossOriginOpenerPolicy?: false | "same-origin" | "same-origin-allow-popups" | "unsafe-none";
/** Defaults to "DENY". */
frameOptions?: false | "DENY" | "SAMEORIGIN";
/** Defaults to "strict-origin-when-cross-origin". */
referrerPolicy?: false | string;
/** Defaults to a restrictive browser capability policy. */
permissionsPolicy?: false | PermissionsPolicyConfig;
/** Extra static headers applied last. */
extraHeaders?: Record<string, string>;
}
const DEFAULT_CSP: Record<string, string[]> = {
"default-src": ["'self'"],
"script-src": ["'self'"],
"style-src": ["'self'", "'unsafe-inline'"],
"img-src": ["'self'", "data:", "blob:"],
"font-src": ["'self'", "data:"],
"connect-src": ["'self'", "ws:", "wss:"],
"object-src": ["'none'"],
"base-uri": ["'self'"],
"frame-ancestors": ["'none'"],
"form-action": ["'self'"],
};
const DEFAULT_PERMISSIONS_POLICY: PermissionsPolicyConfig = {
accelerometer: [],
autoplay: [],
camera: [],
"display-capture": [],
"encrypted-media": [],
fullscreen: ["self"],
geolocation: [],
gyroscope: [],
magnetometer: [],
microphone: [],
midi: [],
payment: [],
"picture-in-picture": [],
"sync-xhr": [],
unload: [],
usb: [],
"xr-spatial-tracking": [],
};
const DEFAULT_CORS_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"];
/**
* Guard a WebSocket upgrade against Cross-Site WebSocket Hijacking: browsers
* always send an `Origin` header on a WS handshake, and — unlike fetch — WS is
* NOT subject to CORS, so cookies would otherwise flow cross-site. We allow
* same-origin (Origin host === Host header), configured CORS origins, and
* non-browser clients (no Origin, which also carry no ambient cookies).
*/
export function isWebSocketOriginAllowed(req: Request, security?: SecurityConfig): boolean {
const origin = req.headers.get("origin");
if (!origin) return true; // native/non-browser client — not the CSWSH threat
let originHost: string;
try {
originHost = new URL(origin).host;
} catch {
return false;
}
if (originHost === req.headers.get("host")) return true; // same-origin
const cors = normalizeCors(security?.cors);
if (cors.enabled) {
const configured = cors.origin ?? "*";
if (configured === "*") return true;
const list = Array.isArray(configured) ? configured : [configured];
return list.includes(origin);
}
return false;
}
export function createCorsPreflightResponse(
req: Request,
security?: SecurityConfig,
): Response | null {
if (req.method.toUpperCase() !== "OPTIONS") return null;
if (!req.headers.has("origin") || !req.headers.has("access-control-request-method")) return null;
const cors = normalizeCors(security?.cors);
if (!cors.enabled) return null;
const headers = new Headers();
const allowed = applyCorsHeaders(req, headers, cors);
if (!allowed) return new Response("CORS origin denied", { status: 403 });
return new Response(null, { status: 204, headers });
}
/**
* Build the request URL, honoring `X-Forwarded-Proto` / `X-Forwarded-Host` when
* `trustProxy` is set (app behind a TLS-terminating reverse proxy). This makes
* `ctx.url.protocol` reflect the EXTERNAL scheme, so protocol-dependent logic —
* `Secure` cookies, canonical URLs — is correct behind nginx / the gateway.
* Security checks that compare the raw `Host`/`Origin` headers don't use this URL,
* so they are unaffected. An invalid forwarded value is ignored by the URL setter.
*/
export function resolveRequestUrl(req: Request, trustProxy?: boolean): URL {
const url = new URL(req.url);
if (!trustProxy) return url;
const proto = req.headers.get("x-forwarded-proto");
if (proto) url.protocol = (proto.split(",")[0] ?? "").trim() + ":";
const host = req.headers.get("x-forwarded-host");
if (host) {
const h = (host.split(",")[0] ?? "").trim();
url.host = h;
if (!h.includes(":")) url.port = ""; // drop the internal proxy port when none forwarded
}
return url;
}
export function withSecurityHeaders(
req: Request,
res: Response,
mode: Mode,
security?: SecurityConfig,
nonce?: string,
): Response {
const headers = new Headers(res.headers);
const cors = normalizeCors(security?.cors);
if (cors.enabled) {
applyCorsHeaders(req, headers, cors);
}
if (security?.headers !== false) {
applyBaseSecurityHeaders(headers, mode, security, nonce);
}
if (security?.extraHeaders) {
Object.entries(security.extraHeaders).forEach(([name, value]) => headers.set(name, value));
}
return new Response(res.body, {
status: res.status,
statusText: res.statusText,
headers,
});
}
function applyBaseSecurityHeaders(
headers: Headers,
mode: Mode,
security?: SecurityConfig,
nonce?: string,
): void {
headers.set("X-Content-Type-Options", "nosniff");
const frameOptions = security?.frameOptions ?? "DENY";
if (frameOptions !== false) headers.set("X-Frame-Options", frameOptions);
const coop = security?.crossOriginOpenerPolicy ?? "same-origin";
if (coop !== false) headers.set("Cross-Origin-Opener-Policy", coop);
const referrerPolicy = security?.referrerPolicy ?? "strict-origin-when-cross-origin";
if (referrerPolicy !== false) headers.set("Referrer-Policy", referrerPolicy);
const configuredPermissions = security?.permissionsPolicy;
const permissionsPolicy =
configuredPermissions === false
? false
: { ...DEFAULT_PERMISSIONS_POLICY, ...(configuredPermissions ?? {}) };
if (permissionsPolicy !== false) {
const value = serializePermissionsPolicy(permissionsPolicy);
if (value) headers.set("Permissions-Policy", value);
}
const csp = serializeCsp(mode, security, nonce);
if (csp) {
const cspConfig = security?.contentSecurityPolicy;
const reportOnly = typeof cspConfig === "object" && cspConfig.reportOnly === true;
headers.set(
reportOnly ? "Content-Security-Policy-Report-Only" : "Content-Security-Policy",
csp,
);
}
const hsts = security?.hsts;
const hstsEnabled =
hsts !== false &&
(mode === "production" || (typeof hsts === "object" && hsts.enabled === true));
if (hstsEnabled) {
headers.set("Strict-Transport-Security", serializeHsts(typeof hsts === "object" ? hsts : {}));
}
}
let warnedCredentialsWildcard = false;
function normalizeCors(cors: SecurityConfig["cors"]): CorsConfig & { enabled: boolean } {
if (cors === true) return { enabled: true, origin: "*" };
if (!cors) return { enabled: false };
const normalized = { ...cors, enabled: cors.enabled !== false };
// `*` + credentials would reflect ANY origin back with credentials allowed —
// effectively disabling the same-origin policy. Refuse the combination and
// drop credentials so it degrades to a safe public (non-credentialed) API.
if (normalized.credentials && (normalized.origin ?? "*") === "*") {
if (!warnedCredentialsWildcard) {
warnedCredentialsWildcard = true;
console.warn(
'[wrnexus] CORS `credentials: true` cannot be combined with `origin: "*"`; ' +
"credentials disabled. Set an explicit origin allowlist to use credentials.",
);
}
normalized.credentials = false;
}
return normalized;
}
function applyCorsHeaders(req: Request, headers: Headers, cors: CorsConfig): boolean {
const origin = req.headers.get("origin");
if (!origin) return true;
const allowOrigin = resolveAllowedOrigin(origin, cors);
if (!allowOrigin) return false;
headers.set("Access-Control-Allow-Origin", allowOrigin);
appendVary(headers, "Origin");
if (cors.credentials) headers.set("Access-Control-Allow-Credentials", "true");
if (cors.exposedHeaders?.length) {
headers.set("Access-Control-Expose-Headers", cors.exposedHeaders.join(", "));
}
if (req.method.toUpperCase() === "OPTIONS") {
headers.set("Access-Control-Allow-Methods", (cors.methods ?? DEFAULT_CORS_METHODS).join(", "));
const requestedHeaders = req.headers.get("access-control-request-headers");
const allowedHeaders = cors.allowedHeaders?.join(", ") ?? requestedHeaders;
if (allowedHeaders) headers.set("Access-Control-Allow-Headers", allowedHeaders);
if (typeof cors.maxAge === "number") {
headers.set("Access-Control-Max-Age", String(Math.max(0, Math.floor(cors.maxAge))));
}
}
return true;
}
function resolveAllowedOrigin(origin: string, cors: CorsConfig): string | null {
const configured = cors.origin ?? "*";
if (configured === "*") return cors.credentials ? origin : "*";
if (typeof configured === "string") return configured === origin ? origin : null;
return configured.includes(origin) ? origin : null;
}
function appendVary(headers: Headers, value: string): void {
const existing = headers.get("Vary");
if (!existing) {
headers.set("Vary", value);
return;
}
const values = existing.split(",").map((item) => item.trim().toLowerCase());
if (!values.includes(value.toLowerCase())) headers.set("Vary", `${existing}, ${value}`);
}
function serializeCsp(mode: Mode, security?: SecurityConfig, nonce?: string): string {
const config = security?.contentSecurityPolicy;
if (config === false || config?.enabled === false) return "";
const directives = new Map<string, string[]>();
if (config?.useDefaults !== false) {
for (const [name, value] of Object.entries(DEFAULT_CSP)) {
directives.set(name, [...value]);
}
if (mode === "development") {
directives.set("script-src", ["'self'", "'unsafe-inline'"]);
} else {
directives.set("upgrade-insecure-requests", []);
}
}
for (const [name, value] of Object.entries(config?.directives ?? {})) {
if (value === false || value === null) {
directives.delete(name);
continue;
}
if (value === undefined) continue;
directives.set(name, Array.isArray(value) ? value : value.split(/\s+/).filter(Boolean));
}
// A per-request nonce lets inline framework scripts run under a strict policy:
// add 'nonce-…' to script-src and drop 'unsafe-inline' (browsers ignore
// 'unsafe-inline' when a nonce is present anyway).
if (nonce) {
const scriptSrc = directives.get("script-src") ?? ["'self'"];
directives.set("script-src", [
...scriptSrc.filter((v) => v !== "'unsafe-inline'"),
`'nonce-${nonce}'`,
]);
}
applyTrustedTypesDirectives(directives, mode, security?.trustedTypes);
return [...directives.entries()]
.map(([name, values]) => (values.length ? `${name} ${values.join(" ")}` : name))
.join("; ");
}
function applyTrustedTypesDirectives(
directives: Map<string, string[]>,
mode: Mode,
trustedTypes: SecurityConfig["trustedTypes"],
): void {
if (trustedTypes === false) return;
const enabled =
typeof trustedTypes === "object" ? trustedTypes.enabled !== false : mode === "production";
if (!enabled) return;
const policyNames =
typeof trustedTypes === "object" && trustedTypes.policyNames?.length
? trustedTypes.policyNames
: ["*"];
const trustedTypesValues = [...policyNames];
if (typeof trustedTypes === "object" && trustedTypes.allowDuplicates) {
trustedTypesValues.push("'allow-duplicates'");
}
directives.set("trusted-types", trustedTypesValues);
const requireForScript =
typeof trustedTypes === "object" ? trustedTypes.requireForScript !== false : true;
if (requireForScript) directives.set("require-trusted-types-for", ["'script'"]);
}
function serializeHsts(config: HstsConfig): string {
const parts = [`max-age=${config.maxAge ?? 31536000}`];
if (config.includeSubDomains !== false) parts.push("includeSubDomains");
if (config.preload !== false) parts.push("preload");
return parts.join("; ");
}
function serializePermissionsPolicy(policy: PermissionsPolicyConfig): string {
return Object.entries(policy)
.flatMap(([feature, value]) => {
if (value === false || value === null || value === undefined) return [];
if (typeof value === "string") return [`${feature}=${value}`];
return [`${feature}=(${value.join(" ")})`];
})
.join(", ");
}
+106
View File
@@ -0,0 +1,106 @@
/**
* @wrnexus/core — shared types and primitives used by every other package.
*/
export type {
Context,
Next,
Middleware,
PageMeta,
PageComponent,
SeoConfig,
TFunction,
} from "./context.ts";
export { createContext, withContextHeaders } from "./context.ts";
export { escapeHtml, isSafeIslandName, isSafeRequestPath } from "./security.ts";
export { csrfToken, verifyCsrf, csrfProtection, CSRF_COOKIE, CSRF_HEADER } from "./csrf.ts";
export {
hashPassword,
verifyPassword,
logIn,
logOut,
getUser,
sessionAuth,
requireAuth,
SESSION_USER_KEY,
} from "./auth.ts";
export type { RequireAuthOptions } from "./auth.ts";
export { rateLimit, peerKey, proxyKey, defaultKey } from "./ratelimit.ts";
export type { RateLimitOptions, RateLimitStore, Bucket } from "./ratelimit.ts";
export { requestLogger } from "./logging.ts";
export type { RequestLoggerOptions, RequestRecord } from "./logging.ts";
export { TTLCache, cacheControl, withCacheControl, etag, notModified } from "./cache.ts";
export type { CacheControlOptions } from "./cache.ts";
export { saveUpload, collectUploads, sanitizeFilename, UploadError } from "./uploads.ts";
export type { SaveUploadOptions, SavedUpload } from "./uploads.ts";
export { streamResponse, sse } from "./stream.ts";
export type { StreamResponseInit, ServerSentEvent } from "./stream.ts";
export {
defineRoom,
isRoomDefinition,
createRealtimeRegistry,
bridgeRealtime,
} from "./realtime.ts";
export type {
RealtimeBus,
RealtimeSocket,
RealtimeHandler,
RawSocket,
Room,
RoomClient,
RoomHandlers,
RoomAuthInfo,
RoomDefinition,
Target,
RealtimeRegistry,
RealtimeConnectMeta,
RealtimeBridge,
RealtimeEnvelope,
} from "./realtime.ts";
export type { Mode } from "./errors.ts";
export {
renderDevError,
renderProdError,
renderError,
renderNotFound,
renderStatusPage,
} from "./errors.ts";
export type {
ContentSecurityPolicyConfig,
CorsConfig,
CorsOrigin,
CspDirectiveValue,
HstsConfig,
PermissionsPolicyConfig,
SecurityConfig,
TrustedTypesConfig,
} from "./headers.ts";
export {
createCorsPreflightResponse,
withSecurityHeaders,
isWebSocketOriginAllowed,
resolveRequestUrl,
} from "./headers.ts";
export type {
CookieOptions,
CookieStore,
LocalStorageSnapshot,
SessionStore,
SessionBackend,
SessionEntry,
AsyncSessionBackend,
} from "./storage.ts";
export { setSessionBackend, loadSession } from "./storage.ts";
export { Fragment, Html, jsx, jsxs, mustache } from "./jsx-runtime.ts";
export type { Component as JSXComponent, Props as JSXProps, Renderable } from "./jsx-runtime.ts";
+2
View File
@@ -0,0 +1,2 @@
export { Fragment, jsx as jsxDEV } from "./jsx-runtime.ts";
export type { JSX } from "./jsx-runtime.ts";
+175
View File
@@ -0,0 +1,175 @@
import { escapeHtml } from "./security.ts";
export type Renderable = Html | string | number | boolean | null | undefined | Renderable[];
export type Props = Record<string, unknown> & {
children?: Renderable;
dangerouslySetInnerHTML?: { __html?: unknown };
};
export type Component<P extends Props = Props> = (props: P) => Renderable;
export type ElementType = string | Component | typeof Fragment;
export class Html {
constructor(public readonly html: string) {}
toString(): string {
return this.html;
}
}
export const Fragment = Symbol.for("wrnexus.fragment");
const VOID_ELEMENTS = new Set([
"area",
"base",
"br",
"col",
"embed",
"hr",
"img",
"input",
"link",
"meta",
"param",
"source",
"track",
"wbr",
]);
const SAFE_TAG_NAME = /^[A-Za-z][A-Za-z0-9._:-]*$/;
const SAFE_ATTR_NAME = /^[A-Za-z_:][A-Za-z0-9:._-]*$/;
function isHtml(value: unknown): value is Html {
return value instanceof Html;
}
function raw(value: string): Html {
return new Html(value);
}
export function mustache(expr: string): Html;
export function mustache(strings: TemplateStringsArray, ...values: unknown[]): Html;
export function mustache(input: string | TemplateStringsArray, ...values: unknown[]): Html {
const expr =
typeof input === "string"
? input
: input.reduce((out, part, index) => {
const value = index < values.length ? String(values[index]) : "";
return out + part + value;
}, "");
return raw(`{{${expr.trim()}}}`);
}
function renderChild(value: Renderable): string {
if (value === null || value === undefined || typeof value === "boolean") return "";
if (Array.isArray(value)) return value.map(renderChild).join("");
if (isHtml(value)) return value.html;
return escapeHtml(String(value));
}
function renderComponentResult(value: Renderable): string {
if (value === null || value === undefined || typeof value === "boolean") return "";
if (Array.isArray(value)) return value.map(renderComponentResult).join("");
if (isHtml(value)) return value.html;
// WrNexus page/component strings are HTML by convention.
if (typeof value === "string") return value;
return escapeHtml(String(value));
}
function attrName(name: string): string {
if (name === "className") return "class";
if (name === "htmlFor") return "for";
return name;
}
function styleToString(value: Record<string, unknown>): string {
return Object.entries(value)
.filter(([, v]) => v !== null && v !== undefined && v !== false)
.map(([k, v]) => `${k.replace(/[A-Z]/g, (ch) => `-${ch.toLowerCase()}`)}: ${String(v)}`)
.join("; ");
}
function renderAttrs(props: Props): string {
const attrs: string[] = [];
for (const [key, value] of Object.entries(props)) {
if (
key === "children" ||
key === "key" ||
key === "ref" ||
key === "dangerouslySetInnerHTML" ||
value === null ||
value === undefined ||
value === false
) {
continue;
}
if (typeof value === "function") continue;
const name = attrName(key);
if (!SAFE_ATTR_NAME.test(name)) continue;
if (value === true) {
attrs.push(name);
continue;
}
const rendered =
key === "style" && typeof value === "object" && !Array.isArray(value)
? styleToString(value as Record<string, unknown>)
: String(value);
attrs.push(`${name}="${escapeHtml(rendered)}"`);
}
return attrs.length ? ` ${attrs.join(" ")}` : "";
}
export function jsx(type: ElementType, props: Props | null): Html {
const safeProps = props ?? {};
if (type === Fragment) {
return raw(renderChild(safeProps.children));
}
if (typeof type === "function") {
return raw(renderComponentResult(type(safeProps)));
}
if (!SAFE_TAG_NAME.test(type)) throw new TypeError(`Invalid JSX tag name: ${type}`);
const attrs = renderAttrs(safeProps);
if (VOID_ELEMENTS.has(type)) {
return raw(`<${type}${attrs}>`);
}
const children =
safeProps.dangerouslySetInnerHTML && "__html" in safeProps.dangerouslySetInnerHTML
? String(safeProps.dangerouslySetInnerHTML.__html ?? "")
: renderChild(safeProps.children);
return raw(`<${type}${attrs}>${children}</${type}>`);
}
export const jsxs = jsx;
// TypeScript's automatic JSX runtime looks for this exported namespace.
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace JSX {
export type Element = Html;
export type ElementType = string | Component;
export interface ElementChildrenAttribute {
children: unknown;
}
export interface IntrinsicAttributes {
key?: string | number;
}
export interface IntrinsicElements {
[tagName: string]: Props;
}
}
+62
View File
@@ -0,0 +1,62 @@
/**
* Structured request logging middleware. Emits one record per request with a
* request id, method, path, status, and duration — as pretty text (dev) or JSON
* (production/log aggregation). The request id is stored on `ctx.locals` so
* downstream handlers can correlate their own logs.
*/
import type { Context, Middleware } from "./context.ts";
export interface RequestRecord {
time: string;
id: string;
method: string;
path: string;
status: number;
durationMs: number;
}
export interface RequestLoggerOptions {
/** "pretty" (default) for humans, "json" for machines. */
format?: "pretty" | "json";
/** Where each finished record goes. Default console.log. */
sink?: (line: string, record: RequestRecord) => void;
/** ctx.locals key for the request id. Default "requestId". */
requestIdKey?: string;
/** Clock injection for tests. Default Date.now. */
now?: () => number;
}
export function requestLogger(options: RequestLoggerOptions = {}): Middleware {
const format = options.format ?? "pretty";
const sink = options.sink ?? ((line) => console.log(line));
const idKey = options.requestIdKey ?? "requestId";
const now = options.now ?? Date.now;
return async (ctx: Context, next) => {
const start = now();
const id = (ctx.locals[idKey] as string | undefined) ?? crypto.randomUUID();
ctx.locals[idKey] = id;
let status = 500;
try {
const res = await next();
status = res.status;
return res;
} finally {
const record: RequestRecord = {
time: new Date(start).toISOString(),
id,
method: ctx.req.method,
path: ctx.url.pathname,
status,
durationMs: now() - start,
};
sink(format === "json" ? JSON.stringify(record) : formatPretty(record), record);
}
};
}
function formatPretty(r: RequestRecord): string {
return `${r.method} ${r.path}${r.status} ${r.durationMs}ms [${r.id.slice(0, 8)}]`;
}
+126
View File
@@ -0,0 +1,126 @@
/**
* Fixed-window rate limiting middleware. Keeps an in-memory counter per key
* (client IP by default, read from `x-forwarded-for` / `x-real-ip`) and rejects
* requests over the limit with a 429 and a `Retry-After` header. Sets the
* `RateLimit-Limit` / `RateLimit-Remaining` / `RateLimit-Reset` headers.
*
* The store is process-local; behind multiple instances use a shared store
* (out of scope here). Suitable as-is for single-process apps and dev.
*/
import type { Context, Middleware } from "./context.ts";
export interface RateLimitOptions {
/** Window length in milliseconds. Default 60_000 (1 minute). */
windowMs?: number;
/** Max requests allowed per key per window. Default 60. */
max?: number;
/** Derive the bucket key from the request. Default: client IP. */
key?: (ctx: Context) => string;
/**
* Trust `x-forwarded-for` / `x-real-ip` for the client IP. Default false —
* those headers are attacker-spoofable, so by default we key on the direct
* socket peer (`ctx.ip`). Enable ONLY when behind a proxy that overwrites
* these headers (nginx, a load balancer, Cloudflare).
*/
trustProxy?: boolean;
/** Body returned on 429. Default "Too Many Requests". */
message?: string;
/** Emit RateLimit-* headers. Default true. */
headers?: boolean;
/** Persistence for the counters. Default: process-local memory. */
store?: RateLimitStore;
/** Maximum in-memory keys before oldest buckets are evicted. Ignored for custom stores. */
maxKeys?: number;
}
export interface Bucket {
count: number;
resetAt: number;
}
/**
* Pluggable rate-limit counter store. The default is process-local memory; swap
* in a shared store (Redis/SQL) so limits hold across instances. `hit` records
* one request for `key` in the current window and returns the running bucket.
* It may be async (e.g. a Redis INCR + PEXPIRE) — the middleware awaits it.
*/
export interface RateLimitStore {
hit(key: string, windowMs: number, now: number): Bucket | Promise<Bucket>;
}
function createMemoryRateLimitStore(maxKeys: number): RateLimitStore {
const buckets = new Map<string, Bucket>();
return {
hit(key, windowMs, now) {
let bucket = buckets.get(key);
if (!bucket || bucket.resetAt <= now) {
if (!bucket && buckets.size >= maxKeys) {
for (const [k, b] of buckets) if (b.resetAt <= now) buckets.delete(k);
while (buckets.size >= maxKeys) buckets.delete(buckets.keys().next().value!);
}
bucket = { count: 0, resetAt: now + windowMs };
buckets.set(key, bucket);
}
bucket.count++;
return bucket;
},
};
}
export function rateLimit(options: RateLimitOptions = {}): Middleware {
const windowMs = options.windowMs ?? 60_000;
const max = options.max ?? 60;
const emitHeaders = options.headers ?? true;
const maxKeys = options.maxKeys ?? 10_000;
if (!Number.isInteger(maxKeys) || maxKeys < 1)
throw new RangeError("rateLimit maxKeys must be a positive integer");
const keyOf = options.key ?? (options.trustProxy ? proxyKey : peerKey);
const store = options.store ?? createMemoryRateLimitStore(maxKeys);
return async (ctx, next) => {
const now = Date.now();
const bucket = await store.hit(keyOf(ctx), windowMs, now);
const resetSec = Math.max(0, Math.ceil((bucket.resetAt - now) / 1000));
const remaining = Math.max(0, max - bucket.count);
if (bucket.count > max) {
const res = new Response(options.message ?? "Too Many Requests", {
status: 429,
headers: { "content-type": "text/plain", "retry-after": String(resetSec) },
});
if (emitHeaders) applyHeaders(res, max, 0, resetSec);
return res;
}
const res = await next();
if (emitHeaders) applyHeaders(res, max, remaining, resetSec);
return res;
};
}
function applyHeaders(res: Response, limit: number, remaining: number, resetSec: number): void {
try {
res.headers.set("RateLimit-Limit", String(limit));
res.headers.set("RateLimit-Remaining", String(remaining));
res.headers.set("RateLimit-Reset", String(resetSec));
} catch {
/* immutable response — skip */
}
}
/** Non-spoofable key: the direct socket peer IP (set by the server). */
export function peerKey(ctx: Context): string {
return ctx.ip ?? "global";
}
/** Proxy-aware key: trusts `x-forwarded-for` / `x-real-ip`, else the peer IP. */
export function proxyKey(ctx: Context): string {
const xff = ctx.req.headers.get("x-forwarded-for");
if (xff) return xff.split(",")[0]!.trim();
return ctx.req.headers.get("x-real-ip") ?? ctx.ip ?? "global";
}
/** @deprecated Use `peerKey` (default) or `proxyKey`. Kept for compatibility. */
export const defaultKey = proxyKey;
+409
View File
@@ -0,0 +1,409 @@
/**
* Realtime rooms.
*
* A file in `app/realtime/` exports `default defineRoom({ onConnect, onMessage,
* onLeave })` and is served at `ws://host/realtime/<name>`. The framework's
* client runtime (`/__wrnexus/realtime.js`) handles the browser side, so pages
* ship NO hand-written WebSocket code.
*
* Handlers get a `RoomClient` with everything you need:
* client.send(msg) → this connection
* client.broadcast(msg) → everyone else in the room
* client.room.broadcast(msg) → everyone (incl. sender)
* client.to(id | ids).send(msg) → specific connection(s)
* client.toUser(u | users).send() → a user / selected users (all their tabs)
* client.user = "u1" → identify a connection for targeting
* client.data / client.room.state → per-connection / shared room state
*
* The dynamic route `app/realtime/[room].ts` gives one handler many independent
* rooms — `/realtime/lobby` and `/realtime/game-7` are separate room instances.
*/
// --- Low-level socket the registry drives (a subset of Bun's ServerWebSocket) ---
export interface RawSocket {
send(data: string): unknown;
close(code?: number, reason?: string): void;
}
// --- Legacy raw handler (still supported alongside defineRoom) ---
export interface RealtimeSocket<Data = unknown> {
readonly data: Data;
send(data: string | Uint8Array): number;
subscribe(topic: string): void;
unsubscribe(topic: string): void;
publish(topic: string, data: string | Uint8Array): number;
isSubscribed(topic: string): boolean;
close(code?: number, reason?: string): void;
}
export interface RealtimeHandler<Data = unknown> {
open?(ws: RealtimeSocket<Data>): void | Promise<void>;
message?(ws: RealtimeSocket<Data>, message: string | Uint8Array): void | Promise<void>;
close?(ws: RealtimeSocket<Data>, code?: number, reason?: string): void | Promise<void>;
drain?(ws: RealtimeSocket<Data>): void | Promise<void>;
}
// --- Room API ---
export interface Target {
/** Send a message (objects are JSON-serialized). */
send(message: unknown): void;
}
export interface Room<TData = Record<string, unknown>> {
readonly name: string;
/** Shared, in-memory room state (lives while ≥1 client is connected). */
readonly state: Record<string, unknown>;
/** All connected clients. */
clients(): RoomClient<TData>[];
/** Number of connected clients. */
count(): number;
/** Send to everyone in the room, including the sender. */
broadcast(message: unknown): void;
/** Target specific connection id(s). */
to(id: string | string[]): Target;
/** Target a user / users by identity (reaches all their connections). */
toUser(user: string | string[]): Target;
}
export interface RoomClient<TData = Record<string, unknown>> {
/** Unique per connection (a tab). */
readonly id: string;
/** App identity for targeting; assign it in `onConnect`. */
user: string | undefined;
/** Query params from the connection URL. */
readonly query: Record<string, string>;
/** Per-connection scratch state. */
readonly data: TData;
readonly room: Room<TData>;
/** Send to THIS connection. */
send(message: unknown): void;
/** Send to everyone else in the room. */
broadcast(message: unknown): void;
/** Target specific connection id(s). */
to(id: string | string[]): Target;
/** Target a user / users by identity. */
toUser(user: string | string[]): Target;
/** Close this connection. */
close(code?: number, reason?: string): void;
}
/** Info available when authorizing a connection, before it is accepted. */
export interface RoomAuthInfo {
/** Authenticated session user id, or `?user=` — undefined when anonymous. */
user?: string;
/** Connection URL query params. */
query: Record<string, string>;
/** The upgrade request's headers (cookies, etc.). */
headers: Headers;
}
export interface RoomHandlers<TData = Record<string, unknown>> {
/**
* Gate the connection BEFORE it is accepted. Return false to reject the
* upgrade with 403 (e.g. `authorize: (info) => !!info.user` to require auth).
*/
authorize?(info: RoomAuthInfo): boolean | Promise<boolean>;
/** A client connected (a new tab joined the room). */
onConnect?(client: RoomClient<TData>): void | Promise<void>;
/** A message arrived (JSON is parsed; non-JSON arrives as a string). */
onMessage?(client: RoomClient<TData>, message: any): void | Promise<void>;
/** A client disconnected. */
onLeave?(client: RoomClient<TData>): void | Promise<void>;
}
export interface RoomDefinition<TData = Record<string, unknown>> {
readonly __wrnexusRoom: true;
readonly handlers: RoomHandlers<TData>;
}
/** Define a realtime room. Export the result as the `default` of a realtime file. */
export function defineRoom<TData = Record<string, unknown>>(
handlers: RoomHandlers<TData>,
): RoomDefinition<TData> {
return { __wrnexusRoom: true, handlers };
}
export function isRoomDefinition(value: unknown): value is RoomDefinition {
return (
!!value &&
typeof value === "object" &&
(value as { __wrnexusRoom?: unknown }).__wrnexusRoom === true
);
}
// --- Registry (server-side connection manager) ---
interface Conn {
id: string;
user?: string;
data: Record<string, unknown>;
query: Record<string, string>;
socket: RawSocket;
roomName: string;
client: RoomClient;
}
interface RoomImpl {
name: string;
state: Record<string, unknown>;
def: RoomDefinition;
conns: Map<string, Conn>;
users: Map<string, Set<string>>; // user identity → connection ids
}
export interface RealtimeConnectMeta {
room: string;
def: RoomDefinition;
query?: Record<string, string>;
user?: string;
}
/** One cross-instance message: a room broadcast, or a targeted user send. */
export interface RealtimeEnvelope {
room: string;
/** If set, deliver only to these user identities; otherwise the whole room. */
users?: string[];
message: unknown;
}
/**
* A pub/sub bridge for horizontal scaling. Wire the registry to a shared bus
* (Redis pub/sub, NATS, …): local broadcasts/`toUser` sends are published to
* peers, and messages received from peers are delivered via `registry.deliver`.
* Connection-targeted sends (`send`, `to(id)`) stay local (ids are per-process).
*/
export interface RealtimeBridge {
publish(envelope: RealtimeEnvelope): void;
}
export interface RealtimeRegistry {
open(socket: RawSocket, meta: RealtimeConnectMeta): void | Promise<void>;
message(socket: RawSocket, raw: string | Uint8Array): void | Promise<void>;
close(socket: RawSocket): void | Promise<void>;
/** Attach a cross-instance bridge (call once at startup). */
setBridge(bridge: RealtimeBridge): void;
/** Deliver an envelope received from a peer to LOCAL connections only. */
deliver(envelope: RealtimeEnvelope): void;
/** Number of live connections (across all rooms) — for tests/metrics. */
size(): number;
}
function serialize(message: unknown): string {
return typeof message === "string" ? message : JSON.stringify(message);
}
/** Create the registry that maps sockets ↔ rooms and drives room handlers. */
export function createRealtimeRegistry(): RealtimeRegistry {
const rooms = new Map<string, RoomImpl>();
const bySocket = new Map<RawSocket, Conn>();
let bridge: RealtimeBridge | null = null;
let applyingRemote = false; // true while delivering a peer envelope (no re-publish)
const publish = (envelope: RealtimeEnvelope): void => {
if (bridge && !applyingRemote) bridge.publish(envelope);
};
const send = (conn: Conn | undefined, payload: string): void => {
if (!conn) return;
try {
conn.socket.send(payload);
} catch {
/* socket already gone */
}
};
const reindexUser = (room: RoomImpl, conn: Conn, next: string | undefined): void => {
if (conn.user === next) return;
if (conn.user) {
const set = room.users.get(conn.user);
if (set) {
set.delete(conn.id);
if (!set.size) room.users.delete(conn.user);
}
}
conn.user = next;
if (next) {
let set = room.users.get(next);
if (!set) room.users.set(next, (set = new Set()));
set.add(conn.id);
}
};
const idsForUsers = (room: RoomImpl, user: string | string[]): string[] => {
const out: string[] = [];
for (const u of Array.isArray(user) ? user : [user]) {
const set = room.users.get(u);
if (set) out.push(...set);
}
return out;
};
const makeRoomApi = (room: RoomImpl): Room => ({
name: room.name,
state: room.state,
clients: () => Array.from(room.conns.values(), (c) => c.client),
count: () => room.conns.size,
broadcast: (message) => {
const payload = serialize(message);
for (const c of room.conns.values()) send(c, payload);
publish({ room: room.name, message });
},
to: (id) => ({
send: (message) => {
// Connection-targeted: local only (ids are per-process).
const payload = serialize(message);
for (const cid of Array.isArray(id) ? id : [id]) send(room.conns.get(cid), payload);
},
}),
toUser: (user) => ({
send: (message) => {
const payload = serialize(message);
for (const cid of idsForUsers(room, user)) send(room.conns.get(cid), payload);
publish({ room: room.name, users: Array.isArray(user) ? user : [user], message });
},
}),
});
const makeClientApi = (room: RoomImpl, conn: Conn): RoomClient => {
const roomApi = makeRoomApi(room);
return {
id: conn.id,
get user() {
return conn.user;
},
set user(value: string | undefined) {
reindexUser(room, conn, value);
},
query: conn.query,
data: conn.data,
room: roomApi,
send: (message) => send(conn, serialize(message)),
broadcast: (message) => {
const payload = serialize(message);
for (const c of room.conns.values()) if (c.id !== conn.id) send(c, payload);
// Peers deliver to all their conns (all "others" relative to this one).
publish({ room: room.name, message });
},
to: roomApi.to,
toUser: roomApi.toUser,
close: (code, reason) => conn.socket.close(code, reason),
};
};
return {
async open(socket, meta) {
let room = rooms.get(meta.room);
if (!room) {
room = { name: meta.room, state: {}, def: meta.def, conns: new Map(), users: new Map() };
rooms.set(meta.room, room);
}
const conn: Conn = {
id: randomId(),
data: {},
query: meta.query ?? {},
socket,
roomName: meta.room,
client: null as unknown as RoomClient,
};
conn.client = makeClientApi(room, conn);
room.conns.set(conn.id, conn);
bySocket.set(socket, conn);
if (meta.user) reindexUser(room, conn, meta.user);
await room.def.handlers.onConnect?.(conn.client);
},
async message(socket, raw) {
const conn = bySocket.get(socket);
if (!conn) return;
const room = rooms.get(conn.roomName);
if (!room) return;
const text = typeof raw === "string" ? raw : new TextDecoder().decode(raw);
let message: unknown;
try {
message = JSON.parse(text);
} catch {
message = text;
}
await room.def.handlers.onMessage?.(conn.client, message);
},
async close(socket) {
const conn = bySocket.get(socket);
if (!conn) return;
bySocket.delete(socket);
const room = rooms.get(conn.roomName);
if (!room) return;
try {
await room.def.handlers.onLeave?.(conn.client);
} finally {
room.conns.delete(conn.id);
reindexUser(room, conn, undefined);
if (room.conns.size === 0) rooms.delete(room.name);
}
},
setBridge(b) {
bridge = b;
},
deliver(envelope) {
const room = rooms.get(envelope.room);
if (!room) return;
applyingRemote = true; // suppress re-publishing what we received
try {
const payload = serialize(envelope.message);
if (envelope.users) {
for (const cid of idsForUsers(room, envelope.users)) send(room.conns.get(cid), payload);
} else {
for (const c of room.conns.values()) send(c, payload);
}
} finally {
applyingRemote = false;
}
},
size: () => bySocket.size,
};
}
/**
* A minimal pub/sub bus (structurally satisfied by `@wrnexus/pubsub`). Used to
* bridge realtime broadcasts across processes without a hard dependency.
*/
export interface RealtimeBus {
publish(topic: string, message: unknown): void | Promise<void>;
subscribe(topic: string, handler: (message: unknown, topic: string) => void): () => void;
}
/**
* Bridge a realtime registry across processes/instances via a pub/sub bus (use
* the Redis driver so it crosses machines). After this, `client.room.broadcast`
* and `client.toUser(...)` reach connected clients on **every** app process/
* instance subscribed to the same bus — the foundation for realtime that works
* with multiple running apps behind the gateway. Connection-targeted sends
* (`send`, `to(id)`) stay local. Returns an unsubscribe function.
*
* import { createRealtimeRegistry, bridgeRealtime } from "@wrnexus/core";
* import { createPubSub } from "@wrnexus/pubsub";
* import { redisDriver } from "@wrnexus/pubsub/redis";
* bridgeRealtime(registry, createPubSub(redisDriver(process.env.REDIS_URL)));
*/
export function bridgeRealtime(
registry: RealtimeRegistry,
bus: RealtimeBus,
topic = "wrnexus:realtime",
): () => void {
registry.setBridge({ publish: (envelope) => void bus.publish(topic, envelope) });
return bus.subscribe(topic, (message) => registry.deliver(message as RealtimeEnvelope));
}
function randomId(): string {
const bytes = new Uint8Array(12);
crypto.getRandomValues(bytes);
let out = "";
for (const b of bytes) out += b.toString(16).padStart(2, "0");
return out;
}
+53
View File
@@ -0,0 +1,53 @@
/**
* Small, dependency-free security helpers shared across packages.
*/
const HTML_ESCAPES: Record<string, string> = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
};
/**
* Escape a string for safe interpolation into HTML text or attributes.
* Used for page metadata (title/description) so untrusted values can't
* break out of an attribute or inject markup.
*/
export function escapeHtml(value: string): string {
return value.replace(/[&<>"']/g, (ch) => HTML_ESCAPES[ch]!);
}
/**
* Client island names come from `data-client="..."` attributes and from
* filenames in `app/client`. We only ever allow a conservative charset so a
* name can never be used to traverse the filesystem or inject code.
*/
const SAFE_NAME = /^[A-Za-z0-9_-]+$/;
export function isSafeIslandName(name: string): boolean {
return SAFE_NAME.test(name);
}
/**
* Reject obvious path-traversal in a request path before it is ever used to
* resolve a file. The router never builds file paths from request input
* (routes are resolved against a pre-scanned table), but this is a cheap
* defense-in-depth guard.
*/
export function isSafeRequestPath(pathname: string): boolean {
if (pathname.includes("\0")) return false;
// Reject `..` segments and backslashes that could escape a directory.
const decoded = safeDecode(pathname);
if (decoded === null) return false;
return !/(^|\/)\.\.(\/|$)/.test(decoded) && !decoded.includes("\\");
}
function safeDecode(value: string): string | null {
try {
return decodeURIComponent(value);
} catch {
return null;
}
}
+389
View File
@@ -0,0 +1,389 @@
import type { Context, Middleware } from "./context.ts";
export interface CookieOptions {
path?: string;
domain?: string;
maxAge?: number;
expires?: Date | string;
httpOnly?: boolean;
secure?: boolean;
sameSite?: "Strict" | "Lax" | "None" | "strict" | "lax" | "none";
}
export interface CookieStore {
get(name: string): string | undefined;
getAll(): Record<string, string>;
has(name: string): boolean;
set(name: string, value: string, options?: CookieOptions): void;
delete(name: string, options?: CookieOptions): void;
headers(): string[];
}
export interface SessionStore {
id(): string;
get<T = unknown>(key: string): T | undefined;
getAll(): Record<string, unknown>;
set(key: string, value: unknown): void;
delete(key: string): void;
/** Issue a fresh session id, keeping the data — defends against fixation. */
regenerate(): void;
clear(): void;
}
export interface LocalStorageSnapshot {
get(key: string): string | undefined;
getAll(): Record<string, string>;
has(key: string): boolean;
}
const SESSION_COOKIE = "wrnexus.sid";
/** Idle timeout: a session expires this long after its last access. */
const SESSION_TTL_MS = 1000 * 60 * 60 * 24; // 24 hours
/** Run a background sweep after this many new sessions (bounds memory). */
const SESSION_GC_EVERY = 500;
/** A stored session: its data plus an absolute expiry timestamp (ms). */
export interface SessionEntry {
data: Record<string, unknown>;
expiresAt: number;
}
/**
* Pluggable session persistence. The default is process-local memory; swap in a
* shared backend (Redis, SQL, etc.) via `setSessionBackend` so sessions survive
* restarts and work across multiple instances. Methods are synchronous, so a
* backend must be sync (e.g. `bun:sqlite`); async stores need a load/save
* wrapper around the request (future work).
*/
export interface SessionBackend {
get(id: string): SessionEntry | undefined;
set(id: string, entry: SessionEntry): void;
delete(id: string): void;
/** Optional: drop expired entries. Called periodically by the store. */
gc?(now: number): void;
}
function createMemorySessionBackend(): SessionBackend {
const map = new Map<string, SessionEntry>();
return {
get: (id) => map.get(id),
set: (id, entry) => void map.set(id, entry),
delete: (id) => void map.delete(id),
gc: (now) => {
for (const [key, entry] of map) if (entry.expiresAt <= now) map.delete(key);
},
};
}
let sessionBackend: SessionBackend = createMemorySessionBackend();
let sessionsSinceGc = 0;
/** Replace the session persistence backend (call once at startup). */
export function setSessionBackend(backend: SessionBackend): void {
sessionBackend = backend;
}
/**
* An ASYNC session store (Redis, a remote DB). Use it via the `loadSession`
* middleware, which loads the session before the request and saves it after —
* keeping the `ctx.session` API synchronous while persistence is shared across
* instances.
*/
export interface AsyncSessionBackend {
load(id: string): Promise<SessionEntry | undefined>;
save(id: string, entry: SessionEntry): Promise<void>;
destroy(id: string): Promise<void>;
}
/**
* Back `ctx.session` with an async store. Register early (before anything reads
* `ctx.session`). Loads once at the start of the request and saves once at the
* end; regenerate/clear destroy the old id.
*/
export function loadSession(
backend: AsyncSessionBackend,
options: { ttlMs?: number } = {},
): Middleware {
const ttlMs = options.ttlMs ?? SESSION_TTL_MS;
return async (ctx: Context, next) => {
let id = ctx.cookies.get(SESSION_COOKIE);
let entry = id ? await backend.load(id) : undefined;
if (id && entry && entry.expiresAt <= Date.now()) {
await backend.destroy(id);
entry = undefined;
id = undefined;
} else if (id && !entry) {
id = undefined; // unknown/expired id → anonymous
}
const destroys = new Set<string>();
const ensure = (): Record<string, unknown> => {
if (!id) {
id = randomId();
ctx.cookies.set(SESSION_COOKIE, id, sessionCookieOptions(ctx.url.protocol === "https:"));
}
if (!entry) entry = { data: {}, expiresAt: Date.now() + ttlMs };
return entry.data;
};
ctx.session = {
id() {
ensure();
return id!;
},
get<T = unknown>(key: string): T | undefined {
return (entry?.data[key] as T | undefined) ?? undefined;
},
getAll() {
return entry ? { ...entry.data } : {};
},
set(key, value) {
ensure()[key] = value;
},
delete(key) {
if (entry) delete entry.data[key];
},
regenerate() {
const data = entry?.data ?? {};
if (id) destroys.add(id);
id = randomId();
entry = { data, expiresAt: Date.now() + ttlMs };
ctx.cookies.set(SESSION_COOKIE, id, sessionCookieOptions(ctx.url.protocol === "https:"));
},
clear() {
if (id) destroys.add(id);
entry = undefined;
id = undefined;
ctx.cookies.delete(SESSION_COOKIE, sessionCookieOptions(ctx.url.protocol === "https:"));
},
};
try {
return await next();
} finally {
for (const gone of destroys) if (gone !== id) await backend.destroy(gone);
if (id && entry) {
entry.expiresAt = Date.now() + ttlMs;
await backend.save(id, entry);
}
}
};
}
const COOKIE_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
/** Read a live (non-expired) session entry, sliding its expiry forward. */
function readSessionEntry(id: string): SessionEntry | undefined {
const entry = sessionBackend.get(id);
if (!entry) return undefined;
if (entry.expiresAt <= Date.now()) {
sessionBackend.delete(id);
return undefined;
}
entry.expiresAt = Date.now() + SESSION_TTL_MS; // sliding idle expiry
sessionBackend.set(id, entry); // persist the slide (matters for external backends)
return entry;
}
export function createCookieStore(req: Request): CookieStore {
const incoming = parseCookieHeader(req.headers.get("cookie") ?? "");
const outgoing: string[] = [];
return {
get(name) {
return incoming[name];
},
getAll() {
return { ...incoming };
},
has(name) {
return Object.prototype.hasOwnProperty.call(incoming, name);
},
set(name, value, options) {
incoming[name] = value;
outgoing.push(serializeCookie(name, value, { path: "/", ...options }));
},
delete(name, options) {
delete incoming[name];
outgoing.push(
serializeCookie(name, "", {
path: "/",
...options,
expires: new Date(0),
maxAge: 0,
}),
);
},
headers() {
return [...outgoing];
},
};
}
export function createSessionStore(
cookies: CookieStore,
req: Request,
cookieName = SESSION_COOKIE,
secure = new URL(req.url).protocol === "https:",
): SessionStore {
let id = cookies.get(cookieName);
let entry = id ? readSessionEntry(id) : undefined;
if (id && !entry) id = undefined; // expired or unknown → treat as anonymous
const persist = (): void => {
if (id && entry) sessionBackend.set(id, entry);
};
const ensure = (): Record<string, unknown> => {
if (!id) {
id = randomId();
cookies.set(cookieName, id, sessionCookieOptions(secure));
}
entry = readSessionEntry(id);
if (!entry) {
if (++sessionsSinceGc >= SESSION_GC_EVERY) {
sessionsSinceGc = 0;
sessionBackend.gc?.(Date.now());
}
entry = { data: {}, expiresAt: Date.now() + SESSION_TTL_MS };
sessionBackend.set(id, entry);
}
return entry.data;
};
return {
id() {
ensure();
return id!;
},
get<T = unknown>(key: string): T | undefined {
return (entry?.data[key] as T | undefined) ?? undefined;
},
getAll() {
return entry ? { ...entry.data } : {};
},
set(key, value) {
ensure()[key] = value;
persist();
},
delete(key) {
if (entry) {
delete entry.data[key];
persist();
}
},
regenerate() {
// Session fixation defense: move existing data under a brand-new id and
// reissue the cookie, so any pre-login id an attacker planted is void.
const data = entry?.data ?? {};
if (id) sessionBackend.delete(id);
id = randomId();
entry = { data, expiresAt: Date.now() + SESSION_TTL_MS };
sessionBackend.set(id, entry);
cookies.set(cookieName, id, sessionCookieOptions(secure));
},
clear() {
if (id) sessionBackend.delete(id);
entry = undefined;
id = undefined;
cookies.delete(cookieName, sessionCookieOptions(secure));
},
};
}
export function createLocalStorageSnapshot(req: Request): LocalStorageSnapshot {
const values = parseLocalStorageHeader(req.headers.get("x-wrnexus-local-storage"));
return {
get(key) {
return values[key];
},
getAll() {
return { ...values };
},
has(key) {
return Object.prototype.hasOwnProperty.call(values, key);
},
};
}
export function applyCookieHeaders(ctx: { cookies?: CookieStore }, headers: Headers): void {
for (const value of ctx.cookies?.headers() ?? []) {
headers.append("Set-Cookie", value);
}
}
function parseCookieHeader(header: string): Record<string, string> {
const out: Record<string, string> = {};
for (const part of header.split(";")) {
const index = part.indexOf("=");
if (index < 0) continue;
const name = part.slice(0, index).trim();
if (!name) continue;
out[name] = safeDecode(part.slice(index + 1).trim());
}
return out;
}
function serializeCookie(name: string, value: string, options: CookieOptions): string {
if (!COOKIE_NAME.test(name)) throw new Error(`Invalid cookie name: ${name}`);
const parts = [`${name}=${encodeURIComponent(value)}`];
if (options.maxAge !== undefined) parts.push(`Max-Age=${Math.floor(options.maxAge)}`);
if (options.domain) parts.push(`Domain=${options.domain}`);
if (options.path) parts.push(`Path=${options.path}`);
if (options.expires) {
const expires = options.expires instanceof Date ? options.expires : new Date(options.expires);
parts.push(`Expires=${expires.toUTCString()}`);
}
if (options.httpOnly) parts.push("HttpOnly");
if (options.secure) parts.push("Secure");
if (options.sameSite) parts.push(`SameSite=${normalizeSameSite(options.sameSite)}`);
return parts.join("; ");
}
function sessionCookieOptions(secure: boolean): CookieOptions {
return {
httpOnly: true,
path: "/",
sameSite: "Lax",
secure,
};
}
function parseLocalStorageHeader(header: string | null): Record<string, string> {
if (!header) return {};
try {
const parsed = JSON.parse(decodeURIComponent(header)) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
const out: Record<string, string> = {};
for (const [key, value] of Object.entries(parsed)) {
if (typeof value === "string") out[key] = value;
}
return out;
} catch {
return {};
}
}
function normalizeSameSite(value: NonNullable<CookieOptions["sameSite"]>): string {
const lower = value.toLowerCase();
return lower === "strict" ? "Strict" : lower === "none" ? "None" : "Lax";
}
function safeDecode(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
/** A 256-bit cryptographically-random session id (no weak fallback). */
function randomId(): string {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
let out = "";
for (const b of bytes) out += b.toString(16).padStart(2, "0");
return out;
}
+93
View File
@@ -0,0 +1,93 @@
/**
* Streaming response primitives.
*
* `streamResponse` turns a (sync or async) iterable of strings/bytes into a
* streaming `Response` — the basis for streaming SSR (send the shell, then flush
* page chunks as they render) and any progressively-generated output. `sse`
* builds a Server-Sent Events stream from an async iterable of events.
*
* API routes and pages can already return a `Response` with a `ReadableStream`
* body and the framework streams it unbuffered; these helpers just make the
* common cases ergonomic.
*/
export interface StreamResponseInit {
status?: number;
headers?: HeadersInit;
/** Content-Type; default "text/html; charset=utf-8". */
contentType?: string;
}
type Chunk = string | Uint8Array;
type ChunkSource = Iterable<Chunk> | AsyncIterable<Chunk>;
/** Build a streaming Response from an (async) iterable of chunks. */
export function streamResponse(source: ChunkSource, init: StreamResponseInit = {}): Response {
const encoder = new TextEncoder();
const iterator = getIterator(source);
const stream = new ReadableStream<Uint8Array>({
async pull(controller) {
try {
const { done, value } = await iterator.next();
if (done) {
controller.close();
return;
}
controller.enqueue(typeof value === "string" ? encoder.encode(value) : value);
} catch (err) {
controller.error(err);
}
},
async cancel() {
await iterator.return?.(undefined);
},
});
const headers = new Headers(init.headers);
if (!headers.has("content-type")) {
headers.set("content-type", init.contentType ?? "text/html; charset=utf-8");
}
// Tell the server's compressor (and proxies) not to buffer/transform a stream.
if (!headers.has("cache-control")) headers.set("cache-control", "no-transform");
return new Response(stream, { status: init.status ?? 200, headers });
}
export interface ServerSentEvent {
data: string;
event?: string;
id?: string;
/** Client reconnection hint in milliseconds. */
retry?: number;
}
/** Build a Server-Sent Events (text/event-stream) Response from events. */
export function sse(source: Iterable<ServerSentEvent> | AsyncIterable<ServerSentEvent>): Response {
const iterator = getIterator(source);
async function* frames(): AsyncGenerator<string> {
for (;;) {
const { done, value } = await iterator.next();
if (done) return;
yield formatEvent(value);
}
}
return streamResponse(frames(), {
contentType: "text/event-stream",
headers: { "cache-control": "no-cache, no-transform", connection: "keep-alive" },
});
}
function formatEvent(e: ServerSentEvent): string {
let out = "";
if (e.event) out += `event: ${e.event}\n`;
if (e.id) out += `id: ${e.id}\n`;
if (e.retry !== undefined) out += `retry: ${Math.floor(e.retry)}\n`;
for (const line of e.data.split("\n")) out += `data: ${line}\n`;
return out + "\n";
}
function getIterator<T>(source: Iterable<T> | AsyncIterable<T>): AsyncIterator<T> | Iterator<T> {
const asAsync = (source as AsyncIterable<T>)[Symbol.asyncIterator];
if (typeof asAsync === "function") return asAsync.call(source);
return (source as Iterable<T>)[Symbol.iterator]();
}
+78
View File
@@ -0,0 +1,78 @@
/**
* File upload helpers. Bun parses `multipart/form-data` natively via
* `Request.formData()`, yielding web `File` objects; these helpers validate and
* persist them safely (size/type limits, filename sanitisation to prevent path
* traversal).
*/
export class UploadError extends Error {
constructor(message: string) {
super(message);
this.name = "UploadError";
}
}
export interface SaveUploadOptions {
/** Destination directory. */
dir: string;
/** Reject files larger than this many bytes. */
maxBytes?: number;
/** Allowed MIME types (e.g. "image/png") and/or extensions (e.g. ".png"). */
allowedTypes?: string[];
/** Choose the stored filename. Default: the sanitised original name. */
filename?: (file: File) => string;
}
export interface SavedUpload {
path: string;
filename: string;
size: number;
type: string;
}
/** All `File` values in a parsed form, with their field names. */
export function collectUploads(form: FormData): { field: string; file: File }[] {
const out: { field: string; file: File }[] = [];
for (const [field, value] of form) {
if (value instanceof File && value.size > 0) out.push({ field, file: value });
}
return out;
}
/** Validate and write one uploaded file to disk. Throws `UploadError` on reject. */
export async function saveUpload(file: File, options: SaveUploadOptions): Promise<SavedUpload> {
if (options.maxBytes !== undefined && file.size > options.maxBytes) {
throw new UploadError(`File "${file.name}" exceeds the ${options.maxBytes}-byte limit`);
}
if (options.allowedTypes && !isAllowed(file, options.allowedTypes)) {
throw new UploadError(`File type not allowed: ${file.type || file.name || "unknown"}`);
}
const filename = sanitizeFilename(
options.filename ? options.filename(file) : file.name || "upload",
);
const path = `${options.dir.replace(/[/\\]+$/, "")}/${filename}`;
await Bun.write(path, file);
return { path, filename, size: file.size, type: file.type };
}
function isAllowed(file: File, allowed: string[]): boolean {
const type = (file.type || "").toLowerCase();
const name = (file.name || "").toLowerCase();
return allowed.some((entry) => {
const e = entry.toLowerCase();
return e.startsWith(".") ? name.endsWith(e) : type === e;
});
}
/** Strip directory separators, traversal, and control chars from a filename. */
export function sanitizeFilename(name: string): string {
const base = name
.replace(/[/\\]+/g, "_") // path separators
.replace(/\.\.+/g, ".") // collapse traversal dots
// eslint-disable-next-line no-control-regex -- intentionally stripping control chars
.replace(/[\x00-\x1f<>:"|?*]/g, "") // control + illegal chars
.replace(/^\.+/, "") // no leading dots
.trim();
return base.length > 0 ? base.slice(0, 255) : "upload";
}
+93
View File
@@ -0,0 +1,93 @@
import { test, expect } from "bun:test";
import {
createContext,
hashPassword,
verifyPassword,
logIn,
logOut,
getUser,
sessionAuth,
requireAuth,
} from "../src/index.ts";
function ctx(method = "GET", path = "/", accept?: string) {
const headers: Record<string, string> = {};
if (accept) headers.accept = accept;
const url = new URL(`http://x${path}`);
const req = new Request(url, { method, headers });
return createContext(req, url);
}
test("hashPassword / verifyPassword round-trip", async () => {
const hash = await hashPassword("correct horse battery staple");
expect(hash).toBeTruthy();
expect(hash).not.toBe("correct horse battery staple");
expect(await verifyPassword("correct horse battery staple", hash)).toBe(true);
expect(await verifyPassword("wrong", hash)).toBe(false);
});
test("verifyPassword tolerates empty/garbage hashes", async () => {
expect(await verifyPassword("x", "")).toBe(false);
expect(await verifyPassword("x", "not-a-real-hash")).toBe(false);
});
test("logIn stores the user; getUser reads it; logOut clears it", () => {
const c = ctx();
expect(getUser(c)).toBeNull();
logIn(c, { id: 1, email: "a@b.com" });
expect(getUser<{ id: number }>(c)?.id).toBe(1);
expect(c.session.get<{ id: number; email: string }>("user")).toEqual({ id: 1, email: "a@b.com" });
logOut(c);
expect(getUser(c)).toBeNull();
expect(c.user).toBeNull();
});
test("logIn regenerates the session id (fixation defense) but keeps data", () => {
const c = ctx();
c.session.set("cart", [1, 2]);
const before = c.session.id();
logIn(c, { id: 1, email: "a@b.com" });
const after = c.session.id();
expect(after).not.toBe(before); // fresh id issued on login
expect(after.length).toBeGreaterThanOrEqual(32);
expect(c.session.get<number[]>("cart")).toEqual([1, 2]); // data preserved
expect(getUser<{ id: number }>(c)?.id).toBe(1);
});
test("sessionAuth hydrates ctx.user from the session", async () => {
const c = ctx();
c.session.set("user", { id: 7 });
let seen: unknown = "unset";
await sessionAuth()(c, () => {
seen = c.user;
return new Response("ok");
});
expect(seen).toEqual({ id: 7 });
});
test("requireAuth: passes through when authenticated", async () => {
const c = ctx();
logIn(c, { id: 1 });
const res = await requireAuth()(c, () => new Response("secret"));
expect(await res.text()).toBe("secret");
});
test("requireAuth: 401 JSON for API paths when anonymous", async () => {
const c = ctx("GET", "/api/me");
const res = await requireAuth()(c, () => new Response("secret"));
expect(res.status).toBe(401);
expect(await res.json()).toEqual({ ok: false, error: "Unauthorized" });
});
test("requireAuth: 302 redirect for page navigations when anonymous", async () => {
const c = ctx("GET", "/dashboard?tab=1", "text/html");
const res = await requireAuth()(c, () => new Response("secret"));
expect(res.status).toBe(302);
expect(res.headers.get("location")).toBe("/login?next=%2Fdashboard%3Ftab%3D1");
});
test("requireAuth: custom loginPath", async () => {
const c = ctx("GET", "/dashboard", "text/html");
const res = await requireAuth({ loginPath: "/signin" })(c, () => new Response("x"));
expect(res.headers.get("location")).toBe("/signin?next=%2Fdashboard");
});
+28
View File
@@ -0,0 +1,28 @@
import { test, expect } from "bun:test";
import { createContext, csrfToken, verifyCsrf, CSRF_COOKIE } from "../src/index.ts";
function ctx(method: string, cookie?: string, header?: string) {
const headers: Record<string, string> = {};
if (cookie) headers.cookie = `${CSRF_COOKIE}=${cookie}`;
if (header) headers["x-csrf-token"] = header;
const req = new Request("http://x/api", { method, headers });
return createContext(req, new URL(req.url));
}
test("csrfToken issues a token", () => {
const token = csrfToken(ctx("GET"));
expect(token).toBeTruthy();
expect(token.length).toBeGreaterThan(16);
});
test("verifyCsrf: safe methods always pass", () => {
expect(verifyCsrf(ctx("GET"))).toBe(true);
expect(verifyCsrf(ctx("HEAD"))).toBe(true);
});
test("verifyCsrf: unsafe methods need matching cookie + header", () => {
expect(verifyCsrf(ctx("POST", "abc", "abc"))).toBe(true);
expect(verifyCsrf(ctx("POST", "abc", "xyz"))).toBe(false); // mismatch
expect(verifyCsrf(ctx("POST", "abc"))).toBe(false); // no header
expect(verifyCsrf(ctx("POST", undefined, "abc"))).toBe(false); // no cookie
});
+66
View File
@@ -0,0 +1,66 @@
import { test, expect } from "bun:test";
import { withSecurityHeaders, isWebSocketOriginAllowed } from "../src/index.ts";
const req = (headers: Record<string, string> = {}) => new Request("https://x/", { headers });
function scriptSrc(csp: string): string {
return csp
.split(";")
.map((s) => s.trim())
.find((s) => s.startsWith("script-src"))!;
}
test("CSP nonce is added to script-src and drops unsafe-inline", () => {
const res = withSecurityHeaders(req(), new Response("x"), "development", undefined, "ABC123");
const directive = scriptSrc(res.headers.get("content-security-policy")!);
expect(directive).toContain("'nonce-ABC123'");
expect(directive).not.toContain("'unsafe-inline'");
});
test("without a nonce, dev script-src keeps unsafe-inline (for HMR)", () => {
const res = withSecurityHeaders(req(), new Response("x"), "development");
expect(scriptSrc(res.headers.get("content-security-policy")!)).toContain("'unsafe-inline'");
});
test("CORS credentials + origin:* is refused (credentials dropped)", () => {
const res = withSecurityHeaders(
req({ origin: "https://evil.test" }),
new Response("x"),
"production",
{
cors: { enabled: true, origin: "*", credentials: true },
},
);
expect(res.headers.get("access-control-allow-credentials")).toBeNull();
});
test("production sets HSTS + strict CSP", () => {
const res = withSecurityHeaders(req(), new Response("x"), "production");
expect(res.headers.get("strict-transport-security")).toContain("max-age=");
expect(res.headers.get("content-security-policy")).toContain("default-src 'self'");
});
test("permissions policy overrides merge with restrictive defaults", () => {
const res = withSecurityHeaders(req(), new Response("x"), "development", {
permissionsPolicy: { camera: ["self"] },
});
const policy = res.headers.get("permissions-policy")!;
expect(policy).toContain("camera=(self)");
expect(policy).toContain("microphone=()");
});
test("isWebSocketOriginAllowed blocks cross-site WS (CSWSH), allows same-origin", () => {
const wsReq = (origin: string | null, host: string) =>
new Request("http://x/realtime/c", {
headers: origin ? { origin, host } : { host },
});
expect(isWebSocketOriginAllowed(wsReq("http://app.test", "app.test"))).toBe(true); // same-origin
expect(isWebSocketOriginAllowed(wsReq("http://evil.test", "app.test"))).toBe(false); // cross-site
expect(isWebSocketOriginAllowed(wsReq(null, "app.test"))).toBe(true); // native client, no cookies
// Explicit CORS allowlist opens a cross-origin WS.
expect(
isWebSocketOriginAllowed(wsReq("http://other.test", "app.test"), {
cors: { enabled: true, origin: "http://other.test" },
}),
).toBe(true);
});
+13
View File
@@ -0,0 +1,13 @@
import { expect, test } from "bun:test";
import { jsx } from "../src/jsx-runtime.ts";
test("JSX rejects dynamic tag-name injection", () => {
expect(() => jsx("div><script>alert(1)</script><div" as "div", {})).toThrow(
"Invalid JSX tag name",
);
});
test("JSX skips invalid spread attribute names", () => {
const html = jsx("div", { 'title" onmouseover="alert(1)': "x", title: "safe" }).toString();
expect(html).toBe('<div title="safe"></div>');
});
+292
View File
@@ -0,0 +1,292 @@
import { test, expect } from "bun:test";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { existsSync } from "node:fs";
import {
createContext,
rateLimit,
requestLogger,
TTLCache,
cacheControl,
withCacheControl,
etag,
notModified,
saveUpload,
collectUploads,
sanitizeFilename,
UploadError,
setSessionBackend,
loadSession,
type SessionBackend,
type SessionEntry,
type AsyncSessionBackend,
type RateLimitStore,
} from "../src/index.ts";
function memoryBackend(): SessionBackend {
const map = new Map<string, SessionEntry>();
return {
get: (id) => map.get(id),
set: (id, e) => void map.set(id, e),
delete: (id) => void map.delete(id),
_map: map,
} as SessionBackend & { _map: Map<string, SessionEntry> };
}
function ctx(path = "/", headers: Record<string, string> = {}) {
const url = new URL(`http://x${path}`);
return createContext(new Request(url, { headers }), url);
}
// --- rate limiting ---------------------------------------------------------
test("rateLimit (trustProxy) allows up to max then 429 with headers", async () => {
const mw = rateLimit({ max: 2, windowMs: 60_000, trustProxy: true });
const ok = () => new Response("ok");
const key = { "x-forwarded-for": "1.1.1.1" };
const r1 = await mw(ctx("/", key), ok);
const r2 = await mw(ctx("/", key), ok);
const r3 = await mw(ctx("/", key), ok);
expect(r1.status).toBe(200);
expect(r1.headers.get("RateLimit-Remaining")).toBe("1");
expect(r2.status).toBe(200);
expect(r2.headers.get("RateLimit-Remaining")).toBe("0");
expect(r3.status).toBe(429);
expect(r3.headers.get("retry-after")).toBeTruthy();
});
test("rateLimit (trustProxy) buckets are independent per key", async () => {
const mw = rateLimit({ max: 1, windowMs: 60_000, trustProxy: true });
const ok = () => new Response("ok");
const a = await mw(ctx("/", { "x-forwarded-for": "2.2.2.2" }), ok);
const b = await mw(ctx("/", { "x-forwarded-for": "3.3.3.3" }), ok);
expect(a.status).toBe(200);
expect(b.status).toBe(200);
});
test("setSessionBackend routes session data through a custom backend", () => {
const backend = memoryBackend() as SessionBackend & { _map: Map<string, SessionEntry> };
setSessionBackend(backend);
try {
const c = ctx("/");
c.session.set("k", "v");
const id = c.session.id();
expect(backend._map.has(id)).toBe(true);
expect(backend._map.get(id)!.data).toEqual({ k: "v" });
} finally {
setSessionBackend(memoryBackend()); // restore an equivalent for other tests
}
});
test("rateLimit accepts a custom (shared) store", async () => {
const hits: string[] = [];
const store: RateLimitStore = {
hit(key, windowMs, now) {
hits.push(key);
return { count: hits.filter((k) => k === key).length, resetAt: now + windowMs };
},
};
const mw = rateLimit({ max: 1, windowMs: 1000, store, trustProxy: true });
const ok = () => new Response("ok");
const key = { "x-forwarded-for": "5.5.5.5" };
expect((await mw(ctx("/", key), ok)).status).toBe(200);
expect((await mw(ctx("/", key), ok)).status).toBe(429);
expect(hits.length).toBe(2); // both requests went through the injected store
});
test("rateLimit awaits an ASYNC store (e.g. Redis)", async () => {
const counts = new Map<string, number>();
const store: RateLimitStore = {
async hit(key, windowMs, now) {
const n = (counts.get(key) ?? 0) + 1;
counts.set(key, n);
return { count: n, resetAt: now + windowMs };
},
};
const mw = rateLimit({ max: 1, windowMs: 1000, store, trustProxy: true });
const ok = () => new Response("ok");
const key = { "x-forwarded-for": "7.7.7.7" };
expect((await mw(ctx("/", key), ok)).status).toBe(200);
expect((await mw(ctx("/", key), ok)).status).toBe(429);
});
test("loadSession persists a session through an ASYNC backend across requests", async () => {
const kv = new Map<string, SessionEntry>();
const backend: AsyncSessionBackend = {
load: async (id) => kv.get(id),
save: async (id, entry) => void kv.set(id, entry),
destroy: async (id) => void kv.delete(id),
};
const mw = loadSession(backend);
// Request 1: write a value, capture the issued session id.
const c1 = ctx("/");
await mw(c1, () => {
c1.session.set("hits", 1);
return new Response("ok");
});
const sid = c1.cookies.get("wrnexus.sid")!;
expect(sid).toBeTruthy();
expect(kv.has(sid)).toBe(true); // saved to the async backend
// Request 2: same cookie → session loads from the backend.
const c2 = ctx("/", { cookie: `wrnexus.sid=${sid}` });
let seen: unknown;
await mw(c2, () => {
seen = c2.session.get("hits");
return new Response("ok");
});
expect(seen).toBe(1);
});
test("rateLimit default keys on the non-spoofable peer IP, not XFF headers", async () => {
const mw = rateLimit({ max: 1, windowMs: 60_000 });
const ok = () => new Response("ok");
// Same peer IP, different spoofed XFF → still one bucket (XFF ignored).
const c1 = ctx("/", { "x-forwarded-for": "9.9.9.9" });
c1.ip = "10.0.0.1";
const c2 = ctx("/", { "x-forwarded-for": "8.8.8.8" });
c2.ip = "10.0.0.1";
expect((await mw(c1, ok)).status).toBe(200);
expect((await mw(c2, ok)).status).toBe(429);
});
test("rateLimit validates its in-memory key bound", () => {
expect(() => rateLimit({ maxKeys: 0 })).toThrow("maxKeys");
});
// --- request logging -------------------------------------------------------
test("requestLogger emits a structured record with duration and id", async () => {
const records: string[] = [];
let t = 1000;
const mw = requestLogger({
format: "json",
sink: (line) => records.push(line),
now: () => (t += 5),
});
const res = await mw(ctx("/api/users"), () => new Response("x", { status: 201 }));
expect(res.status).toBe(201);
const rec = JSON.parse(records[0]!);
expect(rec.method).toBe("GET");
expect(rec.path).toBe("/api/users");
expect(rec.status).toBe(201);
expect(rec.durationMs).toBeGreaterThanOrEqual(0);
expect(rec.id).toBeTruthy();
});
test("requestLogger logs status 500 when the handler throws", async () => {
const records: string[] = [];
const mw = requestLogger({ format: "json", sink: (l) => records.push(l) });
await expect(
mw(ctx("/boom"), () => {
throw new Error("nope");
}),
).rejects.toThrow("nope");
expect(JSON.parse(records[0]!).status).toBe(500);
});
// --- caching ---------------------------------------------------------------
test("TTLCache getOrLoad caches until expiry", async () => {
const cache = new TTLCache<number>(60_000);
let calls = 0;
const load = () => {
calls++;
return 42;
};
expect(await cache.getOrLoad("k", load)).toBe(42);
expect(await cache.getOrLoad("k", load)).toBe(42);
expect(calls).toBe(1);
cache.delete("k");
expect(await cache.getOrLoad("k", load)).toBe(42);
expect(calls).toBe(2);
});
test("TTLCache coalesces concurrent loads for the same key", async () => {
const cache = new TTLCache<number>();
let calls = 0;
const loader = async () => {
calls++;
await Promise.resolve();
return 7;
};
expect(await Promise.all([cache.getOrLoad("x", loader), cache.getOrLoad("x", loader)])).toEqual([
7, 7,
]);
expect(calls).toBe(1);
});
test("TTLCache does not let an old in-flight load overwrite set, delete, or clear", async () => {
const cache = new TTLCache<number>();
let release!: (value: number) => void;
const loading = cache.getOrLoad("x", () => new Promise<number>((resolve) => (release = resolve)));
await Promise.resolve();
cache.set("x", 9);
release(1);
expect(await loading).toBe(1);
expect(cache.get("x")).toBe(9);
let releaseClear!: (value: number) => void;
const clearing = cache.getOrLoad(
"y",
() => new Promise<number>((resolve) => (releaseClear = resolve)),
);
await Promise.resolve();
cache.clear();
releaseClear(2);
await clearing;
expect(cache.get("y")).toBeUndefined();
});
test("cacheControl builds directives; no-store wins", () => {
expect(cacheControl({ maxAge: 60, sMaxAge: 120 })).toBe("public, max-age=60, s-maxage=120");
expect(cacheControl({ private: true, noCache: true })).toBe("private, no-cache");
expect(cacheControl({ noStore: true, maxAge: 99 })).toBe("no-store");
const res = withCacheControl(new Response("x"), { maxAge: 30, immutable: true });
expect(res.headers.get("Cache-Control")).toBe("public, max-age=30, immutable");
});
test("etag + notModified drive conditional requests", () => {
const tag = etag("hello world");
expect(tag).toMatch(/^W\/"/);
expect(etag("hello world")).toBe(tag); // stable
expect(etag("different")).not.toBe(tag);
const req = new Request("http://x", { headers: { "if-none-match": tag } });
expect(notModified(req, tag)).toBe(true);
expect(notModified(new Request("http://x"), tag)).toBe(false);
});
// --- uploads ---------------------------------------------------------------
test("sanitizeFilename strips traversal and separators", () => {
const s = sanitizeFilename("../../etc/passwd");
expect(s).not.toContain("/");
expect(s).not.toContain("..");
expect(s).toContain("passwd");
expect(sanitizeFilename("a/b\\c.png")).toBe("a_b_c.png");
expect(sanitizeFilename("")).toBe("upload");
});
test("saveUpload writes a validated file and enforces limits", async () => {
const dir = join(tmpdir(), "wire-upload-test");
const file = new File(["hello upload"], "note.txt", { type: "text/plain" });
const saved = await saveUpload(file, { dir, allowedTypes: ["text/plain", ".txt"] });
expect(saved.filename).toBe("note.txt");
expect(saved.size).toBe(12);
expect(existsSync(saved.path)).toBe(true);
await expect(saveUpload(file, { dir, maxBytes: 4 })).rejects.toThrow(UploadError);
await expect(saveUpload(file, { dir, allowedTypes: ["image/png"] })).rejects.toThrow(UploadError);
});
test("collectUploads returns only non-empty File fields", async () => {
const form = new FormData();
form.append("name", "ada");
form.append("avatar", new File(["img"], "a.png", { type: "image/png" }));
const uploads = collectUploads(form);
expect(uploads.length).toBe(1);
expect(uploads[0]!.field).toBe("avatar");
});
@@ -0,0 +1,63 @@
import { test, expect } from "bun:test";
import {
createRealtimeRegistry,
bridgeRealtime,
defineRoom,
type RawSocket,
} from "../src/index.ts";
import { createPubSub } from "../../pubsub/src/index.ts";
/** A fake socket that records what the server sends to it. */
function fakeSocket(): RawSocket & { received: string[] } {
const received: string[] = [];
return { received, send: (d: string) => received.push(d), close: () => {} };
}
test("bridgeRealtime: a room broadcast on one registry reaches connections on another", async () => {
// One shared bus stands in for Redis across two 'processes' (registries).
const bus = createPubSub();
const room = defineRoom({
onMessage(client, msg) {
client.room.broadcast({ echo: msg }); // everyone in the room, on every process
},
});
const rA = createRealtimeRegistry();
const rB = createRealtimeRegistry();
bridgeRealtime(rA, bus);
bridgeRealtime(rB, bus);
// A client connected to registry B, in room "chat".
const sB = fakeSocket();
await rB.open(sB, { room: "chat", def: room });
// A client connected to registry A triggers a broadcast.
const sA = fakeSocket();
await rA.open(sA, { room: "chat", def: room });
await rA.message(sA, JSON.stringify({ hi: 1 }));
// The broadcast crossed the bus: B's client received it even though the
// broadcast happened on registry A.
const gotOnB = sB.received.find((p) => p.includes("echo"));
expect(gotOnB).toBeTruthy();
expect(JSON.parse(gotOnB!)).toEqual({ echo: { hi: 1 } });
});
test("bridgeRealtime: no bus means broadcasts stay local", async () => {
const room = defineRoom({
onMessage(client, msg) {
client.room.broadcast({ echo: msg });
},
});
const rA = createRealtimeRegistry();
const rB = createRealtimeRegistry(); // NOT bridged to A
const sB = fakeSocket();
await rB.open(sB, { room: "chat", def: room });
const sA = fakeSocket();
await rA.open(sA, { room: "chat", def: room });
await rA.message(sA, JSON.stringify({ hi: 1 }));
expect(sB.received.length).toBe(0); // isolated — nothing crossed
});
+161
View File
@@ -0,0 +1,161 @@
import { test, expect } from "bun:test";
import {
defineRoom,
isRoomDefinition,
createRealtimeRegistry,
type RawSocket,
} from "../src/index.ts";
interface MockSocket extends RawSocket {
sent: Record<string, unknown>[];
}
function mockSocket(): MockSocket {
const sent: Record<string, unknown>[] = [];
return {
sent,
send(data: string) {
sent.push(JSON.parse(data) as Record<string, unknown>);
},
close() {},
};
}
test("defineRoom marks a room definition", () => {
expect(isRoomDefinition(defineRoom({}))).toBe(true);
expect(isRoomDefinition({})).toBe(false);
expect(isRoomDefinition(null)).toBe(false);
});
test("lifecycle hooks fire; broadcast reaches the whole room", async () => {
const events: string[] = [];
const def = defineRoom({
onConnect(c) {
events.push("connect");
c.broadcast({ type: "join" }); // others only
},
onMessage(c, m) {
events.push("message");
c.room.broadcast({ type: "echo", text: m.text }); // everyone incl. sender
},
onLeave(c) {
events.push("leave");
c.broadcast({ type: "left" });
},
});
const reg = createRealtimeRegistry();
const a = mockSocket();
const b = mockSocket();
await reg.open(a, { room: "/r/x", def });
await reg.open(b, { room: "/r/x", def });
expect(a.sent.some((m) => m.type === "join")).toBe(true); // A saw B join
expect(b.sent.some((m) => m.type === "join")).toBe(false); // B didn't see its own join
await reg.message(a, JSON.stringify({ text: "hi" }));
expect(a.sent.some((m) => m.type === "echo" && m.text === "hi")).toBe(true); // sender sees own
expect(b.sent.some((m) => m.type === "echo" && m.text === "hi")).toBe(true);
await reg.close(b);
expect(a.sent.some((m) => m.type === "left")).toBe(true);
expect(reg.size()).toBe(1);
expect(events).toEqual(["connect", "connect", "message", "leave"]);
});
test("to(connectionId) and toUser(user|users) target precisely", async () => {
const ids: Record<string, string> = {};
const def = defineRoom({
onConnect(c) {
c.user = c.query.as; // identify by ?as=
ids[c.query.as!] = c.id;
},
onMessage(c, m) {
if (m.toUser) c.toUser(m.toUser).send({ type: "dm", text: m.text });
if (m.toId) c.to(m.toId).send({ type: "direct", text: m.text });
},
});
const reg = createRealtimeRegistry();
const alice = mockSocket();
const bob = mockSocket();
const carol = mockSocket();
await reg.open(alice, { room: "/r", def, query: { as: "alice" } });
await reg.open(bob, { room: "/r", def, query: { as: "bob" } });
await reg.open(carol, { room: "/r", def, query: { as: "carol" } });
// single user
await reg.message(alice, JSON.stringify({ toUser: "bob", text: "hey bob" }));
expect(bob.sent.some((m) => m.type === "dm" && m.text === "hey bob")).toBe(true);
expect(carol.sent.some((m) => m.type === "dm")).toBe(false);
// selected users
await reg.message(alice, JSON.stringify({ toUser: ["bob", "carol"], text: "both" }));
expect(bob.sent.filter((m) => m.type === "dm").length).toBe(2);
expect(carol.sent.some((m) => m.text === "both")).toBe(true);
// by connection id
await reg.message(alice, JSON.stringify({ toId: ids.carol, text: "by-id" }));
expect(carol.sent.some((m) => m.type === "direct" && m.text === "by-id")).toBe(true);
});
test("rooms are isolated from each other", async () => {
const def = defineRoom({
onMessage(c, m) {
c.room.broadcast({ type: "x", text: m.text });
},
});
const reg = createRealtimeRegistry();
const a = mockSocket();
const b = mockSocket();
await reg.open(a, { room: "/room/1", def }); // dynamic room instances, one handler
await reg.open(b, { room: "/room/2", def });
await reg.message(a, JSON.stringify({ text: "one" }));
expect(a.sent.some((m) => m.text === "one")).toBe(true);
expect(b.sent.length).toBe(0); // different room, untouched
});
test("bridge relays broadcasts + toUser across registries (horizontal scaling)", async () => {
const regA = createRealtimeRegistry();
const regB = createRealtimeRegistry();
// A shared bus: each instance delivers the other's published envelopes.
regA.setBridge({ publish: (env) => regB.deliver(env) });
regB.setBridge({ publish: (env) => regA.deliver(env) });
const def = defineRoom({
onConnect(c) {
c.user = c.query.as;
},
onMessage(c, m) {
if (m.toUser) c.toUser(m.toUser).send({ type: "dm", text: m.text });
else c.room.broadcast({ type: "x", text: m.text });
},
});
const a = mockSocket();
const b = mockSocket();
await regA.open(a, { room: "/r", def, query: { as: "alice" } });
await regB.open(b, { room: "/r", def, query: { as: "bob" } }); // b is on the OTHER instance
// broadcast from A reaches B through the bridge
await regA.message(a, JSON.stringify({ text: "cross-instance" }));
expect(a.sent.some((m) => m.text === "cross-instance")).toBe(true);
expect(b.sent.some((m) => m.text === "cross-instance")).toBe(true);
// toUser bob (on instance B) from A reaches him via the bridge; alice doesn't
await regA.message(a, JSON.stringify({ toUser: "bob", text: "hi bob" }));
expect(b.sent.some((m) => m.type === "dm" && m.text === "hi bob")).toBe(true);
const aliceDms = a.sent.filter((m) => m.type === "dm").length;
expect(aliceDms).toBe(0); // not looped back / not delivered to the wrong user
});
test("room.state and count() track the live room", async () => {
const def = defineRoom({
onConnect(c) {
c.room.state.hits = ((c.room.state.hits as number) ?? 0) + 1;
c.send({ type: "welcome", online: c.room.count(), hits: c.room.state.hits });
},
});
const reg = createRealtimeRegistry();
const a = mockSocket();
const b = mockSocket();
await reg.open(a, { room: "/r", def });
await reg.open(b, { room: "/r", def });
expect(a.sent[0]).toMatchObject({ online: 1, hits: 1 });
expect(b.sent[0]).toMatchObject({ online: 2, hits: 2 });
});
+38
View File
@@ -0,0 +1,38 @@
import { test, expect } from "bun:test";
import { streamResponse, sse } from "../src/index.ts";
test("streamResponse streams a sync iterable of strings as HTML", async () => {
const res = streamResponse(["<h1>", "Hello", "</h1>"]);
expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
expect(await res.text()).toBe("<h1>Hello</h1>");
});
test("streamResponse streams an async generator (streaming SSR shell + body)", async () => {
async function* page() {
yield '<!doctype html><body><div id="app">';
yield "<p>content</p>";
yield "</div></body>";
}
const res = streamResponse(page(), { status: 200 });
const text = await res.text();
expect(text).toContain('<div id="app">');
expect(text).toContain("<p>content</p>");
});
test("streamResponse honours custom content-type and status", async () => {
const res = streamResponse(["plain"], { contentType: "text/plain", status: 201 });
expect(res.status).toBe(201);
expect(res.headers.get("content-type")).toBe("text/plain");
});
test("sse formats Server-Sent Events frames", async () => {
async function* events() {
yield { data: "hello", event: "greeting", id: "1" };
yield { data: "line1\nline2", retry: 3000 };
}
const res = sse(events());
expect(res.headers.get("content-type")).toBe("text/event-stream");
const text = await res.text();
expect(text).toContain("event: greeting\nid: 1\ndata: hello\n\n");
expect(text).toContain("retry: 3000\ndata: line1\ndata: line2\n\n");
});