fastify 5.9.0 → 5.11.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 (74) hide show
  1. package/PROJECT_CHARTER.md +1 -1
  2. package/README.md +7 -10
  3. package/SPONSORS.md +1 -0
  4. package/build/build-validation.js +2 -2
  5. package/docs/Guides/Delay-Accepting-Requests.md +1 -1
  6. package/docs/Guides/Ecosystem.md +12 -9
  7. package/docs/Guides/Getting-Started.md +10 -10
  8. package/docs/Guides/Migration-Guide-V4.md +2 -2
  9. package/docs/Guides/Migration-Guide-V5.md +1 -1
  10. package/docs/Guides/Plugins-Guide.md +1 -1
  11. package/docs/Guides/Prototype-Poisoning.md +3 -3
  12. package/docs/Guides/Serverless.md +6 -13
  13. package/docs/Guides/Style-Guide.md +1 -1
  14. package/docs/Reference/Errors.md +6 -0
  15. package/docs/Reference/Hooks.md +1 -1
  16. package/docs/Reference/Logging.md +3 -0
  17. package/docs/Reference/Reply.md +2 -2
  18. package/docs/Reference/Request.md +13 -13
  19. package/docs/Reference/Server.md +117 -17
  20. package/docs/Reference/Type-Providers.md +24 -4
  21. package/docs/Reference/TypeScript.md +8 -10
  22. package/docs/Reference/Warnings.md +5 -0
  23. package/eslint.config.js +1 -1
  24. package/fastify.d.ts +60 -13
  25. package/fastify.js +28 -23
  26. package/lib/content-type-parser.js +1 -11
  27. package/lib/content-type.js +70 -19
  28. package/lib/context.js +0 -3
  29. package/lib/error-handler.js +7 -26
  30. package/lib/errors.js +17 -0
  31. package/lib/four-oh-four.js +8 -10
  32. package/lib/handle-request.js +37 -8
  33. package/lib/hooks.js +5 -1
  34. package/lib/log-controller.js +169 -0
  35. package/lib/logger-factory.js +25 -4
  36. package/lib/reply.js +35 -44
  37. package/lib/req-id-gen-factory.js +4 -1
  38. package/lib/request.js +3 -3
  39. package/lib/route.js +28 -36
  40. package/lib/schemas.js +3 -3
  41. package/lib/symbols.js +1 -1
  42. package/lib/validation.js +10 -1
  43. package/lib/warnings.js +19 -1
  44. package/package.json +4 -4
  45. package/test/content-type.test.js +51 -4
  46. package/test/find-route.test.js +35 -0
  47. package/test/fix-6411.test.js +65 -0
  48. package/test/genReqId.test.js +24 -0
  49. package/test/hooks.test.js +71 -0
  50. package/test/internals/all.test.js +2 -1
  51. package/test/internals/errors.test.js +21 -1
  52. package/test/internals/logger.test.js +322 -0
  53. package/test/internals/reply.test.js +57 -4
  54. package/test/internals/request.test.js +10 -7
  55. package/test/logger/logging.test.js +40 -1
  56. package/test/reply-error.test.js +35 -0
  57. package/test/rfc-10008.test.js +147 -0
  58. package/test/route-shorthand.test.js +14 -4
  59. package/test/schema-serialization.test.js +33 -0
  60. package/test/stream.4.test.js +2 -2
  61. package/test/trust-proxy.test.js +49 -30
  62. package/test/types/errors.tst.ts +2 -0
  63. package/test/types/route.tst.ts +3 -3
  64. package/types/content-type-parser.d.ts +26 -6
  65. package/types/errors.d.ts +3 -0
  66. package/types/hooks.d.ts +137 -76
  67. package/types/instance.d.ts +152 -47
  68. package/types/logger.d.ts +57 -2
  69. package/types/plugin.d.ts +7 -3
  70. package/types/register.d.ts +53 -10
  71. package/types/reply.d.ts +62 -14
  72. package/types/route.d.ts +68 -34
  73. package/types/type-provider.d.ts +29 -8
  74. package/types/utils.d.ts +2 -2
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.11.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",
@@ -191,7 +191,7 @@
191
191
  "joi": "^18.0.1",
192
192
  "json-schema-to-ts": "^3.0.1",
193
193
  "JSONStream": "^1.3.5",
194
- "markdownlint-cli2": "^0.22.0",
194
+ "markdownlint-cli2": "^0.23.0",
195
195
  "neostandard": "^0.12.0",
196
196
  "node-forge": "^1.3.1",
197
197
  "proxyquire": "^2.1.3",
@@ -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",
@@ -161,12 +161,11 @@ describe('ContentType class', () => {
161
161
  t.assert.equal(found.mediaType, 'application/json')
162
162
  t.assert.equal(found.type, 'application')
163
163
  t.assert.equal(found.subtype, 'json')
164
- t.assert.equal(found.parameters.size, 3)
164
+ t.assert.equal(found.parameters.size, 2)
165
165
 
166
166
  const expected = [
167
167
  ['charset', 'utf-8'],
168
- ['foo', 'BaR'],
169
- ['baz', 'invalid quoted string']
168
+ ['foo', 'BaR']
170
169
  ]
171
170
  t.assert.deepStrictEqual(
172
171
  Array.from(found.parameters.entries()),
@@ -175,7 +174,55 @@ describe('ContentType class', () => {
175
174
 
176
175
  t.assert.equal(
177
176
  found.toString(),
178
- 'application/json; charset="utf-8"; foo="BaR"; baz="invalid quoted string"'
177
+ 'application/json; charset="utf-8"; foo="BaR"'
179
178
  )
180
179
  })
180
+
181
+ test('preserves a semicolon inside a quoted parameter value', (t) => {
182
+ // RFC 9110 §5.6.6: ';' is a literal qdtext octet inside a quoted-string
183
+ // and does not terminate the parameter value.
184
+ const found = new ContentType('application/json; name="foo;bar"; charset=utf-8')
185
+ t.assert.equal(found.isValid, true)
186
+ t.assert.equal(found.mediaType, 'application/json')
187
+ t.assert.equal(found.parameters.get('name'), 'foo;bar')
188
+ t.assert.equal(found.parameters.get('charset'), 'utf-8')
189
+ })
190
+
191
+ test('does not leak a fake parameter out of a quoted value', (t) => {
192
+ // A `key=value;` sequence inside a quoted-string is opaque content of the
193
+ // enclosing value, not a subsequent parameter.
194
+ const found = new ContentType('application/json; name="a=b;charset=fake"; boundary=xyz')
195
+ t.assert.equal(found.isValid, true)
196
+ t.assert.equal(found.parameters.get('name'), 'a=b;charset=fake')
197
+ t.assert.equal(found.parameters.get('boundary'), 'xyz')
198
+ t.assert.equal(found.parameters.has('charset'), false)
199
+ })
200
+
201
+ test('unescapes a quoted-pair inside a quoted-string', (t) => {
202
+ // RFC 9110 §5.6.4: a quoted-pair MUST be handled as if replaced by
203
+ // the octet following the backslash.
204
+ const found = new ContentType('application/json; name="he said \\"hi\\""')
205
+ t.assert.equal(found.isValid, true)
206
+ t.assert.equal(found.parameters.get('name'), 'he said "hi"')
207
+ })
208
+ })
209
+
210
+ describe('ContentType class cache', () => {
211
+ test('allow access cache', (t) => {
212
+ const contentType1 = ContentType.from('application/json')
213
+ const contentType2 = ContentType.cache.get('application/json')
214
+ t.assert.equal(contentType1, contentType2)
215
+ })
216
+
217
+ test('returns same instance for the same content type string', (t) => {
218
+ const contentType1 = ContentType.from('application/json')
219
+ const contentType2 = ContentType.from('application/json')
220
+ t.assert.equal(contentType1, contentType2)
221
+ })
222
+
223
+ test('returns different instances for different content type strings', (t) => {
224
+ const contentType1 = ContentType.from('application/json')
225
+ const contentType2 = ContentType.from('text/plain')
226
+ t.assert.notEqual(contentType1, contentType2)
227
+ })
181
228
  })
@@ -66,6 +66,22 @@ test('findRoute should return null when when url is not passed', t => {
66
66
  }), null)
67
67
  })
68
68
 
69
+ test('findRoute should return null when method is not passed', t => {
70
+ t.plan(1)
71
+ const fastify = Fastify()
72
+
73
+ fastify.get('/artists/:artistId', {
74
+ schema: {
75
+ params: { artistId: { type: 'integer' } }
76
+ },
77
+ handler: (req, reply) => reply.send(typeof req.params.artistId)
78
+ })
79
+
80
+ t.assert.strictEqual(fastify.findRoute({
81
+ url: '/artists/:artistId'
82
+ }), null)
83
+ })
84
+
69
85
  test('findRoute should return null when route cannot be found due to a different path', t => {
70
86
  t.plan(1)
71
87
  const fastify = Fastify()
@@ -103,6 +119,25 @@ test('findRoute should return the route when found', t => {
103
119
  t.assert.strictEqual(route.params.artistId, ':artistId')
104
120
  })
105
121
 
122
+ test('findRoute should find a route even if method is not uppercased', t => {
123
+ t.plan(2)
124
+ const fastify = Fastify()
125
+
126
+ fastify.get('/artists/:artistId', {
127
+ schema: {
128
+ params: { artistId: { type: 'integer' } }
129
+ },
130
+ handler: (req, reply) => reply.send(typeof req.params.artistId)
131
+ })
132
+
133
+ const route = fastify.findRoute({
134
+ method: 'get',
135
+ url: '/artists/:artistId'
136
+ })
137
+ t.assert.notStrictEqual(route, null)
138
+ t.assert.strictEqual(route.params.artistId, ':artistId')
139
+ })
140
+
106
141
  test('findRoute should work correctly when used within plugins', (t, done) => {
107
142
  t.plan(1)
108
143
  const fastify = Fastify()
@@ -0,0 +1,65 @@
1
+ 'use strict'
2
+
3
+ const { test } = require('node:test')
4
+ const Fastify = require('..')
5
+
6
+ test('custom validatorCompiler returning falsy values should update request param', async (t) => {
7
+ const fastify = Fastify()
8
+
9
+ fastify.setValidatorCompiler(() => {
10
+ return (data) => {
11
+ if (typeof data === 'object' && data !== null && 'coerceTo' in data) {
12
+ return { value: data.coerceTo }
13
+ }
14
+ return true
15
+ }
16
+ })
17
+
18
+ fastify.post('/test', { schema: { body: { type: 'object' } } }, (request, reply) => {
19
+ reply.send({ body: request.body })
20
+ })
21
+
22
+ // value: 0 (falsy)
23
+ {
24
+ const res = await fastify.inject({
25
+ method: 'POST',
26
+ url: '/test',
27
+ payload: { coerceTo: 0 }
28
+ })
29
+ t.assert.strictEqual(res.statusCode, 200)
30
+ t.assert.strictEqual(JSON.parse(res.payload).body, 0, 'should update body to 0')
31
+ }
32
+
33
+ // value: "" (falsy)
34
+ {
35
+ const res = await fastify.inject({
36
+ method: 'POST',
37
+ url: '/test',
38
+ payload: { coerceTo: '' }
39
+ })
40
+ t.assert.strictEqual(res.statusCode, 200)
41
+ t.assert.strictEqual(JSON.parse(res.payload).body, '', 'should update body to empty string')
42
+ }
43
+
44
+ // value: false (falsy)
45
+ {
46
+ const res = await fastify.inject({
47
+ method: 'POST',
48
+ url: '/test',
49
+ payload: { coerceTo: false }
50
+ })
51
+ t.assert.strictEqual(res.statusCode, 200)
52
+ t.assert.strictEqual(JSON.parse(res.payload).body, false, 'should update body to false')
53
+ }
54
+
55
+ // value: null (falsy)
56
+ {
57
+ const res = await fastify.inject({
58
+ method: 'POST',
59
+ url: '/test',
60
+ payload: { coerceTo: null }
61
+ })
62
+ t.assert.strictEqual(res.statusCode, 200)
63
+ t.assert.strictEqual(JSON.parse(res.payload).body, null, 'should update body to null')
64
+ }
65
+ })
@@ -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
+ })
@@ -9,7 +9,8 @@ test('fastify.all should add all the methods to the same url', async t => {
9
9
  const requirePayload = [
10
10
  'POST',
11
11
  'PUT',
12
- 'PATCH'
12
+ 'PATCH',
13
+ 'QUERY'
13
14
  ]
14
15
 
15
16
  const supportedMethods = fastify.supportedMethods
@@ -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 = 94
9
9
 
10
10
  test(`should expose ${expectedErrors} errors`, t => {
11
11
  t.plan(1)
@@ -770,6 +770,26 @@ test('FST_ERR_ROUTE_REWRITE_NOT_STR', t => {
770
770
  t.assert.ok(error instanceof TypeError)
771
771
  })
772
772
 
773
+ test('FST_ERR_ROUTE_MISSING_CONTENT_TYPE', t => {
774
+ t.plan(5)
775
+ const error = new errors.FST_ERR_ROUTE_MISSING_CONTENT_TYPE()
776
+ t.assert.strictEqual(error.name, 'FastifyError')
777
+ t.assert.strictEqual(error.code, 'FST_ERR_ROUTE_MISSING_CONTENT_TYPE')
778
+ t.assert.strictEqual(error.message, "Method '%s' must provide a 'Content-Type' header.")
779
+ t.assert.strictEqual(error.statusCode, 400)
780
+ t.assert.ok(error instanceof Error)
781
+ })
782
+
783
+ test('FST_ERR_ROUTE_MISSING_CONTENT', t => {
784
+ t.plan(5)
785
+ const error = new errors.FST_ERR_ROUTE_MISSING_CONTENT()
786
+ t.assert.strictEqual(error.name, 'FastifyError')
787
+ t.assert.strictEqual(error.code, 'FST_ERR_ROUTE_MISSING_CONTENT')
788
+ t.assert.strictEqual(error.message, "Method '%s' must provide a request body.")
789
+ t.assert.strictEqual(error.statusCode, 400)
790
+ t.assert.ok(error instanceof Error)
791
+ })
792
+
773
793
  test('FST_ERR_REOPENED_CLOSE_SERVER', t => {
774
794
  t.plan(5)
775
795
  const error = new errors.FST_ERR_REOPENED_CLOSE_SERVER()