@znight/http 0.1.2 → 0.1.4

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.
Files changed (46) hide show
  1. package/README.md +154 -35
  2. package/dist/compiler.d.mts +77 -2
  3. package/dist/compiler.mjs +259 -101
  4. package/dist/compiler.mjs.map +1 -1
  5. package/dist/express.mjs +3 -2
  6. package/dist/express.mjs.map +1 -1
  7. package/dist/fastify.mjs +200 -23
  8. package/dist/fastify.mjs.map +1 -1
  9. package/dist/hono.mjs +174 -43
  10. package/dist/hono.mjs.map +1 -1
  11. package/dist/index.d.mts +36 -4
  12. package/dist/index.mjs +1005 -257
  13. package/dist/index.mjs.map +1 -1
  14. package/dist/{node-Clm1Ia7T.mjs → node-DnYv_AMI.mjs} +4 -3
  15. package/dist/{node-Clm1Ia7T.mjs.map → node-DnYv_AMI.mjs.map} +1 -1
  16. package/dist/{node-io-CVnqpVNS.mjs → node-io-BkdRSQeg.mjs} +99 -60
  17. package/dist/{node-io-CVnqpVNS.mjs.map → node-io-BkdRSQeg.mjs.map} +1 -1
  18. package/dist/node.mjs +1 -1
  19. package/dist/utils-DIWdzVht.mjs +46 -0
  20. package/dist/utils-DIWdzVht.mjs.map +1 -0
  21. package/package.json +4 -4
  22. package/src/adapters/express.ts +4 -1
  23. package/src/adapters/fastify.ts +265 -25
  24. package/src/adapters/hono.ts +225 -58
  25. package/src/adapters/node-io.ts +128 -58
  26. package/src/adapters/node.ts +4 -1
  27. package/src/body.ts +31 -2
  28. package/src/coerce.ts +31 -0
  29. package/src/compiler/analyze.ts +3 -3
  30. package/src/compiler/generate.ts +127 -22
  31. package/src/compiler/index.ts +13 -0
  32. package/src/compiler/route-decorators.ts +45 -2
  33. package/src/compiler/route-groups.ts +216 -0
  34. package/src/compiler/validate.ts +15 -126
  35. package/src/context.ts +148 -78
  36. package/src/decorators.ts +19 -3
  37. package/src/index.ts +2 -1
  38. package/src/pipeline.ts +936 -177
  39. package/src/plugin-routes.ts +19 -0
  40. package/src/plugin.ts +7 -3
  41. package/src/response.ts +6 -8
  42. package/src/serialize.ts +48 -21
  43. package/src/utils.ts +25 -2
  44. package/src/validate.ts +28 -12
  45. package/dist/utils-DCbu3a4w.mjs +0 -27
  46. package/dist/utils-DCbu3a4w.mjs.map +0 -1
package/src/context.ts CHANGED
@@ -6,7 +6,7 @@ import type { HeaderValue } from './exceptions.ts'
6
6
  import type { MultipartResult } from './multipart.ts'
7
7
  import type { BodyPlan } from './uploads.ts'
8
8
 
9
- import { checkContentEncoding, hasBody, parseBody, parseMediaType } from './body.ts'
9
+ import { checkContentEncoding, hasBody, parseBodyBytes, parseMediaType } from './body.ts'
10
10
  import { DEFAULT_COOKIE_SETTINGS, missingSecrets, serializeWith, verifySigned } from './cookie-settings.ts'
11
11
  import { parseCookieHeader } from './cookies.ts'
12
12
  import { UnsupportedMediaTypeException } from './exceptions.ts'
@@ -182,6 +182,47 @@ export interface HttpContext {
182
182
  bodyStream(): ReadableStream<Uint8Array>
183
183
  }
184
184
 
185
+ /** What {@link RequestContext.of} needs to create a context. */
186
+ export interface RequestContextInit {
187
+ readonly requestId: RequestId
188
+ readonly adapter: string
189
+ /** `source.url` split (default: split here). */
190
+ readonly url?: RequestTarget
191
+ readonly route: HttpRouteInfo | undefined
192
+ readonly params: Readonly<Record<string, string>>
193
+ readonly body: BodyPlan
194
+ /** Client address and protocol (`trustProxy`); default: the adapter's `ip` and `encrypted`. */
195
+ readonly client?: RequestClient | undefined
196
+ /** Cookie defaults and signing key (plugin option `cookies`); default: secure defaults, no signing. */
197
+ readonly cookies?: CookieSettings
198
+ }
199
+
200
+ /** The client of a request behind trusted proxies (`trustProxy`). */
201
+ export interface RequestClient {
202
+ readonly ip: string | undefined
203
+ readonly protocol: 'http' | 'https'
204
+ }
205
+
206
+ /**
207
+ * The state of a request context that most requests never need (parsed query, `ctx.state`, body,
208
+ * request cookies, abort signal), created on first use: a context is created for every request.
209
+ */
210
+ class ContextState {
211
+ query: QueryRecord | undefined = undefined
212
+ state: Map<string | symbol, unknown> | undefined = undefined
213
+ body: unknown = undefined
214
+ files: Readonly<Record<string, readonly File[]>> | undefined = undefined
215
+ raw: Promise<Uint8Array> | undefined = undefined
216
+ streamed = false
217
+ /** Stops the body stream opened for the request, unless it was read to its end or cancelled. */
218
+ stopBody: (() => void) | undefined = undefined
219
+ parsed: Promise<unknown> | undefined = undefined
220
+ cookies: Readonly<Record<string, string>> | undefined = undefined
221
+ signed: Map<string, string | undefined> | undefined = undefined
222
+ abortReason: RequestAbortedError | undefined = undefined
223
+ abortController: AbortController | undefined = undefined
224
+ }
225
+
185
226
  /** Implementation of {@link HttpContext} used by the pipeline (internal). */
186
227
  export class RequestContext implements HttpContext {
187
228
  readonly requestId: RequestId
@@ -199,80 +240,93 @@ export class RequestContext implements HttpContext {
199
240
  readonly #source: AdapterRequest
200
241
  readonly #plan: BodyPlan
201
242
  readonly #rawQuery: string
202
- #query: QueryRecord | undefined
203
- #state: Map<string | symbol, unknown> | undefined
204
- #responseHeaders: Record<string, HeaderValue> | undefined
205
- #body: unknown
206
- #files: Readonly<Record<string, readonly File[]>> | undefined
207
- #raw: Promise<Uint8Array> | undefined
208
- #streamed = false
209
- /** Stops the body stream opened for the request, unless it was read to its end or cancelled. */
210
- #stopBody: (() => void) | undefined
211
- #parsed: Promise<unknown> | undefined
212
243
  readonly #cookieSettings: CookieSettings
213
- #cookies: Readonly<Record<string, string>> | undefined
214
- #signed: Map<string, string | undefined> | undefined
244
+ #responseHeaders: Record<string, HeaderValue> | undefined = undefined
215
245
  /** Cookies set on the response, by key (name, domain, path), with their `Set-Cookie` line. */
216
- #jar: Map<string, { readonly cookie: ResponseCookie; readonly line: string }> | undefined
246
+ #jar: Map<string, { readonly cookie: ResponseCookie; readonly line: string }> | undefined = undefined
217
247
  /** The response headers were handed to the adapter (cookies can no longer be set). */
218
248
  #committed = false
219
249
  /** The request is over: later aborts are ignored. */
220
250
  #completed = false
221
- #abortReason: RequestAbortedError | undefined
222
- #abortController: AbortController | undefined
251
+ #more: ContextState | undefined = undefined
223
252
 
253
+ /** `path` and `query`: `source.url` split; `client`: see {@link RequestContextInit.client}. */
224
254
  constructor(
225
255
  source: AdapterRequest,
226
- init: {
227
- readonly requestId: RequestId
228
- readonly adapter: string
229
- /** `source.url` split (default: split here). */
230
- readonly url?: RequestTarget
231
- readonly route: HttpRouteInfo | undefined
232
- readonly params: Readonly<Record<string, string>>
233
- readonly body: BodyPlan
234
- /** Client address and protocol (`trustProxy`); default: the adapter's `ip` and `encrypted`. */
235
- readonly client?: { readonly ip: string | undefined; readonly protocol: 'http' | 'https' } | undefined
236
- /** Cookie defaults and signing key (plugin option `cookies`); default: secure defaults, no signing. */
237
- readonly cookies?: CookieSettings
238
- },
256
+ requestId: RequestId,
257
+ adapter: string,
258
+ path: string,
259
+ query: string,
260
+ route: HttpRouteInfo | undefined,
261
+ params: Readonly<Record<string, string>>,
262
+ body: BodyPlan,
263
+ cookies: CookieSettings,
264
+ client: RequestClient | undefined,
239
265
  ) {
240
266
  this.#source = source
241
- this.#plan = init.body
242
- this.#cookieSettings = init.cookies ?? DEFAULT_COOKIE_SETTINGS
243
- const { path, query } = init.url ?? splitUrl(source.url)
267
+ this.#plan = body
268
+ this.#cookieSettings = cookies
244
269
  this.#rawQuery = query
245
- this.requestId = init.requestId
270
+ this.requestId = requestId
246
271
  this.method = source.method
247
272
  this.url = source.url
248
273
  this.path = path
249
- this.params = init.params
274
+ this.params = params
250
275
  this.headers = source.headers
251
- this.route = init.route
276
+ this.route = route
252
277
  this.request = source.raw
253
278
  this.response = source.rawResponse
254
- this.adapter = init.adapter
255
- const connection = source.encrypted === true ? 'https' : 'http'
256
- this.ip = init.client === undefined ? source.ip : init.client.ip
257
- this.protocol = init.client === undefined ? connection : init.client.protocol
279
+ this.adapter = adapter
280
+ if (client === undefined) {
281
+ this.ip = source.ip
282
+ this.protocol = source.encrypted === true ? 'https' : 'http'
283
+ } else {
284
+ this.ip = client.ip
285
+ this.protocol = client.protocol
286
+ }
287
+ }
288
+
289
+ /** A context from named settings (defaults: `source.url` split, secure cookie defaults, the adapter's client). */
290
+ static of(source: AdapterRequest, init: RequestContextInit): RequestContext {
291
+ const { path, query } = init.url ?? splitUrl(source.url)
292
+ return new RequestContext(
293
+ source,
294
+ init.requestId,
295
+ init.adapter,
296
+ path,
297
+ query,
298
+ init.route,
299
+ init.params,
300
+ init.body,
301
+ init.cookies ?? DEFAULT_COOKIE_SETTINGS,
302
+ init.client,
303
+ )
304
+ }
305
+
306
+ /** The state created on first use. */
307
+ get #extra(): ContextState {
308
+ this.#more ??= new ContextState()
309
+ return this.#more
258
310
  }
259
311
 
260
312
  get query(): Readonly<QueryRecord> {
261
- this.#query ??= parseQuery(this.#rawQuery)
262
- return this.#query
313
+ const state = this.#extra
314
+ state.query ??= parseQuery(this.#rawQuery)
315
+ return state.query
263
316
  }
264
317
 
265
318
  get state(): Map<string | symbol, unknown> {
266
- this.#state ??= new Map()
267
- return this.#state
319
+ const state = this.#extra
320
+ state.state ??= new Map()
321
+ return state.state
268
322
  }
269
323
 
270
324
  get body(): unknown {
271
- return this.#body
325
+ return this.#more?.body
272
326
  }
273
327
 
274
328
  get files(): Readonly<Record<string, readonly File[]>> {
275
- return this.#files ?? NO_FILES
329
+ return this.#more?.files ?? NO_FILES
276
330
  }
277
331
 
278
332
  /** Maximum body size of the route, in bytes. */
@@ -307,17 +361,19 @@ export class RequestContext implements HttpContext {
307
361
  }
308
362
 
309
363
  get cookies(): Readonly<Record<string, string>> {
310
- this.#cookies ??= parseCookieHeader(this.headers['cookie'])
311
- return this.#cookies
364
+ const state = this.#extra
365
+ state.cookies ??= parseCookieHeader(this.headers['cookie'])
366
+ return state.cookies
312
367
  }
313
368
 
314
369
  signedCookie(name: string): string | undefined {
315
370
  const signer = this.#cookieSettings.signer
316
371
  if (signer === undefined) throw missingSecrets(`Cannot read the signed cookie ${JSON.stringify(String(name))}`)
317
- this.#signed ??= new Map()
318
- if (this.#signed.has(name)) return this.#signed.get(name)
372
+ const state = this.#extra
373
+ state.signed ??= new Map()
374
+ if (state.signed.has(name)) return state.signed.get(name)
319
375
  const value = verifySigned(signer, name, this.cookies[name])
320
- this.#signed.set(name, value)
376
+ state.signed.set(name, value)
321
377
  return value
322
378
  }
323
379
 
@@ -333,6 +389,11 @@ export class RequestContext implements HttpContext {
333
389
  return this.#jar === undefined ? NO_COOKIES : [...this.#jar.values()].map(entry => entry.cookie)
334
390
  }
335
391
 
392
+ /** Whether {@link setCookie} / {@link clearCookie} were called (internal). */
393
+ get hasCookies(): boolean {
394
+ return this.#jar !== undefined
395
+ }
396
+
336
397
  /** The `Set-Cookie` lines of {@link setCookie} / {@link clearCookie}, with their keys, in order (internal). */
337
398
  get cookieLines(): readonly SerializedCookie[] {
338
399
  return this.#jar === undefined ? NO_LINES : [...this.#jar].map(([key, entry]) => ({ key, line: entry.line }))
@@ -359,18 +420,19 @@ export class RequestContext implements HttpContext {
359
420
  }
360
421
 
361
422
  get signal(): AbortSignal {
362
- if (this.#abortController === undefined) {
423
+ const state = this.#extra
424
+ if (state.abortController === undefined) {
363
425
  const controller = new AbortController()
364
- this.#abortController = controller
365
- if (this.#abortReason !== undefined) controller.abort(this.#abortReason)
426
+ state.abortController = controller
427
+ if (state.abortReason !== undefined) controller.abort(state.abortReason)
366
428
  else if (!this.#completed) this.#source.onAbort?.(() => this.abort('client'))
367
429
  }
368
- return this.#abortController.signal
430
+ return state.abortController.signal
369
431
  }
370
432
 
371
433
  /** Whether {@link signal} aborted (or will be created aborted). */
372
434
  get aborted(): boolean {
373
- return this.#abortReason !== undefined
435
+ return this.#more?.abortReason !== undefined
374
436
  }
375
437
 
376
438
  /**
@@ -378,10 +440,11 @@ export class RequestContext implements HttpContext {
378
440
  * never once the request is over ({@link complete}).
379
441
  */
380
442
  abort(kind: RequestAbortKind): void {
381
- if (this.#abortReason !== undefined || this.#completed) return
443
+ if (this.#more?.abortReason !== undefined || this.#completed) return
382
444
  const reason = new RequestAbortedError(kind)
383
- this.#abortReason = reason
384
- this.#abortController?.abort(reason)
445
+ const state = this.#extra
446
+ state.abortReason = reason
447
+ state.abortController?.abort(reason)
385
448
  }
386
449
 
387
450
  /** The response headers are handed to the adapter: cookies can no longer be set (internal). */
@@ -395,16 +458,18 @@ export class RequestContext implements HttpContext {
395
458
  }
396
459
 
397
460
  rawBody(): Promise<Uint8Array> {
398
- if (this.#raw === undefined) {
399
- if (this.#streamed) return Promise.reject(alreadyStreamed())
400
- this.#raw = this.#source.readBody(this.#plan.limit)
461
+ const state = this.#extra
462
+ if (state.raw === undefined) {
463
+ if (state.streamed) return Promise.reject(alreadyStreamed())
464
+ state.raw = this.#source.readBody(this.#plan.limit)
401
465
  }
402
- return this.#raw
466
+ return state.raw
403
467
  }
404
468
 
405
469
  bodyStream(): ReadableStream<Uint8Array> {
406
- if (this.#raw !== undefined || this.#streamed) throw alreadyStreamed()
407
- this.#streamed = true
470
+ const state = this.#extra
471
+ if (state.raw !== undefined || state.streamed) throw alreadyStreamed()
472
+ state.streamed = true
408
473
  return this.#openBodyStream()
409
474
  }
410
475
 
@@ -414,19 +479,22 @@ export class RequestContext implements HttpContext {
414
479
  * adapter discard the rest of the body, so that the connection can serve the next request.
415
480
  */
416
481
  releaseBody(): void {
417
- const stop = this.#stopBody
418
- this.#stopBody = undefined
482
+ const state = this.#more
483
+ if (state === undefined) return
484
+ const stop = state.stopBody
485
+ state.stopBody = undefined
419
486
  stop?.()
420
487
  }
421
488
 
422
489
  /** The body as a stream (see {@link releaseBody}). */
423
490
  #openBodyStream(): ReadableStream<Uint8Array> {
424
491
  const reader = openBodyStream(this.#source, this.#plan.limit).getReader()
492
+ const state = this.#extra
425
493
  let controller: ReadableStreamDefaultController<Uint8Array> | undefined
426
494
  const settle = (): void => {
427
- this.#stopBody = undefined
495
+ state.stopBody = undefined
428
496
  }
429
- this.#stopBody = (): void => {
497
+ state.stopBody = (): void => {
430
498
  reader.cancel().catch(() => undefined)
431
499
  try {
432
500
  controller?.error(new Error('The request is over: its body can no longer be read'))
@@ -464,27 +532,29 @@ export class RequestContext implements HttpContext {
464
532
  }
465
533
 
466
534
  parseBody(): Promise<unknown> {
467
- this.#parsed ??= this.#parse()
468
- return this.#parsed
535
+ const state = this.#extra
536
+ state.parsed ??= this.#parse()
537
+ return state.parsed
469
538
  }
470
539
 
471
540
  async #parse(): Promise<unknown> {
472
541
  const headers = this.headers
473
542
  const contentType = headers['content-type']
474
543
  const media = parseMediaType(contentType)
544
+ const state = this.#extra
475
545
  if (media?.type === 'multipart/form-data') {
476
546
  checkContentEncoding(headers['content-encoding'])
477
547
  const policy = this.#plan.multipart
478
548
  let result: MultipartResult
479
- if (this.#raw === undefined) {
480
- if (this.#streamed) throw alreadyStreamed()
481
- this.#streamed = true
549
+ if (state.raw === undefined) {
550
+ if (state.streamed) throw alreadyStreamed()
551
+ state.streamed = true
482
552
  result = await readMultipart(this.#openBodyStream(), contentType as string, policy)
483
553
  } else {
484
- result = parseMultipart(await this.#raw, contentType as string, policy)
554
+ result = parseMultipart(await state.raw, contentType as string, policy)
485
555
  }
486
- this.#files = result.files
487
- this.#body = result.fields
556
+ state.files = result.files
557
+ state.body = result.fields
488
558
  return result.fields
489
559
  }
490
560
  if (this.#plan.upload) {
@@ -497,8 +567,8 @@ export class RequestContext implements HttpContext {
497
567
  )
498
568
  }
499
569
  const bytes = await this.rawBody()
500
- const body = await parseBody(bytes, contentType, headers['content-encoding'])
501
- this.#body = body
570
+ const body = parseBodyBytes(bytes, contentType, headers['content-encoding'])
571
+ state.body = body
502
572
  return body
503
573
  }
504
574
  }
package/src/decorators.ts CHANGED
@@ -398,14 +398,30 @@ export interface RouteMetaEntry<T = unknown> {
398
398
  readonly required?: boolean
399
399
  }
400
400
 
401
+ /**
402
+ * Anything that carries effective route metadata: the {@link HttpContext} of a request, and the
403
+ * contexts of other transports that reuse route decorators (a GraphQL resolver context, a WebSocket
404
+ * gateway context). {@link RouteDecorator.get} reads `route.meta[key]`; nothing else is required.
405
+ *
406
+ * ```ts
407
+ * const carrier: RouteMetaCarrier = { route: { meta: { roles: ['admin'] } } }
408
+ * Roles.get(carrier) // ['admin']
409
+ * ```
410
+ */
411
+ export interface RouteMetaCarrier {
412
+ /** The matched route (or field, or handler), `undefined` when there is none. */
413
+ readonly route?: { readonly meta: Readonly<Record<string, unknown>> } | undefined
414
+ }
415
+
401
416
  /** A user-defined route metadata decorator created with {@link createRouteDecorator}. */
402
417
  export interface RouteDecorator<T> extends RouteDecoratorInfo {
403
418
  (...args: RouteDecoratorArgs<T>): ClassMarker & MethodMarker
404
419
  /**
405
420
  * The value of the current route, if set. Reads the effective metadata: the route's decision of
406
- * a group wins over the controller's.
421
+ * a group wins over the controller's. Accepts any {@link RouteMetaCarrier}: an
422
+ * {@link HttpContext}, or the context of another transport exposing `route.meta`.
407
423
  */
408
- get(ctx: HttpContext): T | undefined
424
+ get(ctx: RouteMetaCarrier): T | undefined
409
425
  /**
410
426
  * The same metadata as data (`{ key, value, group, required }`), for routes that are not compiled
411
427
  * controllers (plugin routes: `OpenApiPlugin.configure({ meta: [Public.meta()] })`). The value is
@@ -479,7 +495,7 @@ export function createRouteDecorator<T = true>(key: string, options?: RouteDecor
479
495
  key,
480
496
  group,
481
497
  required,
482
- get: (ctx: HttpContext): T | undefined => ctx.route?.meta[key] as T | undefined,
498
+ get: (ctx: RouteMetaCarrier): T | undefined => ctx.route?.meta[key] as T | undefined,
483
499
  meta: (...args: RouteDecoratorArgs<T>): RouteMetaEntry<T> =>
484
500
  Object.freeze({ key, value: (args.length === 0 ? true : args[0]) as T, group, required }),
485
501
  })
package/src/index.ts CHANGED
@@ -92,6 +92,7 @@ export {
92
92
  type RouteDecoratorArgs,
93
93
  type RouteDecoratorInfo,
94
94
  type RouteDecoratorOptions,
95
+ type RouteMetaCarrier,
95
96
  type RouteMetaEntry,
96
97
  SignedCookie,
97
98
  Sse,
@@ -182,4 +183,4 @@ export {
182
183
  type Uploads,
183
184
  uploads,
184
185
  } from './uploads.ts'
185
- export { validate } from './validate.ts'
186
+ export { isThenable, validate } from './validate.ts'