@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
@@ -0,0 +1,46 @@
1
+ // src/utils.ts
2
+ /** Internal helpers (not part of the public API). */
3
+ /**
4
+ * Whether `value` is a promise-like object (has a callable `then`). The guard keeps the thenable
5
+ * part of the value's type (`Promise<R>` of `R | Promise<R>`, even when `R` is `object` or `any`), so
6
+ * that generated code awaiting it gets `R`.
7
+ */
8
+ function isThenable(value) {
9
+ return value !== null && (typeof value === "object" || typeof value === "function") && typeof value.then === "function";
10
+ }
11
+ /** Message of any thrown value. */
12
+ function errorMessage(error) {
13
+ if (error instanceof Error) return error.message;
14
+ if (typeof error === "string") return error;
15
+ try {
16
+ return String(error);
17
+ } catch {
18
+ return Object.prototype.toString.call(error);
19
+ }
20
+ }
21
+ /** A `Record` without prototype (safe for user-controlled keys such as `__proto__`). */
22
+ function emptyRecord() {
23
+ return Object.create(null);
24
+ }
25
+ /**
26
+ * Constructor of {@link fastRecord}: its prototype is an empty, frozen object without prototype, so
27
+ * nothing is inherited (`__proto__`, `constructor`, `toString`... are plain own keys).
28
+ */
29
+ const FastRecord = function FastRecord() {};
30
+ FastRecord.prototype = Object.freeze(Object.create(null));
31
+ /**
32
+ * A `Record` that inherits nothing, like {@link emptyRecord}, but that V8 keeps in fast mode: an
33
+ * `Object.create(null)` object is a dictionary, about twice as slow to fill and enumerate (response
34
+ * headers: built, completed and enumerated on every request). Its prototype is not `null` (an empty
35
+ * frozen object): use {@link emptyRecord} for records handed to user code (query, fields...).
36
+ */
37
+ function fastRecord() {
38
+ return new FastRecord();
39
+ }
40
+ /** Whether the code runs on Bun. */
41
+ function isBun() {
42
+ return typeof globalThis.Bun !== "undefined";
43
+ }
44
+ export { isThenable as a, isBun as i, errorMessage as n, fastRecord as r, emptyRecord as t };
45
+
46
+ //# sourceMappingURL=utils-DIWdzVht.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"utils-DIWdzVht.mjs","names":[],"sources":["../src/utils.ts"],"sourcesContent":["/** Internal helpers (not part of the public API). */\n\n/**\n * Whether `value` is a promise-like object (has a callable `then`). The guard keeps the thenable\n * part of the value's type (`Promise<R>` of `R | Promise<R>`, even when `R` is `object` or `any`), so\n * that generated code awaiting it gets `R`.\n */\nexport function isThenable<T>(\n value: T,\n): value is unknown extends T ? T & PromiseLike<unknown> : Extract<T, PromiseLike<unknown>> {\n return (\n value !== null &&\n (typeof value === 'object' || typeof value === 'function') &&\n typeof (value as { then?: unknown }).then === 'function'\n )\n}\n\n/** Message of any thrown value. */\nexport function errorMessage(error: unknown): string {\n if (error instanceof Error) return error.message\n if (typeof error === 'string') return error\n try {\n return String(error)\n } catch {\n return Object.prototype.toString.call(error)\n }\n}\n\n/** A `Record` without prototype (safe for user-controlled keys such as `__proto__`). */\nexport function emptyRecord<V>(): Record<string, V> {\n return Object.create(null) as Record<string, V>\n}\n\n/**\n * Constructor of {@link fastRecord}: its prototype is an empty, frozen object without prototype, so\n * nothing is inherited (`__proto__`, `constructor`, `toString`... are plain own keys).\n */\nconst FastRecord = function FastRecord() {} as unknown as new () => object\nFastRecord.prototype = Object.freeze(Object.create(null))\n\n/**\n * A `Record` that inherits nothing, like {@link emptyRecord}, but that V8 keeps in fast mode: an\n * `Object.create(null)` object is a dictionary, about twice as slow to fill and enumerate (response\n * headers: built, completed and enumerated on every request). Its prototype is not `null` (an empty\n * frozen object): use {@link emptyRecord} for records handed to user code (query, fields...).\n */\nexport function fastRecord<V>(): Record<string, V> {\n return new FastRecord() as Record<string, V>\n}\n\n/** Whether the code runs on Bun. */\nexport function isBun(): boolean {\n return typeof (globalThis as { Bun?: unknown }).Bun !== 'undefined'\n}\n"],"mappings":";;;;;;;AAOA,SAAgB,WACd,OAC0F;CAC1F,OACE,UAAU,SACT,OAAO,UAAU,YAAY,OAAO,UAAU,eAC/C,OAAQ,MAA6B,SAAS;AAElD;;AAGA,SAAgB,aAAa,OAAwB;CACnD,IAAI,iBAAiB,OAAO,OAAO,MAAM;CACzC,IAAI,OAAO,UAAU,UAAU,OAAO;CACtC,IAAI;EACF,OAAO,OAAO,KAAK;CACrB,QAAQ;EACN,OAAO,OAAO,UAAU,SAAS,KAAK,KAAK;CAC7C;AACF;;AAGA,SAAgB,cAAoC;CAClD,OAAO,OAAO,OAAO,IAAI;AAC3B;;;;;AAMA,MAAM,aAAa,SAAS,aAAa,CAAC;AAC1C,WAAW,YAAY,OAAO,OAAO,OAAO,OAAO,IAAI,CAAC;;;;;;;AAQxD,SAAgB,aAAmC;CACjD,OAAO,IAAI,WAAW;AACxB;;AAGA,SAAgB,QAAiB;CAC/B,OAAO,OAAQ,WAAiC,QAAQ;AAC1D"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@znight/http",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
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.3"
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.3"
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.3"
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`
@@ -133,6 +133,8 @@ class ExpressHttpAdapter implements ExpressAdapter {
133
133
  }
134
134
 
135
135
  readonly #listener = (request: IncomingMessage, response: ServerResponse): void => {
136
+ // the listening server's connections (an injected request is ignored)
137
+ this.#tracker?.track(request, response)
136
138
  this.app(request as Request, response as Response)
137
139
  }
138
140
 
@@ -141,7 +143,8 @@ class ExpressHttpAdapter implements ExpressAdapter {
141
143
  const server = this.#givenServer ?? createServer()
142
144
  if (!server.listeners('request').includes(this.#listener)) server.on('request', this.#listener)
143
145
  this.#server = server
144
- this.#tracker ??= new ConnectionTracker(server)
146
+ // `#listener` counts the requests
147
+ this.#tracker ??= new ConnectionTracker(server, { requests: false })
145
148
  this.#closing = undefined
146
149
  await new Promise<void>((resolve, reject) => {
147
150
  const onError = (error: Error): void => reject(error)
@@ -3,6 +3,8 @@
3
3
  * peer dependency `fastify`). Routing uses Fastify's router; body parsing, validation and
4
4
  * serialisation stay Znight's, so responses are identical to the other adapters.
5
5
  */
6
+
7
+ import type { OutgoingHttpHeaders } from 'node:http'
6
8
  import type {
7
9
  AdapterCloseOptions,
8
10
  AdapterHandler,
@@ -17,13 +19,17 @@ import type {
17
19
  InjectStreamRequest,
18
20
  InjectStreamResponse,
19
21
  } from '../adapter.ts'
22
+ import type { HeaderValue } from '../exceptions.ts'
20
23
 
24
+ import { tracingChannel } from 'node:diagnostics_channel'
21
25
  import { Readable } from 'node:stream'
22
26
  import Fastify, {
23
27
  type FastifyInstance,
24
28
  type FastifyReply,
25
29
  type FastifyRequest,
26
30
  type FastifyServerOptions,
31
+ type HookHandlerDoneFunction,
32
+ type RouteOptions,
27
33
  } from 'fastify'
28
34
 
29
35
  import { toFastifyPath } from '../path.ts'
@@ -86,11 +92,70 @@ const MAX_PARAM_LENGTH = 8192
86
92
  /** `content-type` headers hidden from Fastify (see `#register`), by request. */
87
93
  const HIDDEN_CONTENT_TYPES = new WeakMap<object, string>()
88
94
 
95
+ /**
96
+ * Fastify's tracing channel of route handlers: while it has subscribers (an APM), every request runs
97
+ * Fastify's whole lifecycle, so that the handler is traced.
98
+ */
99
+ const HANDLER_CHANNEL = tracingChannel('fastify.request.handler')
100
+
101
+ /** The `config` key marking a Znight route that may be served from the `onRequest` hook (see `#onRoute`). */
102
+ const DIRECT = Symbol('znight.direct')
103
+
104
+ /** The hooks that run between `onRequest` and the route handler. */
105
+ const HANDLER_HOOKS = ['onRequest', 'preParsing', 'preValidation', 'preHandler'] as const
106
+
107
+ /** What `#onRoute` records about a Znight route (in its `config`). */
108
+ interface DirectRoute {
109
+ readonly handler: AdapterHandler
110
+ /** No route-level `onSend` or `onResponse` hook. */
111
+ readonly bare: boolean
112
+ }
113
+
114
+ /**
115
+ * How the requests of a Fastify route context are served, decided on its first request (see
116
+ * `#lane`): `handler` from the `onRequest` hook, and `raw` when a text reply may be written on the
117
+ * raw response.
118
+ */
119
+ interface Lane {
120
+ readonly handler: AdapterHandler
121
+ readonly raw: boolean
122
+ }
123
+
124
+ /**
125
+ * The fields of Fastify's route context (`request[Symbol('fastify.context')]`) read by the adapter.
126
+ * They are internal: the adapter only skips Fastify's lifecycle when they have the expected shape.
127
+ */
128
+ interface RouteContext {
129
+ readonly config?: Readonly<Record<PropertyKey, unknown>>
130
+ readonly preParsing?: unknown
131
+ readonly preValidation?: unknown
132
+ readonly preHandler?: unknown
133
+ readonly onSend?: unknown
134
+ readonly onResponse?: unknown
135
+ }
136
+
137
+ /** The keys of Fastify's internal request and reply state read by the adapter (`null`: not found). */
138
+ interface InternalKeys {
139
+ /** The route context of a request. */
140
+ readonly context: symbol | null
141
+ /** The headers set with `reply.header()`. */
142
+ readonly headers: symbol | null
143
+ /** The trailers set with `reply.trailer()`. */
144
+ readonly trailers: symbol | null
145
+ /** The serializer set with `reply.serializer()` (`null` when none). */
146
+ readonly serializer: symbol | null
147
+ }
148
+
89
149
  class FastifyHttpAdapter implements FastifyAdapter {
90
150
  readonly name = 'fastify' as const
91
151
  readonly instance: FastifyInstance
92
152
  readonly #routes: AdapterRoute[] = []
93
153
  readonly #tracker: ConnectionTracker
154
+ /** The Fastify handler of each Znight route (before Fastify binds it). */
155
+ readonly #handlers = new Map<unknown, AdapterHandler>()
156
+ /** By Fastify route context: how its requests are served (`null`: through Fastify's lifecycle). */
157
+ readonly #lanes = new WeakMap<object, Lane | null>()
158
+ #keys: InternalKeys | undefined
94
159
  #fallback: FallbackHandler | undefined
95
160
  #ready: Promise<void> | undefined
96
161
  #closing: Promise<void> | undefined
@@ -129,26 +194,25 @@ class FastifyHttpAdapter implements FastifyAdapter {
129
194
  async #register(): Promise<void> {
130
195
  const routes = this.#routes
131
196
  await this.instance.register((child, _options, done) => {
132
- // Znight reads and parses bodies itself: accept every content type without consuming it
197
+ // Znight reads and parses bodies itself: accept every content type without consuming it. The
198
+ // RegExp parser matches every content type like '*' does, but Fastify caches what a RegExp
199
+ // matched (it parses the content type again on every request to find the '*' parser)
133
200
  child.removeAllContentTypeParsers()
134
- child.addContentTypeParser('*', (_request, _payload, done) => done(null, undefined))
135
- // Fastify answers 415 to a content type it cannot parse (`foo`, an empty one) before the route
136
- // runs: hide such a header from Fastify (Znight reads it, and parses the body as bytes)
137
- child.addHook('onRequest', (request, _reply, done) => {
138
- const contentType = request.headers['content-type']
139
- if (contentType !== undefined && request.mediaType === undefined) {
140
- HIDDEN_CONTENT_TYPES.set(request.raw, contentType)
141
- delete request.raw.headers['content-type']
142
- }
143
- done()
144
- })
201
+ child.addContentTypeParser('*', ignoreBody)
202
+ child.addContentTypeParser(/^/, ignoreBody)
203
+ child.addHook('onRequest', this.#onRequest)
204
+ // added last: sees the route options once every other `onRoute` hook changed them
205
+ child.addHook('onRoute', this.#onRoute)
145
206
  for (const route of routes) {
146
207
  const handler = route.handler
208
+ const fastifyHandler = (request: FastifyRequest, reply: FastifyReply): Promise<FastifyReply> =>
209
+ this.#reply(reply, handler(adapterRequest(request, reply)))
210
+ this.#handlers.set(fastifyHandler, handler)
147
211
  child.route({
148
212
  method: route.method,
149
213
  url: toFastifyPath(route.path),
150
214
  exposeHeadRoute: false,
151
- handler: (request, reply) => this.#reply(reply, handler(adapterRequest(request, reply))),
215
+ handler: fastifyHandler,
152
216
  })
153
217
  }
154
218
  child.setNotFoundHandler((request, reply) =>
@@ -162,6 +226,126 @@ class FastifyHttpAdapter implements FastifyAdapter {
162
226
  await this.instance.ready()
163
227
  }
164
228
 
229
+ /**
230
+ * Records in the `config` of a Znight route whether it may be served from the `onRequest` hook:
231
+ * its handler is still the adapter's (no `onRoute` hook wrapped it) and no route-level hook or
232
+ * schema runs between `onRequest` and the handler. The hooks of the instance are checked on the
233
+ * first request (see `#lane`).
234
+ */
235
+ readonly #onRoute = (options: RouteOptions): void => {
236
+ const handler = this.#handlers.get(options.handler)
237
+ if (handler === undefined || options.schema !== undefined) return
238
+ if (HANDLER_HOOKS.some(hook => hasHooks(options[hook]))) return
239
+ const route: DirectRoute = { handler, bare: !hasHooks(options.onSend) && !hasHooks(options.onResponse) }
240
+ options.config = { ...options.config, [DIRECT]: route }
241
+ }
242
+
243
+ /**
244
+ * The `onRequest` hook of the Znight context. A route request that no later hook, schema or
245
+ * tracing subscriber needs (see `#lane`) is served from here: Fastify skips its content type
246
+ * parsing and the steps between this hook and the handler, which do nothing for Znight routes.
247
+ * Otherwise the request goes on through Fastify's lifecycle.
248
+ */
249
+ readonly #onRequest = (request: FastifyRequest, reply: FastifyReply, done: HookHandlerDoneFunction): void => {
250
+ const lane = this.#lane(request, reply)
251
+ if (lane !== null && !HANDLER_CHANNEL.hasSubscribers) {
252
+ this.#serve(lane, request, reply)
253
+ return
254
+ }
255
+ // Fastify answers 415 to a content type it cannot parse (`foo`, an empty one) before the route
256
+ // runs: hide such a header from Fastify (Znight reads it, and parses the body as bytes)
257
+ const contentType = request.headers['content-type']
258
+ if (contentType !== undefined && request.mediaType === undefined) {
259
+ HIDDEN_CONTENT_TYPES.set(request.raw, contentType)
260
+ delete request.raw.headers['content-type']
261
+ }
262
+ done()
263
+ }
264
+
265
+ /**
266
+ * How the requests of the route of `request` are served, decided once per route context: from the
267
+ * `onRequest` hook when `#onRoute` marked the route and the instance has no `preParsing`,
268
+ * `preValidation` or `preHandler` hook (the `onRequest` hook of the Znight context is then the
269
+ * last one); with a text reply written on the raw response when no `onSend` or `onResponse` hook
270
+ * runs either (see `#writeRaw`). `null`: through Fastify's lifecycle (404s, hooks, a Fastify
271
+ * version whose route context differs).
272
+ */
273
+ #lane(request: FastifyRequest, reply: FastifyReply): Lane | null {
274
+ this.#keys ??= {
275
+ context: ownSymbol(request, 'fastify.context'),
276
+ headers: ownSymbol(reply, 'fastify.reply.headers'),
277
+ trailers: ownSymbol(reply, 'fastify.reply.trailers'),
278
+ serializer: ownSymbol(reply, 'fastify.reply.serializer'),
279
+ }
280
+ const key = this.#keys.context
281
+ const context = key === null ? undefined : (request as unknown as Record<symbol, unknown>)[key]
282
+ if (typeof context !== 'object' || context === null) return null
283
+ let lane = this.#lanes.get(context)
284
+ if (lane === undefined) {
285
+ lane = laneOf(context as RouteContext, this.#keys)
286
+ this.#lanes.set(context, lane)
287
+ }
288
+ return lane
289
+ }
290
+
291
+ /** Serves a route request from the `onRequest` hook (see `#lane`). */
292
+ #serve(lane: Lane, request: FastifyRequest, reply: FastifyReply): void {
293
+ let pending: Promise<AdapterReply>
294
+ try {
295
+ pending = lane.handler(new FastifyAdapterRequest(request, reply, reply))
296
+ } catch (error) {
297
+ // what Fastify does when a route handler throws
298
+ reply.send(asError(error))
299
+ return
300
+ }
301
+ pending.then(
302
+ result => this.#settle(lane, reply, result),
303
+ () => this.#settle(lane, reply, internalError()),
304
+ )
305
+ }
306
+
307
+ /** Writes the reply of a request served from the `onRequest` hook; a failure is handled like Fastify does. */
308
+ #settle(lane: Lane, reply: FastifyReply, result: AdapterReply): void {
309
+ try {
310
+ if (!lane.raw || !this.#writeRaw(reply, result)) this.#write(reply, result)
311
+ } catch (error) {
312
+ // what Fastify does when the promise of a route handler rejects
313
+ if (reply.sent) reply.log.error({ err: error }, 'Promise errored, but reply.sent = true was set')
314
+ else reply.send(asError(error))
315
+ }
316
+ }
317
+
318
+ /**
319
+ * Writes a text reply on the raw response, as `reply.send(text)` would without `onSend` hook: one
320
+ * `writeHead()` with the same headers in the same order, then the text. Only when nothing set
321
+ * reply headers or trailers before (an `onRequest` hook) and Fastify would send the headers and
322
+ * text unchanged (content type, exact `content-length`). Returns whether it wrote the reply.
323
+ */
324
+ #writeRaw(reply: FastifyReply, result: AdapterReply): boolean {
325
+ const text = result.text
326
+ const body = result.body
327
+ if (text === undefined || !(body instanceof Uint8Array) || result.raw === true) return false
328
+ const headers = result.headers
329
+ const length = headers['content-length']
330
+ if (typeof length !== 'string' || Number(length) !== body.byteLength) return false
331
+ // a content type Fastify cannot parse is replaced by `reply.send()` (text/plain)
332
+ const type = headers['content-type']
333
+ if (!sendsTextAsIs(type) || (type !== JSON_TYPE && !MEDIA_TYPE.test(type as string))) return false
334
+ const response = reply.raw
335
+ if (reply.sent || response.destroyed || response.headersSent || reply.request.raw.httpVersionMajor !== 1) {
336
+ return false
337
+ }
338
+ const keys = this.#keys as InternalKeys
339
+ const state = reply as unknown as Record<symbol, unknown>
340
+ if (keys.headers === null || keys.trailers === null || state[keys.trailers] !== null) return false
341
+ // a serializer set by a hook transforms text sent with `reply.send()`
342
+ if (keys.serializer === null || state[keys.serializer] !== null) return false
343
+ if (!isEmptyRecord(state[keys.headers])) return false
344
+ response.writeHead(result.status, headers as OutgoingHttpHeaders)
345
+ response.end(text)
346
+ return true
347
+ }
348
+
165
349
  #dispatchFallback(request: FastifyRequest, reply: FastifyReply, error: unknown): Promise<AdapterReply> {
166
350
  if (this.#fallback === undefined) return Promise.reject(error ?? new Error('No fallback handler'))
167
351
  return this.#fallback(adapterRequest(request, undefined, reply), error)
@@ -172,15 +356,17 @@ class FastifyHttpAdapter implements FastifyAdapter {
172
356
  try {
173
357
  result = await pending
174
358
  } catch {
175
- result = {
176
- status: 500,
177
- headers: { 'content-type': 'application/json; charset=utf-8' },
178
- body: new TextEncoder().encode('{"statusCode":500,"error":"Internal Server Error"}'),
179
- }
359
+ result = internalError()
180
360
  }
361
+ this.#write(reply, result)
362
+ return reply
363
+ }
364
+
365
+ /** Writes a reply through Fastify (`reply.send()`: `onSend` hooks...). */
366
+ #write(reply: FastifyReply, result: AdapterReply): void {
181
367
  if (result.raw === true || reply.sent || reply.raw.destroyed) {
182
368
  discardStream(result.body instanceof ReadableStream ? result.body : null)
183
- return reply
369
+ return
184
370
  }
185
371
  if (result.eventStream === true) {
186
372
  // no onSend hook (compression, ETag...) may rewrite an event stream: write it on the raw
@@ -189,16 +375,25 @@ class FastifyHttpAdapter implements FastifyAdapter {
189
375
  const native = lowerCaseHeaders(reply.getHeaders())
190
376
  const headers = { ...native, ...withNativeCookies(native['set-cookie'], result.headers) }
191
377
  reply.hijack()
192
- void writeNodeReply(reply.raw, { ...result, headers })
193
- return reply
378
+ writeNodeReply(reply.raw, { ...result, headers })
379
+ return
194
380
  }
195
381
  reply.code(result.status)
196
- for (const name of Object.keys(result.headers)) reply.header(name, result.headers[name])
382
+ const headers = result.headers
383
+ for (const name of Object.keys(headers)) reply.header(name, headers[name])
197
384
  const body = result.body
198
- if (body === null) return reply.send()
199
- if (body instanceof Uint8Array) return reply.send(Buffer.from(body.buffer, body.byteOffset, body.byteLength))
385
+ if (body === null) {
386
+ reply.send()
387
+ return
388
+ }
389
+ if (body instanceof Uint8Array) {
390
+ // a string is written with the headers at once (bytes take a second write)
391
+ if (result.text !== undefined && sendsTextAsIs(headers['content-type'])) reply.send(result.text)
392
+ else reply.send(Buffer.isBuffer(body) ? body : Buffer.from(body.buffer, body.byteOffset, body.byteLength))
393
+ return
394
+ }
200
395
  const stream = body instanceof Blob ? body.stream() : body
201
- return reply.send(Readable.fromWeb(stream as import('node:stream/web').ReadableStream))
396
+ reply.send(Readable.fromWeb(stream as import('node:stream/web').ReadableStream))
202
397
  }
203
398
 
204
399
  async listen(port: number, host: string): Promise<HttpServerAddress> {
@@ -236,6 +431,70 @@ class FastifyHttpAdapter implements FastifyAdapter {
236
431
  }
237
432
  }
238
433
 
434
+ /**
435
+ * The lane of a route context (see `FastifyHttpAdapter.#lane`). Once the instance is ready, every
436
+ * lifecycle hook list of a context is `null` or a non-empty array.
437
+ */
438
+ function laneOf(context: RouteContext, keys: InternalKeys): Lane | null {
439
+ const route = context.config?.[DIRECT] as DirectRoute | undefined
440
+ if (route === undefined) return null
441
+ if (context.preParsing !== null || context.preValidation !== null || context.preHandler !== null) return null
442
+ const raw = route.bare && context.onSend === null && context.onResponse === null
443
+ return { handler: route.handler, raw: raw && keys.headers !== null && keys.trailers !== null }
444
+ }
445
+
446
+ /** The content type parser of the Znight context: the body is left unread (Znight reads it). */
447
+ function ignoreBody(_request: FastifyRequest, _payload: unknown, done: (error: null, body: undefined) => void): void {
448
+ done(null, undefined)
449
+ }
450
+
451
+ /** Whether a route option holds hooks (a function or a non-empty array). */
452
+ function hasHooks(value: unknown): boolean {
453
+ return value !== undefined && value !== null && !(Array.isArray(value) && value.length === 0)
454
+ }
455
+
456
+ /** An own symbol-keyed property of a Fastify object, by the symbol's description (internal state). */
457
+ function ownSymbol(target: object, description: string): symbol | null {
458
+ for (const symbol of Object.getOwnPropertySymbols(target)) if (symbol.description === description) return symbol
459
+ return null
460
+ }
461
+
462
+ function isEmptyRecord(value: unknown): boolean {
463
+ if (typeof value !== 'object' || value === null) return false
464
+ for (const _key in value) return false
465
+ return true
466
+ }
467
+
468
+ function asError(error: unknown): Error {
469
+ return error instanceof Error ? error : new Error(String(error), { cause: error })
470
+ }
471
+
472
+ /** The last-resort 500 of a handler promise that rejected (the pipeline maps errors: should not happen). */
473
+ function internalError(): AdapterReply {
474
+ return {
475
+ status: 500,
476
+ headers: { 'content-type': 'application/json; charset=utf-8' },
477
+ body: new TextEncoder().encode('{"statusCode":500,"error":"Internal Server Error"}'),
478
+ }
479
+ }
480
+
481
+ /** The JSON content type of the plugin (with a charset). */
482
+ const JSON_TYPE = 'application/json; charset=utf-8'
483
+
484
+ /**
485
+ * Whether Fastify sends a string with this content type unchanged: it adds a charset to a JSON
486
+ * type without one, and a content type to a string without one.
487
+ */
488
+ /** A `type/subtype` media type, optionally followed by parameters: what Fastify's `ContentType` accepts. */
489
+ const MEDIA_TYPE = /^\s*[\w!#$%&'*+.^`|~-]+\/[\w!#$%&'*+.^`|~-]+\s*(?:;|$)/
490
+
491
+ function sendsTextAsIs(type: HeaderValue | undefined): boolean {
492
+ if (typeof type !== 'string') return false
493
+ if (type === JSON_TYPE) return true
494
+ const semicolon = type.indexOf(';')
495
+ return !(semicolon === -1 ? type : type.slice(0, semicolon)).toLowerCase().includes('json')
496
+ }
497
+
239
498
  function lowerCaseHeaders(
240
499
  headers: Readonly<Record<string, string | number | readonly string[] | undefined>>,
241
500
  ): Record<string, string | string[]> {
@@ -257,24 +516,50 @@ function adapterRequest(
257
516
  HIDDEN_CONTENT_TYPES.delete(request.raw)
258
517
  request.raw.headers['content-type'] = hidden
259
518
  }
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
- },
519
+ return new FastifyAdapterRequest(request, reply, response)
520
+ }
521
+
522
+ /** A Fastify request as an {@link AdapterRequest} (a class: no closure per request). */
523
+ class FastifyAdapterRequest implements AdapterRequest {
524
+ readonly method: string
525
+ readonly url: string
526
+ readonly headers: Readonly<Record<string, string | undefined>>
527
+ readonly ip: string | undefined
528
+ readonly remoteAddress: string | undefined
529
+ readonly encrypted: boolean
530
+ readonly raw: FastifyRequest
531
+ /** `@Res()`: the reply of a route, `undefined` for the fallback handler. */
532
+ readonly rawResponse: FastifyReply | undefined
533
+ /** The reply whose disconnection aborts the request. */
534
+ readonly #response: FastifyReply | undefined
535
+ #body: Promise<Uint8Array> | undefined
536
+
537
+ constructor(request: FastifyRequest, reply: FastifyReply | undefined, response: FastifyReply | undefined) {
538
+ this.method = request.raw.method ?? request.method
539
+ this.url = request.raw.url ?? request.url
540
+ this.headers = normalizeNodeHeaders(request.headers)
541
+ this.ip = request.ip
542
+ this.remoteAddress = request.raw.socket?.remoteAddress
543
+ this.encrypted = isEncrypted(request.raw)
544
+ this.raw = request
545
+ this.rawResponse = reply
546
+ this.#response = response
547
+ }
548
+
549
+ readBody(limit: number): Promise<Uint8Array> {
550
+ this.#body ??= readNodeBody(this.raw.raw, limit)
551
+ return this.#body
552
+ }
553
+
554
+ streamBody(limit: number): ReadableStream<Uint8Array> {
555
+ return nodeBodyStream(this.raw.raw, limit)
556
+ }
557
+
558
+ rawStatus(): number | undefined {
559
+ return this.rawResponse?.statusCode
560
+ }
561
+
562
+ onAbort(listener: () => void): void {
563
+ if (this.#response !== undefined) onResponseAbort(this.#response.raw, listener)
279
564
  }
280
565
  }