@znight/http 0.1.1 → 0.1.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@znight/http",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Znight HTTP plugin: controllers, routing, guards, interceptors, validation with Fastify, Express or Hono",
5
5
  "license": "MIT",
6
6
  "author": "Ugho STEPHAN",
@@ -79,7 +79,7 @@
79
79
  "access": "public"
80
80
  },
81
81
  "dependencies": {
82
- "@znight/core": "0.1.1"
82
+ "@znight/core": "0.1.2"
83
83
  },
84
84
  "peerDependencies": {
85
85
  "@hono/node-server": "^2.1.5",
@@ -87,7 +87,7 @@
87
87
  "fastify": "^5.12.7",
88
88
  "hono": "^4.13.14",
89
89
  "light-my-request": "^6.8.0",
90
- "@znight/compiler": "^0.1.1"
90
+ "@znight/compiler": "^0.1.2"
91
91
  },
92
92
  "peerDependenciesMeta": {
93
93
  "@hono/node-server": {
@@ -118,7 +118,7 @@
118
118
  "valibot": "1.5.0",
119
119
  "zod": "4.6.7",
120
120
  "light-my-request": "6.8.0",
121
- "@znight/compiler": "0.1.1"
121
+ "@znight/compiler": "0.1.2"
122
122
  },
123
123
  "znight": {
124
124
  "apiVersion": 1,
package/src/adapter.ts CHANGED
@@ -81,6 +81,12 @@ export interface AdapterReply {
81
81
  readonly headers: Readonly<Record<string, HeaderValue>>
82
82
  /** `null` for HEAD requests and bodyless statuses (headers such as `content-length` are kept). */
83
83
  readonly body: ReplyBody
84
+ /**
85
+ * The text `body` holds encoded as UTF-8, for a body serialised from a string (JSON, text).
86
+ * Adapters writing strings MAY send it instead of `body`, which writes the same bytes: Node.js then
87
+ * writes the headers and the body at once (bytes take a second write).
88
+ */
89
+ readonly text?: string
84
90
  /**
85
91
  * The `Blob` (file) that `body` streams, for adapters able to send it natively (`sendfile`, exact
86
92
  * length: `new Response(blob)` on `Bun.serve()`). An adapter sending it instead of reading `body`
@@ -17,6 +17,7 @@ import type {
17
17
  InjectStreamRequest,
18
18
  InjectStreamResponse,
19
19
  } from '../adapter.ts'
20
+ import type { HeaderValue } from '../exceptions.ts'
20
21
 
21
22
  import { Readable } from 'node:stream'
22
23
  import Fastify, {
@@ -189,14 +190,18 @@ class FastifyHttpAdapter implements FastifyAdapter {
189
190
  const native = lowerCaseHeaders(reply.getHeaders())
190
191
  const headers = { ...native, ...withNativeCookies(native['set-cookie'], result.headers) }
191
192
  reply.hijack()
192
- void writeNodeReply(reply.raw, { ...result, headers })
193
+ writeNodeReply(reply.raw, { ...result, headers })
193
194
  return reply
194
195
  }
195
196
  reply.code(result.status)
196
197
  for (const name of Object.keys(result.headers)) reply.header(name, result.headers[name])
197
198
  const body = result.body
198
199
  if (body === null) return reply.send()
199
- if (body instanceof Uint8Array) return reply.send(Buffer.from(body.buffer, body.byteOffset, body.byteLength))
200
+ if (body instanceof Uint8Array) {
201
+ // a string is written with the headers at once (bytes take a second write)
202
+ if (result.text !== undefined && sendsTextAsIs(result.headers['content-type'])) return reply.send(result.text)
203
+ return reply.send(Buffer.isBuffer(body) ? body : Buffer.from(body.buffer, body.byteOffset, body.byteLength))
204
+ }
200
205
  const stream = body instanceof Blob ? body.stream() : body
201
206
  return reply.send(Readable.fromWeb(stream as import('node:stream/web').ReadableStream))
202
207
  }
@@ -236,6 +241,20 @@ class FastifyHttpAdapter implements FastifyAdapter {
236
241
  }
237
242
  }
238
243
 
244
+ /** The JSON content type of the plugin (with a charset). */
245
+ const JSON_TYPE = 'application/json; charset=utf-8'
246
+
247
+ /**
248
+ * Whether Fastify sends a string with this content type unchanged: it adds a charset to a JSON
249
+ * type without one, and a content type to a string without one.
250
+ */
251
+ function sendsTextAsIs(type: HeaderValue | undefined): boolean {
252
+ if (typeof type !== 'string') return false
253
+ if (type === JSON_TYPE) return true
254
+ const semicolon = type.indexOf(';')
255
+ return !(semicolon === -1 ? type : type.slice(0, semicolon)).toLowerCase().includes('json')
256
+ }
257
+
239
258
  function lowerCaseHeaders(
240
259
  headers: Readonly<Record<string, string | number | readonly string[] | undefined>>,
241
260
  ): Record<string, string | string[]> {
@@ -257,24 +276,50 @@ function adapterRequest(
257
276
  HIDDEN_CONTENT_TYPES.delete(request.raw)
258
277
  request.raw.headers['content-type'] = hidden
259
278
  }
260
- let body: Promise<Uint8Array> | undefined
261
- return {
262
- method: request.raw.method ?? request.method,
263
- url: request.raw.url ?? request.url,
264
- headers: normalizeNodeHeaders(request.headers),
265
- ip: request.ip,
266
- remoteAddress: request.raw.socket?.remoteAddress,
267
- encrypted: isEncrypted(request.raw),
268
- raw: request,
269
- rawResponse: reply,
270
- readBody: (limit: number): Promise<Uint8Array> => {
271
- body ??= readNodeBody(request.raw, limit)
272
- return body
273
- },
274
- streamBody: (limit: number): ReadableStream<Uint8Array> => nodeBodyStream(request.raw, limit),
275
- rawStatus: (): number | undefined => reply?.statusCode,
276
- onAbort: (listener: () => void): void => {
277
- if (response !== undefined) onResponseAbort(response.raw, listener)
278
- },
279
+ return new FastifyAdapterRequest(request, reply, response)
280
+ }
281
+
282
+ /** A Fastify request as an {@link AdapterRequest} (a class: no closure per request). */
283
+ class FastifyAdapterRequest implements AdapterRequest {
284
+ readonly method: string
285
+ readonly url: string
286
+ readonly headers: Readonly<Record<string, string | undefined>>
287
+ readonly ip: string | undefined
288
+ readonly remoteAddress: string | undefined
289
+ readonly encrypted: boolean
290
+ readonly raw: FastifyRequest
291
+ /** `@Res()`: the reply of a route, `undefined` for the fallback handler. */
292
+ readonly rawResponse: FastifyReply | undefined
293
+ /** The reply whose disconnection aborts the request. */
294
+ readonly #response: FastifyReply | undefined
295
+ #body: Promise<Uint8Array> | undefined
296
+
297
+ constructor(request: FastifyRequest, reply: FastifyReply | undefined, response: FastifyReply | undefined) {
298
+ this.method = request.raw.method ?? request.method
299
+ this.url = request.raw.url ?? request.url
300
+ this.headers = normalizeNodeHeaders(request.headers)
301
+ this.ip = request.ip
302
+ this.remoteAddress = request.raw.socket?.remoteAddress
303
+ this.encrypted = isEncrypted(request.raw)
304
+ this.raw = request
305
+ this.rawResponse = reply
306
+ this.#response = response
307
+ }
308
+
309
+ readBody(limit: number): Promise<Uint8Array> {
310
+ this.#body ??= readNodeBody(this.raw.raw, limit)
311
+ return this.#body
312
+ }
313
+
314
+ streamBody(limit: number): ReadableStream<Uint8Array> {
315
+ return nodeBodyStream(this.raw.raw, limit)
316
+ }
317
+
318
+ rawStatus(): number | undefined {
319
+ return this.rawResponse?.statusCode
320
+ }
321
+
322
+ onAbort(listener: () => void): void {
323
+ if (this.#response !== undefined) onResponseAbort(this.#response.raw, listener)
279
324
  }
280
325
  }
@@ -3,7 +3,7 @@
3
3
  * dependency `hono`). On Bun the server is `Bun.serve()`; on Node.js it is `@hono/node-server`
4
4
  * (optional peer dependency, loaded on `listen()`). `inject()` uses `app.request()`.
5
5
  */
6
- import type { Server, ServerResponse } from 'node:http'
6
+ import type { IncomingMessage, Server, ServerResponse } from 'node:http'
7
7
  import type {
8
8
  AdapterCloseOptions,
9
9
  AdapterHandler,
@@ -18,13 +18,14 @@ import type {
18
18
  InjectStreamRequest,
19
19
  InjectStreamResponse,
20
20
  } from '../adapter.ts'
21
+ import type { HeaderValue } from '../exceptions.ts'
21
22
 
22
23
  import { type Context, Hono, type Next } from 'hono'
23
24
 
24
25
  import { toHonoPath } from '../path.ts'
25
26
  import { discardStream } from '../stream.ts'
26
27
  import { isBun } from '../utils.ts'
27
- import { ConnectionTracker, closeServer, onResponseAbort } from './node-io.ts'
28
+ import { ConnectionTracker, closeServer, nodeBodyStream, onResponseAbort, readNodeBody } from './node-io.ts'
28
29
  import { bufferInjectStream, injectFetchStream, readWebStream, serverAddress, webBodyStream } from './shared.ts'
29
30
 
30
31
  /** Options of {@link honoAdapter}. */
@@ -130,6 +131,8 @@ class HonoHttpAdapter implements HonoAdapter {
130
131
  readonly name = 'hono' as const
131
132
  readonly app: Hono
132
133
  readonly #serveOptions: HonoServeOptions
134
+ /** Number of routes of `app` registered by the adapter (see {@link #middleware}). */
135
+ #routes = 0
133
136
  #fallback: FallbackHandler | undefined
134
137
  #prepared = false
135
138
  #bun: BunServer | undefined
@@ -150,6 +153,7 @@ class HonoHttpAdapter implements HonoAdapter {
150
153
  registerRoute(route: AdapterRoute): void {
151
154
  const handler = route.handler
152
155
  const path = toHonoPath(route.path)
156
+ this.#routes++
153
157
  if (route.method === 'HEAD') {
154
158
  // Hono routes HEAD requests to GET handlers: serve them from a GET handler that only answers HEAD
155
159
  this.app.get(path, (c: Context, next: Next) =>
@@ -187,7 +191,16 @@ class HonoHttpAdapter implements HonoAdapter {
187
191
  return this.#toResponse(c, await this.#fallback(adapterRequest(c), error))
188
192
  }
189
193
 
194
+ /**
195
+ * Whether `app` has routes the adapter did not register (middleware, other routes): only they can
196
+ * set headers on the context or read the response.
197
+ */
198
+ #middleware(): boolean {
199
+ return this.app.routes.length !== this.#routes
200
+ }
201
+
190
202
  #toResponse(c: Context, reply: AdapterReply): Response {
203
+ const middleware = this.#middleware()
191
204
  const outgoing = nodeOutgoing(c)
192
205
  if (outgoing !== undefined) {
193
206
  const body = reply.body instanceof ReadableStream ? reply.body : null
@@ -195,7 +208,7 @@ class HonoHttpAdapter implements HonoAdapter {
195
208
  // @hono/node-server: the client left before the reply was ready (an async pre-flight). The
196
209
  // server would neither read nor cancel the body: release it (event source, request scope)
197
210
  discardStream(body)
198
- return toResponse(c, { ...reply, body: null })
211
+ return toResponse(c, { ...reply, body: null }, middleware)
199
212
  }
200
213
  if (body !== null) {
201
214
  // the client may leave before @hono/node-server reads the body (its own listener then comes
@@ -207,7 +220,7 @@ class HonoHttpAdapter implements HonoAdapter {
207
220
  // a quiet event stream must outlive a socket timeout
208
221
  if (reply.eventStream === true && typeof outgoing.setTimeout === 'function') outgoing.setTimeout(0)
209
222
  }
210
- if (this.#bun === undefined || !(reply.body instanceof ReadableStream)) return toResponse(c, reply)
223
+ if (this.#bun === undefined || !(reply.body instanceof ReadableStream)) return toResponse(c, reply, middleware)
211
224
  // served by Bun.serve(): observe the end of the body (idle timeout, native blob: see #bunFetch)
212
225
  const reader = reply.body.getReader()
213
226
  let ended = false
@@ -247,7 +260,7 @@ class HonoHttpAdapter implements HonoAdapter {
247
260
  onEnd = callback
248
261
  },
249
262
  })
250
- return toResponse(c, { ...reply, body })
263
+ return toResponse(c, { ...reply, body }, middleware)
251
264
  }
252
265
 
253
266
  /** The fetch handler given to `Bun.serve()`. */
@@ -355,49 +368,107 @@ class HonoHttpAdapter implements HonoAdapter {
355
368
  }
356
369
 
357
370
  function adapterRequest(c: Context): AdapterRequest {
358
- const request = c.req.raw
359
- const headers: Record<string, string> = {}
360
- for (const [name, value] of request.headers) headers[name] = value
361
- const incoming = nodeIncoming(c)
362
- const address = remoteAddress(c)
363
- let body: Promise<Uint8Array> | undefined
364
- return {
365
- method: request.method,
371
+ return new HonoAdapterRequest(c)
372
+ }
373
+
374
+ /** A Hono request as an {@link AdapterRequest} (a class: no closure per request). */
375
+ class HonoAdapterRequest implements AdapterRequest {
376
+ readonly method: string
377
+ readonly url: string
378
+ readonly headers: Readonly<Record<string, string | undefined>>
379
+ readonly ip: string | undefined
380
+ readonly remoteAddress: string | undefined
381
+ readonly encrypted: boolean
382
+ readonly raw: Request
383
+ readonly rawResponse: Context
384
+ /** The Node.js request of `@hono/node-server`, absent on Bun and for `inject()`. */
385
+ readonly #incoming: NodeIncoming | undefined
386
+ /**
387
+ * The same request over HTTP/1.1, read directly (Node.js streams): no Fetch `Request` and body
388
+ * stream are built for it.
389
+ */
390
+ readonly #http1: IncomingMessage | undefined
391
+ #body: Promise<Uint8Array> | undefined
392
+
393
+ constructor(c: Context) {
394
+ const request = c.req.raw
395
+ const incoming = nodeIncoming(c)
396
+ const http1 = incoming !== undefined && incoming.httpVersionMajor < 2 ? (incoming as IncomingMessage) : undefined
397
+ const address = remoteAddress(c)
398
+ this.method = request.method
366
399
  // the raw target on Node.js; on Bun only the normalised URL is known
367
- url: pathAndQuery(typeof incoming?.url === 'string' && incoming.url !== '' ? incoming.url : request.url),
368
- headers,
369
- ip: address,
370
- remoteAddress: address,
371
- encrypted: incoming === undefined ? request.url.startsWith('https:') : incoming.socket?.encrypted === true,
372
- raw: request,
373
- rawResponse: c,
374
- readBody: (limit: number): Promise<Uint8Array> => {
375
- body ??= readWebStream(() => request.body, limit, headers['content-length'])
376
- return body
377
- },
378
- streamBody: (limit: number): ReadableStream<Uint8Array> =>
379
- webBodyStream(() => request.body, limit, headers['content-length']),
380
- // asked only without content-length / transfer-encoding: a request read from an HTTP/1.1
381
- // connection then has no body (`@hono/node-server` still gives every POST a body stream), an
382
- // HTTP/2 one unless its HEADERS frame ended the stream; a Web `Request` (Bun, inject) says it
383
- hasBody: (): boolean =>
384
- incoming === undefined
385
- ? request.body !== null
386
- : incoming.httpVersionMajor >= 2 && incoming.stream?.endAfterHeaders === false,
387
- onAbort: (listener: () => void): void => {
388
- const outgoing = nodeOutgoing(c)
389
- if (outgoing !== undefined) {
390
- onResponseAbort(outgoing as unknown as ServerResponse, listener)
391
- return
392
- }
393
- // Bun.serve() and inject(): the request signal aborts when the client leaves
394
- const signal = request.signal
395
- if (signal.aborted) queueMicrotask(listener)
396
- else signal.addEventListener('abort', listener, { once: true })
397
- },
400
+ this.url = pathAndQuery(typeof incoming?.url === 'string' && incoming.url !== '' ? incoming.url : request.url)
401
+ this.headers = (http1 === undefined ? undefined : distinctHeaders(http1)) ?? fetchHeaders(request.headers)
402
+ this.ip = address
403
+ this.remoteAddress = address
404
+ this.encrypted = incoming === undefined ? request.url.startsWith('https:') : incoming.socket?.encrypted === true
405
+ this.raw = request
406
+ this.rawResponse = c
407
+ this.#incoming = incoming
408
+ this.#http1 = http1
409
+ }
410
+
411
+ readBody(limit: number): Promise<Uint8Array> {
412
+ if (this.#body === undefined) {
413
+ const http1 = this.#http1
414
+ this.#body =
415
+ http1 === undefined
416
+ ? readWebStream(() => this.raw.body, limit, this.headers['content-length'])
417
+ : readNodeBody(http1, limit)
418
+ }
419
+ return this.#body
420
+ }
421
+
422
+ streamBody(limit: number): ReadableStream<Uint8Array> {
423
+ const http1 = this.#http1
424
+ if (http1 !== undefined) return nodeBodyStream(http1, limit)
425
+ return webBodyStream(() => this.raw.body, limit, this.headers['content-length'])
426
+ }
427
+
428
+ // asked only without content-length / transfer-encoding: a request read from an HTTP/1.1
429
+ // connection then has no body (`@hono/node-server` still gives every POST a body stream), an
430
+ // HTTP/2 one unless its HEADERS frame ended the stream; a Web `Request` (Bun, inject) says it
431
+ hasBody(): boolean {
432
+ const incoming = this.#incoming
433
+ return incoming === undefined
434
+ ? this.raw.body !== null
435
+ : incoming.httpVersionMajor >= 2 && incoming.stream?.endAfterHeaders === false
436
+ }
437
+
438
+ onAbort(listener: () => void): void {
439
+ const outgoing = nodeOutgoing(this.rawResponse)
440
+ if (outgoing !== undefined) {
441
+ onResponseAbort(outgoing as unknown as ServerResponse, listener)
442
+ return
443
+ }
444
+ // Bun.serve() and inject(): the request signal aborts when the client leaves
445
+ const signal = this.raw.signal
446
+ if (signal.aborted) queueMicrotask(listener)
447
+ else signal.addEventListener('abort', listener, { once: true })
398
448
  }
399
449
  }
400
450
 
451
+ /** The headers of a Fetch request: lower-case names, repeated headers joined (`Headers` semantics). */
452
+ function fetchHeaders(source: Headers): Record<string, string> {
453
+ const headers: Record<string, string> = {}
454
+ for (const [name, value] of source) headers[name] = value
455
+ return headers
456
+ }
457
+
458
+ /**
459
+ * The headers of a Node.js request when none is repeated: then they hold the values of its Fetch
460
+ * `Headers` (Node.js keeps the first of some repeated headers where `Headers` join them all).
461
+ */
462
+ function distinctHeaders(request: IncomingMessage): Record<string, string | undefined> | undefined {
463
+ const headers = request.headers
464
+ let count = 0
465
+ for (const name in headers) {
466
+ if (typeof headers[name] !== 'string') return undefined
467
+ count++
468
+ }
469
+ return count * 2 === request.rawHeaders.length ? (headers as Record<string, string | undefined>) : undefined
470
+ }
471
+
401
472
  /** The part of a Node.js `ServerResponse` used here. */
402
473
  interface NodeOutgoing {
403
474
  readonly writableFinished: boolean
@@ -439,11 +510,22 @@ function remoteAddress(c: Context): string | undefined {
439
510
  return env.incoming?.socket?.remoteAddress
440
511
  }
441
512
 
442
- function toResponse(c: Context, reply: AdapterReply): Response {
513
+ /**
514
+ * The `Response` of a reply. Without `middleware` (see `#middleware`), nothing set headers on the
515
+ * context and nothing reads the response: headers given as a record of strings are sent as is by
516
+ * `@hono/node-server` (no `Headers` object), and a text body as a string.
517
+ */
518
+ function toResponse(c: Context, reply: AdapterReply, middleware: boolean): Response {
443
519
  if (reply.raw === true) {
444
520
  discardStream(reply.body instanceof ReadableStream ? reply.body : null)
445
521
  return c.res
446
522
  }
523
+ if (!middleware && isStringRecord(reply.headers)) {
524
+ return new Response(reply.text ?? reply.body, {
525
+ status: reply.status,
526
+ headers: reply.headers as Readonly<Record<string, string>>,
527
+ })
528
+ }
447
529
  const headers = new Headers()
448
530
  try {
449
531
  for (const name of Object.keys(reply.headers)) {
@@ -460,8 +542,14 @@ function toResponse(c: Context, reply: AdapterReply): Response {
460
542
  headers: { 'content-type': 'application/json; charset=utf-8' },
461
543
  })
462
544
  }
463
- keepContextCookies(c, headers)
464
- return new Response(reply.body, { status: reply.status, headers })
545
+ if (middleware) keepContextCookies(c, headers)
546
+ return new Response(reply.text ?? reply.body, { status: reply.status, headers })
547
+ }
548
+
549
+ /** Whether every header has a single value (a record of strings is a valid `HeadersInit`). */
550
+ function isStringRecord(headers: Readonly<Record<string, HeaderValue>>): boolean {
551
+ for (const name in headers) if (typeof headers[name] !== 'string') return false
552
+ return true
465
553
  }
466
554
 
467
555
  /**
@@ -183,23 +183,52 @@ export function nodeAdapterRequest(
183
183
  response: ServerResponse,
184
184
  options: NodeRequestOptions = {},
185
185
  ): AdapterRequest {
186
- let body: Promise<Uint8Array> | undefined
187
- return {
188
- method: request.method ?? 'GET',
189
- url: options.url ?? request.url ?? '/',
190
- headers: normalizeNodeHeaders(request.headers),
191
- ip: options.ip ?? request.socket?.remoteAddress,
192
- remoteAddress: request.socket?.remoteAddress,
193
- encrypted: isEncrypted(request),
194
- raw: options.raw ?? request,
195
- rawResponse: options.rawResponse ?? response,
196
- readBody: (limit: number): Promise<Uint8Array> => {
197
- body ??= readNodeBody(request, limit)
198
- return body
199
- },
200
- streamBody: (limit: number): ReadableStream<Uint8Array> => nodeBodyStream(request, limit),
201
- rawStatus: (): number => response.statusCode,
202
- onAbort: (listener: () => void): void => onResponseAbort(response, listener),
186
+ return new NodeAdapterRequest(request, response, options)
187
+ }
188
+
189
+ /** A Node.js request/response pair as an {@link AdapterRequest} (a class: no closure per request). */
190
+ class NodeAdapterRequest implements AdapterRequest {
191
+ readonly method: string
192
+ readonly url: string
193
+ readonly headers: Readonly<Record<string, string | undefined>>
194
+ readonly ip: string | undefined
195
+ readonly remoteAddress: string | undefined
196
+ readonly encrypted: boolean
197
+ readonly raw: unknown
198
+ readonly rawResponse: unknown
199
+ readonly #request: IncomingMessage
200
+ readonly #response: ServerResponse
201
+ #body: Promise<Uint8Array> | undefined
202
+
203
+ constructor(request: IncomingMessage, response: ServerResponse, options: NodeRequestOptions) {
204
+ const remoteAddress = request.socket?.remoteAddress
205
+ this.method = request.method ?? 'GET'
206
+ this.url = options.url ?? request.url ?? '/'
207
+ this.headers = normalizeNodeHeaders(request.headers)
208
+ this.ip = options.ip ?? remoteAddress
209
+ this.remoteAddress = remoteAddress
210
+ this.encrypted = isEncrypted(request)
211
+ this.raw = options.raw ?? request
212
+ this.rawResponse = options.rawResponse ?? response
213
+ this.#request = request
214
+ this.#response = response
215
+ }
216
+
217
+ readBody(limit: number): Promise<Uint8Array> {
218
+ this.#body ??= readNodeBody(this.#request, limit)
219
+ return this.#body
220
+ }
221
+
222
+ streamBody(limit: number): ReadableStream<Uint8Array> {
223
+ return nodeBodyStream(this.#request, limit)
224
+ }
225
+
226
+ rawStatus(): number {
227
+ return this.#response.statusCode
228
+ }
229
+
230
+ onAbort(listener: () => void): void {
231
+ onResponseAbort(this.#response, listener)
203
232
  }
204
233
  }
205
234
 
@@ -224,10 +253,10 @@ export function isEncrypted(request: IncomingMessage): boolean {
224
253
  }
225
254
 
226
255
  /**
227
- * Writes a reply to a `ServerResponse`. Resolves when the response is complete (streams included);
228
- * never rejects (a failing stream destroys the response).
256
+ * Writes a reply to a `ServerResponse`, piping a stream body (a failing stream destroys the
257
+ * response). Never throws.
229
258
  */
230
- export async function writeNodeReply(response: ServerResponse, reply: AdapterReply): Promise<void> {
259
+ export function writeNodeReply(response: ServerResponse, reply: AdapterReply): void {
231
260
  if (reply.raw === true) {
232
261
  discardStream(reply.body instanceof ReadableStream ? reply.body : null)
233
262
  return
@@ -263,14 +292,18 @@ export async function writeNodeReply(response: ServerResponse, reply: AdapterRep
263
292
  response.setTimeout(0)
264
293
  }
265
294
  if (body instanceof Uint8Array) {
266
- response.end(body)
295
+ // a string is written with the headers at once (bytes take a second write)
296
+ response.end(reply.text ?? body)
267
297
  return
268
298
  }
269
- const stream = body instanceof Blob ? body.stream() : body
299
+ const fail = (): void => {
300
+ if (!response.destroyed) response.destroy()
301
+ }
270
302
  try {
271
- await pipeline(Readable.fromWeb(stream as import('node:stream/web').ReadableStream), response)
303
+ const stream = body instanceof Blob ? body.stream() : body
304
+ pipeline(Readable.fromWeb(stream as import('node:stream/web').ReadableStream), response).catch(fail)
272
305
  } catch {
273
- if (!response.destroyed) response.destroy()
306
+ fail()
274
307
  }
275
308
  }
276
309
 
package/src/context.ts CHANGED
@@ -11,7 +11,7 @@ import { DEFAULT_COOKIE_SETTINGS, missingSecrets, serializeWith, verifySigned }
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
@@ -226,11 +226,13 @@ export class RequestContext implements HttpContext {
226
226
  init: {
227
227
  readonly requestId: RequestId
228
228
  readonly adapter: string
229
+ /** `source.url` split (default: split here). */
230
+ readonly url?: RequestTarget
229
231
  readonly route: HttpRouteInfo | undefined
230
232
  readonly params: Readonly<Record<string, string>>
231
233
  readonly body: BodyPlan
232
234
  /** Client address and protocol (`trustProxy`); default: the adapter's `ip` and `encrypted`. */
233
- readonly client?: { readonly ip: string | undefined; readonly protocol: 'http' | 'https' }
235
+ readonly client?: { readonly ip: string | undefined; readonly protocol: 'http' | 'https' } | undefined
234
236
  /** Cookie defaults and signing key (plugin option `cookies`); default: secure defaults, no signing. */
235
237
  readonly cookies?: CookieSettings
236
238
  },
@@ -238,7 +240,7 @@ export class RequestContext implements HttpContext {
238
240
  this.#source = source
239
241
  this.#plan = init.body
240
242
  this.#cookieSettings = init.cookies ?? DEFAULT_COOKIE_SETTINGS
241
- const { path, query } = splitUrl(source.url)
243
+ const { path, query } = init.url ?? splitUrl(source.url)
242
244
  this.#rawQuery = query
243
245
  this.requestId = init.requestId
244
246
  this.method = source.method
@@ -333,7 +335,7 @@ export class RequestContext implements HttpContext {
333
335
 
334
336
  /** The `Set-Cookie` lines of {@link setCookie} / {@link clearCookie}, with their keys, in order (internal). */
335
337
  get cookieLines(): readonly SerializedCookie[] {
336
- return this.#jar === undefined ? [] : [...this.#jar].map(([key, entry]) => ({ key, line: entry.line }))
338
+ return this.#jar === undefined ? NO_LINES : [...this.#jar].map(([key, entry]) => ({ key, line: entry.line }))
337
339
  }
338
340
 
339
341
  #addCookie(cookie: ResponseCookie): void {
@@ -530,4 +532,5 @@ function withoutLifetime(options: ClearCookieOptions): ClearCookieOptions {
530
532
 
531
533
  const EMPTY_HEADERS: Readonly<Record<string, HeaderValue>> = Object.freeze({})
532
534
  const NO_COOKIES: readonly ResponseCookie[] = Object.freeze([])
535
+ const NO_LINES: readonly SerializedCookie[] = Object.freeze([])
533
536
  const NO_FILES: Readonly<Record<string, readonly File[]>> = Object.freeze(emptyRecord<readonly File[]>())