Big News: Socket raises $60M Series C at a $1B valuation to secure software supply chains for AI-driven development.Announcement
Sign In

@strav/http

Package Overview
Dependencies
Maintainers
1
Versions
110
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@strav/http - npm Package Compare versions

Comparing version
1.0.0-alpha.36
to
1.0.0-alpha.38
+203
src/built_in/throttle.ts
/**
* `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 @@

@@ -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 })
}
}
}

@@ -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'

@@ -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[] {