fastify 6.0.0-alpha.5 → 6.0.0-alpha.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +1 -0
  2. package/build/build-validation.js +2 -0
  3. package/docs/Guides/Ecosystem.md +4 -0
  4. package/docs/Guides/Migration-Guide-V5.md +2 -2
  5. package/docs/Guides/Serverless.md +33 -10
  6. package/docs/Reference/Errors.md +13 -0
  7. package/docs/Reference/Hooks.md +13 -0
  8. package/docs/Reference/Reply.md +786 -697
  9. package/docs/Reference/Request.md +303 -107
  10. package/docs/Reference/Server.md +39 -2
  11. package/docs/Reference/Validation-and-Serialization.md +15 -1
  12. package/fastify.d.ts +1 -0
  13. package/fastify.js +35 -1
  14. package/lib/config-validator.js +102 -73
  15. package/lib/content-type-parser.js +14 -2
  16. package/lib/context.js +2 -1
  17. package/lib/decorate.js +4 -3
  18. package/lib/error-handler.js +21 -4
  19. package/lib/error-status.js +4 -2
  20. package/lib/errors.js +15 -9
  21. package/lib/reply.js +12 -11
  22. package/lib/request.js +3 -2
  23. package/lib/route.js +34 -5
  24. package/lib/schemas.js +44 -32
  25. package/package.json +8 -3
  26. package/test/close-disposable.test.js +154 -0
  27. package/test/constrained-routes.test.js +4 -2
  28. package/test/content-parser.test.js +39 -0
  29. package/test/decorator.test.js +29 -0
  30. package/test/diagnostics-channel/error-status.test.js +35 -2
  31. package/test/handler-timeout.test.js +224 -0
  32. package/test/internals/all.test.js +5 -0
  33. package/test/internals/errors.test.js +18 -9
  34. package/test/internals/initial-config.test.js +2 -0
  35. package/test/internals/server.test.js +6 -4
  36. package/test/reply-error.test.js +82 -0
  37. package/test/reply-trailers.test.js +159 -1
  38. package/test/router-compile.test.js +241 -0
  39. package/test/schema-serialization.test.js +120 -7
  40. package/test/types/fastify.tst.ts +4 -0
  41. package/test/types/instance.tst.ts +1 -1
  42. package/test/types/route.tst.ts +16 -0
  43. package/test/types/using.tst.ts +20 -0
  44. package/test/validation-error-handling.test.js +21 -1
  45. package/types/instance.d.ts +5 -1
  46. package/types/route.d.ts +1 -0
package/lib/schemas.js CHANGED
@@ -8,7 +8,8 @@ const {
8
8
  FST_ERR_SCH_MISSING_ID,
9
9
  FST_ERR_SCH_ALREADY_PRESENT,
10
10
  FST_ERR_SCH_DUPLICATE,
11
- FST_ERR_SCH_CONTENT_MISSING_SCHEMA
11
+ FST_ERR_SCH_CONTENT_MISSING_SCHEMA,
12
+ FST_ERR_MISSING_CONTENTTYPE_SERIALIZATION_FN
12
13
  } = require('./errors')
13
14
  const ContentType = require('./content-type')
14
15
 
@@ -134,6 +135,40 @@ function generateFluentSchema (schema) {
134
135
  }
135
136
  }
136
137
 
138
+ /**
139
+ * Resolve the right JSON Schema serialization function for a per-media-type
140
+ * (`content`) response schema map, given the response media type.
141
+ *
142
+ * @param {object} contentSchema the compiled per-content-type schema map
143
+ * @param {string} mediaType the response media type (lowercased)
144
+ * @param {string|number} statusCode the resolved http status key
145
+ * @param {boolean} failClosed whether to throw when no schema matches (used
146
+ * for route response serialization) or return false (used by the error
147
+ * handler, which falls back to the default error serializer)
148
+ * @returns {function|false} the serializer function, or false when no schema
149
+ * applies and failClosed is false
150
+ */
151
+ function getContentSerializer (contentSchema, mediaType, statusCode, failClosed) {
152
+ if (contentSchema[mediaType]) {
153
+ return contentSchema[mediaType]
154
+ }
155
+
156
+ // fallback to match all media-types
157
+ if (contentSchema['*/*']) {
158
+ return contentSchema['*/*']
159
+ }
160
+
161
+ // The response content-type is not declared in the route's content map. When
162
+ // serializing a route response, do not fall back to a raw JSON dump (which
163
+ // would bypass the response schema and expose every field); surface the
164
+ // misconfiguration instead.
165
+ if (failClosed) {
166
+ throw new FST_ERR_MISSING_CONTENTTYPE_SERIALIZATION_FN(statusCode, mediaType)
167
+ }
168
+
169
+ return false
170
+ }
171
+
137
172
  /**
138
173
  * Search for the right JSON schema compiled function in the request context
139
174
  * setup by the route configuration `schema.response`.
@@ -142,11 +177,15 @@ function generateFluentSchema (schema) {
142
177
  * @param {object} context the request context
143
178
  * @param {number} statusCode the http status code
144
179
  * @param {string} [contentType] the reply content type
180
+ * @param {object} [opts] serialization options
181
+ * @param {boolean} [opts.failClosed] when true, throw when the response
182
+ * content-type is not declared in the content map; otherwise return false
145
183
  * @returns {function|false} the right JSON Schema function to serialize
146
184
  * the reply or false if it is not set
147
185
  */
148
- function getSchemaSerializer (context, statusCode, contentType) {
186
+ function getSchemaSerializer (context, statusCode, contentType, opts = {}) {
149
187
  const responseSchemaDef = context[kSchemaResponse]
188
+ const failClosed = opts.failClosed === true
150
189
  if (!responseSchemaDef) {
151
190
  return false
152
191
  }
@@ -154,16 +193,7 @@ function getSchemaSerializer (context, statusCode, contentType) {
154
193
  if (responseSchemaDef[statusCode].constructor === Object) {
155
194
  const ct = ContentType.from(contentType)
156
195
  if (ct.isValid) {
157
- if (responseSchemaDef[statusCode][ct.mediaType]) {
158
- return responseSchemaDef[statusCode][ct.mediaType]
159
- }
160
-
161
- // fallback to match all media-type
162
- if (responseSchemaDef[statusCode]['*/*']) {
163
- return responseSchemaDef[statusCode]['*/*']
164
- }
165
-
166
- return false
196
+ return getContentSerializer(responseSchemaDef[statusCode], ct.mediaType, statusCode, failClosed)
167
197
  }
168
198
  }
169
199
  return responseSchemaDef[statusCode]
@@ -173,16 +203,7 @@ function getSchemaSerializer (context, statusCode, contentType) {
173
203
  if (responseSchemaDef[fallbackStatusCode].constructor === Object) {
174
204
  const ct = ContentType.from(contentType)
175
205
  if (ct.isValid) {
176
- if (responseSchemaDef[fallbackStatusCode][ct.mediaType]) {
177
- return responseSchemaDef[fallbackStatusCode][ct.mediaType]
178
- }
179
-
180
- // fallback to match all media-type
181
- if (responseSchemaDef[fallbackStatusCode]['*/*']) {
182
- return responseSchemaDef[fallbackStatusCode]['*/*']
183
- }
184
-
185
- return false
206
+ return getContentSerializer(responseSchemaDef[fallbackStatusCode], ct.mediaType, fallbackStatusCode, failClosed)
186
207
  }
187
208
  }
188
209
 
@@ -192,16 +213,7 @@ function getSchemaSerializer (context, statusCode, contentType) {
192
213
  if (responseSchemaDef.default.constructor === Object) {
193
214
  const ct = ContentType.from(contentType)
194
215
  if (ct.isValid) {
195
- if (responseSchemaDef.default[ct.mediaType]) {
196
- return responseSchemaDef.default[ct.mediaType]
197
- }
198
-
199
- // fallback to match all media-type
200
- if (responseSchemaDef.default['*/*']) {
201
- return responseSchemaDef.default['*/*']
202
- }
203
-
204
- return false
216
+ return getContentSerializer(responseSchemaDef.default, ct.mediaType, 'default', failClosed)
205
217
  }
206
218
  }
207
219
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fastify",
3
- "version": "6.0.0-alpha.5",
3
+ "version": "6.0.0-alpha.6",
4
4
  "description": "Fast and low overhead web framework, for Node.js",
5
5
  "main": "fastify.js",
6
6
  "type": "commonjs",
@@ -18,12 +18,13 @@
18
18
  "lint:fix": "eslint --fix",
19
19
  "lint:markdown": "markdownlint-cli2",
20
20
  "lint:eslint": "eslint",
21
- "prepublishOnly": "cross-env PREPUBLISH=true borp --reporter=@jsumners/line-reporter && npm run test:validator:integrity && npm run build:sync-version",
21
+ "prepublishOnly": "npm run test:version && cross-env PREPUBLISH=true borp --reporter=@jsumners/line-reporter && npm run test:validator:integrity && npm run build:sync-version",
22
22
  "test": "npm run lint && npm run unit && npm run test:types",
23
23
  "test:ci": "npm run unit && npm run test:types",
24
24
  "test:report": "npm run lint && npm run unit:report && npm run test:types",
25
25
  "test:validator:integrity": "npm run build:validation && git diff --quiet --ignore-all-space --ignore-blank-lines --ignore-cr-at-eol lib/error-serializer.js && git diff --quiet --ignore-all-space --ignore-blank-lines --ignore-cr-at-eol lib/config-validator.js",
26
26
  "test:types": "tstyche",
27
+ "test:version": "node --test test/build/version.test.js",
27
28
  "test:watch": "npm run unit -- --watch --coverage-report=none --reporter=terse",
28
29
  "unit": "borp",
29
30
  "unit:report": "c8 --reporter html borp --reporter=@jsumners/line-reporter",
@@ -148,6 +149,10 @@
148
149
  "name": "Jean Michelet",
149
150
  "email": "jean.antoine.michelet@gmail.com",
150
151
  "url": "https://github.com/jean-michelet"
152
+ },
153
+ {
154
+ "name": "Antonio Tripodi",
155
+ "url": "https://github.com/Tony133"
151
156
  }
152
157
  ],
153
158
  "license": "MIT",
@@ -210,7 +215,7 @@
210
215
  "abstract-logging": "^2.0.1",
211
216
  "avvio": "^9.0.0",
212
217
  "fast-json-stringify": "^7.0.0",
213
- "find-my-way": "^9.6.0",
218
+ "find-my-way": "^9.10.0",
214
219
  "light-my-request": "^6.0.0",
215
220
  "pino": "^9.14.0 || ^10.1.0",
216
221
  "process-warning": "^5.1.0",
@@ -0,0 +1,154 @@
1
+ 'use strict'
2
+
3
+ const { test } = require('node:test')
4
+ const Fastify = require('../fastify')
5
+
6
+ test('addHook onClose supports AsyncDisposable objects', async t => {
7
+ const app = Fastify()
8
+ let disposed = false
9
+
10
+ const resource = {
11
+ async [Symbol.asyncDispose] () {
12
+ disposed = true
13
+ }
14
+ }
15
+
16
+ app.addHook('onClose', resource)
17
+ await app.ready()
18
+ t.assert.equal(disposed, false)
19
+
20
+ await app.close()
21
+ t.assert.equal(disposed, true)
22
+ })
23
+
24
+ test('addHook onClose supports synchronous Disposable objects', async t => {
25
+ const app = Fastify()
26
+ let disposed = false
27
+
28
+ const resource = {
29
+ [Symbol.dispose] () {
30
+ disposed = true
31
+ }
32
+ }
33
+
34
+ app.addHook('onClose', resource)
35
+ await app.ready()
36
+ t.assert.equal(disposed, false)
37
+
38
+ await app.close()
39
+ t.assert.equal(disposed, true)
40
+ })
41
+
42
+ test('addHook onClose prefers Symbol.asyncDispose over Symbol.dispose when both are present', async t => {
43
+ const app = Fastify()
44
+ let asyncDisposed = false
45
+ let syncDisposed = false
46
+
47
+ const resource = {
48
+ async [Symbol.asyncDispose] () {
49
+ asyncDisposed = true
50
+ },
51
+ [Symbol.dispose] () {
52
+ syncDisposed = true
53
+ }
54
+ }
55
+
56
+ app.addHook('onClose', resource)
57
+ await app.ready()
58
+ await app.close()
59
+
60
+ t.assert.equal(asyncDisposed, true)
61
+ t.assert.equal(syncDisposed, false)
62
+ })
63
+
64
+ test('addHook onClose propagates rejection from Symbol.asyncDispose', async t => {
65
+ const app = Fastify()
66
+ const customError = new Error('dispose async failed')
67
+
68
+ const resource = {
69
+ async [Symbol.asyncDispose] () {
70
+ throw customError
71
+ }
72
+ }
73
+
74
+ app.addHook('onClose', resource)
75
+ await app.ready()
76
+
77
+ await t.assert.rejects(app.close(), {
78
+ message: 'dispose async failed'
79
+ })
80
+ })
81
+
82
+ test('addHook onClose ignores fulfillment value from Symbol.asyncDispose', async t => {
83
+ const app = Fastify()
84
+
85
+ const resource = {
86
+ async [Symbol.asyncDispose] () {
87
+ return 'OK'
88
+ }
89
+ }
90
+
91
+ app.addHook('onClose', resource)
92
+ await app.ready()
93
+
94
+ await t.assert.doesNotReject(app.close())
95
+ })
96
+
97
+ test('addHook onClose propagates thrown error from Symbol.dispose', async t => {
98
+ const app = Fastify()
99
+ const customError = new Error('dispose sync failed')
100
+
101
+ const resource = {
102
+ [Symbol.dispose] () {
103
+ throw customError
104
+ }
105
+ }
106
+
107
+ app.addHook('onClose', resource)
108
+ await app.ready()
109
+
110
+ await t.assert.rejects(app.close(), {
111
+ message: 'dispose sync failed'
112
+ })
113
+ })
114
+
115
+ test('addHook onClose respects plugin execution order for disposables', async t => {
116
+ const app = Fastify()
117
+ const order = []
118
+
119
+ app.register(function childPlugin (instance, opts, done) {
120
+ instance.addHook('onClose', {
121
+ async [Symbol.asyncDispose] () {
122
+ order.push('child')
123
+ }
124
+ })
125
+ done()
126
+ })
127
+
128
+ app.addHook('onClose', {
129
+ async [Symbol.asyncDispose] () {
130
+ order.push('parent')
131
+ }
132
+ })
133
+
134
+ await app.ready()
135
+ await app.close()
136
+
137
+ t.assert.deepStrictEqual(order, ['child', 'parent'])
138
+ })
139
+
140
+ test('addHook onClose throws FST_ERR_HOOK_INVALID_HANDLER when non-function non-disposable is passed', t => {
141
+ const app = Fastify()
142
+
143
+ t.assert.throws(() => {
144
+ app.addHook('onClose', 'not-a-function-or-disposable')
145
+ }, {
146
+ code: 'FST_ERR_HOOK_INVALID_HANDLER'
147
+ })
148
+
149
+ t.assert.throws(() => {
150
+ app.addHook('onClose', { someProp: 123 })
151
+ }, {
152
+ code: 'FST_ERR_HOOK_INVALID_HANDLER'
153
+ })
154
+ })
@@ -607,7 +607,7 @@ test('Should allow registering constrained routes in a prefixed plugin', (t, don
607
607
  })
608
608
 
609
609
  test('Should allow registering a constrained GET route after a constrained HEAD route', (t, done) => {
610
- t.plan(3)
610
+ t.plan(4)
611
611
  const fastify = Fastify()
612
612
 
613
613
  fastify.route({
@@ -637,7 +637,9 @@ test('Should allow registering a constrained GET route after a constrained HEAD
637
637
  }
638
638
  }, (err, res) => {
639
639
  t.assert.ifError(err)
640
- t.assert.deepStrictEqual(res.payload, 'custom HEAD response')
640
+ // A HEAD response has no body, so check the headers set by the custom HEAD handler
641
+ t.assert.strictEqual(res.headers['content-type'], 'text/plain')
642
+ t.assert.strictEqual(res.headers['content-length'], String(Buffer.byteLength('custom HEAD response')))
641
643
  t.assert.strictEqual(res.statusCode, 200)
642
644
  done()
643
645
  })
@@ -827,3 +827,42 @@ test('string body with invalid UTF-8 is not treated as a content-length mismatch
827
827
  t.assert.strictEqual(res.statusCode, 200)
828
828
  t.assert.strictEqual(res.body, 'a�b')
829
829
  })
830
+
831
+ test('a synchronous throw in a custom parser is handled like a route error, not an uncaught exception', async t => {
832
+ await t.test('stream-mode parser', async t => {
833
+ t.plan(2)
834
+ const fastify = Fastify()
835
+ fastify.addContentTypeParser('application/json', function (req, payload, done) {
836
+ throw new Error('parser boom')
837
+ })
838
+ fastify.post('/', (req, reply) => reply.send('ok'))
839
+ const res = await fastify.inject({ method: 'POST', url: '/', headers: { 'content-type': 'application/json' }, payload: '{}' })
840
+ t.assert.strictEqual(res.statusCode, 500)
841
+ t.assert.strictEqual(res.json().message, 'parser boom')
842
+ })
843
+
844
+ await t.test('parseAs string parser', async t => {
845
+ t.plan(2)
846
+ const fastify = Fastify()
847
+ fastify.addContentTypeParser('application/json', { parseAs: 'string' }, function (req, body, done) {
848
+ throw new Error('parser boom')
849
+ })
850
+ fastify.post('/', (req, reply) => reply.send('ok'))
851
+ const res = await fastify.inject({ method: 'POST', url: '/', headers: { 'content-type': 'application/json' }, payload: '{}' })
852
+ t.assert.strictEqual(res.statusCode, 500)
853
+ t.assert.strictEqual(res.json().message, 'parser boom')
854
+ })
855
+
856
+ await t.test('a thrown error with a statusCode is preserved', async t => {
857
+ t.plan(1)
858
+ const fastify = Fastify()
859
+ fastify.addContentTypeParser('application/json', { parseAs: 'string' }, function (req, body, done) {
860
+ const err = new Error('too big')
861
+ err.statusCode = 413
862
+ throw err
863
+ })
864
+ fastify.post('/', (req, reply) => reply.send('ok'))
865
+ const res = await fastify.inject({ method: 'POST', url: '/', headers: { 'content-type': 'application/json' }, payload: '{}' })
866
+ t.assert.strictEqual(res.statusCode, 413)
867
+ })
868
+ })
@@ -1328,3 +1328,32 @@ test('Request.setDecorator should update an existing decorator', (t, done) => {
1328
1328
  })
1329
1329
  })
1330
1330
  })
1331
+
1332
+ test('getDecorator should evaluate a getter once', async t => {
1333
+ const fastify = Fastify()
1334
+ t.after(() => fastify.close())
1335
+ let calls = 0
1336
+ fastify.decorate('value', {
1337
+ getter () {
1338
+ return ++calls
1339
+ }
1340
+ })
1341
+
1342
+ t.assert.strictEqual(fastify.getDecorator('value'), 1)
1343
+ t.assert.strictEqual(calls, 1)
1344
+ })
1345
+
1346
+ test('getDecorator should bind the function returned by a getter once', async t => {
1347
+ const fastify = Fastify()
1348
+ t.after(() => fastify.close())
1349
+ let calls = 0
1350
+ fastify.decorate('method', {
1351
+ getter () {
1352
+ calls++
1353
+ return function () { return this }
1354
+ }
1355
+ })
1356
+
1357
+ t.assert.strictEqual(fastify.getDecorator('method')(), fastify)
1358
+ t.assert.strictEqual(calls, 1)
1359
+ })
@@ -96,9 +96,12 @@ test('Error.status property support', (t, done) => {
96
96
  const err = new Error('winter is coming')
97
97
  err.status = 418
98
98
 
99
- diagnostics.subscribe('tracing:fastify.request.handler:error', (msg) => {
99
+ const channel = diagnostics.channel('tracing:fastify.request.handler:error')
100
+ const handler = (msg) => {
100
101
  t.assert.strictEqual(msg.error.message, 'winter is coming')
101
- })
102
+ }
103
+ channel.subscribe(handler)
104
+ t.after(() => channel.unsubscribe(handler))
102
105
 
103
106
  fastify.get('/', () => {
104
107
  return Promise.reject(err)
@@ -121,3 +124,33 @@ test('Error.status property support', (t, done) => {
121
124
  done()
122
125
  })
123
126
  })
127
+
128
+ test('diagnostics channel error event should report 500 for error with statusCode >= 600', async (t) => {
129
+ t.plan(4)
130
+ const fastify = Fastify()
131
+ t.after(() => fastify.close())
132
+
133
+ let diagnosticsStatusCode
134
+ let diagnosticsError
135
+
136
+ const channel = diagnostics.channel('tracing:fastify.request.handler:error')
137
+ const handler = (msg) => {
138
+ diagnosticsStatusCode = msg.reply.statusCode
139
+ diagnosticsError = msg.error
140
+ }
141
+ channel.subscribe(handler)
142
+ t.after(() => channel.unsubscribe(handler))
143
+
144
+ fastify.get('/', async () => {
145
+ const err = new Error('custom invalid status')
146
+ err.statusCode = 600
147
+ throw err
148
+ })
149
+
150
+ const res = await fastify.inject('/')
151
+
152
+ t.assert.strictEqual(res.statusCode, 500)
153
+ t.assert.strictEqual(diagnosticsStatusCode, 500, 'diagnostics channel should report 500 when statusCode >= 600')
154
+ t.assert.strictEqual(diagnosticsStatusCode, res.statusCode, 'diagnostics status should match response status')
155
+ t.assert.strictEqual(diagnosticsError.message, 'custom invalid status')
156
+ })