@strav/http
Advanced tools
| /** | ||
| * `throttle` — generic fixed-window rate-limit middleware. | ||
| * | ||
| * Counts hits per (key, window) in a `ThrottleStore`. When `hits > limit` | ||
| * the middleware throws `RateLimitError` with `retryAfter` seconds — | ||
| * `ExceptionHandler` maps that to `429 + Retry-After`. Under the limit | ||
| * the middleware writes `X-RateLimit-Limit / -Remaining / -Reset` | ||
| * headers on the downstream response (opt-out via `headers: false`). | ||
| * | ||
| * Storage is pluggable. The default `MemoryThrottleStore` is fine for | ||
| * single-process dev and tests; multi-instance deployments wire a | ||
| * shared store backed by `@strav/cache` (or any equivalent). A store | ||
| * adapter is a one-method shape — see `ThrottleStore` below — so apps | ||
| * can wrap their existing cache without us depending on `@strav/cache` | ||
| * from this package. | ||
| * | ||
| * Registry: `HttpProvider` registers this as the `'throttle'` factory. | ||
| * Routes opt in with `router.middleware('throttle:60,1m')` — first | ||
| * argument is the limit, second is the window (any `parseTtl`-style | ||
| * spec: `'1m'`, `'30s'`, `'1h'`, or bare seconds). Apps that want a | ||
| * custom key, a non-default store, or programmatic config call | ||
| * `throttle({ ... })` directly. | ||
| * | ||
| * Default key: `${ip}:${method}:${path}`. Apps that need per-user or | ||
| * per-tenant throttling pass `key: ctx => ...` — the `auth` package's | ||
| * lockout helpers do exactly that. | ||
| */ | ||
| import { ConfigError, RateLimitError } from '@strav/kernel' | ||
| import type { HttpContext } from '../context/types.ts' | ||
| import type { MiddlewareFn } from '../middleware/types.ts' | ||
| export interface ThrottleHit { | ||
| /** Hits in the current window AFTER this request. */ | ||
| hits: number | ||
| /** Seconds until the window resets — surfaced as `Retry-After` on 429. */ | ||
| resetIn: number | ||
| } | ||
| /** | ||
| * Storage contract for the throttle middleware. One method: count this | ||
| * hit, return the new total + reset time. Implementations MUST be safe | ||
| * under concurrent calls — the `MemoryThrottleStore` is single-process | ||
| * by definition; shared-store adapters need their backend's atomic | ||
| * increment (Redis `INCR`, Postgres `UPDATE … RETURNING`). | ||
| */ | ||
| export interface ThrottleStore { | ||
| hit(key: string, windowSeconds: number): Promise<ThrottleHit> | ||
| /** | ||
| * Drop the counter for `key`. Used by callers that want to forgive | ||
| * earlier hits after a successful action — e.g. login throttles | ||
| * resetting on a successful authentication. | ||
| */ | ||
| clear(key: string): Promise<void> | ||
| } | ||
| /** | ||
| * Single-process in-memory store. Fine for dev, tests, and apps that | ||
| * run as a single Bun instance. Expired entries are pruned lazily on | ||
| * each `hit()` to keep the implementation honest without a background | ||
| * timer. | ||
| */ | ||
| export class MemoryThrottleStore implements ThrottleStore { | ||
| private readonly buckets = new Map<string, { count: number; expiresAt: number }>() | ||
| private readonly nowFn: () => number | ||
| constructor(opts: { now?: () => number } = {}) { | ||
| this.nowFn = opts.now ?? Date.now | ||
| } | ||
| async hit(key: string, windowSeconds: number): Promise<ThrottleHit> { | ||
| const now = this.nowFn() | ||
| const existing = this.buckets.get(key) | ||
| if (existing === undefined || existing.expiresAt <= now) { | ||
| const expiresAt = now + windowSeconds * 1000 | ||
| this.buckets.set(key, { count: 1, expiresAt }) | ||
| return { hits: 1, resetIn: windowSeconds } | ||
| } | ||
| existing.count += 1 | ||
| const resetIn = Math.max(1, Math.ceil((existing.expiresAt - now) / 1000)) | ||
| return { hits: existing.count, resetIn } | ||
| } | ||
| async clear(key: string): Promise<void> { | ||
| this.buckets.delete(key) | ||
| } | ||
| /** Test-only. Wipe every bucket. */ | ||
| reset(): void { | ||
| this.buckets.clear() | ||
| } | ||
| } | ||
| export interface ThrottleOptions { | ||
| /** Max hits per window. Going over throws `RateLimitError` (429). */ | ||
| limit: number | ||
| /** | ||
| * Window length. Accepts `'30s'` / `'1m'` / `'1h'` / `'1d'` or a bare | ||
| * number-of-seconds. Same parser as `@strav/cache`'s `parseTtl`. | ||
| */ | ||
| window: string | number | ||
| /** | ||
| * Custom key derivation. Defaults to `${ip}:${method}:${path}`. | ||
| * Auth-specific helpers pass `ctx => 'login:' + email + ':' + ip`. | ||
| */ | ||
| key?: (ctx: HttpContext) => string | ||
| /** Storage backend. Defaults to a fresh `MemoryThrottleStore()`. */ | ||
| store?: ThrottleStore | ||
| /** | ||
| * Emit `X-RateLimit-Limit / -Remaining / -Reset` on the downstream | ||
| * response. Default `true`. The `Retry-After` header on 429s comes | ||
| * from `ExceptionHandler`'s `RateLimitError` mapping regardless. | ||
| */ | ||
| headers?: boolean | ||
| /** | ||
| * Custom message on 429. Default `'Too many requests.'`. The | ||
| * `retryAfter` field on the error is always populated from the | ||
| * store's `resetIn`. | ||
| */ | ||
| message?: string | ||
| } | ||
| export function throttle(options: ThrottleOptions): MiddlewareFn { | ||
| const store = options.store ?? new MemoryThrottleStore() | ||
| const windowSeconds = parseWindow(options.window) | ||
| const keyFn = options.key ?? defaultKey | ||
| const emitHeaders = options.headers !== false | ||
| const message = options.message ?? 'Too many requests.' | ||
| return async (ctx, next) => { | ||
| const key = keyFn(ctx) | ||
| const { hits, resetIn } = await store.hit(key, windowSeconds) | ||
| if (hits > options.limit) { | ||
| throw new RateLimitError(message, { retryAfter: resetIn }) | ||
| } | ||
| const response = await next() | ||
| if (emitHeaders) { | ||
| response.headers.set('X-RateLimit-Limit', String(options.limit)) | ||
| response.headers.set('X-RateLimit-Remaining', String(Math.max(0, options.limit - hits))) | ||
| response.headers.set('X-RateLimit-Reset', String(resetIn)) | ||
| } | ||
| return response | ||
| } | ||
| } | ||
| function defaultKey(ctx: HttpContext): string { | ||
| const ip = ctx.server.ip || 'unknown' | ||
| return `${ip}:${ctx.request.method}:${ctx.request.path}` | ||
| } | ||
| /** | ||
| * Internal copy of the TTL parser. We don't import from `@strav/cache` | ||
| * to keep `@strav/http` free of that dep — the syntax is small enough | ||
| * to inline. | ||
| */ | ||
| const WINDOW_PATTERN = /^\s*(\d+)\s*([smhd]?)\s*$/i | ||
| const SUFFIX_SECONDS: Record<string, number> = { s: 1, m: 60, h: 3600, d: 86400 } | ||
| function parseWindow(window: string | number): number { | ||
| if (typeof window === 'number') { | ||
| if (!Number.isFinite(window) || window <= 0) { | ||
| throw new ConfigError(`throttle: window must be a positive number; got ${window}.`) | ||
| } | ||
| return Math.floor(window) | ||
| } | ||
| const match = WINDOW_PATTERN.exec(window) | ||
| if (match === null) { | ||
| throw new ConfigError( | ||
| `throttle: window "${window}" is not parseable. Expected '60s' / '1m' / '1h' / '1d' or a number.`, | ||
| ) | ||
| } | ||
| const amount = Number(match[1]) | ||
| const suffix = (match[2] ?? '').toLowerCase() || 's' | ||
| const multiplier = SUFFIX_SECONDS[suffix] | ||
| if (multiplier === undefined) { | ||
| throw new ConfigError(`throttle: window "${window}" has unknown suffix "${suffix}".`) | ||
| } | ||
| return amount * multiplier | ||
| } | ||
| /** | ||
| * Factory bound into the middleware registry under `'throttle'`. | ||
| * Accepts the `'throttle:60,1m'` shorthand routes use. | ||
| * | ||
| * `'throttle:60'` → limit=60, window=1m (default) | ||
| * `'throttle:60,30s'` → limit=60, window=30s | ||
| * `'throttle:5,1h'` → limit=5, window=1h | ||
| * | ||
| * Apps that need a custom key or store call `throttle({ ... })` | ||
| * directly and register the result under their own name. | ||
| */ | ||
| export function throttleFactory(limit?: string, window?: string): MiddlewareFn { | ||
| if (limit === undefined) { | ||
| throw new ConfigError(`throttle: needs at least a limit, e.g. 'throttle:60,1m'.`) | ||
| } | ||
| const parsed = Number(limit) | ||
| if (!Number.isFinite(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { | ||
| throw new ConfigError(`throttle: limit must be a positive integer; got "${limit}".`) | ||
| } | ||
| return throttle({ limit: parsed, window: window ?? '1m' }) | ||
| } |
| /** | ||
| * `webSocketDispatcher` — the single handler object passed to | ||
| * `Bun.serve({ websocket })`. Each event reads the per-route handlers off | ||
| * `ws.data.handlers` (populated by the kernel at upgrade time) and calls | ||
| * through. Userland never sees this layer. | ||
| */ | ||
| import type { ServerWebSocket } from 'bun' | ||
| import type { WebSocketData } from './types.ts' | ||
| export interface WebSocketDispatcher { | ||
| open(ws: ServerWebSocket<WebSocketData>): void | ||
| message(ws: ServerWebSocket<WebSocketData>, data: string | Buffer): void | ||
| close(ws: ServerWebSocket<WebSocketData>, code: number, reason: string): void | ||
| drain(ws: ServerWebSocket<WebSocketData>): void | ||
| ping(ws: ServerWebSocket<WebSocketData>, data: Buffer): void | ||
| pong(ws: ServerWebSocket<WebSocketData>, data: Buffer): void | ||
| } | ||
| export function webSocketDispatcher( | ||
| onError: (event: string, err: unknown) => void, | ||
| ): WebSocketDispatcher { | ||
| function dispatch(event: string, p: Promise<unknown> | void): void { | ||
| if (p && typeof (p as Promise<unknown>).catch === 'function') { | ||
| ;(p as Promise<unknown>).catch((err) => onError(event, err)) | ||
| } | ||
| } | ||
| return { | ||
| open(ws) { | ||
| const fn = ws.data?.handlers?.open | ||
| if (!fn) return | ||
| try { | ||
| dispatch('open', fn(ws)) | ||
| } catch (err) { | ||
| onError('open', err) | ||
| } | ||
| }, | ||
| message(ws, data) { | ||
| const fn = ws.data?.handlers?.message | ||
| if (!fn) return | ||
| try { | ||
| dispatch('message', fn(ws, data)) | ||
| } catch (err) { | ||
| onError('message', err) | ||
| } | ||
| }, | ||
| close(ws, code, reason) { | ||
| const fn = ws.data?.handlers?.close | ||
| if (!fn) return | ||
| try { | ||
| dispatch('close', fn(ws, code, reason)) | ||
| } catch (err) { | ||
| onError('close', err) | ||
| } | ||
| }, | ||
| drain(ws) { | ||
| const fn = ws.data?.handlers?.drain | ||
| if (!fn) return | ||
| try { | ||
| dispatch('drain', fn(ws)) | ||
| } catch (err) { | ||
| onError('drain', err) | ||
| } | ||
| }, | ||
| ping(ws, data) { | ||
| const fn = ws.data?.handlers?.ping | ||
| if (!fn) return | ||
| try { | ||
| dispatch('ping', fn(ws, data)) | ||
| } catch (err) { | ||
| onError('ping', err) | ||
| } | ||
| }, | ||
| pong(ws, data) { | ||
| const fn = ws.data?.handlers?.pong | ||
| if (!fn) return | ||
| try { | ||
| dispatch('pong', fn(ws, data)) | ||
| } catch (err) { | ||
| onError('pong', err) | ||
| } | ||
| }, | ||
| } | ||
| } |
| // WebSocket subsystem — public exports. | ||
| export { | ||
| type WebSocketDispatcher, | ||
| webSocketDispatcher, | ||
| } from './dispatcher.ts' | ||
| export type { WebSocketData, WebSocketHandlers } from './types.ts' | ||
| export { | ||
| compileWsPattern, | ||
| extractWsParams, | ||
| type WebSocketMatch, | ||
| type WebSocketRoute, | ||
| } from './ws_route.ts' |
| /** | ||
| * WebSocket primitive — server-side types for `router.ws(...)`. | ||
| * | ||
| * The kernel wires `Bun.serve({ websocket })` to a generic dispatcher that | ||
| * looks up the per-route handlers off `ws.data` (set at upgrade time) and | ||
| * fans events through. Each route declares its own `WebSocketHandlers`; the | ||
| * router stores them alongside the path pattern and any group prefix. | ||
| * | ||
| * Per-route middleware is intentionally not in this slice — endpoints can | ||
| * inspect `ws.data.request.headers` (cookies, auth, etc.) inside `open()` to | ||
| * perform their own gate. A pre-upgrade auth hook may land in a follow-up. | ||
| */ | ||
| import type { ServerWebSocket } from 'bun' | ||
| /** Lifecycle callbacks for a single WS route. All slots are optional. */ | ||
| export interface WebSocketHandlers { | ||
| /** Connection opened. Fires once per client, after a successful upgrade. */ | ||
| open?: (ws: ServerWebSocket<WebSocketData>) => void | Promise<void> | ||
| /** Inbound message — text frames arrive as `string`, binary as `Buffer`. */ | ||
| message?: ( | ||
| ws: ServerWebSocket<WebSocketData>, | ||
| data: string | Buffer, | ||
| ) => void | Promise<void> | ||
| /** Connection closed. `code` + `reason` follow the WS close-frame semantics. */ | ||
| close?: ( | ||
| ws: ServerWebSocket<WebSocketData>, | ||
| code: number, | ||
| reason: string, | ||
| ) => void | Promise<void> | ||
| /** Backpressure drained — safe to resume `send()`. */ | ||
| drain?: (ws: ServerWebSocket<WebSocketData>) => void | Promise<void> | ||
| /** Inbound ping. The runtime auto-pongs unless you call `ws.pong(...)` yourself. */ | ||
| ping?: (ws: ServerWebSocket<WebSocketData>, data: Buffer) => void | Promise<void> | ||
| /** Inbound pong — paired with a server-initiated `ws.ping(...)`. */ | ||
| pong?: (ws: ServerWebSocket<WebSocketData>, data: Buffer) => void | Promise<void> | ||
| } | ||
| /** | ||
| * The payload Bun keeps on every `ServerWebSocket` after upgrade. The | ||
| * dispatcher uses `handlers` to fan events; userland reads `params` / | ||
| * `request` (or its own `state`) for auth, routing, and channel decisions. | ||
| * | ||
| * `state` is a free-form per-connection bag — userland writes to it in | ||
| * `open()` and reads it back in `message()`/`close()` without a side map. | ||
| */ | ||
| export interface WebSocketData { | ||
| handlers: WebSocketHandlers | ||
| /** Path params captured from the route pattern (e.g. `:roomId` → `'42'`). */ | ||
| params: Record<string, string> | ||
| /** The original `Request` that triggered the upgrade. Carries headers + URL. */ | ||
| request: Request | ||
| /** Free-form per-connection state populated by userland. */ | ||
| state: Record<string, unknown> | ||
| } |
| /** | ||
| * `WebSocketRoute` — compiled record for a registered `router.ws(...)` endpoint. | ||
| * | ||
| * WS routes live in a small flat list on the router rather than the trie: | ||
| * - The trie keys on HTTP method, and an upgrade request is always GET | ||
| * plus an `Upgrade: websocket` header — the trie can't distinguish that | ||
| * from a plain GET to the same path. | ||
| * - WS endpoints are typically scarce (one or two per app), so linear | ||
| * scanning is cheap. | ||
| * | ||
| * The `regex` matches the full path (group prefix already joined). | ||
| */ | ||
| import type { WebSocketHandlers } from './types.ts' | ||
| export interface WebSocketRoute { | ||
| /** Fully-joined path pattern, e.g. `/api/rooms/:id`. */ | ||
| pattern: string | ||
| /** Compiled matcher for the pattern. */ | ||
| regex: RegExp | ||
| /** Ordered list of `:param` / `*splat` names captured by the pattern. */ | ||
| paramNames: readonly string[] | ||
| /** User-supplied lifecycle callbacks. */ | ||
| handlers: WebSocketHandlers | ||
| } | ||
| export interface WebSocketMatch { | ||
| route: WebSocketRoute | ||
| params: Record<string, string> | ||
| } | ||
| /** | ||
| * Compile a Strav-style path pattern (`/foo/:id/*rest`) into a regex + the | ||
| * ordered list of capture names. Mirrors the router's pattern grammar — see | ||
| * `router/trie.ts`. Kept local so WS matching has no trie dependency. | ||
| */ | ||
| export function compileWsPattern(pattern: string): { | ||
| regex: RegExp | ||
| paramNames: string[] | ||
| } { | ||
| const paramNames: string[] = [] | ||
| const segs = pattern.split('/') | ||
| let body = '' | ||
| for (let i = 0; i < segs.length; i++) { | ||
| const seg = segs[i] ?? '' | ||
| // Optional param swallows the preceding `/` so `/users/:id?` matches | ||
| // both `/users` and `/users/42`. | ||
| if (i > 0 && seg.startsWith(':') && seg.endsWith('?')) { | ||
| paramNames.push(seg.slice(1, -1)) | ||
| body += '(?:/([^/]+))?' | ||
| continue | ||
| } | ||
| if (i > 0) body += '/' | ||
| if (seg.startsWith(':')) { | ||
| paramNames.push(seg.slice(1)) | ||
| body += '([^/]+)' | ||
| } else if (seg.startsWith('*')) { | ||
| paramNames.push(seg.slice(1)) | ||
| body += '(.+)' | ||
| } else { | ||
| body += escapeRegex(seg) | ||
| } | ||
| } | ||
| return { regex: new RegExp(`^${body}$`), paramNames } | ||
| } | ||
| export function extractWsParams( | ||
| names: readonly string[], | ||
| match: RegExpExecArray, | ||
| ): Record<string, string> { | ||
| const out: Record<string, string> = {} | ||
| for (let i = 0; i < names.length; i++) { | ||
| const name = names[i] | ||
| const value = match[i + 1] | ||
| if (name !== undefined && value !== undefined) out[name] = value | ||
| } | ||
| return out | ||
| } | ||
| function escapeRegex(input: string): string { | ||
| return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') | ||
| } |
+3
-3
| { | ||
| "name": "@strav/http", | ||
| "version": "1.0.0-alpha.36", | ||
| "version": "1.0.0-alpha.38", | ||
| "description": "Strav HTTP layer — Router, HttpKernel, HttpContext, middleware composition", | ||
@@ -25,4 +25,4 @@ "type": "module", | ||
| "dependencies": { | ||
| "@strav/cli": "1.0.0-alpha.36", | ||
| "@strav/kernel": "1.0.0-alpha.36", | ||
| "@strav/cli": "1.0.0-alpha.38", | ||
| "@strav/kernel": "1.0.0-alpha.38", | ||
| "zod": "^4.4.3" | ||
@@ -29,0 +29,0 @@ }, |
@@ -17,2 +17,10 @@ // Built-in middleware — public exports. | ||
| } from './security_headers.ts' | ||
| export { | ||
| MemoryThrottleStore, | ||
| throttle, | ||
| type ThrottleHit, | ||
| type ThrottleOptions, | ||
| type ThrottleStore, | ||
| throttleFactory, | ||
| } from './throttle.ts' | ||
@@ -24,2 +32,3 @@ /** Canonical names by which these middleware register in the kernel. */ | ||
| securityHeaders: 'security_headers', | ||
| throttle: 'throttle', | ||
| } as const |
@@ -67,5 +67,4 @@ /** | ||
| const headers: Record<string, string> = {} | ||
| if (norm instanceof RateLimitError) { | ||
| const retry = (norm.context as { retryAfter?: unknown } | undefined)?.retryAfter | ||
| if (typeof retry === 'number') headers['Retry-After'] = String(retry) | ||
| if (norm instanceof RateLimitError && typeof norm.retryAfter === 'number') { | ||
| headers['Retry-After'] = String(norm.retryAfter) | ||
| } | ||
@@ -72,0 +71,0 @@ |
+35
-1
@@ -45,2 +45,4 @@ /** | ||
| import type { CompiledRoute } from './router/types.ts' | ||
| import { webSocketDispatcher } from './ws/dispatcher.ts' | ||
| import type { WebSocketData } from './ws/types.ts' | ||
@@ -310,2 +312,12 @@ /** Per-route artifact computed once at compile and reused per request. */ | ||
| } | ||
| // Single dispatcher object — Bun lets us swap per-route handlers via | ||
| // `ws.data.handlers`, populated below at upgrade time. | ||
| const dispatcher = webSocketDispatcher((event, err) => { | ||
| try { | ||
| const log = kernel.app.has(Logger) ? kernel.app.resolve(Logger) : undefined | ||
| log?.error('http.websocket.handler_failed', { event, err }) | ||
| } catch { | ||
| // logger missing — best-effort | ||
| } | ||
| }) | ||
| // biome-ignore lint/suspicious/noExplicitAny: Bun.Server type | ||
@@ -317,6 +329,28 @@ const server: any = Bun.serve({ | ||
| // biome-ignore lint/suspicious/noExplicitAny: Bun.Server type | ||
| fetch(request: Request, srv: any): Promise<Response> { | ||
| fetch(request: Request, srv: any): Promise<Response> | Response | undefined { | ||
| // WebSocket upgrade — GET + `Upgrade: websocket` matching a router.ws() | ||
| // pattern. Returning `undefined` after a successful upgrade tells Bun | ||
| // the response is handled. | ||
| if ( | ||
| request.method === 'GET' && | ||
| request.headers.get('upgrade')?.toLowerCase() === 'websocket' | ||
| ) { | ||
| const url = new URL(request.url) | ||
| const match = kernel.router.matchWs(url.pathname) | ||
| if (match) { | ||
| const data: WebSocketData = { | ||
| handlers: match.route.handlers, | ||
| params: match.params, | ||
| request, | ||
| state: {}, | ||
| } | ||
| if (srv.upgrade(request, { data })) return undefined | ||
| return new Response('Upgrade failed', { status: 426 }) | ||
| } | ||
| // No WS route matched — fall through so the HTTP handler returns 404. | ||
| } | ||
| const ip = srv?.requestIP?.(request)?.address | ||
| return kernel.handle(request, { ip }) | ||
| }, | ||
| websocket: dispatcher, | ||
| }) | ||
@@ -323,0 +357,0 @@ return { |
@@ -26,2 +26,3 @@ /** | ||
| securityHeadersMiddleware, | ||
| throttleFactory, | ||
| } from './built_in/index.ts' | ||
@@ -121,3 +122,6 @@ import type { HttpContextConfigSlice } from './context/types.ts' | ||
| } | ||
| if (!registry.has(BUILTIN_NAMES.throttle)) { | ||
| registry.register(BUILTIN_NAMES.throttle, throttleFactory, { factory: true }) | ||
| } | ||
| } | ||
| } |
+14
-0
@@ -9,5 +9,11 @@ // Public API of @strav/http. | ||
| corsMiddleware, | ||
| MemoryThrottleStore, | ||
| RequestLog, | ||
| type SecurityHeadersOptions, | ||
| securityHeadersMiddleware, | ||
| throttle, | ||
| type ThrottleHit, | ||
| type ThrottleOptions, | ||
| type ThrottleStore, | ||
| throttleFactory, | ||
| } from './built_in/index.ts' | ||
@@ -92,1 +98,9 @@ export { All, Console, HttpConsoleProvider, RouteList, Serve } from './console/index.ts' | ||
| } from './sse/index.ts' | ||
| export { | ||
| type WebSocketData, | ||
| type WebSocketDispatcher, | ||
| webSocketDispatcher, | ||
| type WebSocketHandlers, | ||
| type WebSocketMatch, | ||
| type WebSocketRoute, | ||
| } from './ws/index.ts' |
+46
-0
@@ -21,2 +21,9 @@ /** | ||
| import { sseResponse, type SSEResponseOptions } from '../sse/sse_response.ts' | ||
| import type { WebSocketHandlers } from '../ws/types.ts' | ||
| import { | ||
| compileWsPattern, | ||
| extractWsParams, | ||
| type WebSocketMatch, | ||
| type WebSocketRoute, | ||
| } from '../ws/ws_route.ts' | ||
| import { Route } from './route.ts' | ||
@@ -36,2 +43,3 @@ import { type MatchResult, RouteTrie } from './trie.ts' | ||
| private readonly routes: Array<{ route: Route; group: GroupState }> = [] | ||
| private readonly wsRoutes: WebSocketRoute[] = [] | ||
| private trie: RouteTrie | undefined | ||
@@ -98,2 +106,40 @@ private nameIndex: Map<string, CompiledRoute> | undefined | ||
| /** | ||
| * Register a WebSocket endpoint. | ||
| * | ||
| * Upgrades happen inside `HttpKernel.serve()`: a GET request carrying | ||
| * `Upgrade: websocket` whose path matches a WS pattern is handed to | ||
| * `Bun.serve`'s upgrade mechanism, after which the kernel's generic | ||
| * dispatcher fans `open` / `message` / `close` / etc. into the supplied | ||
| * handlers. | ||
| * | ||
| * Path patterns follow the same grammar as HTTP routes (`:id`, `:id?`, | ||
| * `*splat`); group `prefix` is honored. Per-route middleware is not yet | ||
| * supported — perform auth inside `open(ws)` by reading | ||
| * `ws.data.request.headers` (cookies, `Authorization`, etc.). | ||
| */ | ||
| ws(pattern: string, handlers: WebSocketHandlers): void { | ||
| if (this.trie) { | ||
| throw new ConfigError(`Router: cannot add WS ${pattern} after compile().`) | ||
| } | ||
| const group = this.currentGroup() | ||
| const fullPath = joinPrefix(group.prefix, pattern) | ||
| const { regex, paramNames } = compileWsPattern(fullPath) | ||
| this.wsRoutes.push({ pattern: fullPath, regex, paramNames, handlers }) | ||
| } | ||
| /** Match an upgrade-eligible path against the registered WS routes. */ | ||
| matchWs(path: string): WebSocketMatch | undefined { | ||
| for (const route of this.wsRoutes) { | ||
| const m = route.regex.exec(path) | ||
| if (m) return { route, params: extractWsParams(route.paramNames, m) } | ||
| } | ||
| return undefined | ||
| } | ||
| /** All registered WS routes in declaration order. */ | ||
| listWs(): readonly WebSocketRoute[] { | ||
| return this.wsRoutes | ||
| } | ||
| /** Register the same handler for every HTTP verb. */ | ||
@@ -100,0 +146,0 @@ any<T = unknown>(pattern: string, handler: RouteHandler<T>): Route[] { |
143390
15.39%44
12.82%3656
16.21%+ Added
+ Added
- Removed
- Removed
Updated
Updated