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.38
to
1.0.0-alpha.39
+199
src/router/resource.ts
/**
* Resource routes — Laravel-style shorthand for registering the seven CRUD
* routes that map to one controller. Keeps boilerplate out of route files
* and makes the convention legible at the top of `route:list`.
*
* Full resource (`router.resource(name, controller)`):
*
* GET /<name> → index name: <name>.index
* GET /<name>/create → create name: <name>.create
* POST /<name> → store name: <name>.store
* GET /<name>/:id → show name: <name>.show
* GET /<name>/:id/edit → edit name: <name>.edit
* PUT /<name>/:id → update name: <name>.update
* PATCH /<name>/:id → update (no name — duplicate of PUT)
* DELETE /<name>/:id → destroy name: <name>.destroy
*
* API resource (`router.apiResource(name, controller)`) drops `create` and
* `edit` — the HTML-form-page actions — since JSON APIs don't need them.
*
* Group state (prefix / middleware / name / subdomain) is honoured because
* registration goes through the normal `router.<verb>(...)` path.
*/
import { ConfigError, type Constructor } from '@strav/kernel'
import type { Router } from './router.ts'
import type { RouteHandler } from './types.ts'
// Local helper: build a `[Controller, methodName]` tuple typed as a
// RouteHandler. The router's verb-typed signatures constrain method names to
// keys of the concrete controller; inside this generic shorthand we don't
// have that type info, so this assertion takes the place of the inference
// that callers normally get.
function action(controller: Constructor<unknown>, method: string): RouteHandler {
return [controller, method] as unknown as RouteHandler
}
export type ResourceAction = 'index' | 'create' | 'store' | 'show' | 'edit' | 'update' | 'destroy'
const FULL_ACTIONS: readonly ResourceAction[] = [
'index',
'create',
'store',
'show',
'edit',
'update',
'destroy',
]
const API_ACTIONS: readonly ResourceAction[] = ['index', 'store', 'show', 'update', 'destroy']
export interface ResourceOptions {
/** Restrict to these actions only. Mutually exclusive with `except`. */
only?: readonly ResourceAction[]
/** Skip these actions. Mutually exclusive with `only`. */
except?: readonly ResourceAction[]
/**
* Override the path param name. Default `'id'` — paths look like
* `/posts/:id`. Set to `'post'` to get `/posts/:post`.
*/
param?: string
/**
* Regex constraint applied to the path param (without the surrounding
* parens). For example `'\\d+'` produces `/posts/:id(\\d+)`.
*/
constraint?: string
/**
* Middleware applied to every generated route. Group middleware still
* stacks on top.
*/
middleware?: string | readonly string[]
/**
* Override the generated route name for one or more actions. The default
* is `<resourceName>.<action>` (e.g. `posts.show`). The override replaces
* the whole name — any group `name:` prefix still concatenates onto it.
*/
names?: Partial<Record<ResourceAction, string>>
}
/** Shared implementation for `router.resource()` / `router.apiResource()`. */
export function registerResource(
router: Router,
name: string,
controller: Constructor<unknown>,
options: ResourceOptions,
defaultActions: readonly ResourceAction[],
): void {
if (options.only && options.except) {
throw new ConfigError(
`Router.resource("${name}"): pass only one of "only" or "except", not both.`,
)
}
const actions = pickActions(defaultActions, options)
assertActionsExist(name, controller, actions)
const param = options.param ?? 'id'
const idSegment = options.constraint ? `:${param}(${options.constraint})` : `:${param}`
const base = `/${stripSlashes(name)}`
const itemPath = `${base}/${idSegment}`
const middleware = normalizeMiddleware(options.middleware)
const nameFor = (action: ResourceAction): string =>
options.names?.[action] ?? `${dotName(name)}.${action}`
// Registration order mirrors Laravel: collection verbs first, then item
// verbs. The trie's "static beats param" precedence makes the path order
// irrelevant for matching, but list() echoes insertion order — keeping it
// conventional makes `route:list` output predictable.
if (actions.includes('index')) {
apply(router.get(base, action(controller, 'index')), nameFor('index'), middleware)
}
if (actions.includes('create')) {
apply(router.get(`${base}/create`, action(controller, 'create')), nameFor('create'), middleware)
}
if (actions.includes('store')) {
apply(router.post(base, action(controller, 'store')), nameFor('store'), middleware)
}
if (actions.includes('show')) {
apply(router.get(itemPath, action(controller, 'show')), nameFor('show'), middleware)
}
if (actions.includes('edit')) {
apply(router.get(`${itemPath}/edit`, action(controller, 'edit')), nameFor('edit'), middleware)
}
if (actions.includes('update')) {
apply(router.put(itemPath, action(controller, 'update')), nameFor('update'), middleware)
// PATCH goes to the same handler. Skip the name — `<name>.update` is
// already taken by PUT and duplicate names throw at compile().
apply(router.patch(itemPath, action(controller, 'update')), undefined, middleware)
}
if (actions.includes('destroy')) {
apply(router.delete(itemPath, action(controller, 'destroy')), nameFor('destroy'), middleware)
}
}
export const RESOURCE_FULL_ACTIONS = FULL_ACTIONS
export const RESOURCE_API_ACTIONS = API_ACTIONS
function apply(
route: ReturnType<Router['get']>,
name: string | undefined,
middleware: readonly string[],
): void {
if (name) route.name(name)
if (middleware.length > 0) route.middleware(...middleware)
}
function pickActions(
defaults: readonly ResourceAction[],
options: ResourceOptions,
): readonly ResourceAction[] {
if (options.only) {
for (const action of options.only) {
if (!defaults.includes(action)) {
throw new ConfigError(
`Router.resource: action "${action}" is not part of this resource (allowed: ${defaults.join(', ')}).`,
)
}
}
return options.only
}
if (options.except) {
const except = new Set(options.except)
return defaults.filter((a) => !except.has(a))
}
return defaults
}
function assertActionsExist(
name: string,
controller: Constructor<unknown>,
actions: readonly ResourceAction[],
): void {
const proto = controller.prototype as Record<string, unknown> | null
if (!proto) {
throw new ConfigError(
`Router.resource("${name}"): controller ${controller.name || 'anonymous'} has no prototype.`,
)
}
const missing = actions.filter((a) => typeof proto[a] !== 'function')
if (missing.length > 0) {
throw new ConfigError(
`Router.resource("${name}"): controller ${controller.name || 'anonymous'} is missing method(s): ${missing.join(', ')}.`,
)
}
}
function stripSlashes(value: string): string {
return value.replace(/^\/+|\/+$/g, '')
}
function dotName(value: string): string {
// Allow `posts` or `admin/posts` — the latter becomes `admin.posts` so the
// route name reads naturally even when callers nest by path.
return stripSlashes(value).replace(/\//g, '.')
}
function normalizeMiddleware(value: string | readonly string[] | undefined): readonly string[] {
if (!value) return []
if (typeof value === 'string') return [value]
return value
}
/**
* Path-segment parsing — shared by the HTTP trie, the WS regex compiler, and
* the route resolver. Centralised so the three never drift on grammar.
*
* Grammar (per segment):
* :name — required param, no constraint
* :name? — optional param (trie expands into two routes)
* :name(regex) — required with regex constraint
* :name?(regex) — optional with regex constraint
* *name — wildcard (terminal), no constraint support
*
* Constraint rules:
* - Body runs from the first `(` to the last `)`; nested `(...)` work fine.
* - Must not contain `/` — paths split on `/` *before* this parser runs, so
* a constraint that tries to span segments would silently never match.
* - Anchors (`^` / `$`) are forbidden; the matcher anchors automatically.
* - Invalid regex throws `ConfigError` at registration, not at match time.
*/
import { ConfigError } from '@strav/kernel'
export interface ParamSegment {
kind: 'param'
name: string
optional: boolean
/** Source text of the constraint (without parens). Undefined if absent. */
constraintSource?: string
/** Compiled constraint, anchored to the full segment. Undefined if absent. */
constraint?: RegExp
}
export interface WildcardSegment {
kind: 'wildcard'
name: string
}
export interface StaticSegment {
kind: 'static'
value: string
}
export type ParsedSegment = ParamSegment | WildcardSegment | StaticSegment
export function parseSegment(seg: string): ParsedSegment {
if (seg.startsWith('*')) {
return { kind: 'wildcard', name: seg.slice(1) }
}
if (!seg.startsWith(':')) {
return { kind: 'static', value: seg }
}
// Param. Grammar: :name [?] [(regex)]
let rest = seg.slice(1)
// Constraint runs from the first `(` to the last `)`.
let constraintSource: string | undefined
const openIdx = rest.indexOf('(')
if (openIdx !== -1) {
if (!rest.endsWith(')')) {
throw new ConfigError(`Router: param "${seg}" — constraint must end with ')'`)
}
constraintSource = rest.slice(openIdx + 1, -1)
rest = rest.slice(0, openIdx)
}
let optional = false
if (rest.endsWith('?')) {
optional = true
rest = rest.slice(0, -1)
}
const name = rest
if (name.length === 0) {
throw new ConfigError(`Router: param "${seg}" has no name.`)
}
const result: ParamSegment = { kind: 'param', name, optional }
if (constraintSource !== undefined) {
if (constraintSource.length === 0) {
throw new ConfigError(`Router: param "${seg}" — empty constraint.`)
}
if (constraintSource.includes('/')) {
throw new ConfigError(
`Router: param "${seg}" — constraint cannot contain '/' (segments are split before matching).`,
)
}
if (constraintSource.startsWith('^') || constraintSource.endsWith('$')) {
throw new ConfigError(
`Router: param "${seg}" — constraint must not include '^' / '$' anchors (added automatically).`,
)
}
let compiled: RegExp
try {
compiled = new RegExp(`^(?:${constraintSource})$`)
} catch (err) {
throw new ConfigError(
`Router: param "${seg}" — invalid regex: ${(err as Error).message}`,
)
}
result.constraintSource = constraintSource
result.constraint = compiled
}
return result
}
/**
* Names of every `:param` / `*wildcard` in a pattern, in declaration order.
* Optional `?` and `(regex)` are stripped — only the bare name is returned.
*/
export function extractParamNames(pattern: string): string[] {
const names: string[] = []
for (const seg of pattern.split('/')) {
if (seg.length === 0) continue
const parsed = parseSegment(seg)
if (parsed.kind === 'param' || parsed.kind === 'wildcard') {
names.push(parsed.name)
}
}
return names
}
/**
* Subdomain helpers shared by the HTTP trie matcher and the WS linear matcher.
*
* Patterns supported:
* - literal: `'api'` — must match the request's subdomain exactly.
* - dynamic: `':tenant'` — must be a single segment starting with `:`;
* the captured value is later injected into route params.
*
* Mixed forms (`'api.:region'`, `'*.api'`, multi-label literals) throw at
* registration time. Single-label keeps the matcher branch-free and the
* mental model honest — multi-label subdomains are vanishingly rare in
* application routing and easy to layer on later if needed.
*/
import { ConfigError } from '@strav/kernel'
export interface ParsedSubdomain {
/** Literal subdomain value or — when `paramName` is set — the param name. */
value: string
/** When set, `value` is a param name and matches any non-empty subdomain. */
paramName?: string
}
export function parseSubdomain(pattern: string): ParsedSubdomain {
if (pattern.length === 0) {
throw new ConfigError('Router: subdomain pattern cannot be empty.')
}
if (pattern.includes('.')) {
throw new ConfigError(
`Router: subdomain "${pattern}" must be a single label (no dots). ` +
'Multi-label subdomains are not supported.',
)
}
if (pattern.startsWith(':')) {
const name = pattern.slice(1)
if (name.length === 0) {
throw new ConfigError('Router: dynamic subdomain must have a param name (e.g. ":tenant").')
}
return { value: name, paramName: name }
}
return { value: pattern }
}
/**
* Apply the subdomain filter. Unscoped routes (no `subdomain`) match every
* host — that's the "unscoped is catch-all" rule documented on
* `RouteGroupOptions.subdomain`.
*/
export function matchSubdomain(
route: { subdomain?: string; subdomainParamName?: string },
subdomain: string | undefined,
): boolean {
if (route.subdomain === undefined) return true
if (route.subdomainParamName) return subdomain !== undefined && subdomain.length > 0
return route.subdomain === subdomain
}
// Signed-URL subsystem — public exports.
export {
type SignedMiddlewareOptions,
signedMiddleware,
} from './signed_middleware.ts'
export {
type SignOptions,
UrlSigner,
type VerifyFailure,
type VerifyResult,
} from './url_signer.ts'
/**
* `signed` middleware — rejects requests whose URL doesn't carry a valid
* `_signature` minted by the `UrlSigner` bound in the container.
*
* Failure mapping:
* - missing or invalid signature → 403 `signed-url.invalid`
* - signature expired → 403 `signed-url.expired`
*
* The middleware reads the *raw* request URL — exactly what the signer
* produced, including any extra query params — so reverse-proxies that
* normalise (e.g. lower-case the host) won't affect verification as long
* as `signOptions.includeHost` is left at its default `false`.
*
* Registry: `HttpProvider` registers this as `'signed'`. Apps that need
* `includeHost: true` or a different binding name wire it themselves —
* see `docs/http/guides/signed-urls.md`.
*/
import { AuthorizationError } from '@strav/kernel'
import type { HttpContext } from '../context/types.ts'
import type { MiddlewareFn, NextFn } from '../middleware/types.ts'
import { UrlSigner } from './url_signer.ts'
export interface SignedMiddlewareOptions {
/** Match the signer's `includeHost` setting if you turned it on. */
includeHost?: boolean
}
export function signedMiddleware(options: SignedMiddlewareOptions = {}): MiddlewareFn {
return async (ctx: HttpContext, next: NextFn) => {
const signer = ctx.container.resolve(UrlSigner)
const result = signer.verify(ctx.request.raw.url, { includeHost: options.includeHost })
if (!result.ok) {
if (result.reason === 'expired') {
throw new AuthorizationError('Signed URL has expired.', {
code: 'signed-url.expired',
})
}
throw new AuthorizationError('Signed URL is missing or invalid.', {
code: 'signed-url.invalid',
})
}
return next()
}
}
/**
* `UrlSigner` — HMAC-SHA256 sign + verify for URLs.
*
* Wire format: the signature is appended as `?_signature=<hex>`. An optional
* `?_expires=<unix-ms>` ties the URL to a deadline. The canonical input is
* `<pathname>?<sorted query without _signature>` — sorting the query
* eliminates intermediate proxies that re-order parameters as a forgery
* vector.
*
* The host is *not* signed by default. Apps that need cross-host protection
* (signed URL meant for `admin.example.com` should not work on
* `api.example.com`) pass `includeHost: true`.
*
* Use cases: password-reset links, file-download URLs, webhook callbacks,
* email-confirmation links. Pairs with the `signed` middleware, which calls
* `verify(...)` on the incoming request URL and 403s on failure.
*/
import { createHmac, timingSafeEqual } from 'node:crypto'
import { ConfigError } from '@strav/kernel'
export interface SignOptions {
/** Absolute deadline. Encoded as `_expires=<unix-ms>` and covered by the HMAC. */
expiresAt?: Date
/**
* Include the host (`u.host`) in the canonical form so a signature minted
* for one host won't verify against another. Off by default — apps that
* use subdomain routing should turn this on. The verifier must agree.
*/
includeHost?: boolean
}
export type VerifyFailure =
| { ok: false; reason: 'missing' }
| { ok: false; reason: 'invalid' }
| { ok: false; reason: 'expired' }
export type VerifyResult = { ok: true } | VerifyFailure
const SIGNATURE_PARAM = '_signature'
const EXPIRES_PARAM = '_expires'
export class UrlSigner {
private readonly secret: Buffer
constructor(secret: string | Uint8Array) {
if (!secret || (typeof secret === 'string' && secret.length === 0)) {
throw new ConfigError('UrlSigner: secret is required.')
}
this.secret =
typeof secret === 'string' ? Buffer.from(secret, 'utf8') : Buffer.from(secret)
}
/**
* Append `?_signature=...` (and `?_expires=...` if applicable) to `url`.
* Accepts absolute (`https://host/path?q`) or relative (`/path?q`) URLs;
* the return form matches the input form.
*/
sign(url: string, options: SignOptions = {}): string {
const { isAbsolute, parsed } = parseUrl(url)
parsed.searchParams.delete(SIGNATURE_PARAM)
if (options.expiresAt) {
parsed.searchParams.set(EXPIRES_PARAM, String(options.expiresAt.getTime()))
}
const canonical = canonicalize(parsed, options.includeHost ?? false)
const signature = this.mac(canonical)
parsed.searchParams.append(SIGNATURE_PARAM, signature)
return formatUrl(parsed, isAbsolute)
}
/**
* Validate a signed URL. `now` is overridable for tests; production code
* uses the default (`new Date()`).
*/
verify(
url: string,
options: { includeHost?: boolean; now?: Date } = {},
): VerifyResult {
const { parsed } = parseUrl(url)
const provided = parsed.searchParams.get(SIGNATURE_PARAM)
if (!provided) return { ok: false, reason: 'missing' }
parsed.searchParams.delete(SIGNATURE_PARAM)
const canonical = canonicalize(parsed, options.includeHost ?? false)
const expected = this.mac(canonical)
if (!constantTimeEqualHex(expected, provided)) return { ok: false, reason: 'invalid' }
const expiresRaw = parsed.searchParams.get(EXPIRES_PARAM)
if (expiresRaw !== null) {
const expires = Number(expiresRaw)
if (!Number.isFinite(expires)) return { ok: false, reason: 'invalid' }
const now = options.now ?? new Date()
if (now.getTime() > expires) return { ok: false, reason: 'expired' }
}
return { ok: true }
}
private mac(input: string): string {
return createHmac('sha256', this.secret).update(input).digest('hex')
}
}
function parseUrl(url: string): { isAbsolute: boolean; parsed: URL } {
const isAbsolute = /^[a-z][a-z0-9+.-]*:\/\//i.test(url)
// The placeholder origin is dropped on the relative round-trip via
// `formatUrl` — only `pathname + search` is re-emitted in that case.
const parsed = isAbsolute ? new URL(url) : new URL(url, 'http://signed.local')
return { isAbsolute, parsed }
}
function formatUrl(parsed: URL, isAbsolute: boolean): string {
if (isAbsolute) return parsed.toString()
const search = parsed.searchParams.toString()
return search.length > 0 ? `${parsed.pathname}?${search}` : parsed.pathname
}
/**
* Build the deterministic canonical input for the HMAC: optional `host`,
* the pathname, and the query string sorted by key (then by value within a
* key to stabilise repeated keys). The signature param is assumed already
* removed by the caller; `_expires` (when present) is included.
*/
function canonicalize(parsed: URL, includeHost: boolean): string {
const entries = [...parsed.searchParams.entries()]
entries.sort(([a, aValue], [b, bValue]) => {
if (a !== b) return a < b ? -1 : 1
return aValue < bValue ? -1 : aValue > bValue ? 1 : 0
})
const sortedQuery = entries
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
.join('&')
const prefix = includeHost ? parsed.host : ''
return sortedQuery.length > 0
? `${prefix}${parsed.pathname}?${sortedQuery}`
: `${prefix}${parsed.pathname}`
}
function constantTimeEqualHex(a: string, b: string): boolean {
if (a.length !== b.length) return false
// timingSafeEqual requires equal-length buffers and compares byte-by-byte
// in constant time — the only protection against signature-validation
// timing oracles.
return timingSafeEqual(Buffer.from(a, 'hex'), Buffer.from(b, 'hex'))
}
+3
-3
{
"name": "@strav/http",
"version": "1.0.0-alpha.38",
"version": "1.0.0-alpha.39",
"description": "Strav HTTP layer — Router, HttpKernel, HttpContext, middleware composition",

@@ -25,4 +25,4 @@ "type": "module",

"dependencies": {
"@strav/cli": "1.0.0-alpha.38",
"@strav/kernel": "1.0.0-alpha.38",
"@strav/cli": "1.0.0-alpha.39",
"@strav/kernel": "1.0.0-alpha.39",
"zod": "^4.4.3"

@@ -29,0 +29,0 @@ },

@@ -31,3 +31,4 @@ // Built-in middleware — public exports.

securityHeaders: 'security_headers',
signed: 'signed',
throttle: 'throttle',
} as const

@@ -157,6 +157,8 @@ /**

const url = new URL(request.url)
const match = this.router.match(request.method, url.pathname)
// Build a request scope + context up-front so the error path can use
// ctx.log / ctx.request / ctx.response just like the happy path.
// ctx.log / ctx.request / ctx.response just like the happy path. Server
// info is computed before route matching so the subdomain filter has the
// host available — operators on a subdomain (`admin.example.com`) only
// see routes scoped to that subdomain plus the unscoped catch-all set.
const scope = this.app.createScope()

@@ -169,2 +171,11 @@ const server = buildServerInfo({

})
// HEAD falls back to the matching GET handler when no explicit HEAD route
// is registered. The handler runs as if it were a GET; we strip the body
// from the final response below. RFC 9110 §9.3.2: HEAD and GET must yield
// identical headers, so reusing the GET pipeline is the spec-aligned move.
let match = this.router.match(request.method, url.pathname, server.subdomain)
if (request.method === 'HEAD' && match.kind !== 'found') {
const getMatch = this.router.match('GET', url.pathname, server.subdomain)
if (getMatch.kind === 'found') match = getMatch
}

@@ -278,4 +289,19 @@ const params = match.kind === 'found' ? match.params : {}

const finalResponse = httpResponse.applyPending(response)
let finalResponse = httpResponse.applyPending(response)
// HEAD response bodies are forbidden per RFC 9110 §9.3.2 — strip whatever
// the GET handler produced while keeping every header (incl. content-length
// and content-type). Skip 1xx / 204 / 304 which don't carry a body anyway.
if (request.method === 'HEAD') {
const status = finalResponse.status
const hasNoBody = status === 204 || status === 304 || (status >= 100 && status < 200)
if (!hasNoBody) {
finalResponse = new Response(null, {
status,
statusText: finalResponse.statusText,
headers: finalResponse.headers,
})
}
}
// Terminate hooks run after we have the response. Best-effort — exceptions

@@ -339,3 +365,12 @@ // are caught and logged but never propagate back to the client.

const url = new URL(request.url)
const match = kernel.router.matchWs(url.pathname)
// Compute server info so WS upgrades honour subdomain routing.
// The result is cheap and gets recomputed inside `handle()` if the
// upgrade falls through to HTTP — keeping it local keeps the data
// flow obvious at this seam.
const upgradeInfo = buildServerInfo({
request,
appDomain: kernel.contextConfig.appDomain,
trustProxy: false,
})
const match = kernel.router.matchWs(url.pathname, upgradeInfo.subdomain)
if (match) {

@@ -342,0 +377,0 @@ const data: WebSocketData = {

@@ -18,4 +18,10 @@ /**

import { type Application, ConfigRepository, Logger, ServiceProvider } from '@strav/kernel'
import {
type Application,
ConfigError,
ConfigRepository,
Logger,
ServiceProvider,
} from '@strav/kernel'
import {
BUILTIN_NAMES,

@@ -34,2 +40,3 @@ type CorsOptions,

import { Router } from './router/router.ts'
import { signedMiddleware, UrlSigner } from './signing/index.ts'

@@ -56,2 +63,12 @@ export interface HttpConfigShape {

publicDir?: string
/**
* Signed-URL configuration. Required only when the app uses
* `resolveRoute(..., { sign: signer })` or the `signed` middleware.
* Same secret on signer + verifier — typically `env.required('APP_KEY')`.
*/
signing?: {
secret?: string | Uint8Array
/** Match the signer + middleware: include host in the canonical form. */
includeHost?: boolean
}
}

@@ -65,2 +82,13 @@

app.singleton(Router, () => new Router())
app.singleton(UrlSigner, (c) => {
const config = c.resolve(ConfigRepository).get('http') as HttpConfigShape | undefined
const secret = config?.signing?.secret
if (!secret) {
throw new ConfigError(
'UrlSigner: `config.http.signing.secret` is not set. Add a secret (typically `env.required("APP_KEY")`) to use signed URLs.',
{ code: 'http.signing.missing-secret' },
)
}
return new UrlSigner(secret)
})
app.singleton(MiddlewareRegistry, () => {

@@ -128,3 +156,7 @@ const registry = new MiddlewareRegistry()

}
if (!registry.has(BUILTIN_NAMES.signed)) {
const includeHost = config.signing?.includeHost ?? false
registry.register(BUILTIN_NAMES.signed, signedMiddleware({ includeHost }))
}
}
}

@@ -83,2 +83,4 @@ // Public API of @strav/http.

type ResolveOptions,
type ResourceAction,
type ResourceOptions,
Route,

@@ -99,2 +101,10 @@ type RouteGroupOptions,

export {
type SignedMiddlewareOptions,
signedMiddleware,
type SignOptions,
UrlSigner,
type VerifyFailure,
type VerifyResult,
} from './signing/index.ts'
export {
type WebSocketData,

@@ -101,0 +111,0 @@ type WebSocketDispatcher,

// Router subsystem — public exports.
export type { ResourceAction, ResourceOptions } from './resource.ts'
export { Route } from './route.ts'

@@ -4,0 +5,0 @@ export { type ResolveOptions, resolveRoute } from './route_resolver.ts'

@@ -15,3 +15,5 @@ /**

import { ConfigError } from '@strav/kernel'
import type { UrlSigner } from '../signing/url_signer.ts'
import type { Router } from './router.ts'
import { parseSegment } from './segment.ts'

@@ -21,4 +23,32 @@ export interface ResolveOptions {

abs?: boolean
/**
* Explicit host. When omitted and the route is subdomain-scoped, the
* resolver synthesizes `<subdomain>.<appDomain>` — supply `appDomain` in
* that case. An explicit `host` overrides everything (escape hatch).
*/
host?: string
protocol?: 'http' | 'https'
/**
* Registrable apex used to synthesize the host for subdomain-scoped routes
* when `host` is not supplied. Required for `abs: true` on a subdomain
* route; ignored when the route has no subdomain or `host` is set.
*/
appDomain?: string
/**
* Pass a `UrlSigner` to append `?_signature=...` (and optionally
* `?_expires=...`) to the resolved URL. Verifies on the receiving end via
* the `signed` middleware. The signer is typically resolved from the
* container — `app.resolve(UrlSigner)`.
*/
sign?: UrlSigner
/**
* Deadline for the signed URL. Encoded as `_expires=<unix-ms>` and covered
* by the HMAC. Ignored when `sign` is not set.
*/
expiresAt?: Date
/**
* Match the signer + middleware: include host in the canonical form. Set
* `true` if your `signed` middleware was configured with `includeHost`.
*/
signIncludeHost?: boolean
}

@@ -37,25 +67,49 @@

// Consume the subdomain param up-front (when the route is dynamic) so it
// doesn't leak into the query string as an "extra" param.
const consumed = new Set<string>()
let subdomainValue: string | undefined
if (route.subdomain !== undefined) {
if (route.subdomainParamName) {
const provided = params[route.subdomainParamName]
if (provided === undefined || provided === null || provided === '') {
throw new ConfigError(
`route("${name}"): missing subdomain param "${route.subdomainParamName}".`,
)
}
subdomainValue = String(provided)
consumed.add(route.subdomainParamName)
} else {
subdomainValue = route.subdomain
}
}
const segments = route.pattern.split('/')
const consumed = new Set<string>()
const out: string[] = []
for (const seg of segments) {
if (seg.startsWith(':')) {
const optional = seg.endsWith('?')
const paramName = optional ? seg.slice(1, -1) : seg.slice(1)
const value = params[paramName]
consumed.add(paramName)
if (!seg.startsWith(':') && !seg.startsWith('*')) {
out.push(seg)
continue
}
const parsed = parseSegment(seg)
if (parsed.kind === 'param') {
const value = params[parsed.name]
consumed.add(parsed.name)
if (value === undefined || value === null || value === '') {
if (optional) continue
throw new ConfigError(`route("${name}"): missing param "${paramName}".`)
if (parsed.optional) continue
throw new ConfigError(`route("${name}"): missing param "${parsed.name}".`)
}
out.push(encodeURIComponent(String(value)))
continue
}
if (seg.startsWith('*')) {
const paramName = seg.slice(1)
const value = params[paramName]
consumed.add(paramName)
const stringValue = String(value)
if (parsed.constraint && !parsed.constraint.test(stringValue)) {
throw new ConfigError(
`route("${name}"): param "${parsed.name}" value "${stringValue}" violates constraint /${parsed.constraintSource}/.`,
)
}
out.push(encodeURIComponent(stringValue))
} else if (parsed.kind === 'wildcard') {
const value = params[parsed.name]
consumed.add(parsed.name)
if (value === undefined || value === null) {
throw new ConfigError(`route("${name}"): missing wildcard "${paramName}".`)
throw new ConfigError(`route("${name}"): missing wildcard "${parsed.name}".`)
}

@@ -69,5 +123,5 @@ // Wildcards pass through "/" unencoded; encode the rest piecewise.

)
continue
} else {
out.push(parsed.value)
}
out.push(seg)
}

@@ -92,12 +146,56 @@

let resolved: string
if (options.abs) {
const protocol = options.protocol ?? 'https'
const host = options.host
if (!host) {
throw new ConfigError(`route("${name}"): abs: true requires options.host.`)
const host = resolveHost(name, route, subdomainValue, options)
resolved = `${protocol}://${host}${path}`
} else {
if (options.signIncludeHost) {
throw new ConfigError(
`route("${name}"): signIncludeHost requires abs: true (the host must be in the URL to be signed).`,
)
}
return `${protocol}://${host}${path}`
resolved = path
}
return path
if (options.expiresAt && !options.sign) {
throw new ConfigError(
`route("${name}"): expiresAt without a sign signer has no effect — pass options.sign.`,
)
}
if (options.sign) {
const signOptions: { expiresAt?: Date; includeHost?: boolean } = {}
if (options.expiresAt) signOptions.expiresAt = options.expiresAt
if (options.signIncludeHost) signOptions.includeHost = options.signIncludeHost
resolved = options.sign.sign(resolved, signOptions)
}
return resolved
}
/**
* Pick the host for an absolute URL. Order of precedence:
* 1. Explicit `options.host` — always wins (escape hatch).
* 2. Subdomain-scoped route + `options.appDomain` →
* `<subdomain>.<appDomain>`.
* 3. Non-subdomain route + `options.appDomain` → the apex itself.
* 4. Nothing — throw, the caller didn't give us enough to build a host.
*/
function resolveHost(
name: string,
route: { subdomain?: string },
subdomainValue: string | undefined,
options: ResolveOptions,
): string {
if (options.host) return options.host
if (options.appDomain) {
if (subdomainValue !== undefined) return `${subdomainValue}.${options.appDomain}`
return options.appDomain
}
if (route.subdomain !== undefined) {
throw new ConfigError(
`route("${name}"): abs: true on a subdomain route requires options.host or options.appDomain.`,
)
}
throw new ConfigError(`route("${name}"): abs: true requires options.host or options.appDomain.`)
}

@@ -28,3 +28,12 @@ /**

} from '../ws/ws_route.ts'
import type { Constructor } from '@strav/kernel'
import {
registerResource,
RESOURCE_API_ACTIONS,
RESOURCE_FULL_ACTIONS,
type ResourceOptions,
} from './resource.ts'
import { Route } from './route.ts'
import { extractParamNames } from './segment.ts'
import { matchSubdomain, parseSubdomain } from './subdomain.ts'
import { type MatchResult, RouteTrie } from './trie.ts'

@@ -37,2 +46,5 @@ import type { CompiledRoute, HttpMethod, RouteGroupOptions, RouteHandler } from './types.ts'

name: string
/** Literal subdomain or param name (when `subdomainParamName` is set). */
subdomain?: string
subdomainParamName?: string
}

@@ -127,10 +139,27 @@

const { regex, paramNames } = compileWsPattern(fullPath)
this.wsRoutes.push({ pattern: fullPath, regex, paramNames, handlers })
const wsRoute: WebSocketRoute = { pattern: fullPath, regex, paramNames, handlers }
if (group.subdomain !== undefined) wsRoute.subdomain = group.subdomain
if (group.subdomainParamName !== undefined) {
wsRoute.subdomainParamName = group.subdomainParamName
}
this.wsRoutes.push(wsRoute)
}
/** Match an upgrade-eligible path against the registered WS routes. */
matchWs(path: string): WebSocketMatch | undefined {
/**
* Match an upgrade-eligible path against the registered WS routes.
*
* Subdomain semantics mirror HTTP: unscoped WS routes (no `router.subdomain`
* wrapper) match every host; scoped routes must match exactly (literal) or
* have a non-empty subdomain (dynamic, captured into params).
*/
matchWs(path: string, subdomain?: string): WebSocketMatch | undefined {
for (const route of this.wsRoutes) {
if (!matchSubdomain(route, subdomain)) continue
const m = route.regex.exec(path)
if (m) return { route, params: extractWsParams(route.paramNames, m) }
if (!m) continue
const params = extractWsParams(route.paramNames, m)
if (route.subdomainParamName && subdomain !== undefined) {
params[route.subdomainParamName] = subdomain
}
return { route, params }
}

@@ -162,2 +191,10 @@ return undefined

const parent = this.currentGroup()
// Explicit subdomain on the child wins; otherwise inherit the parent's.
let subdomain = parent.subdomain
let subdomainParamName = parent.subdomainParamName
if (options.subdomain !== undefined) {
const parsed = parseSubdomain(options.subdomain)
subdomain = parsed.value
subdomainParamName = parsed.paramName
}
const next: GroupState = {

@@ -168,2 +205,4 @@ prefix: joinPrefix(parent.prefix, options.prefix ?? ''),

}
if (subdomain !== undefined) next.subdomain = subdomain
if (subdomainParamName !== undefined) next.subdomainParamName = subdomainParamName
this.groupStack.push(next)

@@ -177,2 +216,34 @@ try {

/**
* Register the seven CRUD routes for a controller in one call (Laravel-
* style). Honors the current group state (prefix / middleware / name /
* subdomain). See [`resource.ts`](./resource.ts) for the verb table and
* route-name layout, and `ResourceOptions` for `only` / `except` / `param`
* / `constraint` / `middleware` / `names`.
*/
resource(name: string, controller: Constructor<unknown>, options: ResourceOptions = {}): void {
registerResource(this, name, controller, options, RESOURCE_FULL_ACTIONS)
}
/**
* Same as `resource()` but skips the `create` and `edit` actions (the
* HTML-form endpoints), leaving the five JSON-API CRUD routes.
*/
apiResource(
name: string,
controller: Constructor<unknown>,
options: ResourceOptions = {},
): void {
registerResource(this, name, controller, options, RESOURCE_API_ACTIONS)
}
/**
* Shortcut for `router.group({ subdomain }, callback)`. `pattern` is either
* a literal label (`'api'`) or a dynamic param (`':tenant'`) — single label
* only; mixed forms (`'api.:region'`) are rejected.
*/
subdomain(pattern: string, callback: (router: Router) => void): void {
this.group({ subdomain: pattern }, callback)
}
// ─── Compile + match ───────────────────────────────────────────────────────

@@ -197,2 +268,6 @@

}
if (group.subdomain !== undefined) compiled.subdomain = group.subdomain
if (group.subdomainParamName !== undefined) {
compiled.subdomainParamName = group.subdomainParamName
}
trie.insert(compiled)

@@ -210,7 +285,11 @@ if (compiled.name) {

/** Look up a route. Compiles the trie on first call if not already compiled. */
match(method: string, path: string): MatchResult {
/**
* Look up a route. Compiles the trie on first call if not already compiled.
* `subdomain` is the host's subdomain (from `ctx.server.subdomain`) — pass
* `undefined` for the apex / when no subdomain matching is needed.
*/
match(method: string, path: string, subdomain?: string): MatchResult {
if (!this.trie) this.compile()
// biome-ignore lint/style/noNonNullAssertion: trie is set by compile()
return this.trie!.match(method, path)
return this.trie!.match(method, path, subdomain)
}

@@ -221,10 +300,17 @@

if (!this.trie) this.compile()
return this.routes.map(({ route, group }) => ({
method: route.method,
pattern: joinPrefix(group.prefix, route.pattern),
paramNames: extractParamNames(joinPrefix(group.prefix, route.pattern)),
handler: route.handler,
middleware: [...group.middleware, ...route.getMiddleware()],
name: route.getName() ? group.name + route.getName() : undefined,
}))
return this.routes.map(({ route, group }) => {
const compiled: CompiledRoute = {
method: route.method,
pattern: joinPrefix(group.prefix, route.pattern),
paramNames: extractParamNames(joinPrefix(group.prefix, route.pattern)),
handler: route.handler,
middleware: [...group.middleware, ...route.getMiddleware()],
name: route.getName() ? group.name + route.getName() : undefined,
}
if (group.subdomain !== undefined) compiled.subdomain = group.subdomain
if (group.subdomainParamName !== undefined) {
compiled.subdomainParamName = group.subdomainParamName
}
return compiled
})
}

@@ -247,3 +333,7 @@

}
const route = new Route(method, normalizePattern(pattern), handler)
const normalized = normalizePattern(pattern)
// Validate segments eagerly so bad regexes / malformed params throw at the
// `router.get(...)` call site instead of waiting until `compile()`.
extractParamNames(normalized)
const route = new Route(method, normalized, handler)
this.routes.push({ route, group: this.currentGroup() })

@@ -279,13 +369,1 @@ return route

function extractParamNames(pattern: string): string[] {
const names: string[] = []
for (const seg of pattern.split('/')) {
if (seg.startsWith(':')) {
const name = seg.endsWith('?') ? seg.slice(1, -1) : seg.slice(1)
if (name.length > 0) names.push(name)
} else if (seg.startsWith('*')) {
names.push(seg.slice(1))
}
}
return names
}

@@ -15,9 +15,16 @@ /**

*
* Subdomain matching is a *post*-walk filter: multiple routes may share the
* same (method, path) as long as their subdomains differ, so each trie node's
* handler slot holds a list rather than a single route. The match algorithm
* picks the first list entry whose subdomain matches the request host.
*
* Match result:
* - `{ kind: 'found', route, params }` — full match.
* - `{ kind: 'method-not-allowed', allowed }` — path matched a handler node
* but the method didn't.
* - `{ kind: 'not-found' }` — no path match.
* - `{ kind: 'method-not-allowed', allowed }` — path + subdomain matched a
* handler node but the method didn't.
* - `{ kind: 'not-found' }` — no path or no host match.
*/
import { parseSegment } from './segment.ts'
import { matchSubdomain } from './subdomain.ts'
import type { CompiledRoute, HttpMethod } from './types.ts'

@@ -27,5 +34,9 @@

staticChildren: Map<string, TrieNode>
paramChild?: { name: string; node: TrieNode }
paramChild?: { name: string; constraint?: RegExp; constraintSource?: string; node: TrieNode }
wildcardChild?: { name: string; node: TrieNode }
handlers: Map<HttpMethod, CompiledRoute>
/**
* Multiple routes per (method, path) are allowed when their subdomains
* differ. The list is small in practice (one per subdomain scope).
*/
handlers: Map<HttpMethod, CompiledRoute[]>
}

@@ -56,3 +67,3 @@

match(method: string, path: string): MatchResult {
match(method: string, path: string, subdomain?: string): MatchResult {
const segments = splitPath(path)

@@ -64,8 +75,17 @@ const found: Array<{ route: CompiledRoute; params: Record<string, string> }> = []

// Filter by subdomain first: a path that only exists on a different host
// should 404, not 405. This matches operator intent — `admin.example.com`
// shouldn't leak the methods of `api.example.com/users`.
const hostMatches = found.filter((c) => matchSubdomain(c.route, subdomain))
if (hostMatches.length === 0) return { kind: 'not-found' }
const methodUpper = method.toUpperCase() as HttpMethod
for (const candidate of found) {
const route = candidate.route.method === methodUpper ? candidate.route : undefined
if (route) return { kind: 'found', route, params: candidate.params }
for (const candidate of hostMatches) {
if (candidate.route.method !== methodUpper) continue
const params = candidate.route.subdomainParamName
? { ...candidate.params, [candidate.route.subdomainParamName]: subdomain ?? '' }
: candidate.params
return { kind: 'found', route: candidate.route, params }
}
const allowed = [...new Set(found.map((c) => c.route.method))]
const allowed = [...new Set(hostMatches.map((c) => c.route.method))]
return { kind: 'method-not-allowed', allowed }

@@ -99,9 +119,24 @@ }

if (seg.startsWith(':')) {
const name = seg.slice(1)
const parsed = parseSegment(seg)
if (parsed.kind !== 'param') {
throw new Error(`Router: unexpected segment "${seg}" at "${pattern}".`)
}
if (!cur.paramChild) {
cur.paramChild = { name, node: freshNode() }
} else if (cur.paramChild.name !== name) {
throw new Error(
`Router: param name conflict — "${cur.paramChild.name}" vs "${name}" at "${pattern}".`,
)
const child: TrieNode['paramChild'] = { name: parsed.name, node: freshNode() }
if (parsed.constraint) {
child.constraint = parsed.constraint
child.constraintSource = parsed.constraintSource
}
cur.paramChild = child
} else {
if (cur.paramChild.name !== parsed.name) {
throw new Error(
`Router: param name conflict — "${cur.paramChild.name}" vs "${parsed.name}" at "${pattern}".`,
)
}
if ((cur.paramChild.constraintSource ?? '') !== (parsed.constraintSource ?? '')) {
throw new Error(
`Router: param constraint conflict on ":${parsed.name}" — "${cur.paramChild.constraintSource ?? '(none)'}" vs "${parsed.constraintSource ?? '(none)'}" at "${pattern}".`,
)
}
}

@@ -120,12 +155,29 @@ cur = cur.paramChild.node

// Same (method, pattern) being inserted twice is a real conflict — distinct
// expansions of one optional pattern always land on different trie nodes,
// so collision here means a duplicate `router.get('/x', …)` call.
if (cur.handlers.has(route.method)) {
throw new Error(`Router: duplicate route ${route.method} ${pattern}`)
// A real duplicate is same (method, pattern, subdomain). Two routes that
// share (method, pattern) but differ by subdomain coexist in the same list
// and are disambiguated at match time. We compare subdomain literal *and*
// the dynamic flag so `:tenant` and a literal `'tenant'` aren't conflated.
const existing = cur.handlers.get(route.method) ?? []
for (const prior of existing) {
if (sameSubdomainScope(prior, route)) {
const scope = describeSubdomain(route)
throw new Error(`Router: duplicate route ${route.method} ${pattern}${scope}`)
}
}
cur.handlers.set(route.method, route)
existing.push(route)
cur.handlers.set(route.method, existing)
}
}
function sameSubdomainScope(a: CompiledRoute, b: CompiledRoute): boolean {
if (a.subdomain !== b.subdomain) return false
return Boolean(a.subdomainParamName) === Boolean(b.subdomainParamName)
}
function describeSubdomain(route: CompiledRoute): string {
if (route.subdomain === undefined) return ''
if (route.subdomainParamName) return ` (subdomain :${route.subdomain})`
return ` (subdomain ${route.subdomain})`
}
function freshNode(): TrieNode {

@@ -158,4 +210,6 @@ return { staticChildren: new Map(), handlers: new Map() }

if (node.handlers.size > 0) {
for (const route of node.handlers.values()) {
found.push({ route, params: { ...params } })
for (const routes of node.handlers.values()) {
for (const route of routes) {
found.push({ route, params: { ...params } })
}
}

@@ -173,4 +227,7 @@ }

if (node.paramChild && seg.length > 0) {
const childParams = { ...params, [node.paramChild.name]: decodeURIComponent(seg) }
walk(node.paramChild.node, segments, index + 1, childParams, found)
const decoded = decodeURIComponent(seg)
if (!node.paramChild.constraint || node.paramChild.constraint.test(decoded)) {
const childParams = { ...params, [node.paramChild.name]: decoded }
walk(node.paramChild.node, segments, index + 1, childParams, found)
}
}

@@ -185,4 +242,6 @@

if (node.wildcardChild.node.handlers.size > 0) {
for (const route of node.wildcardChild.node.handlers.values()) {
found.push({ route, params: childParams })
for (const routes of node.wildcardChild.node.handlers.values()) {
for (const route of routes) {
found.push({ route, params: childParams })
}
}

@@ -202,4 +261,8 @@ }

for (const seg of segments) {
if (seg.startsWith(':') && seg.endsWith('?')) {
const required = seg.slice(0, -1) // drop the `?`
const parsed = seg.startsWith(':') ? parseSegment(seg) : undefined
if (parsed?.kind === 'param' && parsed.optional) {
// Required-form segment keeps the name + constraint, drops the `?`.
const required = parsed.constraintSource
? `:${parsed.name}(${parsed.constraintSource})`
: `:${parsed.name}`
const withSeg = patterns.map((p) => [...p, required])

@@ -206,0 +269,0 @@ const withoutSeg = patterns.map((p) => [...p])

@@ -84,2 +84,12 @@ /**

name?: string
/**
* Subdomain pattern matched against `ctx.server.subdomain`. Single-label only.
* - literal: `'api'` → matches host `api.<appDomain>` exactly
* - dynamic: `':tenant'` → matches any non-empty subdomain, captured into params
*
* Routes registered outside any subdomain group respond on every host
* (apex + every subdomain) — that's the "unscoped is catch-all" rule.
* Nested groups override outer subdomains; explicit always wins.
*/
subdomain?: string
}

@@ -100,2 +110,12 @@

name?: string
/**
* Literal subdomain value the route is scoped to, or `undefined` for routes
* declared outside any subdomain group. When the source pattern was dynamic
* (e.g. `':tenant'`), `subdomain` is the *param name* and
* `subdomainParamName` is set — the runtime distinguishes the two via the
* `subdomainParamName` flag.
*/
subdomain?: string
/** When set, `subdomain` is a param name; matched values land in `ctx.request.params`. */
subdomainParamName?: string
}

@@ -14,2 +14,3 @@ /**

import { parseSegment } from '../router/segment.ts'
import type { WebSocketHandlers } from './types.ts'

@@ -26,2 +27,6 @@

handlers: WebSocketHandlers
/** Literal subdomain or dynamic param name; see `RouteGroupOptions.subdomain`. */
subdomain?: string
/** When set, `subdomain` is a param name (dynamic subdomain). */
subdomainParamName?: string
}

@@ -48,20 +53,30 @@

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 += '(.+)'
// The leading "" before the root `/` keeps i===0 a synthetic head; from
// i===1 on, every segment is preceded by `/` (or by the optional group
// that swallows it).
if (i === 0) continue
const parsed =
seg.startsWith(':') || seg.startsWith('*')
? parseSegment(seg)
: { kind: 'static' as const, value: seg }
if (parsed.kind === 'param') {
paramNames.push(parsed.name)
const body1 = parsed.constraintSource ?? '[^/]+'
if (parsed.optional) {
// Optional param swallows the preceding `/` so `/users/:id?` matches
// both `/users` and `/users/42`.
body += `(?:/(${body1}))?`
} else {
body += `/(${body1})`
}
} else if (parsed.kind === 'wildcard') {
paramNames.push(parsed.name)
body += '/(.+)'
} else {
body += escapeRegex(seg)
body += `/${escapeRegex(parsed.value)}`
}
}
if (body.length === 0) body = '/?'
return { regex: new RegExp(`^${body}$`), paramNames }

@@ -68,0 +83,0 @@ }