first commit

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