fastify 5.9.0 → 5.10.0

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 (52) hide show
  1. package/PROJECT_CHARTER.md +1 -1
  2. package/README.md +7 -10
  3. package/build/build-validation.js +2 -2
  4. package/docs/Guides/Delay-Accepting-Requests.md +1 -1
  5. package/docs/Guides/Ecosystem.md +10 -7
  6. package/docs/Guides/Getting-Started.md +2 -2
  7. package/docs/Guides/Migration-Guide-V4.md +2 -2
  8. package/docs/Guides/Migration-Guide-V5.md +1 -1
  9. package/docs/Guides/Plugins-Guide.md +1 -1
  10. package/docs/Guides/Prototype-Poisoning.md +3 -3
  11. package/docs/Guides/Serverless.md +6 -13
  12. package/docs/Guides/Style-Guide.md +1 -1
  13. package/docs/Reference/Errors.md +2 -0
  14. package/docs/Reference/Hooks.md +1 -1
  15. package/docs/Reference/Logging.md +3 -0
  16. package/docs/Reference/Request.md +2 -2
  17. package/docs/Reference/Server.md +107 -8
  18. package/docs/Reference/Type-Providers.md +24 -4
  19. package/docs/Reference/TypeScript.md +3 -3
  20. package/docs/Reference/Warnings.md +4 -0
  21. package/fastify.d.ts +5 -0
  22. package/fastify.js +22 -22
  23. package/lib/content-type-parser.js +1 -11
  24. package/lib/content-type.js +34 -1
  25. package/lib/context.js +0 -3
  26. package/lib/error-handler.js +7 -26
  27. package/lib/errors.js +7 -0
  28. package/lib/four-oh-four.js +8 -10
  29. package/lib/handle-request.js +2 -1
  30. package/lib/log-controller.js +169 -0
  31. package/lib/logger-factory.js +25 -4
  32. package/lib/reply.js +35 -44
  33. package/lib/req-id-gen-factory.js +4 -1
  34. package/lib/request.js +3 -3
  35. package/lib/route.js +27 -35
  36. package/lib/symbols.js +1 -1
  37. package/lib/validation.js +9 -0
  38. package/lib/warnings.js +19 -1
  39. package/package.json +3 -3
  40. package/test/content-type.test.js +20 -0
  41. package/test/genReqId.test.js +24 -0
  42. package/test/hooks.test.js +71 -0
  43. package/test/internals/errors.test.js +1 -1
  44. package/test/internals/logger.test.js +322 -0
  45. package/test/internals/reply.test.js +35 -4
  46. package/test/internals/request.test.js +10 -7
  47. package/test/logger/logging.test.js +40 -1
  48. package/test/stream.4.test.js +2 -2
  49. package/test/trust-proxy.test.js +49 -30
  50. package/types/errors.d.ts +1 -0
  51. package/types/instance.d.ts +2 -0
  52. package/types/logger.d.ts +22 -0
package/lib/reply.js CHANGED
@@ -16,7 +16,6 @@ const {
16
16
  kReplyHasStatusCode,
17
17
  kReplyIsRunningOnErrorHook,
18
18
  kReplyNextErrorHandler,
19
- kDisableRequestLogging,
20
19
  kSchemaResponse,
21
20
  kReplyCacheSerializeFns,
22
21
  kSchemaController,
@@ -24,7 +23,8 @@ const {
24
23
  kRouteContext,
25
24
  kTimeoutTimer,
26
25
  kOnAbort,
27
- kRequestSignal
26
+ kRequestSignal,
27
+ kLogController
28
28
  } = require('./symbols.js')
29
29
  const {
30
30
  onSendHookRunner,
@@ -58,6 +58,7 @@ const {
58
58
  FST_ERR_DEC_UNDECLARED
59
59
  } = require('./errors')
60
60
  const decorators = require('./decorate')
61
+ const ContentType = require('./content-type.js')
61
62
 
62
63
  const toString = Object.prototype.toString
63
64
  const HTTP2_WRITE_CHUNK_SIZE = 64 * 1024
@@ -135,6 +136,14 @@ Reply.prototype.hijack = function () {
135
136
  this.request[kOnAbort] = null
136
137
  }
137
138
  }
139
+ // Clear socket._meta so hijacked replies (e.g. WebSocket upgrades) do not
140
+ // retain request/reply objects for the lifetime of the keep-alive socket
141
+ // when an onTimeout hook is registered.
142
+ const socket = this.request.raw.socket
143
+ if (socket?._meta?.request === this.request) {
144
+ socket.removeListener('timeout', socket._meta.onTimeout)
145
+ socket._meta = null
146
+ }
138
147
  return this
139
148
  }
140
149
 
@@ -159,8 +168,8 @@ Reply.prototype.send = function (payload) {
159
168
  return this
160
169
  }
161
170
 
162
- const contentType = this.getHeader('content-type')
163
- const hasContentType = contentType !== undefined
171
+ const contentType = ContentType.from(this.getHeader('content-type'))
172
+ const hasContentType = contentType.isEmpty === false
164
173
 
165
174
  if (payload !== null) {
166
175
  if (
@@ -201,18 +210,17 @@ Reply.prototype.send = function (payload) {
201
210
  payload = this[kReplySerializer](payload)
202
211
 
203
212
  // The indexOf below also matches custom json mimetypes such as 'application/hal+json' or 'application/ld+json'
204
- } else if (!hasContentType || contentType.indexOf('json') !== -1) {
213
+ } else if (!hasContentType || contentType.mediaType.indexOf('json') !== -1) {
205
214
  if (!hasContentType) {
206
215
  this[kReplyHeaders]['content-type'] = CONTENT_TYPE.JSON
207
- } else if (contentType.indexOf('charset') === -1) {
216
+ } else if (contentType.parameters.has('charset') === false) {
217
+ // The lookup is case-insensitive because HTTP parameter names are
218
+ // case-insensitive (RFC 9110 section 5.6.6).
208
219
  // If user doesn't set charset, we will set charset to utf-8
209
- const customContentType = contentType.trim()
210
- if (customContentType.endsWith(';')) {
211
- // custom content-type is ended with ';'
212
- this[kReplyHeaders]['content-type'] = `${customContentType} charset=utf-8`
213
- } else {
214
- this[kReplyHeaders]['content-type'] = `${customContentType}; charset=utf-8`
215
- }
220
+ const customContentType = contentType.toString()
221
+ // ContentType.toString already handle the trailing ;
222
+ // We can safely append the charset parameter to the end of the string
223
+ this[kReplyHeaders]['content-type'] = `${customContentType}; charset=utf-8`
216
224
  }
217
225
 
218
226
  if (typeof payload !== 'string') {
@@ -688,6 +696,12 @@ function isHttp2Reply (reply) {
688
696
 
689
697
  function writePayload (payload, res, reply) {
690
698
  if (!isHttp2Reply(reply) || Buffer.byteLength(payload) <= HTTP2_WRITE_CHUNK_SIZE) {
699
+ if (reply[kReplyTrailers] === null) {
700
+ // end(payload) corks the socket, so headers, payload and the
701
+ // finishing chunk are flushed with a single write
702
+ res.end(payload, null, null) // avoid ArgumentsAdaptorTrampoline from V8
703
+ return
704
+ }
691
705
  res.write(payload)
692
706
  // then send trailers
693
707
  sendTrailer(payload, res, reply)
@@ -727,14 +741,8 @@ function writeHttp2Payload (payload, res, done) {
727
741
  writeChunk()
728
742
  }
729
743
 
730
- function logStreamError (logger, err, res) {
731
- if (err.code === 'ERR_STREAM_PREMATURE_CLOSE') {
732
- if (!logger[kDisableRequestLogging]) {
733
- logger.info({ res }, 'stream closed prematurely')
734
- }
735
- } else {
736
- logger.warn({ err }, 'response terminated with an error with headers already sent')
737
- }
744
+ function logStreamError (err, reply, res) {
745
+ reply.server[kLogController].streamError(err, reply.request, reply)
738
746
  }
739
747
 
740
748
  function sendWebStream (payload, res, reply) {
@@ -751,7 +759,7 @@ function sendWebStream (payload, res, reply) {
751
759
  if (sourceOpen) {
752
760
  if (err != null && res.headersSent && !errorLogged) {
753
761
  errorLogged = true
754
- logStreamError(reply.log, err, res)
762
+ logStreamError(err, reply, res)
755
763
  }
756
764
  reader.cancel().catch(noop)
757
765
  }
@@ -799,7 +807,7 @@ function sendWebStream (payload, res, reply) {
799
807
  if (res.headersSent || reply.request.raw.aborted === true) {
800
808
  if (!errorLogged) {
801
809
  errorLogged = true
802
- logStreamError(reply.log, err, reply)
810
+ logStreamError(err, reply, reply)
803
811
  }
804
812
  res.destroy()
805
813
  } else {
@@ -823,7 +831,7 @@ function sendStream (payload, res, reply) {
823
831
  if (res.headersSent || reply.request.raw.aborted === true) {
824
832
  if (!errorLogged) {
825
833
  errorLogged = true
826
- logStreamError(reply.log, err, reply)
834
+ logStreamError(err, reply, reply)
827
835
  }
828
836
  res.destroy()
829
837
  } else {
@@ -837,7 +845,7 @@ function sendStream (payload, res, reply) {
837
845
  if (sourceOpen) {
838
846
  if (err != null && res.headersSent && !errorLogged) {
839
847
  errorLogged = true
840
- logStreamError(reply.log, err, res)
848
+ logStreamError(err, reply, res)
841
849
  }
842
850
  if (typeof payload.destroy === 'function') {
843
851
  payload.destroy()
@@ -966,6 +974,7 @@ function setupResponseListeners (reply) {
966
974
  // past the response on keep-alive connections.
967
975
  const socket = reply.request.raw.socket
968
976
  if (socket && socket._meta && socket._meta.request === reply.request) {
977
+ socket.removeListener('timeout', socket._meta.onTimeout)
969
978
  socket._meta = null
970
979
  }
971
980
 
@@ -986,25 +995,7 @@ function setupResponseListeners (reply) {
986
995
  }
987
996
 
988
997
  function onResponseCallback (err, request, reply) {
989
- if (reply.log[kDisableRequestLogging]) {
990
- return
991
- }
992
-
993
- const responseTime = reply.elapsedTime
994
-
995
- if (err != null) {
996
- reply.log.error({
997
- res: reply,
998
- err,
999
- responseTime
1000
- }, 'request errored')
1001
- return
1002
- }
1003
-
1004
- reply.log.info({
1005
- res: reply,
1006
- responseTime
1007
- }, 'request completed')
998
+ reply.server[kLogController].requestCompleted(err, request, reply)
1008
999
  }
1009
1000
 
1010
1001
  function buildReply (R) {
@@ -1,5 +1,7 @@
1
1
  'use strict'
2
2
 
3
+ const { kGenReqId } = require('./symbols.js')
4
+
3
5
  /**
4
6
  * @callback GenerateRequestId
5
7
  * @param {Object} req
@@ -22,7 +24,8 @@ function reqIdGenFactory (requestIdHeader, optGenReqId) {
22
24
  }
23
25
 
24
26
  function getGenReqId (contextServer, req) {
25
- return contextServer.genReqId(req)
27
+ // direct symbol access skips the `genReqId` getter indirection
28
+ return contextServer[kGenReqId](req)
26
29
  }
27
30
 
28
31
  function buildDefaultGenReqId () {
package/lib/request.js CHANGED
@@ -18,6 +18,7 @@ const {
18
18
  } = require('./symbols')
19
19
  const { FST_ERR_REQ_INVALID_VALIDATION_INVOCATION, FST_ERR_DEC_UNDECLARED } = require('./errors')
20
20
  const decorators = require('./decorate')
21
+ const ContentType = require('./content-type')
21
22
 
22
23
  const HTTP_PART_SYMBOL_MAP = {
23
24
  body: kSchemaBody,
@@ -258,8 +259,7 @@ Object.defineProperties(Request.prototype, {
258
259
  port: {
259
260
  get () {
260
261
  const portReg = /(?<port>:\d+)$/
261
- const host = this.headers.host ?? this.headers[':authority'] ?? ''
262
- const matches = portReg.exec(host)
262
+ const matches = portReg.exec(this.host)
263
263
  if (matches === null || matches[1] === undefined) {
264
264
  return null
265
265
  }
@@ -287,7 +287,7 @@ Object.defineProperties(Request.prototype, {
287
287
  mediaType: {
288
288
  get () {
289
289
  if (!this[kRequestContentType] && this.headers['content-type'] !== undefined) {
290
- this[kRequestContentType] = this[kRouteContext].contentTypeParser.getContentType(this.headers['content-type'])
290
+ this[kRequestContentType] = ContentType.from(this.headers['content-type'])
291
291
  }
292
292
  return this[kRequestContentType]?.mediaType
293
293
  }
package/lib/route.js CHANGED
@@ -43,7 +43,6 @@ const {
43
43
  kReplySerializerDefault,
44
44
  kReplyIsError,
45
45
  kRequestPayloadStream,
46
- kDisableRequestLogging,
47
46
  kSchemaErrorFormatter,
48
47
  kErrorHandler,
49
48
  kHasBeenDecorated,
@@ -52,10 +51,11 @@ const {
52
51
  kRouteContext,
53
52
  kRequestSignal,
54
53
  kTimeoutTimer,
55
- kOnAbort
54
+ kOnAbort,
55
+ kLogController
56
56
  } = require('./symbols.js')
57
57
  const { buildErrorHandler } = require('./error-handler')
58
- const { createChildLogger } = require('./logger-factory.js')
58
+ const { createChildLogger, defaultChildLoggerFactory } = require('./logger-factory.js')
59
59
  const { getGenReqId } = require('./req-id-gen-factory.js')
60
60
  const { FSTDEP022 } = require('./warnings')
61
61
 
@@ -83,8 +83,6 @@ function buildRouting (options) {
83
83
  let hasLogger
84
84
  let setupResponseListeners
85
85
  let throwIfAlreadyStarted
86
- let disableRequestLogging
87
- let disableRequestLoggingFn
88
86
  let ignoreTrailingSlash
89
87
  let ignoreDuplicateSlashes
90
88
  let return503OnClosing
@@ -107,10 +105,6 @@ function buildRouting (options) {
107
105
  throwIfAlreadyStarted = fastifyArgs.throwIfAlreadyStarted
108
106
 
109
107
  globalExposeHeadRoutes = options.exposeHeadRoutes
110
- disableRequestLogging = options.disableRequestLogging
111
- if (typeof disableRequestLogging === 'function') {
112
- disableRequestLoggingFn = options.disableRequestLogging
113
- }
114
108
 
115
109
  ignoreTrailingSlash = options.routerOptions.ignoreTrailingSlash
116
110
  ignoreDuplicateSlashes = options.routerOptions.ignoreDuplicateSlashes
@@ -463,16 +457,21 @@ function buildRouting (options) {
463
457
  function routeHandler (req, res, params, context, query) {
464
458
  const id = getGenReqId(context.server, req)
465
459
 
466
- const loggerOpts = {
467
- level: context.logLevel
468
- }
460
+ let childLogger
461
+ if (hasLogger === false && context.childLoggerFactory === defaultChildLoggerFactory) {
462
+ // the null logger children are the logger itself, so we can skip
463
+ // the child logger creation altogether
464
+ childLogger = logger
465
+ } else {
466
+ const loggerOpts = {
467
+ level: context.logLevel
468
+ }
469
469
 
470
- if (context.logSerializers) {
471
- loggerOpts.serializers = context.logSerializers
470
+ if (context.logSerializers) {
471
+ loggerOpts.serializers = context.logSerializers
472
+ }
473
+ childLogger = createChildLogger(context, logger, req, id, loggerOpts)
472
474
  }
473
- const childLogger = createChildLogger(context, logger, req, id, loggerOpts)
474
- // Set initial value; will be re-evaluated after FastifyRequest is constructed if it's a function
475
- childLogger[kDisableRequestLogging] = disableRequestLoggingFn ? false : disableRequestLogging
476
475
 
477
476
  if (closing === true) {
478
477
  /* istanbul ignore next mac, windows */
@@ -493,7 +492,9 @@ function buildRouting (options) {
493
492
  }
494
493
  res.writeHead(503, headers)
495
494
  res.end('{"error":"Service Unavailable","message":"Service Unavailable","statusCode":503}')
496
- childLogger.info({ res: { statusCode: 503 } }, 'request aborted - refusing to accept new requests as server is closing')
495
+
496
+ context.server[kLogController].serviceUnavailable(childLogger, context.server)
497
+
497
498
  return
498
499
  }
499
500
  }
@@ -518,16 +519,7 @@ function buildRouting (options) {
518
519
  const request = new context.Request(id, params, req, query, childLogger, context)
519
520
  const reply = new context.Reply(res, request, childLogger)
520
521
 
521
- // Evaluate disableRequestLogging after FastifyRequest is constructed
522
- // so the caller has access to decorations and customizations
523
- const resolvedDisableRequestLogging = disableRequestLoggingFn
524
- ? disableRequestLoggingFn(request)
525
- : disableRequestLogging
526
- childLogger[kDisableRequestLogging] = resolvedDisableRequestLogging
527
-
528
- if (resolvedDisableRequestLogging === false) {
529
- childLogger.info({ req: request }, 'incoming request')
530
- }
522
+ context.server[kLogController].incomingRequest(request, reply)
531
523
 
532
524
  // Handler timeout setup — only when configured (zero overhead otherwise)
533
525
  const handlerTimeout = context.handlerTimeout
@@ -558,6 +550,13 @@ function buildRouting (options) {
558
550
  setupResponseListeners(reply)
559
551
  }
560
552
 
553
+ if (context.onTimeout !== null) {
554
+ if (!request.raw.socket._meta) {
555
+ request.raw.socket.on('timeout', handleTimeout)
556
+ }
557
+ request.raw.socket._meta = { context, request, reply, onTimeout: handleTimeout }
558
+ }
559
+
561
560
  if (context.onRequest !== null) {
562
561
  onRequestHookRunner(
563
562
  context.onRequest,
@@ -581,13 +580,6 @@ function buildRouting (options) {
581
580
  }
582
581
  })
583
582
  }
584
-
585
- if (context.onTimeout !== null) {
586
- if (!request.raw.socket._meta) {
587
- request.raw.socket.on('timeout', handleTimeout)
588
- }
589
- request.raw.socket._meta = { context, request, reply }
590
- }
591
583
  }
592
584
  }
593
585
 
package/lib/symbols.js CHANGED
@@ -13,7 +13,6 @@ const keys = {
13
13
  kContentTypeParser: Symbol('fastify.contentTypeParser'),
14
14
  kState: Symbol('fastify.state'),
15
15
  kOptions: Symbol('fastify.options'),
16
- kDisableRequestLogging: Symbol('fastify.disableRequestLogging'),
17
16
  kPluginNameChain: Symbol('fastify.pluginNameChain'),
18
17
  kRouteContext: Symbol('fastify.context'),
19
18
  kGenReqId: Symbol('fastify.genReqId'),
@@ -67,6 +66,7 @@ const keys = {
67
66
  kHasBeenDecorated: Symbol('fastify.hasBeenDecorated'),
68
67
  kKeepAliveConnections: Symbol('fastify.keepAliveConnections'),
69
68
  kRouteByFastify: Symbol('fastify.routeByFastify'),
69
+ kLogController: Symbol('fastify.logController'),
70
70
  kDiagnosticsStore: Symbol('fastify.diagnosticsStore')
71
71
  }
72
72
 
package/lib/validation.js CHANGED
@@ -146,6 +146,15 @@ function validateParam (validatorFunction, request, paramName) {
146
146
  function validate (context, request, execution) {
147
147
  const runExecution = execution === undefined
148
148
 
149
+ if (runExecution &&
150
+ context[paramsSchema] === undefined &&
151
+ context[bodySchema] === undefined &&
152
+ context[querystringSchema] === undefined &&
153
+ context[headersSchema] === undefined) {
154
+ // the route has no validation schemas
155
+ return false
156
+ }
157
+
149
158
  if (runExecution || !execution.skipParams) {
150
159
  const params = validateParam(context[paramsSchema], request, 'params')
151
160
  if (params) {
package/lib/warnings.js CHANGED
@@ -7,6 +7,8 @@ const { createWarning } = require('process-warning')
7
7
  * - FSTWRN001
8
8
  * - FSTSEC001
9
9
  * - FSTDEP022
10
+ * - FSTDEP023
11
+ * - FSTDEP024
10
12
  *
11
13
  * Deprecation Codes FSTDEP001 - FSTDEP021 were used by v4 and MUST NOT be reused.
12
14
  * - FSTDEP022 is used by v5 and MUST NOT be reused.
@@ -48,10 +50,26 @@ const FSTDEP022 = createWarning({
48
50
  unlimited: true
49
51
  })
50
52
 
53
+ const FSTDEP023 = createWarning({
54
+ name: 'FastifyDeprecation',
55
+ code: 'FSTDEP023',
56
+ message: 'disableRequestLogging option is deprecated. Use the logController option with disableRequestLogging or isLogDisabled override instead. The disableRequestLogging top-level option will be removed in `fastify@6`.',
57
+ unlimited: true
58
+ })
59
+
60
+ const FSTDEP024 = createWarning({
61
+ name: 'FastifyDeprecation',
62
+ code: 'FSTDEP024',
63
+ message: 'requestIdLogLabel option is deprecated. Use the logController option with requestIdLogLabel instead. The requestIdLogLabel top-level option will be removed in `fastify@6`.',
64
+ unlimited: true
65
+ })
66
+
51
67
  module.exports = {
52
68
  FSTWRN001,
53
69
  FSTWRN003,
54
70
  FSTWRN004,
55
71
  FSTSEC001,
56
- FSTDEP022
72
+ FSTDEP022,
73
+ FSTDEP023,
74
+ FSTDEP024
57
75
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fastify",
3
- "version": "5.9.0",
3
+ "version": "5.10.0",
4
4
  "description": "Fast and low overhead web framework, for Node.js",
5
5
  "main": "fastify.js",
6
6
  "type": "commonjs",
@@ -170,7 +170,7 @@
170
170
  "@sinonjs/fake-timers": "^11.2.2",
171
171
  "@stylistic/eslint-plugin": "^5.1.0",
172
172
  "@stylistic/eslint-plugin-js": "^4.1.0",
173
- "@types/node": "^25.0.3",
173
+ "@types/node": "^26.0.1",
174
174
  "ajv": "^8.12.0",
175
175
  "ajv-errors": "^3.0.0",
176
176
  "ajv-formats": "^3.0.1",
@@ -210,7 +210,7 @@
210
210
  "@fastify/proxy-addr": "^5.0.0",
211
211
  "abstract-logging": "^2.0.1",
212
212
  "avvio": "^9.0.0",
213
- "fast-json-stringify": "^6.0.0",
213
+ "fast-json-stringify": "^7.0.0",
214
214
  "find-my-way": "^9.6.0",
215
215
  "light-my-request": "^6.0.0",
216
216
  "pino": "^9.14.0 || ^10.1.0",
@@ -179,3 +179,23 @@ describe('ContentType class', () => {
179
179
  )
180
180
  })
181
181
  })
182
+
183
+ describe('ContentType class cache', () => {
184
+ test('allow access cache', (t) => {
185
+ const contentType1 = ContentType.from('application/json')
186
+ const contentType2 = ContentType.cache.get('application/json')
187
+ t.assert.equal(contentType1, contentType2)
188
+ })
189
+
190
+ test('returns same instance for the same content type string', (t) => {
191
+ const contentType1 = ContentType.from('application/json')
192
+ const contentType2 = ContentType.from('application/json')
193
+ t.assert.equal(contentType1, contentType2)
194
+ })
195
+
196
+ test('returns different instances for different content type strings', (t) => {
197
+ const contentType1 = ContentType.from('application/json')
198
+ const contentType2 = ContentType.from('text/plain')
199
+ t.assert.notEqual(contentType1, contentType2)
200
+ })
201
+ })
@@ -85,6 +85,30 @@ test('Should handle properly requestIdHeader option', t => {
85
85
  t.assert.strictEqual(Fastify({ requestIdHeader: 'x-request-id' }).initialConfig.requestIdHeader, 'x-request-id')
86
86
  })
87
87
 
88
+ test('Should expose the genReqId function via the getter', (t, done) => {
89
+ t.plan(5)
90
+
91
+ const fastify = Fastify()
92
+ t.assert.strictEqual(typeof fastify.genReqId, 'function')
93
+ t.assert.strictEqual(typeof fastify.genReqId({ headers: {} }), 'string')
94
+
95
+ const custom = function (req) {
96
+ return 'custom'
97
+ }
98
+
99
+ fastify.register(function (instance, opts, next) {
100
+ instance.setGenReqId(custom)
101
+ t.assert.strictEqual(instance.genReqId(), 'custom')
102
+ next()
103
+ })
104
+
105
+ fastify.ready(err => {
106
+ t.assert.ifError(err)
107
+ t.assert.notStrictEqual(fastify.genReqId, custom)
108
+ done()
109
+ })
110
+ })
111
+
88
112
  test('Should accept option to set genReqId with setGenReqId option', (t, done) => {
89
113
  t.plan(9)
90
114
 
@@ -3599,3 +3599,74 @@ test('onRequestAbort should handle async errors / 2', (t, testDone) => {
3599
3599
  sleep(500).then(() => socket.destroy())
3600
3600
  })
3601
3601
  })
3602
+
3603
+ test('socket timeout listener is removed when socket._meta is cleared after response', async t => {
3604
+ t.plan(4)
3605
+
3606
+ const fastify = Fastify({ connectionTimeout: 200 })
3607
+ t.after(() => fastify.close())
3608
+
3609
+ fastify.addHook('onTimeout', function (req, reply, done) { done() })
3610
+
3611
+ fastify.addHook('onResponse', function (req, reply, done) {
3612
+ const socket = req.raw.socket
3613
+ t.assert.strictEqual(socket._meta, null, 'socket._meta must be null after response')
3614
+ t.assert.strictEqual(socket.listeners('timeout').some(listener => listener.name === 'handleTimeout'), false, 'Fastify timeout listener must be removed after response')
3615
+ done()
3616
+ })
3617
+
3618
+ fastify.get('/', async () => ({ ok: true }))
3619
+
3620
+ const address = await fastify.listen({ port: 0 })
3621
+ const result = await fetch(address)
3622
+ t.assert.ok(result.ok)
3623
+ t.assert.strictEqual(result.status, 200)
3624
+ })
3625
+
3626
+ test('socket._meta and timeout listener are cleared on reply.hijack() when onTimeout is registered', async t => {
3627
+ // reply.hijack() opts out of Fastify's response lifecycle so onResFinished
3628
+ // never fires. Before this fix, socket._meta was left pointing at the
3629
+ // request/reply objects for the full keep-alive socket lifetime when
3630
+ // an onTimeout hook was registered.
3631
+ t.plan(3)
3632
+
3633
+ const net = require('node:net')
3634
+ const fastify = Fastify({ connectionTimeout: 300 })
3635
+ t.after(() => fastify.close())
3636
+
3637
+ fastify.addHook('onTimeout', function (req, reply, done) { done() })
3638
+
3639
+ let metaAfterHijack = 'not-set'
3640
+ let hasFastifyTimeoutListenerAfterHijack = true
3641
+ fastify.get('/', async (req, reply) => {
3642
+ reply.hijack()
3643
+ // _meta must be cleared synchronously inside hijack()
3644
+ metaAfterHijack = req.raw.socket._meta
3645
+ hasFastifyTimeoutListenerAfterHijack = req.raw.socket.listeners('timeout').some(listener => listener.name === 'handleTimeout')
3646
+ // Write a minimal valid HTTP/1.1 response and close
3647
+ reply.raw.write('HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok')
3648
+ reply.raw.end()
3649
+ return reply
3650
+ })
3651
+
3652
+ await fastify.listen({ port: 0 })
3653
+
3654
+ // Use raw TCP so we are not affected by fetch() rejecting a closed socket.
3655
+ // Extract host/port from the server address object to handle IPv6 (::1) on Windows.
3656
+ const { port, address: host } = fastify.server.address()
3657
+ await new Promise((resolve, reject) => {
3658
+ const socket = net.connect(port, host, () => {
3659
+ socket.write('GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n')
3660
+ })
3661
+ socket.on('data', () => {})
3662
+ socket.on('close', resolve)
3663
+ socket.on('error', reject)
3664
+ })
3665
+
3666
+ t.assert.ok(true, 'no crash during hijacked request with onTimeout registered')
3667
+ t.assert.ok(
3668
+ metaAfterHijack == null,
3669
+ `socket._meta must be null or undefined after reply.hijack() — got: ${JSON.stringify(metaAfterHijack)}`
3670
+ )
3671
+ t.assert.strictEqual(hasFastifyTimeoutListenerAfterHijack, false, 'Fastify timeout listener must be removed after reply.hijack()')
3672
+ })
@@ -5,7 +5,7 @@ const errors = require('../../lib/errors')
5
5
  const { readFileSync } = require('node:fs')
6
6
  const { resolve } = require('node:path')
7
7
 
8
- const expectedErrors = 91
8
+ const expectedErrors = 92
9
9
 
10
10
  test(`should expose ${expectedErrors} errors`, t => {
11
11
  t.plan(1)