galbe 0.15.6 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/types.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { SocketAddress, TLSOptions } from 'bun'
2
+ import type { OpenAPIV3 } from 'openapi-types'
2
3
  import type {
3
4
  STAny,
4
5
  STArray,
@@ -59,6 +60,24 @@ export type STBodyValue =
59
60
 
60
61
  export type STBodyContent = Partial<Record<MediaType, STBodyValue>>
61
62
  export type STBody = STNull | STBodyContent
63
+ /**
64
+ * Metadata a request body may carry beside its media types — describing the
65
+ * body itself rather than any one of its schemas. Kept out of `STBodyContent`
66
+ * on purpose: `Context` maps over the body's keys to derive `contentType`, so
67
+ * anything intersected there would surface as a bogus content type. `MediaType`
68
+ * is a `${string}/${string}` pattern, so these keys never collide with a body.
69
+ */
70
+ export type STBodyMeta = {
71
+ /** Describes the request body itself, as opposed to any one of its schemas. */
72
+ description?: string
73
+ /** Whether the request body is required. Inferred from the schemas when unset. */
74
+ required?: boolean
75
+ /**
76
+ * Names the `components.requestBodies` entry this body came from, so spec
77
+ * generators can emit it once and `$ref` it. Set by `galbe generate code`.
78
+ */
79
+ _requestBodyId?: string
80
+ }
62
81
  export type STBodyType = MediaType
63
82
 
64
83
  export type STResponseBodyValue =
@@ -80,10 +99,28 @@ export type STResponseBodyValue =
80
99
  export type STResponseContent = Partial<Record<MediaType, STResponseBodyValue>> & {
81
100
  description?: string
82
101
  responseHeaders?: Record<string, STSchema>
102
+ /** The OpenAPI `links` object for this response, carried verbatim. Documentation only. */
103
+ responseLinks?: Record<string, any>
104
+ /** A single example, applied to every media type this response offers. */
105
+ example?: any
106
+ /** Named examples (OpenAPI `examples`), applied to every media type offered. */
107
+ examples?: Record<string, any>
108
+ /**
109
+ * Names the `components.responses` entry this response came from, so spec
110
+ * generators can emit it once and `$ref` it. Set by `galbe generate code`.
111
+ */
112
+ _responseId?: string
83
113
  }
84
114
  export type STResponseBodyKey = MediaType
85
115
  export type STResponseEntry = STResponseValue | STResponseContent
86
- export type STResponse = Partial<Record<number | 'default', STResponseEntry>>
116
+ /**
117
+ * A wildcard status range, spelled as in OpenAPI: `'4XX'` covers every 4xx
118
+ * status. An exact status always wins over the range that contains it, which
119
+ * in turn wins over `'default'` — for response validation, for the content
120
+ * type inferred on a string response, and for the generated spec alike.
121
+ */
122
+ export type STResponseRange = '1XX' | '2XX' | '3XX' | '4XX' | '5XX'
123
+ export type STResponse = Partial<Record<number | STResponseRange | 'default', STResponseEntry>>
87
124
 
88
125
  export type MaybeArray<T> = T | T[]
89
126
  export type MaybeSTArray<T extends STSchema> = T | STArray<T>
@@ -109,9 +146,17 @@ type STParamsValue = MaybeSTUnion<STParamsPrimaryValue>
109
146
  export type STParams<Path extends string = string> = Record<ExtractParams<Path>, STParamsValue>
110
147
 
111
148
  type STQueryPrimaryValue = STString | STBoolean | STNumber | STInteger | STLiteral
112
- type STQueryValue = MaybeSTArray<MaybeSTUnion<STQueryPrimaryValue>>
149
+ // An object query parameter is OpenAPI's `deepObject`: `?filter[lat]=1&filter[lon]=2`.
150
+ // A JSON-encoded value is accepted for it too.
151
+ type STQueryValue = MaybeSTArray<MaybeSTUnion<STQueryPrimaryValue>> | STObject
113
152
  export type STQuery = Record<string, STQueryValue>
114
153
 
154
+ // A cookie arrives as one string; like headers and query params it is parsed
155
+ // into the declared primitive, so the same value shapes apply.
156
+ type STCookiesPrimaryValue = STString | STBoolean | STNumber | STInteger | STLiteral
157
+ type STCookiesValue = MaybeSTUnion<STCookiesPrimaryValue>
158
+ export type STCookies = Record<string, STCookiesValue>
159
+
115
160
  /**
116
161
  * #### GalbeConfig
117
162
  * Instanciate a Galbe web server
@@ -129,6 +174,42 @@ export type STQuery = Record<string, STQueryValue>
129
174
  * export default new Galbe(config)
130
175
  * ```
131
176
  */
177
+ /**
178
+ * #### OpenAPIConfig
179
+ * Customize the top-level document fields (`info`, `servers`) of the OpenAPI
180
+ * specification produced by `OpenAPISerializer` (`galbe/extras`).
181
+ *
182
+ * Explicit values set here take precedence over the package.json inference
183
+ * applied by `galbe generate spec`, which itself takes precedence over the
184
+ * built-in defaults (`title: 'Galbe app'`, `version: '0.1.0'`).
185
+ */
186
+ export type OpenAPIConfig = {
187
+ /** Overrides for the spec's `info` object (`title`, `version`, `description`, `contact`, `license`, `termsOfService`). Defaults to `{ title: 'Galbe app', version: '0.1.0' }`. */
188
+ info?: Partial<OpenAPIV3.InfoObject>
189
+ /** The spec's `servers` list. Unset by default. */
190
+ servers?: OpenAPIV3.ServerObject[]
191
+ /**
192
+ * The spec's `components.securitySchemes`. Route-level `@security <name>` tags
193
+ * name a scheme; this is where the scheme itself is defined. A scheme declared
194
+ * here always wins over the `bearerAuth` the serializer infers from an
195
+ * `Authorization: Bearer` header.
196
+ */
197
+ securitySchemes?: Record<string, OpenAPIV3.SecuritySchemeObject | OpenAPIV3.ReferenceObject>
198
+ /** The document's `externalDocs`. Route-level docs come from the `@externalDocs` tag instead. */
199
+ externalDocs?: OpenAPIV3.ExternalDocumentationObject
200
+ /**
201
+ * The document's `tags` list — the descriptions behind the names operations
202
+ * use. Operations name their tags through `@tags`; this is where a tag is
203
+ * described.
204
+ */
205
+ tags?: OpenAPIV3.TagObject[]
206
+ /**
207
+ * The document-level `security` requirement, applied to every operation that
208
+ * does not declare its own through `@security`.
209
+ */
210
+ security?: OpenAPIV3.SecurityRequirementObject[]
211
+ }
212
+
132
213
  export type GalbeConfig = {
133
214
  /** The port number that the server will be listening on. */
134
215
  port?: number
@@ -140,17 +221,39 @@ export type GalbeConfig = {
140
221
  basePath?: string
141
222
  /** Enable or disable TLS support. */
142
223
  tls?: TLSOptions
224
+ /**
225
+ * How many hops in front of the app are yours, so `ctx.clientAddress` can be resolved from
226
+ * `X-Forwarded-For`: a hop count, or the addresses/CIDR ranges your proxies connect from.
227
+ * Default `false` — the header is ignored entirely and the client is the socket peer. Set it
228
+ * to match the real topology: too high is a spoofing hole, too low buckets every client together.
229
+ */
230
+ trustProxy?: false | number | string[]
143
231
  /** Extra options passed through to `Bun.serve` (e.g. `maxRequestBodySize`, `idleTimeout`). `port`, `fetch` and `error` are ignored, and the dedicated `hostname`, `reusePort` and `tls` config keys take precedence. */
144
232
  server?: Partial<Omit<Parameters<typeof Bun.serve>[0], 'port' | 'fetch' | 'error'>> | TLSOptions
145
- /** A Glob Pattern or a list of Glob patterns defining the route files to be analyzed by the Automatic Route Analyzer. */
146
- routes?: boolean | string | string[]
147
- router?: { cacheEnabled: boolean; cacheLimit?: number }
233
+ /**
234
+ * Route files picked up by the Automatic Route Analyzer: a glob pattern (or list of), `false` to
235
+ * disable the analyzer, or an object form to also control directory groups. By default a route
236
+ * file's directory relative to its glob's static base becomes its path prefix
237
+ * (`src/api/users.route.ts` → `/api`); set `dirPrefix: false` to opt out.
238
+ */
239
+ routes?: boolean | string | string[] | { pattern?: string | string[]; dirPrefix?: boolean }
240
+ /**
241
+ * Middleware files discovered by the Automatic Route Analyzer (default `src/**­/*.middleware.{js,ts}`).
242
+ * Files default-export `Hook | Hook[] | MiddlewareDef`, scoped to their directory subtree. `false`
243
+ * disables middleware discovery only; `routes: false` disables the whole analyzer.
244
+ */
245
+ middleware?: boolean | string | string[]
246
+ router?: { cacheEnabled: boolean; cacheLimit?: number; warn?: (message: string) => void }
148
247
  /** A property that can be used by plugins to add plugin's specific configuration. */
149
248
  plugin?: Record<string, any>
150
249
  /** Enable or disable the request schema validation.*/
151
250
  requestValidator?: { enabled: boolean }
152
251
  /** Enable or disable the response schema validation.*/
153
252
  responseValidator?: { enabled: boolean }
253
+ /** Maximum request body size in bytes; larger bodies are rejected with a 413 error. Can be overridden per route with the schema's `bodyLimit`. Unset by default: only Bun's `maxRequestBodySize` (128 MB, see `server`) applies. */
254
+ bodyLimit?: number
255
+ /** Customize the document-level blocks (`info`, `servers`, `tags`, `security`, `externalDocs`, `securitySchemes`) of the OpenAPI spec generated by `OpenAPISerializer` (`galbe/extras`). */
256
+ openapi?: OpenAPIConfig
154
257
  }
155
258
  /**
156
259
  * #### Schema
@@ -179,21 +282,38 @@ export type RequestSchema<
179
282
  Q extends STQuery = STQuery,
180
283
  B extends STBody = STBody,
181
284
  R extends Partial<STResponse> = STResponse,
285
+ C extends STCookies = STCookies,
182
286
  > = {
183
287
  headers?: H
184
288
  params?: P
185
289
  query?: Q
290
+ /**
291
+ * Declares the request cookies. Each one is parsed out of the `Cookie` header
292
+ * and validated like a query parameter, so `ctx.cookies` comes back typed and
293
+ * coerced. Undeclared cookies are still present, as strings.
294
+ */
295
+ cookies?: C
186
296
  body?: B
187
297
  response?: R
298
+ /** Maximum request body size in bytes for this route; overrides the global `bodyLimit` config. Larger bodies are rejected with a 413 error. */
299
+ bodyLimit?: number
188
300
  }
189
301
 
302
+ /** The route's declared path params, with the `params?:` optionality peeled off. */
303
+ type STParamsOf<S extends RequestSchema> = Exclude<S['params'], undefined>
304
+ /** What `params` infers to, before the not-declared keys are dropped. */
305
+ type StaticParams<S extends RequestSchema> = Static<STObject<STParamsOf<S>>>
306
+ /**
307
+ * The declared params, minus the ones the schema itself marks optional
308
+ * (`params: { id?: $T.integer() }`) — those fall back to `string` in the
309
+ * context, like an undeclared param. `keyof StaticParams<S>` is `keyof
310
+ * STParamsOf<S>` by construction, so the `K extends keyof` guard on the value
311
+ * only exists to keep the indexed access provable while `S` is still generic.
312
+ */
190
313
  type OmitNotDefined<S extends RequestSchema> = {
191
- [K in keyof Exclude<S['params'], undefined> as Exclude<S['params'], undefined>[K] extends Required<
192
- Exclude<S['params'], undefined>
193
- >[K]
194
- ? K
195
- : //@ts-ignore
196
- never]: Static<STObject<Exclude<S['params'], undefined>>>[K]
314
+ [
315
+ K in keyof STParamsOf<S> as STParamsOf<S>[K] extends Required<STParamsOf<S>>[K] ? K : never
316
+ ]: K extends keyof StaticParams<S> ? StaticParams<S>[K] : never
197
317
  }
198
318
  type StaticBody<T extends STSchema> = T extends STOptional<STSchema> ? Static<T> | null : Static<T>
199
319
  export type ContextSet = {
@@ -204,67 +324,66 @@ export type ContextSet = {
204
324
  status?: number
205
325
  cookie: (name: string, value: string, opt?: CookieOptions) => void
206
326
  }
327
+ /** Methods whose request carries no body: `contentType` and `body` collapse regardless of the schema. */
328
+ type EmptyBodyMethod = 'get' | 'options' | 'head'
329
+ /**
330
+ * `Fallback` when `T` is `any`, `T` otherwise — the `0 extends 1 & T` trick,
331
+ * which only ever holds for `any`. A route registered without a body schema
332
+ * leaves `B` at its `any` default, and `keyof any` is `string | number |
333
+ * symbol`: mapping over it would type `ctx.contentType` as `string` instead of
334
+ * a media type. Falling back to the `STBody` constraint types such a route
335
+ * exactly like the unparameterized {@link Context}, which is also what keeps a
336
+ * shared `(ctx: Context) => …` handler assignable to every route.
337
+ */
338
+ type IfAny<T, Fallback> = 0 extends 1 & T ? Fallback : T
339
+ /** The declared body's media-type map. `never` when the route declares no body, or declares `$T.null()`. */
340
+ type STBodyOf<S extends RequestSchema> = Exclude<IfAny<S['body'], STBody>, undefined | STNull>
341
+ /**
342
+ * `contentType` and `body` are the only two context fields the request's media
343
+ * type reaches, so they are the only ones derived per media type: one member
344
+ * per key of the body map, which is what makes `ctx.contentType` a discriminant
345
+ * for `ctx.body`. Everything else lives in the single object literal below.
346
+ */
347
+ type ContextBody<M extends Method, S extends RequestSchema> = [STBodyOf<S>] extends [never]
348
+ ? { contentType: undefined; body: null }
349
+ : {
350
+ [K in keyof STBodyOf<S>]: {
351
+ contentType: M extends EmptyBodyMethod ? undefined : K
352
+ body: M extends EmptyBodyMethod ? null : StaticBody<Extract<Exclude<STBodyOf<S>[K], undefined>, STSchema>>
353
+ }
354
+ }[keyof STBodyOf<S>]
355
+ /**
356
+ * The context shape, written once. `B` is a naked type parameter so the
357
+ * conditional distributes over {@link ContextBody}'s union — one context per
358
+ * media type — and resolves to a bare object literal, which is what keeps
359
+ * `ctx` hovering as its expanded shape rather than as an alias reference.
360
+ */
361
+ type ContextOf<Path extends string, S extends RequestSchema, B> = B extends {
362
+ contentType: infer CT
363
+ body: infer Body
364
+ }
365
+ ? {
366
+ headers: Static<STObject<Exclude<S['headers'], undefined>>>
367
+ params: {
368
+ [P in ExtractParams<Path>]: P extends keyof OmitNotDefined<S> ? OmitNotDefined<S>[P] : string
369
+ }
370
+ query: Static<STObject<Exclude<S['query'], undefined>>>
371
+ contentType: CT
372
+ body: Body
373
+ request: Request
374
+ remoteAddress: SocketAddress | null
375
+ clientAddress: string | null
376
+ route?: Route
377
+ state: Record<string, any>
378
+ set: ContextSet
379
+ cookies: Static<STObject<Exclude<S['cookies'], undefined>>>
380
+ }
381
+ : never
207
382
  export type Context<
208
383
  M extends Method = Method,
209
384
  Path extends string = string,
210
385
  S extends RequestSchema = RequestSchema,
211
- > = 0 extends 1 & Exclude<S['body'], undefined | STNull>
212
- ? {
213
- [K in keyof Exclude<S['body'], undefined | STNull>]: {
214
- headers: Static<STObject<Exclude<S['headers'], undefined>>>
215
- params: {
216
- [P in ExtractParams<Path>]: P extends keyof OmitNotDefined<S> ? OmitNotDefined<S>[P] : string
217
- }
218
- query: Static<STObject<Exclude<S['query'], undefined>>>
219
- contentType: M extends 'get' | 'options' | 'head' ? undefined : K
220
- body: M extends 'get' | 'options' | 'head'
221
- ? null
222
- : Exclude<S['body'], undefined> extends STNull
223
- ? null
224
- : StaticBody<Extract<Exclude<Exclude<S['body'], undefined | STNull>[K], undefined>, STSchema>>
225
- request: Request
226
- remoteAddress: SocketAddress | null
227
- route?: Route
228
- state: Record<string, any>
229
- set: ContextSet
230
- cookies: Record<string, string>
231
- }
232
- }[keyof Exclude<S['body'], undefined | STNull>]
233
- : [Exclude<S['body'], undefined | STNull>] extends [never]
234
- ? {
235
- headers: Static<STObject<Exclude<S['headers'], undefined>>>
236
- params: {
237
- [P in ExtractParams<Path>]: P extends keyof OmitNotDefined<S> ? OmitNotDefined<S>[P] : string
238
- }
239
- query: Static<STObject<Exclude<S['query'], undefined>>>
240
- contentType: undefined
241
- body: null
242
- request: Request
243
- remoteAddress: SocketAddress | null
244
- route?: Route
245
- state: Record<string, any>
246
- set: ContextSet
247
- cookies: Record<string, string>
248
- }
249
- : {
250
- [K in keyof Exclude<S['body'], undefined | STNull>]: {
251
- headers: Static<STObject<Exclude<S['headers'], undefined>>>
252
- params: {
253
- [P in ExtractParams<Path>]: P extends keyof OmitNotDefined<S> ? OmitNotDefined<S>[P] : string
254
- }
255
- query: Static<STObject<Exclude<S['query'], undefined>>>
256
- contentType: M extends 'get' | 'options' | 'head' ? undefined : K
257
- body: M extends 'get' | 'options' | 'head'
258
- ? null
259
- : StaticBody<Extract<Exclude<Exclude<S['body'], undefined | STNull>[K], undefined>, STSchema>>
260
- request: Request
261
- remoteAddress: SocketAddress | null
262
- route?: Route
263
- state: Record<string, any>
264
- set: ContextSet
265
- cookies: Record<string, string>
266
- }
267
- }[keyof Exclude<S['body'], undefined | STNull>]
386
+ > = ContextOf<Path, S, ContextBody<M, S>>
268
387
  export type Next = () => void | Promise<any>
269
388
  export type Hook<M extends Method = Method, Path extends string = string, S extends RequestSchema = RequestSchema> = (
270
389
  ctx: Context<M, Path, S>,
@@ -275,55 +394,187 @@ export type Handler<
275
394
  Path extends string = string,
276
395
  S extends RequestSchema = RequestSchema,
277
396
  > = (ctx: Context<M, Path, S>) => any
278
- export type Endpoint<M extends Method> = {
397
+ /** Request contract a middleware imposes, merged into the schema of every route it matches. */
398
+ export type MiddlewareSchema = Pick<RequestSchema, 'headers' | 'query' | 'params'>
399
+ type IsAny<T> = 0 extends 1 & T ? true : false
400
+ type FragHeaders<F extends MiddlewareSchema> = F['headers'] extends STHeaders ? F['headers'] : {}
401
+ type FragQuery<F extends MiddlewareSchema> = F['query'] extends STQuery ? F['query'] : {}
402
+ /**
403
+ * Fragment keys merged under route-declared ones — route wins, as at runtime.
404
+ * A route that declares no schema of its own keeps today's permissive `any`
405
+ * when there is no fragment, and gets exactly the fragment's keys when there is.
406
+ */
407
+ type MergeFragment<Frag, Declared> = [keyof Frag] extends [never]
408
+ ? Declared
409
+ : IsAny<Declared> extends true
410
+ ? Frag
411
+ : Omit<Frag, keyof Declared> & Declared
412
+ /** The request schema a fragment implies for the middleware's own hooks. */
413
+ type FragmentRequest<F extends MiddlewareSchema> = RequestSchema<Method, string, FragHeaders<F>, {}, FragQuery<F>>
414
+ /**
415
+ * The context a {@link PreParseHook} receives. It deliberately lacks `body`,
416
+ * `params` and the parsed `query`: none of them exist yet at that point. Raw
417
+ * headers and search params remain reachable through `ctx.request`.
418
+ */
419
+ export type PreParseContext = Pick<
420
+ Context,
421
+ 'request' | 'set' | 'state' | 'route' | 'cookies' | 'remoteAddress' | 'clientAddress'
422
+ >
423
+ /**
424
+ * A `beforeParse` hook: runs once the route is known but before the body is
425
+ * read or the request validated. No `next()` — hooks run sequentially and
426
+ * short-circuit by returning a `Response`, like a plugin's `onRoute`.
427
+ */
428
+ export type PreParseHook = (ctx: PreParseContext) => MaybePromise<Response | void>
429
+ /**
430
+ * A route-scoped response hook — the `afterHandle` slot. It runs once the
431
+ * request has a `Response`, whatever it ended on, and transforms it: return a
432
+ * `Response` to replace it, return nothing to keep it. Not an onion — by the
433
+ * time a `Response` exists the hook chain has unwound, so there is nothing left
434
+ * to wrap. `error` is what the request ended on, and is `undefined` on success.
435
+ */
436
+ export type ResponseHook = (response: Response, ctx: Context, error?: unknown) => MaybePromise<Response | void>
437
+ /**
438
+ * #### MiddlewareDef
439
+ * Middleware as a value: the hooks to run, plus the request contract they
440
+ * impose. Accepted everywhere a hook is — `galbe.middleware`, `group.middleware`
441
+ * and middleware files — so a packaged middleware is one exportable thing.
442
+ *
443
+ * The `schema` fragment types the def's own `hooks`: declaring a header means
444
+ * reading it back typed, with no annotation. Wrap the def in `middleware()` to
445
+ * get that inference — a bare object literal has nothing to contextually type
446
+ * its handlers against.
447
+ *
448
+ * ---
449
+ * @example
450
+ * ```typescript
451
+ * galbe.middleware('/api/*', middleware({
452
+ * schema: { headers: { authorization: $T.string() } },
453
+ * hooks: ctx => { ctx.headers.authorization }, // string
454
+ * security: 'bearerAuth',
455
+ * securitySchemes: { bearerAuth: { type: 'http', scheme: 'bearer' } }
456
+ * }))
457
+ * ```
458
+ */
459
+ export type MiddlewareDef<F extends MiddlewareSchema = any> = {
460
+ /**
461
+ * Hooks run on every matched route after routing and before the body is
462
+ * parsed — the point where auth or rate limiting can reject a request
463
+ * without reading it. See {@link PreParseHook}.
464
+ */
465
+ beforeParse?: MaybeArray<PreParseHook>
466
+ /** Hooks composed into the chain of every matched route, ahead of the route's own hooks. */
467
+ hooks?: MaybeArray<Hook<Method, string, FragmentRequest<F>>>
468
+ /**
469
+ * Hooks run on every matched route once its response is parsed — on success
470
+ * and on failure alike, the error response included — ahead of the plugins'
471
+ * `afterHandle`. They transform the `Response`. See {@link ResponseHook}.
472
+ */
473
+ afterHandle?: MaybeArray<ResponseHook>
474
+ /**
475
+ * Headers, query and params the hooks require, merged into matched routes.
476
+ * Route-declared keys win. `params` is merged and validated at runtime but
477
+ * cannot type the hooks: a middleware pattern is not a typed route path.
478
+ */
479
+ schema?: F
480
+ /**
481
+ * OpenAPI security scheme name(s) enforced by the hooks, applied to every
482
+ * matched operation exactly like a middleware file's `@security` header.
483
+ * Scopes follow the name, space-separated (`'oauth2 read write'`); `'none'`
484
+ * documents the scope as public. Metadata only — never affects runtime.
485
+ */
486
+ security?: string | string[]
487
+ /**
488
+ * Definitions for the schemes {@link MiddlewareDef.security} names, merged
489
+ * into `components.securitySchemes`. A packaged middleware that is not bearer
490
+ * auth needs this: `apiKey` and `basic` cannot be expressed by a name alone.
491
+ * Same shape as `config.openapi.securitySchemes`, which wins on conflict.
492
+ */
493
+ securitySchemes?: Record<string, OpenAPIV3.SecuritySchemeObject | OpenAPIV3.ReferenceObject>
494
+ }
495
+ /**
496
+ * Prefix middleware entry registered via `galbe.middleware`. Patterns match
497
+ * registered route paths (not request URLs) and are resolved at registration:
498
+ * matched hooks are composed into the route's hook chain and the schema
499
+ * fragment is merged into the route's schema.
500
+ */
501
+ export type GalbeMiddleware = {
502
+ /** the pattern as registered, e.g. `/api/*` */
503
+ pattern: string
504
+ /** pattern split into segments, precomputed at registration */
505
+ segments: string[]
506
+ beforeParse: PreParseHook[]
507
+ hooks: Hook[]
508
+ afterHandle: ResponseHook[]
509
+ schema?: MiddlewareSchema
510
+ security?: string | string[]
511
+ securitySchemes?: Record<string, OpenAPIV3.SecuritySchemeObject | OpenAPIV3.ReferenceObject>
512
+ }
513
+ // Prefix is prepended to Path at the type level (route groups): context params,
514
+ // schemas and the returned Route are typed against the full, joined path. F is
515
+ // the schema fragment of the group's middleware def, merged under the route's
516
+ // own declarations so handlers read fragment-declared entries typed.
517
+ export type Endpoint<M extends Method, Prefix extends string = '', F extends MiddlewareSchema = {}> = {
279
518
  <
280
519
  Path extends string,
281
- P extends Partial<STParams<Path>>,
520
+ P extends Partial<STParams<`${Prefix}${Path}`>>,
282
521
  H extends STHeaders = any,
283
522
  Q extends STQuery = any,
284
523
  B extends STBody = any,
285
524
  R extends STResponse = STResponse,
525
+ C extends STCookies = any,
526
+ MH extends STHeaders = MergeFragment<FragHeaders<F>, H>,
527
+ MQ extends STQuery = MergeFragment<FragQuery<F>, Q>,
286
528
  >(
287
529
  path: Path,
288
- schema: RequestSchema<M, Path, H, P, Q, B, R>,
289
- hooks: Hook<M, Path, RequestSchema<M, Path, H, P, Q, B, R>>[],
290
- handler: Handler<M, Path, RequestSchema<M, Path, H, P, Q, B, R>>
291
- ): Route<M, Path, P, H, Q, B, R>
530
+ schema: RequestSchema<M, `${Prefix}${Path}`, H, P, Q, B, R, C>,
531
+ hooks: Hook<M, `${Prefix}${Path}`, RequestSchema<M, `${Prefix}${Path}`, MH, P, MQ, B, R, C>>[],
532
+ handler: Handler<M, `${Prefix}${Path}`, RequestSchema<M, `${Prefix}${Path}`, MH, P, MQ, B, R, C>>
533
+ ): Route<M, `${Prefix}${Path}`, P, MH, MQ, B, R, C>
292
534
  <
293
535
  Path extends string,
294
- P extends Partial<STParams<Path>>,
536
+ P extends Partial<STParams<`${Prefix}${Path}`>>,
295
537
  H extends STHeaders = any,
296
538
  Q extends STQuery = any,
297
539
  B extends STBody = any,
298
540
  R extends STResponse = STResponse,
541
+ C extends STCookies = any,
542
+ MH extends STHeaders = MergeFragment<FragHeaders<F>, H>,
543
+ MQ extends STQuery = MergeFragment<FragQuery<F>, Q>,
299
544
  >(
300
545
  path: Path,
301
- schema: RequestSchema<M, Path, H, P, Q, B, R>,
302
- handler: Handler<M, Path, RequestSchema<M, Path, H, P, Q, B, R>>
303
- ): Route<M, Path, P, H, Q, B, R>
546
+ schema: RequestSchema<M, `${Prefix}${Path}`, H, P, Q, B, R, C>,
547
+ handler: Handler<M, `${Prefix}${Path}`, RequestSchema<M, `${Prefix}${Path}`, MH, P, MQ, B, R, C>>
548
+ ): Route<M, `${Prefix}${Path}`, P, MH, MQ, B, R, C>
304
549
  <
305
550
  Path extends string,
306
- P extends Partial<STParams<Path>>,
551
+ P extends Partial<STParams<`${Prefix}${Path}`>>,
307
552
  H extends STHeaders = any,
308
553
  Q extends STQuery = any,
309
554
  B extends STBody = any,
310
555
  R extends STResponse = STResponse,
556
+ C extends STCookies = any,
557
+ MH extends STHeaders = MergeFragment<FragHeaders<F>, H>,
558
+ MQ extends STQuery = MergeFragment<FragQuery<F>, Q>,
311
559
  >(
312
560
  path: Path,
313
- hooks: Hook<M, Path, RequestSchema<M, Path, H, P, Q, B, R>>[],
314
- handler: Handler<M, Path, RequestSchema<M, Path, H, P, Q, B, R>>
315
- ): Route<M, Path, P, H, Q, B, R>
561
+ hooks: Hook<M, `${Prefix}${Path}`, RequestSchema<M, `${Prefix}${Path}`, MH, P, MQ, B, R, C>>[],
562
+ handler: Handler<M, `${Prefix}${Path}`, RequestSchema<M, `${Prefix}${Path}`, MH, P, MQ, B, R, C>>
563
+ ): Route<M, `${Prefix}${Path}`, P, MH, MQ, B, R, C>
316
564
  <
317
565
  Path extends string,
318
- P extends Partial<STParams<Path>>,
566
+ P extends Partial<STParams<`${Prefix}${Path}`>>,
319
567
  H extends STHeaders = any,
320
568
  Q extends STQuery = any,
321
569
  B extends STBody = any,
322
570
  R extends STResponse = STResponse,
571
+ C extends STCookies = any,
572
+ MH extends STHeaders = MergeFragment<FragHeaders<F>, H>,
573
+ MQ extends STQuery = MergeFragment<FragQuery<F>, Q>,
323
574
  >(
324
575
  path: Path,
325
- handler: Handler<M, Path, RequestSchema<M, Path, H, P, Q, B, R>>
326
- ): Route<M, Path, P, H, Q, B, R>
576
+ handler: Handler<M, `${Prefix}${Path}`, RequestSchema<M, `${Prefix}${Path}`, MH, P, MQ, B, R, C>>
577
+ ): Route<M, `${Prefix}${Path}`, P, MH, MQ, B, R, C>
327
578
  }
328
579
 
329
580
  export type StaticEndpointOptions = {
@@ -333,7 +584,7 @@ export type StaticEndpoint<P extends string = string, T extends string = string>
333
584
  path: P,
334
585
  target: T,
335
586
  options?: StaticEndpointOptions
336
- ) => Route<'get', P, {}, {}, {}, STBody, STResponse, T>
587
+ ) => Route<'get', P, {}, {}, {}, STBody, STResponse, {}, T>
337
588
 
338
589
  export class RequestError extends Error {
339
590
  status: number
@@ -358,6 +609,8 @@ export type ErrorHandler = (error: any, context: Context) => any
358
609
  export type RouteNode = {
359
610
  routes: { [K in Method]?: Route }
360
611
  param?: RouteNode
612
+ /** name of the first param registered on this node, e.g. 'id' for /user/:id */
613
+ paramName?: string
361
614
  children?: Record<string, RouteNode>
362
615
  }
363
616
 
@@ -369,15 +622,38 @@ export type Route<
369
622
  Q extends STQuery = STQuery,
370
623
  B extends STBody = STBody,
371
624
  R extends STResponse = STResponse,
625
+ C extends STCookies = STCookies,
372
626
  SP extends string = string,
373
627
  SR extends string = string,
374
628
  > = {
375
629
  method: M
376
630
  path: Path
377
- schema: RequestSchema<M, Path, H, P, Q, B, R>
378
- context: Context<M, Path, RequestSchema<M, Path, H, P, Q, B, R>>
379
- hooks: Hook<M, Path, RequestSchema<M, Path, H, P, Q, B, R>>[]
380
- handler: Handler<M, Path, RequestSchema<M, Path, H, P, Q, B, R>>
631
+ schema: RequestSchema<M, Path, H, P, Q, B, R, C>
632
+ hooks: Hook<M, Path, RequestSchema<M, Path, H, P, Q, B, R, C>>[]
633
+ handler: Handler<M, Path, RequestSchema<M, Path, H, P, Q, B, R, C>>
634
+ /**
635
+ * Hook/handler chain composed at registration — and recomposed if a later
636
+ * `middleware()` call matches the route — never per request. Runs matched
637
+ * middleware, then the hooks, then the handler and resolves to the handler's
638
+ * response — or a hook's short-circuit value.
639
+ */
640
+ composed: (context: Context<M, Path, RequestSchema<M, Path, H, P, Q, B, R, C>>) => Promise<any>
641
+ /**
642
+ * Matched middleware `beforeParse` hooks, composed at registration like
643
+ * {@link Route.composed}. Left `undefined` when no matched middleware fills
644
+ * the slot, so the request path skips the stage with a single check. Runs
645
+ * after the plugins' `onRoute` and before the body is read; a returned
646
+ * `Response` short-circuits the request.
647
+ */
648
+ composedPre?: (context: PreParseContext) => Promise<Response | void>
649
+ /**
650
+ * Matched middleware `afterHandle` hooks, composed at registration like
651
+ * {@link Route.composedPre}, and left `undefined` when no matched middleware
652
+ * fills the slot. Runs on the parsed `Response` — the error response
653
+ * included — ahead of the plugins' `afterHandle`; a returned `Response`
654
+ * replaces it.
655
+ */
656
+ composedPost?: (response: Response, context: Context, error?: unknown) => Promise<Response>
381
657
  static?: { path: SP; root: SR }
382
658
  }
383
659