first commit
This commit is contained in:
@@ -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";
|
||||
}
|
||||
Reference in New Issue
Block a user