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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fastify",
|
|
3
|
-
"version": "6.0.0-alpha.
|
|
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.
|
|
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(
|
|
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
|
-
|
|
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
|
+
})
|
package/test/decorator.test.js
CHANGED
|
@@ -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.
|
|
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
|
}
|