@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
@@ -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,14 +18,24 @@ 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
 
25
+ import { BadRequestException } from '../exceptions.ts'
24
26
  import { toHonoPath } from '../path.ts'
25
27
  import { discardStream } from '../stream.ts'
26
28
  import { isBun } from '../utils.ts'
27
- import { ConnectionTracker, closeServer, onResponseAbort } from './node-io.ts'
28
- import { bufferInjectStream, injectFetchStream, readWebStream, serverAddress, webBodyStream } from './shared.ts'
29
+ import { ConnectionTracker, closeServer, nodeBodyStream, onResponseAbort, readNodeBody } from './node-io.ts'
30
+ import {
31
+ bufferInjectStream,
32
+ exceedsDeclaredLength,
33
+ injectFetchStream,
34
+ payloadTooLarge,
35
+ readWebStream,
36
+ serverAddress,
37
+ webBodyStream,
38
+ } from './shared.ts'
29
39
 
30
40
  /** Options of {@link honoAdapter}. */
31
41
  export interface HonoAdapterOptions {
@@ -102,12 +112,47 @@ interface BunRuntime {
102
112
  /** Default `idleTimeout` of `Bun.serve()`, in seconds. */
103
113
  const BUN_IDLE_TIMEOUT = 10
104
114
 
105
- /** A response body built while serving with `Bun.serve()` (see `#bunFetch`). */
106
- interface BunBody {
115
+ /**
116
+ * A streamed response body built for a request of `Bun.serve()` (see `#bunStream`); with middleware,
117
+ * pending until `#bunFetch` takes it or it ends.
118
+ */
119
+ class BunBody {
107
120
  /** The `Blob` (file) the body reads. */
108
121
  readonly blob: Blob | undefined
109
- /** Sets the function called when the body ends (read to its end, failed or cancelled). */
110
- onEnd(callback: () => void): void
122
+ #settle: (() => void) | undefined
123
+ #onEnd: (() => void) | undefined
124
+ #ended = false
125
+
126
+ /** `settle` is called once, when the body is taken by `#bunFetch` or ends, whichever comes first. */
127
+ constructor(blob: Blob | undefined, settle: (() => void) | undefined) {
128
+ this.blob = blob
129
+ this.#settle = settle
130
+ }
131
+
132
+ /** Whether the body ended (read to its end, failed or cancelled). */
133
+ get ended(): boolean {
134
+ return this.#ended
135
+ }
136
+
137
+ /** The body is no longer pending (taken by `#bunFetch`). */
138
+ settle(): void {
139
+ const settle = this.#settle
140
+ this.#settle = undefined
141
+ settle?.()
142
+ }
143
+
144
+ /** Sets the function called when the body ends. */
145
+ onEnd(callback: () => void): void {
146
+ this.#onEnd = callback
147
+ }
148
+
149
+ /** The body ended (read to its end, failed or cancelled). */
150
+ end(): void {
151
+ if (this.#ended) return
152
+ this.#ended = true
153
+ this.settle()
154
+ this.#onEnd?.()
155
+ }
111
156
  }
112
157
 
113
158
  /** Response bodies built while serving with `Bun.serve()`, by body (Hono may copy a `Response`, not its body). */
@@ -126,13 +171,31 @@ const INJECT_ENV = Object.freeze({ incoming: Object.freeze({ socket: Object.free
126
171
 
127
172
  type HonoMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS'
128
173
 
174
+ /** The handlers of the GET and HEAD routes of one path. */
175
+ interface ReadHandlers {
176
+ get: AdapterHandler | undefined
177
+ head: AdapterHandler | undefined
178
+ }
179
+
129
180
  class HonoHttpAdapter implements HonoAdapter {
130
181
  readonly name = 'hono' as const
131
182
  readonly app: Hono
132
183
  readonly #serveOptions: HonoServeOptions
184
+ /** Number of routes of `app` registered by the adapter (see {@link #middleware}). */
185
+ #routes = 0
186
+ /** The GET and HEAD handlers of each path shape, served by one route of `app` (see {@link registerRoute}). */
187
+ readonly #reads = new Map<string, ReadHandlers>()
133
188
  #fallback: FallbackHandler | undefined
134
189
  #prepared = false
135
190
  #bun: BunServer | undefined
191
+ /**
192
+ * Streamed bodies of `Bun.serve()` replies (with middleware) neither taken by `#bunFetch` nor ended
193
+ * (see {@link BunBody}): while there is none, `#bunFetch` returns responses untouched (reading
194
+ * `response.body` would turn a text body into a stream that Bun then sends as one).
195
+ */
196
+ #pendingBodies = 0
197
+ /** Idle timeout of the `Bun.serve()` server, in seconds (`0`: none). */
198
+ #idleTimeout = BUN_IDLE_TIMEOUT
136
199
  #node: Server | undefined
137
200
  #tracker: ConnectionTracker | undefined
138
201
  #closing: Promise<void> | undefined
@@ -150,13 +213,29 @@ class HonoHttpAdapter implements HonoAdapter {
150
213
  registerRoute(route: AdapterRoute): void {
151
214
  const handler = route.handler
152
215
  const path = toHonoPath(route.path)
153
- if (route.method === 'HEAD') {
154
- // Hono routes HEAD requests to GET handlers: serve them from a GET handler that only answers HEAD
155
- this.app.get(path, (c: Context, next: Next) =>
156
- c.req.raw.method === 'HEAD' ? this.#serve(c, handler) : next().then(() => c.res),
157
- )
216
+ if (route.method === 'GET' || route.method === 'HEAD') {
217
+ // Hono routes HEAD requests to GET handlers: one GET handler per path serves both methods (the
218
+ // plugin registers HEAD routes first), so that a request matches a single handler and Hono
219
+ // skips its middleware composition. Paths are keyed by their shape (`/users/:` for
220
+ // `/users/:id` and `/users/:userId`): a GET route joins the HEAD route matching the same
221
+ // requests, at its rank (Znight reads the parameters itself, never Hono's names)
222
+ const existing = this.#reads.get(route.path.shape)
223
+ if (existing !== undefined) {
224
+ if (route.method === 'GET') existing.get = handler
225
+ else existing.head = handler
226
+ return
227
+ }
228
+ const reads: ReadHandlers =
229
+ route.method === 'GET' ? { get: handler, head: undefined } : { get: undefined, head: handler }
230
+ this.#reads.set(route.path.shape, reads)
231
+ this.#routes++
232
+ this.app.get(path, (c: Context, next: Next) => {
233
+ const read = c.req.raw.method === 'HEAD' ? reads.head : reads.get
234
+ return read === undefined ? next().then(() => c.res) : this.#serve(c, read)
235
+ })
158
236
  return
159
237
  }
238
+ this.#routes++
160
239
  this.app.on(route.method as HonoMethod, path, (c: Context) => this.#serve(c, handler))
161
240
  }
162
241
 
@@ -172,11 +251,12 @@ class HonoHttpAdapter implements HonoAdapter {
172
251
  }
173
252
 
174
253
  async #serve(c: Context, handler: AdapterHandler): Promise<Response> {
175
- const request = adapterRequest(c)
254
+ const request = this.#request(c)
176
255
  // Hono routes the WHATWG-normalised URL (`/a/../b` → `/b`, `%2e%2e` resolved): when the raw
177
- // target differs (Node.js), let the plugin route the raw path, like on the other adapters
256
+ // target differs (Node.js; on Bun only the normalised URL is known), let the plugin route the raw
257
+ // path, like on the other adapters
178
258
  const reply =
179
- this.#fallback !== undefined && pathOf(request.url) !== pathOf(pathAndQuery(c.req.raw.url))
259
+ this.#fallback !== undefined && request.rawTarget && pathOf(request.url) !== pathOf(pathAndQuery(c.req.raw.url))
180
260
  ? await this.#fallback(request)
181
261
  : await handler(request)
182
262
  return this.#toResponse(c, reply)
@@ -184,10 +264,33 @@ class HonoHttpAdapter implements HonoAdapter {
184
264
 
185
265
  async #serveFallback(c: Context, error: unknown): Promise<Response> {
186
266
  if (this.#fallback === undefined) throw error ?? new Error('No fallback handler')
187
- return this.#toResponse(c, await this.#fallback(adapterRequest(c), error))
267
+ return this.#toResponse(c, await this.#fallback(this.#request(c), error))
268
+ }
269
+
270
+ #request(c: Context): HonoAdapterRequest {
271
+ return new HonoAdapterRequest(c, this.#servedByBun(c) ? this.#bun : undefined)
272
+ }
273
+
274
+ /** Whether the request of `c` was received by the `Bun.serve()` server of the adapter. */
275
+ #servedByBun(c: Context): boolean {
276
+ return this.#bun !== undefined && c.env === this.#bun
277
+ }
278
+
279
+ /**
280
+ * Whether `app` has routes the adapter did not register (middleware, other routes): only they can
281
+ * set headers on the context or read the response.
282
+ */
283
+ #middleware(): boolean {
284
+ return this.app.routes.length !== this.#routes
188
285
  }
189
286
 
190
287
  #toResponse(c: Context, reply: AdapterReply): Response {
288
+ const middleware = this.#middleware()
289
+ if (this.#servedByBun(c)) {
290
+ return reply.body instanceof ReadableStream
291
+ ? this.#bunStream(c, reply, middleware)
292
+ : toResponse(c, reply, middleware)
293
+ }
191
294
  const outgoing = nodeOutgoing(c)
192
295
  if (outgoing !== undefined) {
193
296
  const body = reply.body instanceof ReadableStream ? reply.body : null
@@ -195,7 +298,7 @@ class HonoHttpAdapter implements HonoAdapter {
195
298
  // @hono/node-server: the client left before the reply was ready (an async pre-flight). The
196
299
  // server would neither read nor cancel the body: release it (event source, request scope)
197
300
  discardStream(body)
198
- return toResponse(c, { ...reply, body: null })
301
+ return toResponse(c, { ...reply, body: null }, middleware)
199
302
  }
200
303
  if (body !== null) {
201
304
  // the client may leave before @hono/node-server reads the body (its own listener then comes
@@ -207,16 +310,23 @@ class HonoHttpAdapter implements HonoAdapter {
207
310
  // a quiet event stream must outlive a socket timeout
208
311
  if (reply.eventStream === true && typeof outgoing.setTimeout === 'function') outgoing.setTimeout(0)
209
312
  }
210
- if (this.#bun === undefined || !(reply.body instanceof ReadableStream)) return toResponse(c, reply)
211
- // served by Bun.serve(): observe the end of the body (idle timeout, native blob: see #bunFetch)
212
- const reader = reply.body.getReader()
213
- let ended = false
214
- let onEnd = (): void => undefined
215
- const end = (): void => {
216
- if (ended) return
217
- ended = true
218
- onEnd()
219
- }
313
+ return toResponse(c, reply, middleware)
314
+ }
315
+
316
+ /**
317
+ * The response of a streamed reply to a request of `Bun.serve()`: the end of its body is observed
318
+ * (idle timeout, native blob: see {@link #bunSend}).
319
+ */
320
+ #bunStream(c: Context, reply: AdapterReply, middleware: boolean): Response {
321
+ const reader = (reply.body as ReadableStream<Uint8Array>).getReader()
322
+ const served = new BunBody(
323
+ reply.blob,
324
+ middleware
325
+ ? () => {
326
+ this.#pendingBodies--
327
+ }
328
+ : undefined,
329
+ )
220
330
  const body = new ReadableStream<Uint8Array>(
221
331
  {
222
332
  async pull(controller) {
@@ -224,39 +334,53 @@ class HonoHttpAdapter implements HonoAdapter {
224
334
  try {
225
335
  result = await reader.read()
226
336
  } catch (error) {
227
- end()
337
+ served.end()
228
338
  throw error
229
339
  }
230
340
  if (result.done) {
231
- end() // before closing: Bun ignores timeout() once the response is complete
341
+ served.end() // before closing: Bun ignores timeout() once the response is complete
232
342
  controller.close()
233
343
  return
234
344
  }
235
345
  controller.enqueue(result.value)
236
346
  },
237
347
  cancel(reason) {
238
- end()
348
+ served.end()
239
349
  return reader.cancel(reason)
240
350
  },
241
351
  },
242
352
  { highWaterMark: 0 },
243
353
  )
244
- BUN_BODIES.set(body, {
245
- blob: reply.blob,
246
- onEnd: callback => {
247
- onEnd = callback
248
- },
249
- })
250
- return toResponse(c, { ...reply, body })
354
+ const response = toResponse(c, { ...reply, body }, middleware)
355
+ if (!middleware) {
356
+ // nothing else handles the request (see the fetch handler given to Bun.serve()): Bun sends this
357
+ // response, unless it does not carry the body (an invalid header: the body was discarded)
358
+ return served.ended ? response : this.#bunSend(response, body, served, c.req.raw)
359
+ }
360
+ // middleware may copy the response (not its body) or replace it: #bunFetch finds the body
361
+ this.#pendingBodies++
362
+ BUN_BODIES.set(body, served)
363
+ return response
251
364
  }
252
365
 
253
- /** The fetch handler given to `Bun.serve()`. */
254
- async #bunFetch(request: Request, server: BunServer, idleTimeout: number): Promise<Response> {
366
+ /** The fetch handler given to `Bun.serve()` when `app` has middleware (see {@link #bunStream}). */
367
+ async #bunFetch(request: Request, server: BunServer): Promise<Response> {
255
368
  const response = await this.app.fetch(request, server)
369
+ // only a streamed reply needs more: leave the other responses untouched (see #pendingBodies)
370
+ if (this.#pendingBodies === 0) return response
256
371
  const body = response.body
257
372
  const served = body === null ? undefined : BUN_BODIES.get(body)
258
373
  if (body === null || served === undefined) return response
259
374
  BUN_BODIES.delete(body)
375
+ served.settle()
376
+ return this.#bunSend(response, body, served, request)
377
+ }
378
+
379
+ /**
380
+ * The response Bun sends for a streamed reply, once final: a file `Blob` is sent natively, a
381
+ * stream with the idle timeout of the connection lifted.
382
+ */
383
+ #bunSend(response: Response, body: ReadableStream<Uint8Array>, served: BunBody, request: Request): Response {
260
384
  if (served.blob !== undefined && !body.locked) {
261
385
  // a Blob is sent by Bun itself (sendfile, exact Content-Length; a stream would be chunked). Bun
262
386
  // opens its file as soon as it takes the response: release the stream (and with it the request
@@ -269,9 +393,12 @@ class HonoHttpAdapter implements HonoAdapter {
269
393
  setImmediate(() => discardStream(body))
270
394
  return native
271
395
  }
272
- if (idleTimeout > 0 && typeof server.timeout === 'function') {
396
+ const server = this.#bun
397
+ const idleTimeout = this.#idleTimeout
398
+ if (idleTimeout > 0 && !served.ended && typeof server?.timeout === 'function') {
273
399
  // the idle timeout of Bun.serve() also cuts a response whose stream pauses (SSE, slow producers):
274
- // lift it while the stream is sent, then restore it for the keep-alive connection
400
+ // lift it while the stream is sent, then restore it for the keep-alive connection (a body that
401
+ // already ended would never restore it)
275
402
  server.timeout(request, 0)
276
403
  served.onEnd(() => {
277
404
  try {
@@ -290,14 +417,16 @@ class HonoHttpAdapter implements HonoAdapter {
290
417
  const bun = (globalThis as { Bun?: BunRuntime }).Bun
291
418
  if (isBun() && bun !== undefined) {
292
419
  const options = this.#serveOptions
293
- const idleTimeout = typeof options.idleTimeout === 'number' ? options.idleTimeout : BUN_IDLE_TIMEOUT
420
+ this.#idleTimeout = typeof options.idleTimeout === 'number' ? options.idleTimeout : BUN_IDLE_TIMEOUT
294
421
  const server = bun.serve({
295
422
  // Znight enforces the body limits (route @BodyLimit, plugin bodyLimit): not Bun's 128 MiB default
296
423
  maxRequestBodySize: Number.MAX_SAFE_INTEGER,
297
424
  ...options,
298
425
  port,
299
426
  hostname: host,
300
- fetch: (request, server) => this.#bunFetch(request, server, idleTimeout),
427
+ // without middleware, the response of a Znight route is the one Bun sends (no wrapper needed)
428
+ fetch: (request, server) =>
429
+ this.#middleware() ? this.#bunFetch(request, server) : this.app.fetch(request, server),
301
430
  })
302
431
  this.#bun = server
303
432
  return serverAddress(server.hostname, server.port)
@@ -354,48 +483,153 @@ class HonoHttpAdapter implements HonoAdapter {
354
483
  }
355
484
  }
356
485
 
357
- function adapterRequest(c: Context): AdapterRequest {
358
- const request = c.req.raw
486
+ /** A Hono request as an {@link AdapterRequest} (a class: no closure per request). */
487
+ class HonoAdapterRequest implements AdapterRequest {
488
+ readonly method: string
489
+ readonly url: string
490
+ readonly headers: Readonly<Record<string, string | undefined>>
491
+ readonly ip: string | undefined
492
+ readonly remoteAddress: string | undefined
493
+ readonly encrypted: boolean
494
+ readonly raw: Request
495
+ readonly rawResponse: Context
496
+ /** `url` is the raw request target (Node.js), not the WHATWG-normalised URL of `raw`. */
497
+ readonly rawTarget: boolean
498
+ /** The Node.js request of `@hono/node-server`, absent on Bun and for `inject()`. */
499
+ readonly #incoming: NodeIncoming | undefined
500
+ /**
501
+ * The same request over HTTP/1.1, read directly (Node.js streams): no Fetch `Request` and body
502
+ * stream are built for it.
503
+ */
504
+ readonly #http1: IncomingMessage | undefined
505
+ /** Received by the `Bun.serve()` server of the adapter: its body is framed by the HTTP parser. */
506
+ readonly #bun: boolean
507
+ #body: Promise<Uint8Array> | undefined
508
+
509
+ constructor(c: Context, bun: BunServer | undefined) {
510
+ const request = c.req.raw
511
+ this.method = request.method
512
+ this.raw = request
513
+ this.rawResponse = c
514
+ this.#bun = bun !== undefined
515
+ if (bun !== undefined) {
516
+ // Bun.serve(): only the normalised URL is known; the address is asked now (Bun no longer knows
517
+ // it once the response is sent)
518
+ const url = request.url
519
+ const address = bun.requestIP?.(request)?.address ?? undefined
520
+ this.url = pathAndQuery(url)
521
+ this.rawTarget = false
522
+ this.headers = fetchHeaders(request.headers)
523
+ this.ip = address
524
+ this.remoteAddress = address
525
+ this.encrypted = url.startsWith('https:')
526
+ this.#incoming = undefined
527
+ this.#http1 = undefined
528
+ return
529
+ }
530
+ const incoming = nodeIncoming(c)
531
+ const http1 = incoming !== undefined && incoming.httpVersionMajor < 2 ? (incoming as IncomingMessage) : undefined
532
+ const address = remoteAddress(c)
533
+ const target = incoming?.url
534
+ // the raw target on Node.js; for a Web request only the normalised URL is known
535
+ this.rawTarget = typeof target === 'string' && target !== ''
536
+ this.url = pathAndQuery(this.rawTarget ? (target as string) : request.url)
537
+ this.headers = (http1 === undefined ? undefined : distinctHeaders(http1)) ?? fetchHeaders(request.headers)
538
+ this.ip = address
539
+ this.remoteAddress = address
540
+ this.encrypted = incoming === undefined ? request.url.startsWith('https:') : incoming.socket?.encrypted === true
541
+ this.#incoming = incoming
542
+ this.#http1 = http1
543
+ }
544
+
545
+ readBody(limit: number): Promise<Uint8Array> {
546
+ if (this.#body === undefined) {
547
+ const http1 = this.#http1
548
+ if (http1 !== undefined) this.#body = readNodeBody(http1, limit)
549
+ else if (this.#bun && this.#framed()) this.#body = readFramedBody(this.raw, limit, this.headers['content-length'])
550
+ else this.#body = readWebStream(() => this.raw.body, limit, this.headers['content-length'])
551
+ }
552
+ return this.#body
553
+ }
554
+
555
+ /**
556
+ * Whether the whole body can be read at once from `Bun.serve()`: its length is declared (the
557
+ * HTTP parser then reads no more) and nothing read it yet.
558
+ */
559
+ #framed(): boolean {
560
+ const headers = this.headers
561
+ return headers['content-length'] !== undefined && headers['transfer-encoding'] === undefined && !this.raw.bodyUsed
562
+ }
563
+
564
+ streamBody(limit: number): ReadableStream<Uint8Array> {
565
+ const http1 = this.#http1
566
+ if (http1 !== undefined) return nodeBodyStream(http1, limit)
567
+ return webBodyStream(() => this.raw.body, limit, this.headers['content-length'])
568
+ }
569
+
570
+ // asked only without content-length / transfer-encoding: a request read from an HTTP/1.1
571
+ // connection then has no body (`@hono/node-server` still gives every POST a body stream), an
572
+ // HTTP/2 one unless its HEADERS frame ended the stream; a Web `Request` (Bun, inject) says it
573
+ hasBody(): boolean {
574
+ const incoming = this.#incoming
575
+ return incoming === undefined
576
+ ? this.raw.body !== null
577
+ : incoming.httpVersionMajor >= 2 && incoming.stream?.endAfterHeaders === false
578
+ }
579
+
580
+ onAbort(listener: () => void): void {
581
+ const outgoing = this.#bun ? undefined : nodeOutgoing(this.rawResponse)
582
+ if (outgoing !== undefined) {
583
+ onResponseAbort(outgoing as unknown as ServerResponse, listener)
584
+ return
585
+ }
586
+ // Bun.serve() and inject(): the request signal aborts when the client leaves
587
+ const signal = this.raw.signal
588
+ if (signal.aborted) queueMicrotask(listener)
589
+ else signal.addEventListener('abort', listener, { once: true })
590
+ }
591
+ }
592
+
593
+ /** The headers of a Fetch request: lower-case names, repeated headers joined (`Headers` semantics). */
594
+ function fetchHeaders(source: Headers): Record<string, string> {
359
595
  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,
366
- // 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
- },
596
+ // forEach: no iterator nor entry array per header
597
+ source.forEach((value, name) => {
598
+ headers[name] = value
599
+ })
600
+ return headers
601
+ }
602
+
603
+ /**
604
+ * Reads at once (`request.bytes()`, no stream) the body of a request received by `Bun.serve()`
605
+ * whose `content-length` is declared: Bun reads exactly that many bytes, so checking the declared
606
+ * length enforces `limit`. Failures are those of {@link readWebStream}: 413 above `limit`, 400 when
607
+ * the client aborts.
608
+ */
609
+ async function readFramedBody(request: Request, limit: number, contentLength: string | undefined): Promise<Uint8Array> {
610
+ if (exceedsDeclaredLength(contentLength, limit)) throw payloadTooLarge(limit)
611
+ let bytes: Uint8Array
612
+ try {
613
+ bytes = await request.bytes()
614
+ } catch (error) {
615
+ throw new BadRequestException('Failed to read the request body', undefined, { cause: error })
616
+ }
617
+ if (bytes.byteLength > limit) throw payloadTooLarge(limit)
618
+ return bytes
619
+ }
620
+
621
+ /**
622
+ * The headers of a Node.js request when none is repeated: then they hold the values of its Fetch
623
+ * `Headers` (Node.js keeps the first of some repeated headers where `Headers` join them all).
624
+ */
625
+ function distinctHeaders(request: IncomingMessage): Record<string, string | undefined> | undefined {
626
+ const headers = request.headers
627
+ let count = 0
628
+ for (const name in headers) {
629
+ if (typeof headers[name] !== 'string') return undefined
630
+ count++
398
631
  }
632
+ return count * 2 === request.rawHeaders.length ? (headers as Record<string, string | undefined>) : undefined
399
633
  }
400
634
 
401
635
  /** The part of a Node.js `ServerResponse` used here. */
@@ -439,11 +673,22 @@ function remoteAddress(c: Context): string | undefined {
439
673
  return env.incoming?.socket?.remoteAddress
440
674
  }
441
675
 
442
- function toResponse(c: Context, reply: AdapterReply): Response {
676
+ /**
677
+ * The `Response` of a reply. Without `middleware` (see `#middleware`), nothing set headers on the
678
+ * context and nothing reads the response: headers given as a record of strings are sent as is by
679
+ * `@hono/node-server` (no `Headers` object), and a text body as a string.
680
+ */
681
+ function toResponse(c: Context, reply: AdapterReply, middleware: boolean): Response {
443
682
  if (reply.raw === true) {
444
683
  discardStream(reply.body instanceof ReadableStream ? reply.body : null)
445
684
  return c.res
446
685
  }
686
+ if (!middleware && isStringRecord(reply.headers)) {
687
+ return new Response(reply.text ?? reply.body, {
688
+ status: reply.status,
689
+ headers: reply.headers as Readonly<Record<string, string>>,
690
+ })
691
+ }
447
692
  const headers = new Headers()
448
693
  try {
449
694
  for (const name of Object.keys(reply.headers)) {
@@ -460,8 +705,18 @@ function toResponse(c: Context, reply: AdapterReply): Response {
460
705
  headers: { 'content-type': 'application/json; charset=utf-8' },
461
706
  })
462
707
  }
463
- keepContextCookies(c, headers)
464
- return new Response(reply.body, { status: reply.status, headers })
708
+ if (middleware) keepContextCookies(c, headers)
709
+ return new Response(reply.text ?? reply.body, { status: reply.status, headers })
710
+ }
711
+
712
+ /** Whether every header has a single value (a record of strings is a valid `HeadersInit`). */
713
+ function isStringRecord(headers: Readonly<Record<string, HeaderValue>>): boolean {
714
+ // Object.keys: faster than for...in on the null-prototype records of the pipeline (V8)
715
+ const names = Object.keys(headers)
716
+ for (let index = 0; index < names.length; index++) {
717
+ if (typeof headers[names[index] as string] !== 'string') return false
718
+ }
719
+ return true
465
720
  }
466
721
 
467
722
  /**