@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.
- package/LICENSE +1 -1
- package/README.md +83 -32
- package/dist/{adapter-CGSceVEN.d.mts → adapter-J0UXFnNM.d.mts} +7 -1
- package/dist/compiler.d.mts +1 -1
- package/dist/compiler.mjs +82 -13
- package/dist/compiler.mjs.map +1 -1
- package/dist/express.d.mts +1 -1
- package/dist/express.mjs +3 -2
- package/dist/express.mjs.map +1 -1
- package/dist/fastify.d.mts +1 -1
- package/dist/fastify.mjs +254 -42
- package/dist/fastify.mjs.map +1 -1
- package/dist/hono.d.mts +1 -1
- package/dist/hono.mjs +272 -67
- package/dist/hono.mjs.map +1 -1
- package/dist/index.d.mts +10 -2
- package/dist/index.mjs +930 -278
- package/dist/index.mjs.map +1 -1
- package/dist/{node-BUnkdSsa.mjs → node-DnYv_AMI.mjs} +4 -3
- package/dist/{node-BUnkdSsa.mjs.map → node-DnYv_AMI.mjs.map} +1 -1
- package/dist/{node-io-BY1ZDLRQ.mjs → node-io-BkdRSQeg.mjs} +151 -86
- package/dist/{node-io-BY1ZDLRQ.mjs.map → node-io-BkdRSQeg.mjs.map} +1 -1
- package/dist/node.d.mts +1 -1
- package/dist/node.mjs +1 -1
- package/dist/utils-DIWdzVht.mjs +46 -0
- package/dist/utils-DIWdzVht.mjs.map +1 -0
- package/package.json +4 -4
- package/src/adapter.ts +6 -0
- package/src/adapters/express.ts +4 -1
- package/src/adapters/fastify.ts +329 -44
- package/src/adapters/hono.ts +341 -86
- package/src/adapters/node-io.ts +185 -82
- package/src/adapters/node.ts +4 -1
- package/src/body.ts +31 -2
- package/src/coerce.ts +31 -0
- package/src/compiler/generate.ts +127 -22
- package/src/context.ts +151 -78
- package/src/index.ts +1 -1
- package/src/pipeline.ts +809 -208
- package/src/plugin.ts +10 -4
- package/src/query.ts +7 -1
- package/src/response.ts +6 -8
- package/src/serialize.ts +83 -16
- package/src/utils.ts +25 -2
- package/src/validate.ts +28 -12
- package/dist/utils-DCbu3a4w.mjs +0 -27
- 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,
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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 =
|
|
240
|
-
this.#cookieSettings =
|
|
241
|
-
const { path, query } = splitUrl(source.url)
|
|
267
|
+
this.#plan = body
|
|
268
|
+
this.#cookieSettings = cookies
|
|
242
269
|
this.#rawQuery = query
|
|
243
|
-
this.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 =
|
|
274
|
+
this.params = params
|
|
248
275
|
this.headers = source.headers
|
|
249
|
-
this.route =
|
|
276
|
+
this.route = route
|
|
250
277
|
this.request = source.raw
|
|
251
278
|
this.response = source.rawResponse
|
|
252
|
-
this.adapter =
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
260
|
-
|
|
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
|
-
|
|
265
|
-
|
|
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
|
-
|
|
309
|
-
|
|
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
|
-
|
|
316
|
-
|
|
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
|
-
|
|
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 ?
|
|
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
|
-
|
|
423
|
+
const state = this.#extra
|
|
424
|
+
if (state.abortController === undefined) {
|
|
361
425
|
const controller = new AbortController()
|
|
362
|
-
|
|
363
|
-
if (
|
|
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
|
|
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
|
-
|
|
382
|
-
|
|
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
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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
|
|
466
|
+
return state.raw
|
|
401
467
|
}
|
|
402
468
|
|
|
403
469
|
bodyStream(): ReadableStream<Uint8Array> {
|
|
404
|
-
|
|
405
|
-
|
|
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
|
|
416
|
-
|
|
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
|
-
|
|
495
|
+
state.stopBody = undefined
|
|
426
496
|
}
|
|
427
|
-
|
|
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
|
-
|
|
466
|
-
|
|
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 (
|
|
478
|
-
if (
|
|
479
|
-
|
|
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
|
|
554
|
+
result = parseMultipart(await state.raw, contentType as string, policy)
|
|
483
555
|
}
|
|
484
|
-
|
|
485
|
-
|
|
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 =
|
|
499
|
-
|
|
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