@znight/http 0.1.2 → 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.
@@ -22,11 +22,20 @@ import type { HeaderValue } from '../exceptions.ts'
22
22
 
23
23
  import { type Context, Hono, type Next } from 'hono'
24
24
 
25
+ import { BadRequestException } from '../exceptions.ts'
25
26
  import { toHonoPath } from '../path.ts'
26
27
  import { discardStream } from '../stream.ts'
27
28
  import { isBun } from '../utils.ts'
28
29
  import { ConnectionTracker, closeServer, nodeBodyStream, onResponseAbort, readNodeBody } from './node-io.ts'
29
- import { bufferInjectStream, injectFetchStream, readWebStream, serverAddress, webBodyStream } from './shared.ts'
30
+ import {
31
+ bufferInjectStream,
32
+ exceedsDeclaredLength,
33
+ injectFetchStream,
34
+ payloadTooLarge,
35
+ readWebStream,
36
+ serverAddress,
37
+ webBodyStream,
38
+ } from './shared.ts'
30
39
 
31
40
  /** Options of {@link honoAdapter}. */
32
41
  export interface HonoAdapterOptions {
@@ -103,12 +112,47 @@ interface BunRuntime {
103
112
  /** Default `idleTimeout` of `Bun.serve()`, in seconds. */
104
113
  const BUN_IDLE_TIMEOUT = 10
105
114
 
106
- /** A response body built while serving with `Bun.serve()` (see `#bunFetch`). */
107
- 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 {
108
120
  /** The `Blob` (file) the body reads. */
109
121
  readonly blob: Blob | undefined
110
- /** Sets the function called when the body ends (read to its end, failed or cancelled). */
111
- 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
+ }
112
156
  }
113
157
 
114
158
  /** Response bodies built while serving with `Bun.serve()`, by body (Hono may copy a `Response`, not its body). */
@@ -127,15 +171,31 @@ const INJECT_ENV = Object.freeze({ incoming: Object.freeze({ socket: Object.free
127
171
 
128
172
  type HonoMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS'
129
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
+
130
180
  class HonoHttpAdapter implements HonoAdapter {
131
181
  readonly name = 'hono' as const
132
182
  readonly app: Hono
133
183
  readonly #serveOptions: HonoServeOptions
134
184
  /** Number of routes of `app` registered by the adapter (see {@link #middleware}). */
135
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>()
136
188
  #fallback: FallbackHandler | undefined
137
189
  #prepared = false
138
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
139
199
  #node: Server | undefined
140
200
  #tracker: ConnectionTracker | undefined
141
201
  #closing: Promise<void> | undefined
@@ -153,14 +213,29 @@ class HonoHttpAdapter implements HonoAdapter {
153
213
  registerRoute(route: AdapterRoute): void {
154
214
  const handler = route.handler
155
215
  const path = toHonoPath(route.path)
156
- this.#routes++
157
- if (route.method === 'HEAD') {
158
- // Hono routes HEAD requests to GET handlers: serve them from a GET handler that only answers HEAD
159
- this.app.get(path, (c: Context, next: Next) =>
160
- c.req.raw.method === 'HEAD' ? this.#serve(c, handler) : next().then(() => c.res),
161
- )
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
+ })
162
236
  return
163
237
  }
238
+ this.#routes++
164
239
  this.app.on(route.method as HonoMethod, path, (c: Context) => this.#serve(c, handler))
165
240
  }
166
241
 
@@ -176,11 +251,12 @@ class HonoHttpAdapter implements HonoAdapter {
176
251
  }
177
252
 
178
253
  async #serve(c: Context, handler: AdapterHandler): Promise<Response> {
179
- const request = adapterRequest(c)
254
+ const request = this.#request(c)
180
255
  // Hono routes the WHATWG-normalised URL (`/a/../b` → `/b`, `%2e%2e` resolved): when the raw
181
- // 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
182
258
  const reply =
183
- 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))
184
260
  ? await this.#fallback(request)
185
261
  : await handler(request)
186
262
  return this.#toResponse(c, reply)
@@ -188,7 +264,16 @@ class HonoHttpAdapter implements HonoAdapter {
188
264
 
189
265
  async #serveFallback(c: Context, error: unknown): Promise<Response> {
190
266
  if (this.#fallback === undefined) throw error ?? new Error('No fallback handler')
191
- 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
192
277
  }
193
278
 
194
279
  /**
@@ -201,6 +286,11 @@ class HonoHttpAdapter implements HonoAdapter {
201
286
 
202
287
  #toResponse(c: Context, reply: AdapterReply): Response {
203
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
+ }
204
294
  const outgoing = nodeOutgoing(c)
205
295
  if (outgoing !== undefined) {
206
296
  const body = reply.body instanceof ReadableStream ? reply.body : null
@@ -220,16 +310,23 @@ class HonoHttpAdapter implements HonoAdapter {
220
310
  // a quiet event stream must outlive a socket timeout
221
311
  if (reply.eventStream === true && typeof outgoing.setTimeout === 'function') outgoing.setTimeout(0)
222
312
  }
223
- if (this.#bun === undefined || !(reply.body instanceof ReadableStream)) return toResponse(c, reply, middleware)
224
- // served by Bun.serve(): observe the end of the body (idle timeout, native blob: see #bunFetch)
225
- const reader = reply.body.getReader()
226
- let ended = false
227
- let onEnd = (): void => undefined
228
- const end = (): void => {
229
- if (ended) return
230
- ended = true
231
- onEnd()
232
- }
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
+ )
233
330
  const body = new ReadableStream<Uint8Array>(
234
331
  {
235
332
  async pull(controller) {
@@ -237,39 +334,53 @@ class HonoHttpAdapter implements HonoAdapter {
237
334
  try {
238
335
  result = await reader.read()
239
336
  } catch (error) {
240
- end()
337
+ served.end()
241
338
  throw error
242
339
  }
243
340
  if (result.done) {
244
- end() // before closing: Bun ignores timeout() once the response is complete
341
+ served.end() // before closing: Bun ignores timeout() once the response is complete
245
342
  controller.close()
246
343
  return
247
344
  }
248
345
  controller.enqueue(result.value)
249
346
  },
250
347
  cancel(reason) {
251
- end()
348
+ served.end()
252
349
  return reader.cancel(reason)
253
350
  },
254
351
  },
255
352
  { highWaterMark: 0 },
256
353
  )
257
- BUN_BODIES.set(body, {
258
- blob: reply.blob,
259
- onEnd: callback => {
260
- onEnd = callback
261
- },
262
- })
263
- return toResponse(c, { ...reply, body }, middleware)
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
264
364
  }
265
365
 
266
- /** The fetch handler given to `Bun.serve()`. */
267
- 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> {
268
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
269
371
  const body = response.body
270
372
  const served = body === null ? undefined : BUN_BODIES.get(body)
271
373
  if (body === null || served === undefined) return response
272
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 {
273
384
  if (served.blob !== undefined && !body.locked) {
274
385
  // a Blob is sent by Bun itself (sendfile, exact Content-Length; a stream would be chunked). Bun
275
386
  // opens its file as soon as it takes the response: release the stream (and with it the request
@@ -282,9 +393,12 @@ class HonoHttpAdapter implements HonoAdapter {
282
393
  setImmediate(() => discardStream(body))
283
394
  return native
284
395
  }
285
- 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') {
286
399
  // the idle timeout of Bun.serve() also cuts a response whose stream pauses (SSE, slow producers):
287
- // 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)
288
402
  server.timeout(request, 0)
289
403
  served.onEnd(() => {
290
404
  try {
@@ -303,14 +417,16 @@ class HonoHttpAdapter implements HonoAdapter {
303
417
  const bun = (globalThis as { Bun?: BunRuntime }).Bun
304
418
  if (isBun() && bun !== undefined) {
305
419
  const options = this.#serveOptions
306
- const idleTimeout = typeof options.idleTimeout === 'number' ? options.idleTimeout : BUN_IDLE_TIMEOUT
420
+ this.#idleTimeout = typeof options.idleTimeout === 'number' ? options.idleTimeout : BUN_IDLE_TIMEOUT
307
421
  const server = bun.serve({
308
422
  // Znight enforces the body limits (route @BodyLimit, plugin bodyLimit): not Bun's 128 MiB default
309
423
  maxRequestBodySize: Number.MAX_SAFE_INTEGER,
310
424
  ...options,
311
425
  port,
312
426
  hostname: host,
313
- 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),
314
430
  })
315
431
  this.#bun = server
316
432
  return serverAddress(server.hostname, server.port)
@@ -367,10 +483,6 @@ class HonoHttpAdapter implements HonoAdapter {
367
483
  }
368
484
  }
369
485
 
370
- function adapterRequest(c: Context): AdapterRequest {
371
- return new HonoAdapterRequest(c)
372
- }
373
-
374
486
  /** A Hono request as an {@link AdapterRequest} (a class: no closure per request). */
375
487
  class HonoAdapterRequest implements AdapterRequest {
376
488
  readonly method: string
@@ -381,6 +493,8 @@ class HonoAdapterRequest implements AdapterRequest {
381
493
  readonly encrypted: boolean
382
494
  readonly raw: Request
383
495
  readonly rawResponse: Context
496
+ /** `url` is the raw request target (Node.js), not the WHATWG-normalised URL of `raw`. */
497
+ readonly rawTarget: boolean
384
498
  /** The Node.js request of `@hono/node-server`, absent on Bun and for `inject()`. */
385
499
  readonly #incoming: NodeIncoming | undefined
386
500
  /**
@@ -388,22 +502,42 @@ class HonoAdapterRequest implements AdapterRequest {
388
502
  * stream are built for it.
389
503
  */
390
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
391
507
  #body: Promise<Uint8Array> | undefined
392
508
 
393
- constructor(c: Context) {
509
+ constructor(c: Context, bun: BunServer | undefined) {
394
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
+ }
395
530
  const incoming = nodeIncoming(c)
396
531
  const http1 = incoming !== undefined && incoming.httpVersionMajor < 2 ? (incoming as IncomingMessage) : undefined
397
532
  const address = remoteAddress(c)
398
- this.method = request.method
399
- // the raw target on Node.js; on Bun only the normalised URL is known
400
- this.url = pathAndQuery(typeof incoming?.url === 'string' && incoming.url !== '' ? incoming.url : request.url)
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)
401
537
  this.headers = (http1 === undefined ? undefined : distinctHeaders(http1)) ?? fetchHeaders(request.headers)
402
538
  this.ip = address
403
539
  this.remoteAddress = address
404
540
  this.encrypted = incoming === undefined ? request.url.startsWith('https:') : incoming.socket?.encrypted === true
405
- this.raw = request
406
- this.rawResponse = c
407
541
  this.#incoming = incoming
408
542
  this.#http1 = http1
409
543
  }
@@ -411,14 +545,22 @@ class HonoAdapterRequest implements AdapterRequest {
411
545
  readBody(limit: number): Promise<Uint8Array> {
412
546
  if (this.#body === undefined) {
413
547
  const http1 = this.#http1
414
- this.#body =
415
- http1 === undefined
416
- ? readWebStream(() => this.raw.body, limit, this.headers['content-length'])
417
- : readNodeBody(http1, limit)
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'])
418
551
  }
419
552
  return this.#body
420
553
  }
421
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
+
422
564
  streamBody(limit: number): ReadableStream<Uint8Array> {
423
565
  const http1 = this.#http1
424
566
  if (http1 !== undefined) return nodeBodyStream(http1, limit)
@@ -436,7 +578,7 @@ class HonoAdapterRequest implements AdapterRequest {
436
578
  }
437
579
 
438
580
  onAbort(listener: () => void): void {
439
- const outgoing = nodeOutgoing(this.rawResponse)
581
+ const outgoing = this.#bun ? undefined : nodeOutgoing(this.rawResponse)
440
582
  if (outgoing !== undefined) {
441
583
  onResponseAbort(outgoing as unknown as ServerResponse, listener)
442
584
  return
@@ -451,10 +593,31 @@ class HonoAdapterRequest implements AdapterRequest {
451
593
  /** The headers of a Fetch request: lower-case names, repeated headers joined (`Headers` semantics). */
452
594
  function fetchHeaders(source: Headers): Record<string, string> {
453
595
  const headers: Record<string, string> = {}
454
- for (const [name, value] of source) headers[name] = value
596
+ // forEach: no iterator nor entry array per header
597
+ source.forEach((value, name) => {
598
+ headers[name] = value
599
+ })
455
600
  return headers
456
601
  }
457
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
+
458
621
  /**
459
622
  * The headers of a Node.js request when none is repeated: then they hold the values of its Fetch
460
623
  * `Headers` (Node.js keeps the first of some repeated headers where `Headers` join them all).
@@ -548,7 +711,11 @@ function toResponse(c: Context, reply: AdapterReply, middleware: boolean): Respo
548
711
 
549
712
  /** Whether every header has a single value (a record of strings is a valid `HeadersInit`). */
550
713
  function isStringRecord(headers: Readonly<Record<string, HeaderValue>>): boolean {
551
- for (const name in headers) if (typeof headers[name] !== 'string') return false
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
+ }
552
719
  return true
553
720
  }
554
721