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
@@ -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 = 95
8
+ const expectedErrors = 94
9
9
 
10
10
  test(`should expose ${expectedErrors} errors`, t => {
11
11
  t.plan(1)
@@ -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,23 +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)
791
- })
792
-
793
- test('FST_ERR_ROUTE_MISSING_CONTENT', t => {
794
- t.plan(5)
795
- const error = new errors.FST_ERR_ROUTE_MISSING_CONTENT()
796
- t.assert.strictEqual(error.name, 'FastifyError')
797
- t.assert.strictEqual(error.code, 'FST_ERR_ROUTE_MISSING_CONTENT')
798
- t.assert.strictEqual(error.message, "Method '%s' must provide a request body.")
799
- t.assert.strictEqual(error.statusCode, 400)
800
- t.assert.ok(error instanceof Error)
798
+ t.assert.strictEqual(error.stack, undefined)
801
799
  })
802
800
 
803
801
  test('FST_ERR_REOPENED_CLOSE_SERVER', t => {
@@ -921,13 +919,14 @@ test('FST_ERR_PLUGIN_TIMEOUT', t => {
921
919
  })
922
920
 
923
921
  test('FST_ERR_VALIDATION', t => {
924
- t.plan(5)
922
+ t.plan(6)
925
923
  const error = new errors.FST_ERR_VALIDATION()
926
924
  t.assert.strictEqual(error.name, 'FastifyError')
927
925
  t.assert.strictEqual(error.code, 'FST_ERR_VALIDATION')
928
926
  t.assert.strictEqual(error.message, '%s')
929
927
  t.assert.strictEqual(error.statusCode, 400)
930
928
  t.assert.ok(error instanceof Error)
929
+ t.assert.strictEqual(error.stack, undefined)
931
930
  })
932
931
 
933
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,
@@ -712,3 +712,23 @@ test('Reply#serializeInput', async t => {
712
712
  })
713
713
  })
714
714
  })
715
+
716
+ test('Reply#compileSerializationSchema omits absent metadata instead of passing null', async t => {
717
+ t.plan(2)
718
+
719
+ const fastify = Fastify()
720
+
721
+ fastify.setSerializerCompiler(({ httpStatus, contentType }) => {
722
+ t.assert.strictEqual(httpStatus, undefined)
723
+ t.assert.strictEqual(contentType, undefined)
724
+
725
+ return input => JSON.stringify(input)
726
+ })
727
+
728
+ fastify.get('/', (req, reply) => {
729
+ reply.compileSerializationSchema(getDefaultSchema())
730
+ reply.send({ hello: 'world' })
731
+ })
732
+
733
+ await fastify.inject({ path: '/', method: 'GET' })
734
+ })
@@ -35,7 +35,7 @@ const doGet = async function (url) {
35
35
 
36
36
  test('Once called, Reply should return an object with methods', t => {
37
37
  t.plan(16)
38
- const response = { res: 'res' }
38
+ const response = { res: 'res', getHeader: () => undefined }
39
39
  const context = {
40
40
  config: { onSend: [] },
41
41
  schema: {},
@@ -1003,6 +1003,35 @@ test('reply can set multiple instances of same header', async t => {
1003
1003
  t.assert.deepStrictEqual(result.headers.getSetCookie(), ['one', 'two'])
1004
1004
  })
1005
1005
 
1006
+ test('reply.header does not mutate a set-cookie array passed by the caller', async t => {
1007
+ t.plan(5)
1008
+
1009
+ const fastify = require('../../')()
1010
+ const defaults = ['a=1']
1011
+
1012
+ fastify.get('/headers', function (req, reply) {
1013
+ reply
1014
+ .header('set-cookie', defaults)
1015
+ .header('set-cookie', 'b=2')
1016
+ .send({})
1017
+ })
1018
+
1019
+ const fastifyServer = await fastify.listen({ port: 0 })
1020
+ t.after(() => fastify.close())
1021
+
1022
+ const first = await fetch(`${fastifyServer}/headers`)
1023
+ t.assert.deepStrictEqual(first.headers.getSetCookie(), ['a=1', 'b=2'])
1024
+ const second = await fetch(`${fastifyServer}/headers`)
1025
+ t.assert.deepStrictEqual(second.headers.getSetCookie(), ['a=1', 'b=2'])
1026
+ t.assert.deepStrictEqual(defaults, ['a=1'])
1027
+
1028
+ const reply = new Reply({ setHeader () {}, removeHeader () {} }, {}, {})
1029
+ const cookies = ['one']
1030
+ reply.header('set-cookie', cookies).header('set-cookie', 'two')
1031
+ t.assert.deepStrictEqual(reply.getHeader('set-cookie'), ['one', 'two'])
1032
+ t.assert.deepStrictEqual(cookies, ['one'])
1033
+ })
1034
+
1006
1035
  test('reply.hasHeader returns correct values', async t => {
1007
1036
  t.plan(2)
1008
1037
 
@@ -402,6 +402,56 @@ test('Request with trust proxy - handles multiple entries in x-forwarded-host/pr
402
402
  t.assert.strictEqual(request.protocol, 'https')
403
403
  })
404
404
 
405
+ test('Request with trust proxy - host getter reads the merged headers once', t => {
406
+ t.plan(2)
407
+ const req = {
408
+ method: 'GET',
409
+ url: '/',
410
+ socket: { remoteAddress: 'ip' },
411
+ headers: { host: 'fastify.test' }
412
+ }
413
+
414
+ const TpRequest = Request.buildRequest(Request, true)
415
+ const request = new TpRequest('id', 'params', req, 'query', 'log')
416
+ // the headers getter may allocate a new object once request.headers was
417
+ // assigned in a hook, so the host getter must read it once per access
418
+ const additional = { 'user-assigned': 'yes' }
419
+ let reads = 0
420
+ Object.defineProperty(request, 'headers', {
421
+ get () {
422
+ reads++
423
+ return Object.assign({}, req.headers, additional)
424
+ }
425
+ })
426
+ t.assert.strictEqual(request.host, 'fastify.test')
427
+ t.assert.strictEqual(reads, 1)
428
+ })
429
+
430
+ test('Request with trust proxy - protocol getter reads the merged headers once', t => {
431
+ t.plan(2)
432
+ const req = {
433
+ method: 'GET',
434
+ url: '/',
435
+ socket: { remoteAddress: 'ip' },
436
+ headers: { 'x-forwarded-proto': 'https' }
437
+ }
438
+
439
+ const TpRequest = Request.buildRequest(Request, true)
440
+ const request = new TpRequest('id', 'params', req, 'query', 'log')
441
+ // the headers getter may allocate a new object once request.headers was
442
+ // assigned in a hook, so the protocol getter must read it once per access
443
+ const additional = { 'user-assigned': 'yes' }
444
+ let reads = 0
445
+ Object.defineProperty(request, 'headers', {
446
+ get () {
447
+ reads++
448
+ return Object.assign({}, req.headers, additional)
449
+ }
450
+ })
451
+ t.assert.strictEqual(request.protocol, 'https')
452
+ t.assert.strictEqual(reads, 1)
453
+ })
454
+
405
455
  test('Request with trust proxy - plain', t => {
406
456
  t.plan(1)
407
457
  const headers = {
@@ -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
+ })
@@ -129,3 +129,60 @@ test('reply.mediaType should reflect the last type set', async (t) => {
129
129
  const body = response.body
130
130
  t.assert.strictEqual(body, 'mediaType = text/plain')
131
131
  })
132
+
133
+ test('reply.mediaType should match the content-type set via raw.setHeader', async (t) => {
134
+ t.plan(2)
135
+
136
+ const fastify = Fastify()
137
+
138
+ fastify.get('/', (request, reply) => {
139
+ reply.raw.setHeader('content-type', 'application/json; charset=utf-8')
140
+ t.assert.strictEqual(reply.mediaType, 'application/json')
141
+ reply.send({ mediaType: reply.mediaType })
142
+ })
143
+
144
+ const response = await fastify.inject({
145
+ method: 'GET',
146
+ url: '/'
147
+ })
148
+ const body = await response.json()
149
+ t.assert.strictEqual(body.mediaType, 'application/json')
150
+ })
151
+
152
+ test('reply.mediaType should match case-insensitive content-type set via raw.setHeader', async (t) => {
153
+ t.plan(2)
154
+
155
+ const fastify = Fastify()
156
+
157
+ fastify.get('/', (request, reply) => {
158
+ reply.raw.setHeader('Content-Type', 'text/html; charset=utf-8')
159
+ t.assert.strictEqual(reply.mediaType, 'text/html')
160
+ reply.send(reply.mediaType)
161
+ })
162
+
163
+ const response = await fastify.inject({
164
+ method: 'GET',
165
+ url: '/'
166
+ })
167
+ t.assert.strictEqual(response.body, 'text/html')
168
+ })
169
+
170
+ test('reply.mediaType should prioritize reply.type over raw.setHeader', async (t) => {
171
+ t.plan(2)
172
+
173
+ const fastify = Fastify()
174
+
175
+ fastify.get('/', (request, reply) => {
176
+ reply.raw.setHeader('content-type', 'application/xml')
177
+ reply.type('application/json')
178
+ t.assert.strictEqual(reply.mediaType, 'application/json')
179
+ reply.send({ mediaType: reply.mediaType })
180
+ })
181
+
182
+ const response = await fastify.inject({
183
+ method: 'GET',
184
+ url: '/'
185
+ })
186
+ const body = await response.json()
187
+ t.assert.strictEqual(body.mediaType, 'application/json')
188
+ })
@@ -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
 
@@ -99,12 +99,9 @@ describe('RFC 10008', () => {
99
99
  t.assert.equal(res.json().code, 'FST_ERR_CTP_INVALID_JSON_BODY')
100
100
  })
101
101
 
102
- test('should return 400 if no content is provided for QUERY method', async (t) => {
103
- // If a media type is specified but is inconsistent
104
- // with the actual request content, a 400 (Bad Request)
105
- // can be returned. That is, a server is not allowed to
106
- // infer a media type from the request content and then
107
- // override a missing or "erroneous" value (i.e., "content sniffing").
102
+ test('should return 200 if body is empty but media type allows it for QUERY method', async (t) => {
103
+ // If a media type is set and allows the body to be empty,
104
+ // the server must allow it.
108
105
  t.plan(2)
109
106
 
110
107
  const fastify = Fastify()
@@ -116,11 +113,11 @@ describe('RFC 10008', () => {
116
113
  const res = await fastify.inject({
117
114
  method: 'QUERY',
118
115
  url: '/query',
119
- headers: { 'Content-Type': 'application/json' }
116
+ headers: { 'Content-Type': 'text/plain' }
120
117
  })
121
118
 
122
- t.assert.equal(res.statusCode, 400)
123
- t.assert.equal(res.json().code, 'FST_ERR_ROUTE_MISSING_CONTENT')
119
+ t.assert.equal(res.statusCode, 200)
120
+ t.assert.equal(res.body, '')
124
121
  })
125
122
 
126
123
  test('should return 415 if unsupported media type is provided for QUERY method', async (t) => {