@strav/http
Advanced tools
| /** | ||
| * 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 |
+39
-4
@@ -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 = { |
+33
-1
@@ -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 })) | ||
| } | ||
| } | ||
| } |
+10
-0
@@ -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' |
+121
-23
@@ -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.`) | ||
| } |
+106
-28
@@ -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 | ||
| } |
+93
-30
@@ -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]) |
+20
-0
@@ -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 | ||
| } |
+30
-15
@@ -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 @@ } |
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
179526
25.2%50
13.64%4521
23.66%+ Added
+ Added
- Removed
- Removed
Updated
Updated