fastify 6.0.0-alpha.4 → 6.0.0-alpha.6

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 (63) hide show
  1. package/README.md +1 -0
  2. package/SECURITY.md +6 -0
  3. package/build/build-validation.js +2 -0
  4. package/docs/Guides/Ecosystem.md +12 -53
  5. package/docs/Guides/Migration-Guide-V5.md +2 -2
  6. package/docs/Guides/Serverless.md +33 -10
  7. package/docs/Reference/Errors.md +13 -2
  8. package/docs/Reference/Hooks.md +13 -0
  9. package/docs/Reference/Reply.md +787 -681
  10. package/docs/Reference/Request.md +308 -104
  11. package/docs/Reference/Routes.md +6 -2
  12. package/docs/Reference/Server.md +41 -4
  13. package/docs/Reference/Validation-and-Serialization.md +48 -1
  14. package/docs/Reference/Warnings.md +2 -0
  15. package/fastify.d.ts +1 -0
  16. package/fastify.js +35 -1
  17. package/lib/config-validator.js +102 -73
  18. package/lib/content-type-parser.js +35 -14
  19. package/lib/content-type.js +4 -4
  20. package/lib/context.js +2 -1
  21. package/lib/decorate.js +4 -3
  22. package/lib/error-handler.js +21 -4
  23. package/lib/error-status.js +4 -2
  24. package/lib/errors.js +15 -14
  25. package/lib/handle-request.js +7 -20
  26. package/lib/reply.js +18 -13
  27. package/lib/request.js +12 -7
  28. package/lib/route.js +40 -7
  29. package/lib/schemas.js +44 -32
  30. package/lib/validation.js +67 -10
  31. package/lib/warnings.js +10 -1
  32. package/package.json +8 -3
  33. package/test/child-logger-factory.test.js +25 -0
  34. package/test/close-disposable.test.js +154 -0
  35. package/test/constrained-routes.test.js +4 -2
  36. package/test/content-parser.test.js +99 -0
  37. package/test/decorator.test.js +29 -0
  38. package/test/diagnostics-channel/error-status.test.js +35 -2
  39. package/test/handler-timeout.test.js +224 -0
  40. package/test/internals/all.test.js +5 -0
  41. package/test/internals/errors.test.js +19 -20
  42. package/test/internals/initial-config.test.js +2 -0
  43. package/test/internals/reply-serialize.test.js +20 -0
  44. package/test/internals/reply.test.js +30 -1
  45. package/test/internals/request.test.js +50 -0
  46. package/test/internals/server.test.js +6 -4
  47. package/test/reply-error.test.js +82 -0
  48. package/test/reply-media-type.test.js +57 -0
  49. package/test/reply-trailers.test.js +159 -1
  50. package/test/rfc-10008.test.js +6 -9
  51. package/test/router-compile.test.js +241 -0
  52. package/test/schema-feature.test.js +1 -1
  53. package/test/schema-serialization.test.js +120 -7
  54. package/test/schema-validation.test.js +251 -1
  55. package/test/types/errors.tst.ts +0 -1
  56. package/test/types/fastify.tst.ts +9 -0
  57. package/test/types/instance.tst.ts +1 -1
  58. package/test/types/route.tst.ts +37 -0
  59. package/test/types/using.tst.ts +20 -0
  60. package/test/validation-error-handling.test.js +21 -1
  61. package/types/errors.d.ts +0 -1
  62. package/types/instance.d.ts +9 -5
  63. package/types/route.d.ts +4 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fastify",
3
- "version": "6.0.0-alpha.4",
3
+ "version": "6.0.0-alpha.6",
4
4
  "description": "Fast and low overhead web framework, for Node.js",
5
5
  "main": "fastify.js",
6
6
  "type": "commonjs",
@@ -18,12 +18,13 @@
18
18
  "lint:fix": "eslint --fix",
19
19
  "lint:markdown": "markdownlint-cli2",
20
20
  "lint:eslint": "eslint",
21
- "prepublishOnly": "cross-env PREPUBLISH=true borp --reporter=@jsumners/line-reporter && npm run test:validator:integrity && npm run build:sync-version",
21
+ "prepublishOnly": "npm run test:version && cross-env PREPUBLISH=true borp --reporter=@jsumners/line-reporter && npm run test:validator:integrity && npm run build:sync-version",
22
22
  "test": "npm run lint && npm run unit && npm run test:types",
23
23
  "test:ci": "npm run unit && npm run test:types",
24
24
  "test:report": "npm run lint && npm run unit:report && npm run test:types",
25
25
  "test:validator:integrity": "npm run build:validation && git diff --quiet --ignore-all-space --ignore-blank-lines --ignore-cr-at-eol lib/error-serializer.js && git diff --quiet --ignore-all-space --ignore-blank-lines --ignore-cr-at-eol lib/config-validator.js",
26
26
  "test:types": "tstyche",
27
+ "test:version": "node --test test/build/version.test.js",
27
28
  "test:watch": "npm run unit -- --watch --coverage-report=none --reporter=terse",
28
29
  "unit": "borp",
29
30
  "unit:report": "c8 --reporter html borp --reporter=@jsumners/line-reporter",
@@ -148,6 +149,10 @@
148
149
  "name": "Jean Michelet",
149
150
  "email": "jean.antoine.michelet@gmail.com",
150
151
  "url": "https://github.com/jean-michelet"
152
+ },
153
+ {
154
+ "name": "Antonio Tripodi",
155
+ "url": "https://github.com/Tony133"
151
156
  }
152
157
  ],
153
158
  "license": "MIT",
@@ -210,7 +215,7 @@
210
215
  "abstract-logging": "^2.0.1",
211
216
  "avvio": "^9.0.0",
212
217
  "fast-json-stringify": "^7.0.0",
213
- "find-my-way": "^9.6.0",
218
+ "find-my-way": "^9.10.0",
214
219
  "light-my-request": "^6.0.0",
215
220
  "pino": "^9.14.0 || ^10.1.0",
216
221
  "process-warning": "^5.1.0",
@@ -126,3 +126,28 @@ test('should throw error if invalid logger is returned', (t, done) => {
126
126
  }, { code: 'FST_ERR_LOG_INVALID_LOGGER' })
127
127
  })
128
128
  })
129
+
130
+ test('request child loggers only receive a level when the route sets one', async t => {
131
+ t.plan(3)
132
+
133
+ const fastify = Fastify({ logger: { level: 'info', stream: { write () {} } } })
134
+ const levels = []
135
+ fastify.setChildLoggerFactory(function (logger, bindings, opts) {
136
+ // an empty level would make pino reset the level of every request child
137
+ levels.push(Object.hasOwn(opts, 'level') ? opts.level : 'inherited')
138
+ return logger.child(bindings, opts)
139
+ })
140
+
141
+ fastify.get('/', (req, reply) => {
142
+ reply.send(req.log.level)
143
+ })
144
+ fastify.get('/warn', { logLevel: 'warn' }, (req, reply) => {
145
+ reply.send(req.log.level)
146
+ })
147
+
148
+ t.after(() => fastify.close())
149
+
150
+ t.assert.strictEqual((await fastify.inject('/')).body, 'info')
151
+ t.assert.strictEqual((await fastify.inject('/warn')).body, 'warn')
152
+ t.assert.deepStrictEqual(levels, ['inherited', 'warn'])
153
+ })
@@ -0,0 +1,154 @@
1
+ 'use strict'
2
+
3
+ const { test } = require('node:test')
4
+ const Fastify = require('../fastify')
5
+
6
+ test('addHook onClose supports AsyncDisposable objects', async t => {
7
+ const app = Fastify()
8
+ let disposed = false
9
+
10
+ const resource = {
11
+ async [Symbol.asyncDispose] () {
12
+ disposed = true
13
+ }
14
+ }
15
+
16
+ app.addHook('onClose', resource)
17
+ await app.ready()
18
+ t.assert.equal(disposed, false)
19
+
20
+ await app.close()
21
+ t.assert.equal(disposed, true)
22
+ })
23
+
24
+ test('addHook onClose supports synchronous Disposable objects', async t => {
25
+ const app = Fastify()
26
+ let disposed = false
27
+
28
+ const resource = {
29
+ [Symbol.dispose] () {
30
+ disposed = true
31
+ }
32
+ }
33
+
34
+ app.addHook('onClose', resource)
35
+ await app.ready()
36
+ t.assert.equal(disposed, false)
37
+
38
+ await app.close()
39
+ t.assert.equal(disposed, true)
40
+ })
41
+
42
+ test('addHook onClose prefers Symbol.asyncDispose over Symbol.dispose when both are present', async t => {
43
+ const app = Fastify()
44
+ let asyncDisposed = false
45
+ let syncDisposed = false
46
+
47
+ const resource = {
48
+ async [Symbol.asyncDispose] () {
49
+ asyncDisposed = true
50
+ },
51
+ [Symbol.dispose] () {
52
+ syncDisposed = true
53
+ }
54
+ }
55
+
56
+ app.addHook('onClose', resource)
57
+ await app.ready()
58
+ await app.close()
59
+
60
+ t.assert.equal(asyncDisposed, true)
61
+ t.assert.equal(syncDisposed, false)
62
+ })
63
+
64
+ test('addHook onClose propagates rejection from Symbol.asyncDispose', async t => {
65
+ const app = Fastify()
66
+ const customError = new Error('dispose async failed')
67
+
68
+ const resource = {
69
+ async [Symbol.asyncDispose] () {
70
+ throw customError
71
+ }
72
+ }
73
+
74
+ app.addHook('onClose', resource)
75
+ await app.ready()
76
+
77
+ await t.assert.rejects(app.close(), {
78
+ message: 'dispose async failed'
79
+ })
80
+ })
81
+
82
+ test('addHook onClose ignores fulfillment value from Symbol.asyncDispose', async t => {
83
+ const app = Fastify()
84
+
85
+ const resource = {
86
+ async [Symbol.asyncDispose] () {
87
+ return 'OK'
88
+ }
89
+ }
90
+
91
+ app.addHook('onClose', resource)
92
+ await app.ready()
93
+
94
+ await t.assert.doesNotReject(app.close())
95
+ })
96
+
97
+ test('addHook onClose propagates thrown error from Symbol.dispose', async t => {
98
+ const app = Fastify()
99
+ const customError = new Error('dispose sync failed')
100
+
101
+ const resource = {
102
+ [Symbol.dispose] () {
103
+ throw customError
104
+ }
105
+ }
106
+
107
+ app.addHook('onClose', resource)
108
+ await app.ready()
109
+
110
+ await t.assert.rejects(app.close(), {
111
+ message: 'dispose sync failed'
112
+ })
113
+ })
114
+
115
+ test('addHook onClose respects plugin execution order for disposables', async t => {
116
+ const app = Fastify()
117
+ const order = []
118
+
119
+ app.register(function childPlugin (instance, opts, done) {
120
+ instance.addHook('onClose', {
121
+ async [Symbol.asyncDispose] () {
122
+ order.push('child')
123
+ }
124
+ })
125
+ done()
126
+ })
127
+
128
+ app.addHook('onClose', {
129
+ async [Symbol.asyncDispose] () {
130
+ order.push('parent')
131
+ }
132
+ })
133
+
134
+ await app.ready()
135
+ await app.close()
136
+
137
+ t.assert.deepStrictEqual(order, ['child', 'parent'])
138
+ })
139
+
140
+ test('addHook onClose throws FST_ERR_HOOK_INVALID_HANDLER when non-function non-disposable is passed', t => {
141
+ const app = Fastify()
142
+
143
+ t.assert.throws(() => {
144
+ app.addHook('onClose', 'not-a-function-or-disposable')
145
+ }, {
146
+ code: 'FST_ERR_HOOK_INVALID_HANDLER'
147
+ })
148
+
149
+ t.assert.throws(() => {
150
+ app.addHook('onClose', { someProp: 123 })
151
+ }, {
152
+ code: 'FST_ERR_HOOK_INVALID_HANDLER'
153
+ })
154
+ })
@@ -607,7 +607,7 @@ test('Should allow registering constrained routes in a prefixed plugin', (t, don
607
607
  })
608
608
 
609
609
  test('Should allow registering a constrained GET route after a constrained HEAD route', (t, done) => {
610
- t.plan(3)
610
+ t.plan(4)
611
611
  const fastify = Fastify()
612
612
 
613
613
  fastify.route({
@@ -637,7 +637,9 @@ test('Should allow registering a constrained GET route after a constrained HEAD
637
637
  }
638
638
  }, (err, res) => {
639
639
  t.assert.ifError(err)
640
- t.assert.deepStrictEqual(res.payload, 'custom HEAD response')
640
+ // A HEAD response has no body, so check the headers set by the custom HEAD handler
641
+ t.assert.strictEqual(res.headers['content-type'], 'text/plain')
642
+ t.assert.strictEqual(res.headers['content-length'], String(Buffer.byteLength('custom HEAD response')))
641
643
  t.assert.strictEqual(res.statusCode, 200)
642
644
  done()
643
645
  })
@@ -2,6 +2,7 @@
2
2
 
3
3
  const { test } = require('node:test')
4
4
  const { spyWarning } = require('process-warning')
5
+ const { Readable } = require('node:stream')
5
6
  const Fastify = require('..')
6
7
  const keys = require('../lib/symbols')
7
8
  const { FST_ERR_CTP_ALREADY_PRESENT, FST_ERR_CTP_INVALID_TYPE, FST_ERR_CTP_INVALID_MEDIA_TYPE } = require('../lib/errors')
@@ -767,3 +768,101 @@ test('content-type fail when not a valid type', async t => {
767
768
  t.assert.equal(error.message, 'The content type should be a string or a RegExp')
768
769
  }
769
770
  })
771
+
772
+ test('string body keeps multi-byte characters split across chunks', async t => {
773
+ t.plan(2)
774
+
775
+ const fastify = Fastify()
776
+ const encoded = Buffer.from(JSON.stringify({ hello: 'wörld ✓ 😀' }))
777
+
778
+ fastify.addHook('preParsing', async () => {
779
+ // Emit one byte per chunk so every multi-byte character is split
780
+ return Readable.from(Array.from(encoded, byte => Buffer.from([byte])), { objectMode: false })
781
+ })
782
+ fastify.post('/', async (request) => request.body)
783
+
784
+ const res = await fastify.inject({
785
+ method: 'POST',
786
+ url: '/',
787
+ headers: { 'content-type': 'application/json' },
788
+ payload: encoded
789
+ })
790
+ t.assert.strictEqual(res.statusCode, 200)
791
+ t.assert.deepStrictEqual(res.json(), { hello: 'wörld ✓ 😀' })
792
+ })
793
+
794
+ test('string body accepts string chunks from a preParsing stream', async t => {
795
+ t.plan(2)
796
+
797
+ const fastify = Fastify()
798
+
799
+ fastify.addHook('preParsing', async (request, reply, payload) => {
800
+ payload.setEncoding('utf8')
801
+ return payload
802
+ })
803
+ fastify.post('/', async (request) => request.body)
804
+
805
+ const res = await fastify.inject({
806
+ method: 'POST',
807
+ url: '/',
808
+ headers: { 'content-type': 'text/plain' },
809
+ payload: 'hellö'
810
+ })
811
+ t.assert.strictEqual(res.statusCode, 200)
812
+ t.assert.strictEqual(res.body, 'hellö')
813
+ })
814
+
815
+ test('string body with invalid UTF-8 is not treated as a content-length mismatch', async t => {
816
+ t.plan(2)
817
+
818
+ const fastify = Fastify()
819
+ fastify.post('/', async (request) => request.body)
820
+
821
+ const res = await fastify.inject({
822
+ method: 'POST',
823
+ url: '/',
824
+ headers: { 'content-type': 'text/plain' },
825
+ payload: Buffer.from([0x61, 0xff, 0x62])
826
+ })
827
+ t.assert.strictEqual(res.statusCode, 200)
828
+ t.assert.strictEqual(res.body, 'a�b')
829
+ })
830
+
831
+ test('a synchronous throw in a custom parser is handled like a route error, not an uncaught exception', async t => {
832
+ await t.test('stream-mode parser', async t => {
833
+ t.plan(2)
834
+ const fastify = Fastify()
835
+ fastify.addContentTypeParser('application/json', function (req, payload, done) {
836
+ throw new Error('parser boom')
837
+ })
838
+ fastify.post('/', (req, reply) => reply.send('ok'))
839
+ const res = await fastify.inject({ method: 'POST', url: '/', headers: { 'content-type': 'application/json' }, payload: '{}' })
840
+ t.assert.strictEqual(res.statusCode, 500)
841
+ t.assert.strictEqual(res.json().message, 'parser boom')
842
+ })
843
+
844
+ await t.test('parseAs string parser', async t => {
845
+ t.plan(2)
846
+ const fastify = Fastify()
847
+ fastify.addContentTypeParser('application/json', { parseAs: 'string' }, function (req, body, done) {
848
+ throw new Error('parser boom')
849
+ })
850
+ fastify.post('/', (req, reply) => reply.send('ok'))
851
+ const res = await fastify.inject({ method: 'POST', url: '/', headers: { 'content-type': 'application/json' }, payload: '{}' })
852
+ t.assert.strictEqual(res.statusCode, 500)
853
+ t.assert.strictEqual(res.json().message, 'parser boom')
854
+ })
855
+
856
+ await t.test('a thrown error with a statusCode is preserved', async t => {
857
+ t.plan(1)
858
+ const fastify = Fastify()
859
+ fastify.addContentTypeParser('application/json', { parseAs: 'string' }, function (req, body, done) {
860
+ const err = new Error('too big')
861
+ err.statusCode = 413
862
+ throw err
863
+ })
864
+ fastify.post('/', (req, reply) => reply.send('ok'))
865
+ const res = await fastify.inject({ method: 'POST', url: '/', headers: { 'content-type': 'application/json' }, payload: '{}' })
866
+ t.assert.strictEqual(res.statusCode, 413)
867
+ })
868
+ })
@@ -1328,3 +1328,32 @@ test('Request.setDecorator should update an existing decorator', (t, done) => {
1328
1328
  })
1329
1329
  })
1330
1330
  })
1331
+
1332
+ test('getDecorator should evaluate a getter once', async t => {
1333
+ const fastify = Fastify()
1334
+ t.after(() => fastify.close())
1335
+ let calls = 0
1336
+ fastify.decorate('value', {
1337
+ getter () {
1338
+ return ++calls
1339
+ }
1340
+ })
1341
+
1342
+ t.assert.strictEqual(fastify.getDecorator('value'), 1)
1343
+ t.assert.strictEqual(calls, 1)
1344
+ })
1345
+
1346
+ test('getDecorator should bind the function returned by a getter once', async t => {
1347
+ const fastify = Fastify()
1348
+ t.after(() => fastify.close())
1349
+ let calls = 0
1350
+ fastify.decorate('method', {
1351
+ getter () {
1352
+ calls++
1353
+ return function () { return this }
1354
+ }
1355
+ })
1356
+
1357
+ t.assert.strictEqual(fastify.getDecorator('method')(), fastify)
1358
+ t.assert.strictEqual(calls, 1)
1359
+ })
@@ -96,9 +96,12 @@ test('Error.status property support', (t, done) => {
96
96
  const err = new Error('winter is coming')
97
97
  err.status = 418
98
98
 
99
- diagnostics.subscribe('tracing:fastify.request.handler:error', (msg) => {
99
+ const channel = diagnostics.channel('tracing:fastify.request.handler:error')
100
+ const handler = (msg) => {
100
101
  t.assert.strictEqual(msg.error.message, 'winter is coming')
101
- })
102
+ }
103
+ channel.subscribe(handler)
104
+ t.after(() => channel.unsubscribe(handler))
102
105
 
103
106
  fastify.get('/', () => {
104
107
  return Promise.reject(err)
@@ -121,3 +124,33 @@ test('Error.status property support', (t, done) => {
121
124
  done()
122
125
  })
123
126
  })
127
+
128
+ test('diagnostics channel error event should report 500 for error with statusCode >= 600', async (t) => {
129
+ t.plan(4)
130
+ const fastify = Fastify()
131
+ t.after(() => fastify.close())
132
+
133
+ let diagnosticsStatusCode
134
+ let diagnosticsError
135
+
136
+ const channel = diagnostics.channel('tracing:fastify.request.handler:error')
137
+ const handler = (msg) => {
138
+ diagnosticsStatusCode = msg.reply.statusCode
139
+ diagnosticsError = msg.error
140
+ }
141
+ channel.subscribe(handler)
142
+ t.after(() => channel.unsubscribe(handler))
143
+
144
+ fastify.get('/', async () => {
145
+ const err = new Error('custom invalid status')
146
+ err.statusCode = 600
147
+ throw err
148
+ })
149
+
150
+ const res = await fastify.inject('/')
151
+
152
+ t.assert.strictEqual(res.statusCode, 500)
153
+ t.assert.strictEqual(diagnosticsStatusCode, 500, 'diagnostics channel should report 500 when statusCode >= 600')
154
+ t.assert.strictEqual(diagnosticsStatusCode, res.statusCode, 'diagnostics status should match response status')
155
+ t.assert.strictEqual(diagnosticsError.message, 'custom invalid status')
156
+ })
@@ -2,6 +2,7 @@
2
2
 
3
3
  const { test } = require('node:test')
4
4
  const net = require('node:net')
5
+ const http2 = require('node:http2')
5
6
  const Fastify = require('..')
6
7
  const { Readable } = require('node:stream')
7
8
  const { kTimeoutTimer, kOnAbort } = require('../lib/symbols')
@@ -201,6 +202,229 @@ test('streaming response: timer clears when response finishes', async t => {
201
202
  t.assert.strictEqual(res.status, 200)
202
203
  })
203
204
 
205
+ // Regression test for GHSA-3qgw-9275-xf8w:
206
+ // a streaming response that has already transmitted its headers must not
207
+ // crash the server when handlerTimeout expires. Instead of trying to send
208
+ // an error response (which would raise an uncaught ERR_HTTP_HEADERS_SENT),
209
+ // the in-flight response is aborted and the server stays alive.
210
+ test('streaming response exceeding handlerTimeout aborts instead of crashing', async t => {
211
+ t.plan(4)
212
+
213
+ const fastify = Fastify({ handlerTimeout: 100 })
214
+
215
+ // A logical gate (a deferred promise) keeps the stream open. The gateway
216
+ // is only released during teardown, so the stream never ends on its own
217
+ // and the handlerTimeout is what terminates the response. Relying on a
218
+ // wall-clock setTimeout to control the stream would be flaky (it can fire
219
+ // after the stream has already been destroyed).
220
+ let release
221
+ const gate = new Promise((resolve) => { release = resolve })
222
+
223
+ fastify.get('/stream', async (request, reply) => {
224
+ let started = false
225
+ const stream = new Readable({
226
+ read () {
227
+ if (started) return
228
+ started = true
229
+ // Emit one chunk so the response headers are transmitted before the
230
+ // handler timeout fires, then hold the stream open on the gate until
231
+ // the timeout aborts the response.
232
+ this.push('first')
233
+ gate.then(() => {
234
+ if (!this.destroyed) this.push(null)
235
+ })
236
+ }
237
+ })
238
+ reply.type('text/plain').send(stream)
239
+ return reply
240
+ })
241
+
242
+ fastify.get('/after', async () => ({ ok: true }))
243
+
244
+ await fastify.listen({ port: 0 })
245
+ t.after(async () => {
246
+ release()
247
+ await fastify.close()
248
+ })
249
+
250
+ const base = `http://127.0.0.1:${fastify.server.address().port}`
251
+
252
+ // Headers and the first chunk are transmitted before the timeout expires, so
253
+ // the client observes a 200, but the body is aborted when the timeout fires.
254
+ const res = await fetch(`${base}/stream`)
255
+ t.assert.strictEqual(res.status, 200)
256
+ t.assert.strictEqual(res.headers.get('content-type'), 'text/plain')
257
+ await t.assert.rejects(() => res.text())
258
+
259
+ // The server must not have crashed and must still serve other requests.
260
+ const after = await fetch(`${base}/after`)
261
+ t.assert.strictEqual(after.status, 200)
262
+ })
263
+
264
+ // Over HTTP/2, payloads larger than 64 KiB are written in chunks and the
265
+ // response is ended only after the last one. A client that never reads the
266
+ // body keeps the response open with its headers already sent.
267
+ test('HTTP/2 payload still being written when handlerTimeout fires aborts instead of crashing', async t => {
268
+ t.plan(2)
269
+
270
+ const fastify = Fastify({ http2: true, handlerTimeout: 100 })
271
+ const big = Buffer.alloc(4 * 1024 * 1024, 'a')
272
+
273
+ fastify.get('/big', async (request, reply) => {
274
+ reply.type('text/plain')
275
+ return big
276
+ })
277
+
278
+ fastify.get('/after', async () => 'ok')
279
+
280
+ await fastify.listen({ port: 0 })
281
+
282
+ const client = http2.connect(`http://127.0.0.1:${fastify.server.address().port}`)
283
+ t.after(async () => {
284
+ client.destroy()
285
+ await fastify.close()
286
+ })
287
+
288
+ await new Promise((resolve) => {
289
+ const req = client.request({ ':path': '/big' })
290
+ req.on('response', (headers) => {
291
+ t.assert.strictEqual(headers[':status'], 200)
292
+ // Never read the body, so the flow-control window is never replenished.
293
+ req.pause()
294
+ })
295
+ req.on('error', () => {})
296
+ req.on('close', resolve)
297
+ req.end()
298
+ })
299
+
300
+ // The server must not have crashed and must still serve other requests.
301
+ const body = await new Promise((resolve, reject) => {
302
+ const req = client.request({ ':path': '/after' })
303
+ let data = ''
304
+ req.setEncoding('utf8')
305
+ req.on('data', (chunk) => { data += chunk })
306
+ req.on('end', () => resolve(data))
307
+ req.on('error', reject)
308
+ req.end()
309
+ })
310
+ t.assert.strictEqual(body, 'ok')
311
+ })
312
+
313
+ // Regression test for GHSA-3qgw-9275-xf8w:
314
+ // aborting request.signal on timeout runs any application abort listener
315
+ // synchronously. If that listener sends the response itself, the timeout path
316
+ // must not then destroy it: doing so resets the keep-alive connection (and any
317
+ // other in-flight requests on it). The connection must stay usable afterwards.
318
+ test('handlerTimeout keeps the connection usable when an abort listener sends the response', async t => {
319
+ t.plan(2)
320
+
321
+ const fastify = Fastify({ handlerTimeout: 100 })
322
+
323
+ // A deferred gate keeps the handler pending so the timeout is what ends the
324
+ // request, while the abort listener sends a complete 504 response.
325
+ let release
326
+ const gate = new Promise((resolve) => { release = resolve })
327
+
328
+ fastify.get('/timeout', async (request, reply) => {
329
+ request.signal.addEventListener('abort', () => {
330
+ reply.code(504).type('text/plain').send('handled by abort listener')
331
+ })
332
+ await gate
333
+ })
334
+ fastify.get('/after', async () => 'ok')
335
+
336
+ await fastify.listen({ port: 0 })
337
+ t.after(async () => {
338
+ release()
339
+ await fastify.close()
340
+ })
341
+
342
+ const socket = net.connect(fastify.server.address().port, '127.0.0.1')
343
+ t.after(() => socket.destroy())
344
+
345
+ const second = await new Promise((resolve, reject) => {
346
+ let buffer = ''
347
+ let reused = false
348
+ socket.setEncoding('utf8')
349
+ socket.on('connect', () => {
350
+ socket.write('GET /timeout HTTP/1.1\r\nHost: localhost\r\nConnection: keep-alive\r\n\r\n')
351
+ })
352
+ socket.on('data', (chunk) => {
353
+ buffer += chunk
354
+ if (!reused && buffer.includes('handled by abort listener')) {
355
+ // The abort listener's response arrived; reuse the same socket.
356
+ reused = true
357
+ buffer = ''
358
+ socket.write('GET /after HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n')
359
+ } else if (reused && buffer.includes('ok')) {
360
+ resolve(buffer)
361
+ }
362
+ })
363
+ socket.on('error', reject)
364
+ // Without the fix the server destroys the socket after the first response,
365
+ // so the follow-up request never completes.
366
+ socket.on('close', () => reject(new Error('connection was reset before the follow-up request completed')))
367
+ })
368
+
369
+ t.assert.strictEqual(second.startsWith('HTTP/1.1 200'), true)
370
+ t.assert.strictEqual(second.includes('ok'), true)
371
+ })
372
+
373
+ // A response can be ended (reply.sent === true) while its body is still being
374
+ // flushed to a slow client, so 'finish' has not fired and the timer is still
375
+ // pending. When it fires it must leave the response and request.signal alone.
376
+ // 'finish' is held back explicitly so the test does not depend on how much
377
+ // data the OS socket buffers absorb.
378
+ test('handlerTimeout firing after the reply ended but before it finished is a no-op', async t => {
379
+ t.plan(4)
380
+
381
+ const fastify = Fastify({ handlerTimeout: 50 })
382
+
383
+ let signal
384
+ fastify.get('/', async (request, reply) => {
385
+ signal = request.signal
386
+ const res = reply.raw
387
+ const emit = res.emit
388
+ res.emit = function (event, ...args) {
389
+ if (event !== 'finish') return emit.call(this, event, ...args)
390
+ // Emit 'finish' only well after the handlerTimeout has fired.
391
+ setTimeout(() => {
392
+ t.assert.strictEqual(reply.sent, true)
393
+ t.assert.strictEqual(signal.aborted, false)
394
+ emit.call(this, event, ...args)
395
+ }, 200)
396
+ return true
397
+ }
398
+ return 'ok'
399
+ })
400
+
401
+ await fastify.listen({ port: 0 })
402
+ t.after(() => fastify.close())
403
+
404
+ // A raw keep-alive connection, so nothing but the timer can abort the
405
+ // request while 'finish' is pending.
406
+ const socket = net.connect(fastify.server.address().port, '127.0.0.1')
407
+ t.after(() => socket.destroy())
408
+
409
+ const response = await new Promise((resolve, reject) => {
410
+ let buffer = ''
411
+ socket.setEncoding('utf8')
412
+ socket.on('connect', () => {
413
+ socket.write('GET / HTTP/1.1\r\nHost: localhost\r\n\r\n')
414
+ })
415
+ socket.on('data', (chunk) => {
416
+ buffer += chunk
417
+ if (buffer.endsWith('ok')) resolve(buffer)
418
+ })
419
+ socket.on('error', reject)
420
+ })
421
+ t.assert.strictEqual(response.startsWith('HTTP/1.1 200'), true)
422
+
423
+ // Wait for the deferred 'finish', then the request must still not be aborted.
424
+ await new Promise((resolve) => setTimeout(resolve, 300))
425
+ t.assert.strictEqual(signal.aborted, false)
426
+ })
427
+
204
428
  // --- SSE with reply.hijack() ---
205
429
 
206
430
  test('reply.hijack() clears timeout timer', async t => {
@@ -33,6 +33,11 @@ test('fastify.all should add all the methods to the same url', async t => {
33
33
  }
34
34
 
35
35
  const res = await fastify.inject(options)
36
+ if (method === 'HEAD') {
37
+ // A HEAD response has no body, only the headers of the GET-like response
38
+ t.assert.strictEqual(res.headers['content-length'], String(Buffer.byteLength(JSON.stringify({ method }))))
39
+ return
40
+ }
36
41
  const payload = JSON.parse(res.payload)
37
42
  t.assert.deepStrictEqual(payload, { method })
38
43
  }