fastify 6.0.0-alpha.5 → 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 (46) hide show
  1. package/README.md +1 -0
  2. package/build/build-validation.js +2 -0
  3. package/docs/Guides/Ecosystem.md +4 -0
  4. package/docs/Guides/Migration-Guide-V5.md +2 -2
  5. package/docs/Guides/Serverless.md +33 -10
  6. package/docs/Reference/Errors.md +13 -0
  7. package/docs/Reference/Hooks.md +13 -0
  8. package/docs/Reference/Reply.md +786 -697
  9. package/docs/Reference/Request.md +303 -107
  10. package/docs/Reference/Server.md +39 -2
  11. package/docs/Reference/Validation-and-Serialization.md +15 -1
  12. package/fastify.d.ts +1 -0
  13. package/fastify.js +35 -1
  14. package/lib/config-validator.js +102 -73
  15. package/lib/content-type-parser.js +14 -2
  16. package/lib/context.js +2 -1
  17. package/lib/decorate.js +4 -3
  18. package/lib/error-handler.js +21 -4
  19. package/lib/error-status.js +4 -2
  20. package/lib/errors.js +15 -9
  21. package/lib/reply.js +12 -11
  22. package/lib/request.js +3 -2
  23. package/lib/route.js +34 -5
  24. package/lib/schemas.js +44 -32
  25. package/package.json +8 -3
  26. package/test/close-disposable.test.js +154 -0
  27. package/test/constrained-routes.test.js +4 -2
  28. package/test/content-parser.test.js +39 -0
  29. package/test/decorator.test.js +29 -0
  30. package/test/diagnostics-channel/error-status.test.js +35 -2
  31. package/test/handler-timeout.test.js +224 -0
  32. package/test/internals/all.test.js +5 -0
  33. package/test/internals/errors.test.js +18 -9
  34. package/test/internals/initial-config.test.js +2 -0
  35. package/test/internals/server.test.js +6 -4
  36. package/test/reply-error.test.js +82 -0
  37. package/test/reply-trailers.test.js +159 -1
  38. package/test/router-compile.test.js +241 -0
  39. package/test/schema-serialization.test.js +120 -7
  40. package/test/types/fastify.tst.ts +4 -0
  41. package/test/types/instance.tst.ts +1 -1
  42. package/test/types/route.tst.ts +16 -0
  43. package/test/types/using.tst.ts +20 -0
  44. package/test/validation-error-handling.test.js +21 -1
  45. package/types/instance.d.ts +5 -1
  46. package/types/route.d.ts +1 -0
@@ -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
  }
@@ -151,53 +151,58 @@ test('FST_ERR_CTP_INVALID_PARSE_TYPE', t => {
151
151
  })
152
152
 
153
153
  test('FST_ERR_CTP_BODY_TOO_LARGE', t => {
154
- t.plan(5)
154
+ t.plan(6)
155
155
  const error = new errors.FST_ERR_CTP_BODY_TOO_LARGE()
156
156
  t.assert.strictEqual(error.name, 'FastifyError')
157
157
  t.assert.strictEqual(error.code, 'FST_ERR_CTP_BODY_TOO_LARGE')
158
158
  t.assert.strictEqual(error.message, 'Request body is too large')
159
159
  t.assert.strictEqual(error.statusCode, 413)
160
160
  t.assert.ok(error instanceof RangeError)
161
+ t.assert.strictEqual(error.stack, undefined)
161
162
  })
162
163
 
163
164
  test('FST_ERR_CTP_INVALID_MEDIA_TYPE', t => {
164
- t.plan(5)
165
+ t.plan(6)
165
166
  const error = new errors.FST_ERR_CTP_INVALID_MEDIA_TYPE()
166
167
  t.assert.strictEqual(error.name, 'FastifyError')
167
168
  t.assert.strictEqual(error.code, 'FST_ERR_CTP_INVALID_MEDIA_TYPE')
168
169
  t.assert.strictEqual(error.message, 'Unsupported Media Type')
169
170
  t.assert.strictEqual(error.statusCode, 415)
170
171
  t.assert.ok(error instanceof Error)
172
+ t.assert.strictEqual(error.stack, undefined)
171
173
  })
172
174
 
173
175
  test('FST_ERR_CTP_INVALID_CONTENT_LENGTH', t => {
174
- t.plan(5)
176
+ t.plan(6)
175
177
  const error = new errors.FST_ERR_CTP_INVALID_CONTENT_LENGTH()
176
178
  t.assert.strictEqual(error.name, 'FastifyError')
177
179
  t.assert.strictEqual(error.code, 'FST_ERR_CTP_INVALID_CONTENT_LENGTH')
178
180
  t.assert.strictEqual(error.message, 'Request body size did not match Content-Length')
179
181
  t.assert.strictEqual(error.statusCode, 400)
180
182
  t.assert.ok(error instanceof RangeError)
183
+ t.assert.strictEqual(error.stack, undefined)
181
184
  })
182
185
 
183
186
  test('FST_ERR_CTP_EMPTY_JSON_BODY', t => {
184
- t.plan(5)
187
+ t.plan(6)
185
188
  const error = new errors.FST_ERR_CTP_EMPTY_JSON_BODY()
186
189
  t.assert.strictEqual(error.name, 'FastifyError')
187
190
  t.assert.strictEqual(error.code, 'FST_ERR_CTP_EMPTY_JSON_BODY')
188
191
  t.assert.strictEqual(error.message, "Body cannot be empty when content-type is set to 'application/json'")
189
192
  t.assert.strictEqual(error.statusCode, 400)
190
193
  t.assert.ok(error instanceof Error)
194
+ t.assert.strictEqual(error.stack, undefined)
191
195
  })
192
196
 
193
197
  test('FST_ERR_CTP_INVALID_JSON_BODY', t => {
194
- t.plan(5)
198
+ t.plan(6)
195
199
  const error = new errors.FST_ERR_CTP_INVALID_JSON_BODY()
196
200
  t.assert.strictEqual(error.name, 'FastifyError')
197
201
  t.assert.strictEqual(error.code, 'FST_ERR_CTP_INVALID_JSON_BODY')
198
202
  t.assert.strictEqual(error.message, "Body is not valid JSON but content-type is set to 'application/json'")
199
203
  t.assert.strictEqual(error.statusCode, 400)
200
204
  t.assert.ok(error instanceof Error)
205
+ t.assert.strictEqual(error.stack, undefined)
201
206
  })
202
207
 
203
208
  test('FST_ERR_CTP_INSTANCE_ALREADY_STARTED', t => {
@@ -621,23 +626,25 @@ test('FST_ERR_DUPLICATED_ROUTE', t => {
621
626
  })
622
627
 
623
628
  test('FST_ERR_BAD_URL', t => {
624
- t.plan(5)
629
+ t.plan(6)
625
630
  const error = new errors.FST_ERR_BAD_URL()
626
631
  t.assert.strictEqual(error.name, 'FastifyError')
627
632
  t.assert.strictEqual(error.code, 'FST_ERR_BAD_URL')
628
633
  t.assert.strictEqual(error.message, "'%s' is not a valid url component")
629
634
  t.assert.strictEqual(error.statusCode, 400)
630
635
  t.assert.ok(error instanceof Error)
636
+ t.assert.strictEqual(error.stack, undefined)
631
637
  })
632
638
 
633
639
  test('FST_ERR_MAX_PARAM_LENGTH', t => {
634
- t.plan(5)
640
+ t.plan(6)
635
641
  const error = new errors.FST_ERR_MAX_PARAM_LENGTH()
636
642
  t.assert.strictEqual(error.name, 'FastifyError')
637
643
  t.assert.strictEqual(error.code, 'FST_ERR_MAX_PARAM_LENGTH')
638
644
  t.assert.strictEqual(error.message, "'%s' is exceeding the max param length")
639
645
  t.assert.strictEqual(error.statusCode, 414)
640
646
  t.assert.ok(error instanceof Error)
647
+ t.assert.strictEqual(error.stack, undefined)
641
648
  })
642
649
 
643
650
  test('FST_ERR_ASYNC_CONSTRAINT', t => {
@@ -781,13 +788,14 @@ test('FST_ERR_ROUTE_REWRITE_NOT_STR', t => {
781
788
  })
782
789
 
783
790
  test('FST_ERR_ROUTE_MISSING_CONTENT_TYPE', t => {
784
- t.plan(5)
791
+ t.plan(6)
785
792
  const error = new errors.FST_ERR_ROUTE_MISSING_CONTENT_TYPE()
786
793
  t.assert.strictEqual(error.name, 'FastifyError')
787
794
  t.assert.strictEqual(error.code, 'FST_ERR_ROUTE_MISSING_CONTENT_TYPE')
788
795
  t.assert.strictEqual(error.message, "Method '%s' must provide a 'Content-Type' header.")
789
796
  t.assert.strictEqual(error.statusCode, 400)
790
797
  t.assert.ok(error instanceof Error)
798
+ t.assert.strictEqual(error.stack, undefined)
791
799
  })
792
800
 
793
801
  test('FST_ERR_REOPENED_CLOSE_SERVER', t => {
@@ -911,13 +919,14 @@ test('FST_ERR_PLUGIN_TIMEOUT', t => {
911
919
  })
912
920
 
913
921
  test('FST_ERR_VALIDATION', t => {
914
- t.plan(5)
922
+ t.plan(6)
915
923
  const error = new errors.FST_ERR_VALIDATION()
916
924
  t.assert.strictEqual(error.name, 'FastifyError')
917
925
  t.assert.strictEqual(error.code, 'FST_ERR_VALIDATION')
918
926
  t.assert.strictEqual(error.message, '%s')
919
927
  t.assert.strictEqual(error.statusCode, 400)
920
928
  t.assert.ok(error instanceof Error)
929
+ t.assert.strictEqual(error.stack, undefined)
921
930
  })
922
931
 
923
932
  test('FST_ERR_LISTEN_OPTIONS_INVALID', t => {
@@ -45,6 +45,7 @@ test('without options passed to Fastify, initialConfig should expose default val
45
45
  routerOptions: {
46
46
  allowUnsafeRegex: false,
47
47
  caseSensitive: true,
48
+ compile: false,
48
49
  constraints: undefined,
49
50
  ignoreTrailingSlash: false,
50
51
  ignoreDuplicateSlashes: false,
@@ -302,6 +303,7 @@ test('Should not have issues when passing stream options to Pino.js', (t, done)
302
303
  routerOptions: {
303
304
  allowUnsafeRegex: false,
304
305
  caseSensitive: true,
306
+ compile: false,
305
307
  constraints: undefined,
306
308
  ignoreTrailingSlash: true,
307
309
  ignoreDuplicateSlashes: false,
@@ -12,13 +12,15 @@ const handler = (req, res) => {
12
12
  }
13
13
 
14
14
  test('start listening', async t => {
15
+ t.plan(1)
15
16
  const { server, listen } = createServer({}, handler)
17
+ t.after(() => server.close())
16
18
  await listen.call(Fastify(), { port: 0, host: 'localhost' })
17
- server.close()
18
- t.assert.ok(true, 'server started')
19
+ t.assert.ok(server.listening, 'the server is listening')
19
20
  })
20
21
 
21
22
  test('DNS errors does not stop the main server on localhost - promise interface', async t => {
23
+ t.plan(1)
22
24
  const { createServer } = proxyquire('../../lib/server', {
23
25
  'node:dns': {
24
26
  lookup: (hostname, options, cb) => {
@@ -27,9 +29,9 @@ test('DNS errors does not stop the main server on localhost - promise interface'
27
29
  }
28
30
  })
29
31
  const { server, listen } = createServer({}, handler)
32
+ t.after(() => server.close())
30
33
  await listen.call(Fastify(), { port: 0, host: 'localhost' })
31
- server.close()
32
- t.assert.ok(true, 'server started')
34
+ t.assert.ok(server.listening, 'the DNS error did not stop the server listening')
33
35
  })
34
36
 
35
37
  test('DNS errors does not stop the main server on localhost - callback interface', (t, done) => {
@@ -245,6 +245,35 @@ test('Error status code below 400 defaults to 500', (t, testDone) => {
245
245
  })
246
246
  })
247
247
 
248
+ test('Error status code above 599 defaults to 500', (t, testDone) => {
249
+ t.plan(3)
250
+ const fastify = Fastify()
251
+ t.after(() => fastify.close())
252
+ const err = new Error('winter is coming')
253
+ err.statusCode = 600
254
+
255
+ fastify.get('/', () => {
256
+ return Promise.reject(err)
257
+ })
258
+
259
+ fastify.inject({
260
+ method: 'GET',
261
+ url: '/'
262
+ }, (error, res) => {
263
+ t.assert.ifError(error)
264
+ t.assert.strictEqual(res.statusCode, 500)
265
+ t.assert.deepStrictEqual(
266
+ {
267
+ error: statusCodes['500'],
268
+ message: err.message,
269
+ statusCode: 500
270
+ },
271
+ JSON.parse(res.payload)
272
+ )
273
+ testDone()
274
+ })
275
+ })
276
+
248
277
  test('Error.status property support', (t, testDone) => {
249
278
  t.plan(3)
250
279
  const fastify = Fastify()
@@ -848,3 +877,56 @@ test('should catch error when setting invalid header on async onSend hook', asyn
848
877
  app.get('/', async () => ({ ok: true }))
849
878
  await app.inject({ method: 'GET', path: '/' })
850
879
  })
880
+
881
+ // The fallback error handler must not try to write a new response once the
882
+ // current one has started transmitting: that would raise an uncaught
883
+ // ERR_HTTP_HEADERS_SENT and terminate the process. It aborts the response.
884
+ test('fallback error handler aborts the response when headers were already sent', async (t) => {
885
+ t.plan(2)
886
+
887
+ const app = Fastify()
888
+ app.addHook('onSend', async (request, reply) => {
889
+ if (reply.raw.headersSent === false) {
890
+ reply.raw.writeHead(200, { 'content-type': 'text/plain' })
891
+ reply.raw.write('partial')
892
+ }
893
+ throw new Error('kaboom')
894
+ })
895
+ app.get('/', async () => 'ok')
896
+
897
+ await app.listen({ port: 0 })
898
+ t.after(() => app.close())
899
+
900
+ const base = `http://127.0.0.1:${app.server.address().port}`
901
+ await t.assert.rejects(async () => {
902
+ const res = await fetch(`${base}/`)
903
+ await res.text()
904
+ })
905
+
906
+ // The server must not have crashed.
907
+ t.assert.strictEqual(app.server.listening, true)
908
+ })
909
+
910
+ test('fallback error handler aborts the response when writeHead throws ERR_HTTP_HEADERS_SENT', async (t) => {
911
+ t.plan(2)
912
+
913
+ const app = Fastify()
914
+ app.addHook('onSend', async (request, reply, payload) => {
915
+ reply.raw.writeHead = function () {
916
+ const err = new Error('Cannot write headers after they are sent to the client')
917
+ err.code = 'ERR_HTTP_HEADERS_SENT'
918
+ throw err
919
+ }
920
+ return payload
921
+ })
922
+ app.get('/', async () => 'ok')
923
+
924
+ await app.listen({ port: 0 })
925
+ t.after(() => app.close())
926
+
927
+ const base = `http://127.0.0.1:${app.server.address().port}`
928
+ await t.assert.rejects(fetch(`${base}/`))
929
+
930
+ // The server must not have crashed.
931
+ t.assert.strictEqual(app.server.listening, true)
932
+ })
@@ -4,7 +4,28 @@ const { test, describe } = require('node:test')
4
4
  const Fastify = require('..')
5
5
  const { Readable } = require('node:stream')
6
6
  const { createHash } = require('node:crypto')
7
- const { sleep } = require('./helper')
7
+ const { sleep, getServerUrl } = require('./helper')
8
+ const http = require('node:http')
9
+
10
+ function getWithTrailers (url) {
11
+ return new Promise((resolve, reject) => {
12
+ const req = http.request(url, (res) => {
13
+ const chunks = []
14
+ res.on('data', (chunk) => chunks.push(chunk))
15
+ res.on('end', () => {
16
+ resolve({
17
+ statusCode: res.statusCode,
18
+ headers: res.headers,
19
+ trailers: res.trailers,
20
+ body: Buffer.concat(chunks).toString()
21
+ })
22
+ })
23
+ res.on('error', reject)
24
+ })
25
+ req.on('error', reject)
26
+ req.end()
27
+ })
28
+ }
8
29
 
9
30
  test('send trailers when payload is empty string', (t, testDone) => {
10
31
  t.plan(5)
@@ -142,6 +163,143 @@ test('send trailers when payload is stream', (t, testDone) => {
142
163
  })
143
164
  })
144
165
 
166
+ // fastify.inject() never touches a real socket, so it cannot observe
167
+ // the res.end() race between Readable#pipe() and the trailer flush.
168
+ describe('trailers over a real socket', () => {
169
+ test('send trailers when payload is a Readable stream', async (t) => {
170
+ const fastify = Fastify()
171
+
172
+ fastify.get('/', function (request, reply) {
173
+ reply.trailer('ETag', function (reply, payload, done) {
174
+ done(null, 'custom-etag')
175
+ })
176
+ const stream = Readable.from([JSON.stringify({ hello: 'world' })])
177
+ reply.send(stream)
178
+ })
179
+
180
+ await fastify.listen({ port: 0 })
181
+ t.after(() => fastify.close())
182
+
183
+ const res = await getWithTrailers(getServerUrl(fastify))
184
+ t.assert.strictEqual(res.statusCode, 200)
185
+ t.assert.strictEqual(res.headers['transfer-encoding'], 'chunked')
186
+ t.assert.strictEqual(res.headers.trailer, 'etag')
187
+ t.assert.strictEqual(res.trailers.etag, 'custom-etag')
188
+ t.assert.strictEqual(res.body, JSON.stringify({ hello: 'world' }))
189
+ })
190
+
191
+ test('send trailers when payload is a web ReadableStream', async (t) => {
192
+ const fastify = Fastify()
193
+
194
+ fastify.get('/', function (request, reply) {
195
+ reply.trailer('ETag', function (reply, payload, done) {
196
+ done(null, 'custom-etag')
197
+ })
198
+ const webStream = new ReadableStream({
199
+ start (controller) {
200
+ controller.enqueue(Buffer.from(JSON.stringify({ hello: 'world' })))
201
+ controller.close()
202
+ }
203
+ })
204
+ reply.send(webStream)
205
+ })
206
+
207
+ await fastify.listen({ port: 0 })
208
+ t.after(() => fastify.close())
209
+
210
+ const res = await getWithTrailers(getServerUrl(fastify))
211
+ t.assert.strictEqual(res.statusCode, 200)
212
+ t.assert.strictEqual(res.headers.trailer, 'etag')
213
+ t.assert.strictEqual(res.trailers.etag, 'custom-etag')
214
+ t.assert.strictEqual(res.body, JSON.stringify({ hello: 'world' }))
215
+ })
216
+
217
+ test('send trailers when payload is a pre-ended Readable stream', async (t) => {
218
+ const fastify = Fastify()
219
+
220
+ // drain the stream and let 'end' pass before it is ever handed to
221
+ // send(), so sendStream() attaches its listeners after the stream
222
+ // is already done. the route handler stays synchronous so fastify's
223
+ // async "did the handler forget to send" rescue in wrap-thenable
224
+ // never gets a chance to paper over a stuck response.
225
+ const stream = Readable.from([JSON.stringify({ hello: 'world' })])
226
+ stream.resume()
227
+ await new Promise((resolve) => stream.on('end', resolve))
228
+
229
+ fastify.get('/', function (request, reply) {
230
+ reply.trailer('ETag', function (reply, payload, done) {
231
+ done(null, 'custom-etag')
232
+ })
233
+ reply.send(stream)
234
+ })
235
+
236
+ await fastify.listen({ port: 0 })
237
+ t.after(() => fastify.close())
238
+
239
+ const res = await Promise.race([
240
+ getWithTrailers(getServerUrl(fastify)),
241
+ new Promise((resolve, reject) => {
242
+ const hangTimer = setTimeout(() => reject(new Error('response hung')), 2000)
243
+ t.after(() => clearTimeout(hangTimer))
244
+ })
245
+ ])
246
+ t.assert.strictEqual(res.statusCode, 200)
247
+ t.assert.strictEqual(res.headers.trailer, 'etag')
248
+ t.assert.strictEqual(res.trailers.etag, 'custom-etag')
249
+ t.assert.strictEqual(res.body, '')
250
+ })
251
+
252
+ test('stream response with no trailers registered ends normally', async (t) => {
253
+ const fastify = Fastify()
254
+
255
+ fastify.get('/', function (request, reply) {
256
+ const stream = Readable.from(['no-trailer-payload'])
257
+ reply.send(stream)
258
+ })
259
+
260
+ await fastify.listen({ port: 0 })
261
+ t.after(() => fastify.close())
262
+
263
+ const res = await getWithTrailers(getServerUrl(fastify))
264
+ t.assert.strictEqual(res.statusCode, 200)
265
+ t.assert.deepStrictEqual(res.trailers, {})
266
+ t.assert.strictEqual(res.body, 'no-trailer-payload')
267
+ })
268
+
269
+ test('stream error before end does not hang the response', async (t) => {
270
+ const fastify = Fastify({ logger: false })
271
+
272
+ fastify.get('/', function (request, reply) {
273
+ reply.trailer('ETag', function (reply, payload, done) {
274
+ done(null, 'custom-etag')
275
+ })
276
+ const stream = new Readable({
277
+ read () {
278
+ this.push('partial-data')
279
+ process.nextTick(() => this.destroy(new Error('boom')))
280
+ }
281
+ })
282
+ reply.send(stream)
283
+ })
284
+
285
+ await fastify.listen({ port: 0 })
286
+ t.after(() => fastify.close())
287
+
288
+ await new Promise((resolve, reject) => {
289
+ const req = http.request(getServerUrl(fastify), (res) => {
290
+ res.on('data', () => {})
291
+ res.on('error', () => resolve())
292
+ res.on('close', () => resolve())
293
+ res.on('end', () => resolve())
294
+ })
295
+ req.on('error', () => resolve())
296
+ req.end()
297
+ const hangTimer = setTimeout(() => reject(new Error('response hung')), 2000)
298
+ t.after(() => clearTimeout(hangTimer))
299
+ })
300
+ })
301
+ })
302
+
145
303
  test('remove trailer while stream is being consumed', (t, testDone) => {
146
304
  t.plan(5)
147
305