@znight/http 0.1.1 → 0.1.3

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 (47) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +83 -32
  3. package/dist/{adapter-CGSceVEN.d.mts → adapter-J0UXFnNM.d.mts} +7 -1
  4. package/dist/compiler.d.mts +1 -1
  5. package/dist/compiler.mjs +82 -13
  6. package/dist/compiler.mjs.map +1 -1
  7. package/dist/express.d.mts +1 -1
  8. package/dist/express.mjs +3 -2
  9. package/dist/express.mjs.map +1 -1
  10. package/dist/fastify.d.mts +1 -1
  11. package/dist/fastify.mjs +254 -42
  12. package/dist/fastify.mjs.map +1 -1
  13. package/dist/hono.d.mts +1 -1
  14. package/dist/hono.mjs +272 -67
  15. package/dist/hono.mjs.map +1 -1
  16. package/dist/index.d.mts +10 -2
  17. package/dist/index.mjs +930 -278
  18. package/dist/index.mjs.map +1 -1
  19. package/dist/{node-BUnkdSsa.mjs → node-DnYv_AMI.mjs} +4 -3
  20. package/dist/{node-BUnkdSsa.mjs.map → node-DnYv_AMI.mjs.map} +1 -1
  21. package/dist/{node-io-BY1ZDLRQ.mjs → node-io-BkdRSQeg.mjs} +151 -86
  22. package/dist/{node-io-BY1ZDLRQ.mjs.map → node-io-BkdRSQeg.mjs.map} +1 -1
  23. package/dist/node.d.mts +1 -1
  24. package/dist/node.mjs +1 -1
  25. package/dist/utils-DIWdzVht.mjs +46 -0
  26. package/dist/utils-DIWdzVht.mjs.map +1 -0
  27. package/package.json +4 -4
  28. package/src/adapter.ts +6 -0
  29. package/src/adapters/express.ts +4 -1
  30. package/src/adapters/fastify.ts +329 -44
  31. package/src/adapters/hono.ts +341 -86
  32. package/src/adapters/node-io.ts +185 -82
  33. package/src/adapters/node.ts +4 -1
  34. package/src/body.ts +31 -2
  35. package/src/coerce.ts +31 -0
  36. package/src/compiler/generate.ts +127 -22
  37. package/src/context.ts +151 -78
  38. package/src/index.ts +1 -1
  39. package/src/pipeline.ts +809 -208
  40. package/src/plugin.ts +10 -4
  41. package/src/query.ts +7 -1
  42. package/src/response.ts +6 -8
  43. package/src/serialize.ts +83 -16
  44. package/src/utils.ts +25 -2
  45. package/src/validate.ts +28 -12
  46. package/dist/utils-DCbu3a4w.mjs +0 -27
  47. package/dist/utils-DCbu3a4w.mjs.map +0 -1
package/src/context.ts CHANGED
@@ -6,12 +6,12 @@ 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'
13
13
  import { parseMultipart, readMultipart } from './multipart.ts'
14
- import { parseQuery, type QueryRecord, splitUrl } from './query.ts'
14
+ import { parseQuery, type QueryRecord, type RequestTarget, splitUrl } from './query.ts'
15
15
  import { emptyRecord } from './utils.ts'
16
16
 
17
17
  declare const requestIdBrand: unique symbol
@@ -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,78 +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
- readonly route: HttpRouteInfo | undefined
230
- readonly params: Readonly<Record<string, string>>
231
- readonly body: BodyPlan
232
- /** Client address and protocol (`trustProxy`); default: the adapter's `ip` and `encrypted`. */
233
- readonly client?: { readonly ip: string | undefined; readonly protocol: 'http' | 'https' }
234
- /** Cookie defaults and signing key (plugin option `cookies`); default: secure defaults, no signing. */
235
- readonly cookies?: CookieSettings
236
- },
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,
237
265
  ) {
238
266
  this.#source = source
239
- this.#plan = init.body
240
- this.#cookieSettings = init.cookies ?? DEFAULT_COOKIE_SETTINGS
241
- const { path, query } = splitUrl(source.url)
267
+ this.#plan = body
268
+ this.#cookieSettings = cookies
242
269
  this.#rawQuery = query
243
- this.requestId = init.requestId
270
+ this.requestId = requestId
244
271
  this.method = source.method
245
272
  this.url = source.url
246
273
  this.path = path
247
- this.params = init.params
274
+ this.params = params
248
275
  this.headers = source.headers
249
- this.route = init.route
276
+ this.route = route
250
277
  this.request = source.raw
251
278
  this.response = source.rawResponse
252
- this.adapter = init.adapter
253
- const connection = source.encrypted === true ? 'https' : 'http'
254
- this.ip = init.client === undefined ? source.ip : init.client.ip
255
- 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
256
310
  }
257
311
 
258
312
  get query(): Readonly<QueryRecord> {
259
- this.#query ??= parseQuery(this.#rawQuery)
260
- return this.#query
313
+ const state = this.#extra
314
+ state.query ??= parseQuery(this.#rawQuery)
315
+ return state.query
261
316
  }
262
317
 
263
318
  get state(): Map<string | symbol, unknown> {
264
- this.#state ??= new Map()
265
- return this.#state
319
+ const state = this.#extra
320
+ state.state ??= new Map()
321
+ return state.state
266
322
  }
267
323
 
268
324
  get body(): unknown {
269
- return this.#body
325
+ return this.#more?.body
270
326
  }
271
327
 
272
328
  get files(): Readonly<Record<string, readonly File[]>> {
273
- return this.#files ?? NO_FILES
329
+ return this.#more?.files ?? NO_FILES
274
330
  }
275
331
 
276
332
  /** Maximum body size of the route, in bytes. */
@@ -305,17 +361,19 @@ export class RequestContext implements HttpContext {
305
361
  }
306
362
 
307
363
  get cookies(): Readonly<Record<string, string>> {
308
- this.#cookies ??= parseCookieHeader(this.headers['cookie'])
309
- return this.#cookies
364
+ const state = this.#extra
365
+ state.cookies ??= parseCookieHeader(this.headers['cookie'])
366
+ return state.cookies
310
367
  }
311
368
 
312
369
  signedCookie(name: string): string | undefined {
313
370
  const signer = this.#cookieSettings.signer
314
371
  if (signer === undefined) throw missingSecrets(`Cannot read the signed cookie ${JSON.stringify(String(name))}`)
315
- this.#signed ??= new Map()
316
- 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)
317
375
  const value = verifySigned(signer, name, this.cookies[name])
318
- this.#signed.set(name, value)
376
+ state.signed.set(name, value)
319
377
  return value
320
378
  }
321
379
 
@@ -331,9 +389,14 @@ export class RequestContext implements HttpContext {
331
389
  return this.#jar === undefined ? NO_COOKIES : [...this.#jar.values()].map(entry => entry.cookie)
332
390
  }
333
391
 
392
+ /** Whether {@link setCookie} / {@link clearCookie} were called (internal). */
393
+ get hasCookies(): boolean {
394
+ return this.#jar !== undefined
395
+ }
396
+
334
397
  /** The `Set-Cookie` lines of {@link setCookie} / {@link clearCookie}, with their keys, in order (internal). */
335
398
  get cookieLines(): readonly SerializedCookie[] {
336
- return this.#jar === undefined ? [] : [...this.#jar].map(([key, entry]) => ({ key, line: entry.line }))
399
+ return this.#jar === undefined ? NO_LINES : [...this.#jar].map(([key, entry]) => ({ key, line: entry.line }))
337
400
  }
338
401
 
339
402
  #addCookie(cookie: ResponseCookie): void {
@@ -357,18 +420,19 @@ export class RequestContext implements HttpContext {
357
420
  }
358
421
 
359
422
  get signal(): AbortSignal {
360
- if (this.#abortController === undefined) {
423
+ const state = this.#extra
424
+ if (state.abortController === undefined) {
361
425
  const controller = new AbortController()
362
- this.#abortController = controller
363
- if (this.#abortReason !== undefined) controller.abort(this.#abortReason)
426
+ state.abortController = controller
427
+ if (state.abortReason !== undefined) controller.abort(state.abortReason)
364
428
  else if (!this.#completed) this.#source.onAbort?.(() => this.abort('client'))
365
429
  }
366
- return this.#abortController.signal
430
+ return state.abortController.signal
367
431
  }
368
432
 
369
433
  /** Whether {@link signal} aborted (or will be created aborted). */
370
434
  get aborted(): boolean {
371
- return this.#abortReason !== undefined
435
+ return this.#more?.abortReason !== undefined
372
436
  }
373
437
 
374
438
  /**
@@ -376,10 +440,11 @@ export class RequestContext implements HttpContext {
376
440
  * never once the request is over ({@link complete}).
377
441
  */
378
442
  abort(kind: RequestAbortKind): void {
379
- if (this.#abortReason !== undefined || this.#completed) return
443
+ if (this.#more?.abortReason !== undefined || this.#completed) return
380
444
  const reason = new RequestAbortedError(kind)
381
- this.#abortReason = reason
382
- this.#abortController?.abort(reason)
445
+ const state = this.#extra
446
+ state.abortReason = reason
447
+ state.abortController?.abort(reason)
383
448
  }
384
449
 
385
450
  /** The response headers are handed to the adapter: cookies can no longer be set (internal). */
@@ -393,16 +458,18 @@ export class RequestContext implements HttpContext {
393
458
  }
394
459
 
395
460
  rawBody(): Promise<Uint8Array> {
396
- if (this.#raw === undefined) {
397
- if (this.#streamed) return Promise.reject(alreadyStreamed())
398
- 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)
399
465
  }
400
- return this.#raw
466
+ return state.raw
401
467
  }
402
468
 
403
469
  bodyStream(): ReadableStream<Uint8Array> {
404
- if (this.#raw !== undefined || this.#streamed) throw alreadyStreamed()
405
- this.#streamed = true
470
+ const state = this.#extra
471
+ if (state.raw !== undefined || state.streamed) throw alreadyStreamed()
472
+ state.streamed = true
406
473
  return this.#openBodyStream()
407
474
  }
408
475
 
@@ -412,19 +479,22 @@ export class RequestContext implements HttpContext {
412
479
  * adapter discard the rest of the body, so that the connection can serve the next request.
413
480
  */
414
481
  releaseBody(): void {
415
- const stop = this.#stopBody
416
- this.#stopBody = undefined
482
+ const state = this.#more
483
+ if (state === undefined) return
484
+ const stop = state.stopBody
485
+ state.stopBody = undefined
417
486
  stop?.()
418
487
  }
419
488
 
420
489
  /** The body as a stream (see {@link releaseBody}). */
421
490
  #openBodyStream(): ReadableStream<Uint8Array> {
422
491
  const reader = openBodyStream(this.#source, this.#plan.limit).getReader()
492
+ const state = this.#extra
423
493
  let controller: ReadableStreamDefaultController<Uint8Array> | undefined
424
494
  const settle = (): void => {
425
- this.#stopBody = undefined
495
+ state.stopBody = undefined
426
496
  }
427
- this.#stopBody = (): void => {
497
+ state.stopBody = (): void => {
428
498
  reader.cancel().catch(() => undefined)
429
499
  try {
430
500
  controller?.error(new Error('The request is over: its body can no longer be read'))
@@ -462,27 +532,29 @@ export class RequestContext implements HttpContext {
462
532
  }
463
533
 
464
534
  parseBody(): Promise<unknown> {
465
- this.#parsed ??= this.#parse()
466
- return this.#parsed
535
+ const state = this.#extra
536
+ state.parsed ??= this.#parse()
537
+ return state.parsed
467
538
  }
468
539
 
469
540
  async #parse(): Promise<unknown> {
470
541
  const headers = this.headers
471
542
  const contentType = headers['content-type']
472
543
  const media = parseMediaType(contentType)
544
+ const state = this.#extra
473
545
  if (media?.type === 'multipart/form-data') {
474
546
  checkContentEncoding(headers['content-encoding'])
475
547
  const policy = this.#plan.multipart
476
548
  let result: MultipartResult
477
- if (this.#raw === undefined) {
478
- if (this.#streamed) throw alreadyStreamed()
479
- this.#streamed = true
549
+ if (state.raw === undefined) {
550
+ if (state.streamed) throw alreadyStreamed()
551
+ state.streamed = true
480
552
  result = await readMultipart(this.#openBodyStream(), contentType as string, policy)
481
553
  } else {
482
- result = parseMultipart(await this.#raw, contentType as string, policy)
554
+ result = parseMultipart(await state.raw, contentType as string, policy)
483
555
  }
484
- this.#files = result.files
485
- this.#body = result.fields
556
+ state.files = result.files
557
+ state.body = result.fields
486
558
  return result.fields
487
559
  }
488
560
  if (this.#plan.upload) {
@@ -495,8 +567,8 @@ export class RequestContext implements HttpContext {
495
567
  )
496
568
  }
497
569
  const bytes = await this.rawBody()
498
- const body = await parseBody(bytes, contentType, headers['content-encoding'])
499
- this.#body = body
570
+ const body = parseBodyBytes(bytes, contentType, headers['content-encoding'])
571
+ state.body = body
500
572
  return body
501
573
  }
502
574
  }
@@ -530,4 +602,5 @@ function withoutLifetime(options: ClearCookieOptions): ClearCookieOptions {
530
602
 
531
603
  const EMPTY_HEADERS: Readonly<Record<string, HeaderValue>> = Object.freeze({})
532
604
  const NO_COOKIES: readonly ResponseCookie[] = Object.freeze([])
605
+ const NO_LINES: readonly SerializedCookie[] = Object.freeze([])
533
606
  const NO_FILES: Readonly<Record<string, readonly File[]>> = Object.freeze(emptyRecord<readonly File[]>())
package/src/index.ts CHANGED
@@ -182,4 +182,4 @@ export {
182
182
  type Uploads,
183
183
  uploads,
184
184
  } from './uploads.ts'
185
- export { validate } from './validate.ts'
185
+ export { isThenable, validate } from './validate.ts'