@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/LICENSE +1 -1
- package/README.md +4 -0
- package/dist/{adapter-CGSceVEN.d.mts → adapter-J0UXFnNM.d.mts} +7 -1
- package/dist/compiler.d.mts +1 -1
- package/dist/express.d.mts +1 -1
- package/dist/express.mjs +1 -1
- package/dist/fastify.d.mts +1 -1
- package/dist/fastify.mjs +57 -22
- package/dist/fastify.mjs.map +1 -1
- package/dist/hono.d.mts +1 -1
- package/dist/hono.mjs +112 -38
- package/dist/hono.mjs.map +1 -1
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +251 -157
- package/dist/index.mjs.map +1 -1
- package/dist/{node-BUnkdSsa.mjs → node-Clm1Ia7T.mjs} +2 -2
- package/dist/{node-BUnkdSsa.mjs.map → node-Clm1Ia7T.mjs.map} +1 -1
- package/dist/{node-io-BY1ZDLRQ.mjs → node-io-CVnqpVNS.mjs} +52 -26
- package/dist/{node-io-BY1ZDLRQ.mjs.map → node-io-CVnqpVNS.mjs.map} +1 -1
- package/dist/node.d.mts +1 -1
- package/dist/node.mjs +1 -1
- package/package.json +4 -4
- package/src/adapter.ts +6 -0
- package/src/adapters/fastify.ts +66 -21
- package/src/adapters/hono.ts +135 -47
- package/src/adapters/node-io.ts +57 -24
- package/src/context.ts +7 -4
- package/src/pipeline.ts +237 -174
- package/src/plugin.ts +9 -2
- package/src/query.ts +7 -1
- package/src/serialize.ts +51 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@znight/http",
|
|
3
|
-
"version": "0.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.
|
|
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.
|
|
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.
|
|
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`
|
package/src/adapters/fastify.ts
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
}
|
package/src/adapters/hono.ts
CHANGED
|
@@ -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
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
|
368
|
-
headers
|
|
369
|
-
ip
|
|
370
|
-
remoteAddress
|
|
371
|
-
encrypted
|
|
372
|
-
raw
|
|
373
|
-
rawResponse
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
|
-
|
|
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
|
/**
|
package/src/adapters/node-io.ts
CHANGED
|
@@ -183,23 +183,52 @@ export function nodeAdapterRequest(
|
|
|
183
183
|
response: ServerResponse,
|
|
184
184
|
options: NodeRequestOptions = {},
|
|
185
185
|
): AdapterRequest {
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
|
228
|
-
*
|
|
256
|
+
* Writes a reply to a `ServerResponse`, piping a stream body (a failing stream destroys the
|
|
257
|
+
* response). Never throws.
|
|
229
258
|
*/
|
|
230
|
-
export
|
|
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
|
-
|
|
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
|
|
299
|
+
const fail = (): void => {
|
|
300
|
+
if (!response.destroyed) response.destroy()
|
|
301
|
+
}
|
|
270
302
|
try {
|
|
271
|
-
|
|
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
|
-
|
|
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 ?
|
|
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[]>())
|