@znight/http 0.1.2 → 0.1.4

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 (46) hide show
  1. package/README.md +154 -35
  2. package/dist/compiler.d.mts +77 -2
  3. package/dist/compiler.mjs +259 -101
  4. package/dist/compiler.mjs.map +1 -1
  5. package/dist/express.mjs +3 -2
  6. package/dist/express.mjs.map +1 -1
  7. package/dist/fastify.mjs +200 -23
  8. package/dist/fastify.mjs.map +1 -1
  9. package/dist/hono.mjs +174 -43
  10. package/dist/hono.mjs.map +1 -1
  11. package/dist/index.d.mts +36 -4
  12. package/dist/index.mjs +1005 -257
  13. package/dist/index.mjs.map +1 -1
  14. package/dist/{node-Clm1Ia7T.mjs → node-DnYv_AMI.mjs} +4 -3
  15. package/dist/{node-Clm1Ia7T.mjs.map → node-DnYv_AMI.mjs.map} +1 -1
  16. package/dist/{node-io-CVnqpVNS.mjs → node-io-BkdRSQeg.mjs} +99 -60
  17. package/dist/{node-io-CVnqpVNS.mjs.map → node-io-BkdRSQeg.mjs.map} +1 -1
  18. package/dist/node.mjs +1 -1
  19. package/dist/utils-DIWdzVht.mjs +46 -0
  20. package/dist/utils-DIWdzVht.mjs.map +1 -0
  21. package/package.json +4 -4
  22. package/src/adapters/express.ts +4 -1
  23. package/src/adapters/fastify.ts +265 -25
  24. package/src/adapters/hono.ts +225 -58
  25. package/src/adapters/node-io.ts +128 -58
  26. package/src/adapters/node.ts +4 -1
  27. package/src/body.ts +31 -2
  28. package/src/coerce.ts +31 -0
  29. package/src/compiler/analyze.ts +3 -3
  30. package/src/compiler/generate.ts +127 -22
  31. package/src/compiler/index.ts +13 -0
  32. package/src/compiler/route-decorators.ts +45 -2
  33. package/src/compiler/route-groups.ts +216 -0
  34. package/src/compiler/validate.ts +15 -126
  35. package/src/context.ts +148 -78
  36. package/src/decorators.ts +19 -3
  37. package/src/index.ts +2 -1
  38. package/src/pipeline.ts +936 -177
  39. package/src/plugin-routes.ts +19 -0
  40. package/src/plugin.ts +7 -3
  41. package/src/response.ts +6 -8
  42. package/src/serialize.ts +48 -21
  43. package/src/utils.ts +25 -2
  44. package/src/validate.ts +28 -12
  45. package/dist/utils-DCbu3a4w.mjs +0 -27
  46. 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.2",
3
+ "version": "0.1.4",
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.2"
82
+ "@znight/core": "0.1.4"
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.2"
90
+ "@znight/compiler": "^0.1.4"
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.2"
121
+ "@znight/compiler": "0.1.4"
122
122
  },
123
123
  "znight": {
124
124
  "apiVersion": 1,
@@ -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,
@@ -19,12 +21,15 @@ import type {
19
21
  } from '../adapter.ts'
20
22
  import type { HeaderValue } from '../exceptions.ts'
21
23
 
24
+ import { tracingChannel } from 'node:diagnostics_channel'
22
25
  import { Readable } from 'node:stream'
23
26
  import Fastify, {
24
27
  type FastifyInstance,
25
28
  type FastifyReply,
26
29
  type FastifyRequest,
27
30
  type FastifyServerOptions,
31
+ type HookHandlerDoneFunction,
32
+ type RouteOptions,
28
33
  } from 'fastify'
29
34
 
30
35
  import { toFastifyPath } from '../path.ts'
@@ -87,11 +92,70 @@ const MAX_PARAM_LENGTH = 8192
87
92
  /** `content-type` headers hidden from Fastify (see `#register`), by request. */
88
93
  const HIDDEN_CONTENT_TYPES = new WeakMap<object, string>()
89
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
+
90
149
  class FastifyHttpAdapter implements FastifyAdapter {
91
150
  readonly name = 'fastify' as const
92
151
  readonly instance: FastifyInstance
93
152
  readonly #routes: AdapterRoute[] = []
94
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
95
159
  #fallback: FallbackHandler | undefined
96
160
  #ready: Promise<void> | undefined
97
161
  #closing: Promise<void> | undefined
@@ -130,26 +194,25 @@ class FastifyHttpAdapter implements FastifyAdapter {
130
194
  async #register(): Promise<void> {
131
195
  const routes = this.#routes
132
196
  await this.instance.register((child, _options, done) => {
133
- // 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)
134
200
  child.removeAllContentTypeParsers()
135
- child.addContentTypeParser('*', (_request, _payload, done) => done(null, undefined))
136
- // Fastify answers 415 to a content type it cannot parse (`foo`, an empty one) before the route
137
- // runs: hide such a header from Fastify (Znight reads it, and parses the body as bytes)
138
- child.addHook('onRequest', (request, _reply, done) => {
139
- const contentType = request.headers['content-type']
140
- if (contentType !== undefined && request.mediaType === undefined) {
141
- HIDDEN_CONTENT_TYPES.set(request.raw, contentType)
142
- delete request.raw.headers['content-type']
143
- }
144
- done()
145
- })
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)
146
206
  for (const route of routes) {
147
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)
148
211
  child.route({
149
212
  method: route.method,
150
213
  url: toFastifyPath(route.path),
151
214
  exposeHeadRoute: false,
152
- handler: (request, reply) => this.#reply(reply, handler(adapterRequest(request, reply))),
215
+ handler: fastifyHandler,
153
216
  })
154
217
  }
155
218
  child.setNotFoundHandler((request, reply) =>
@@ -163,6 +226,126 @@ class FastifyHttpAdapter implements FastifyAdapter {
163
226
  await this.instance.ready()
164
227
  }
165
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
+
166
349
  #dispatchFallback(request: FastifyRequest, reply: FastifyReply, error: unknown): Promise<AdapterReply> {
167
350
  if (this.#fallback === undefined) return Promise.reject(error ?? new Error('No fallback handler'))
168
351
  return this.#fallback(adapterRequest(request, undefined, reply), error)
@@ -173,15 +356,17 @@ class FastifyHttpAdapter implements FastifyAdapter {
173
356
  try {
174
357
  result = await pending
175
358
  } catch {
176
- result = {
177
- status: 500,
178
- headers: { 'content-type': 'application/json; charset=utf-8' },
179
- body: new TextEncoder().encode('{"statusCode":500,"error":"Internal Server Error"}'),
180
- }
359
+ result = internalError()
181
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 {
182
367
  if (result.raw === true || reply.sent || reply.raw.destroyed) {
183
368
  discardStream(result.body instanceof ReadableStream ? result.body : null)
184
- return reply
369
+ return
185
370
  }
186
371
  if (result.eventStream === true) {
187
372
  // no onSend hook (compression, ETag...) may rewrite an event stream: write it on the raw
@@ -191,19 +376,24 @@ class FastifyHttpAdapter implements FastifyAdapter {
191
376
  const headers = { ...native, ...withNativeCookies(native['set-cookie'], result.headers) }
192
377
  reply.hijack()
193
378
  writeNodeReply(reply.raw, { ...result, headers })
194
- return reply
379
+ return
195
380
  }
196
381
  reply.code(result.status)
197
- 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])
198
384
  const body = result.body
199
- if (body === null) return reply.send()
385
+ if (body === null) {
386
+ reply.send()
387
+ return
388
+ }
200
389
  if (body instanceof Uint8Array) {
201
390
  // 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))
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
204
394
  }
205
395
  const stream = body instanceof Blob ? body.stream() : body
206
- return reply.send(Readable.fromWeb(stream as import('node:stream/web').ReadableStream))
396
+ reply.send(Readable.fromWeb(stream as import('node:stream/web').ReadableStream))
207
397
  }
208
398
 
209
399
  async listen(port: number, host: string): Promise<HttpServerAddress> {
@@ -241,6 +431,53 @@ class FastifyHttpAdapter implements FastifyAdapter {
241
431
  }
242
432
  }
243
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
+
244
481
  /** The JSON content type of the plugin (with a charset). */
245
482
  const JSON_TYPE = 'application/json; charset=utf-8'
246
483
 
@@ -248,6 +485,9 @@ const JSON_TYPE = 'application/json; charset=utf-8'
248
485
  * Whether Fastify sends a string with this content type unchanged: it adds a charset to a JSON
249
486
  * type without one, and a content type to a string without one.
250
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
+
251
491
  function sendsTextAsIs(type: HeaderValue | undefined): boolean {
252
492
  if (typeof type !== 'string') return false
253
493
  if (type === JSON_TYPE) return true