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.
- package/README.md +1 -0
- package/SECURITY.md +6 -0
- package/build/build-validation.js +2 -0
- package/docs/Guides/Ecosystem.md +12 -53
- package/docs/Guides/Migration-Guide-V5.md +2 -2
- package/docs/Guides/Serverless.md +33 -10
- package/docs/Reference/Errors.md +13 -2
- package/docs/Reference/Hooks.md +13 -0
- package/docs/Reference/Reply.md +787 -681
- package/docs/Reference/Request.md +308 -104
- package/docs/Reference/Routes.md +6 -2
- package/docs/Reference/Server.md +41 -4
- package/docs/Reference/Validation-and-Serialization.md +48 -1
- package/docs/Reference/Warnings.md +2 -0
- package/fastify.d.ts +1 -0
- package/fastify.js +35 -1
- package/lib/config-validator.js +102 -73
- package/lib/content-type-parser.js +35 -14
- package/lib/content-type.js +4 -4
- package/lib/context.js +2 -1
- package/lib/decorate.js +4 -3
- package/lib/error-handler.js +21 -4
- package/lib/error-status.js +4 -2
- package/lib/errors.js +15 -14
- package/lib/handle-request.js +7 -20
- package/lib/reply.js +18 -13
- package/lib/request.js +12 -7
- package/lib/route.js +40 -7
- package/lib/schemas.js +44 -32
- package/lib/validation.js +67 -10
- package/lib/warnings.js +10 -1
- package/package.json +8 -3
- package/test/child-logger-factory.test.js +25 -0
- package/test/close-disposable.test.js +154 -0
- package/test/constrained-routes.test.js +4 -2
- package/test/content-parser.test.js +99 -0
- package/test/decorator.test.js +29 -0
- package/test/diagnostics-channel/error-status.test.js +35 -2
- package/test/handler-timeout.test.js +224 -0
- package/test/internals/all.test.js +5 -0
- package/test/internals/errors.test.js +19 -20
- package/test/internals/initial-config.test.js +2 -0
- package/test/internals/reply-serialize.test.js +20 -0
- package/test/internals/reply.test.js +30 -1
- package/test/internals/request.test.js +50 -0
- package/test/internals/server.test.js +6 -4
- package/test/reply-error.test.js +82 -0
- package/test/reply-media-type.test.js +57 -0
- package/test/reply-trailers.test.js +159 -1
- package/test/rfc-10008.test.js +6 -9
- package/test/router-compile.test.js +241 -0
- package/test/schema-feature.test.js +1 -1
- package/test/schema-serialization.test.js +120 -7
- package/test/schema-validation.test.js +251 -1
- package/test/types/errors.tst.ts +0 -1
- package/test/types/fastify.tst.ts +9 -0
- package/test/types/instance.tst.ts +1 -1
- package/test/types/route.tst.ts +37 -0
- package/test/types/using.tst.ts +20 -0
- package/test/validation-error-handling.test.js +21 -1
- package/types/errors.d.ts +0 -1
- package/types/instance.d.ts +9 -5
- 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 =
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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.
|
|
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.
|
|
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) => {
|
package/test/reply-error.test.js
CHANGED
|
@@ -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
|
|
package/test/rfc-10008.test.js
CHANGED
|
@@ -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
|
|
103
|
-
// If a media type is
|
|
104
|
-
//
|
|
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': '
|
|
116
|
+
headers: { 'Content-Type': 'text/plain' }
|
|
120
117
|
})
|
|
121
118
|
|
|
122
|
-
t.assert.equal(res.statusCode,
|
|
123
|
-
t.assert.equal(res.
|
|
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) => {
|