first commit
This commit is contained in:
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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(", ");
|
||||
}
|
||||
@@ -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";
|
||||
@@ -0,0 +1,2 @@
|
||||
export { Fragment, jsx as jsxDEV } from "./jsx-runtime.ts";
|
||||
export type { JSX } from "./jsx-runtime.ts";
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)}]`;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Small, dependency-free security helpers shared across packages.
|
||||
*/
|
||||
|
||||
const HTML_ESCAPES: Record<string, string> = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
};
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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]();
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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>');
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
Reference in New Issue
Block a user