fastify 5.12.4 → 5.12.5

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.
@@ -277,8 +277,9 @@ requires heavy resources to be sent after the `data`, for example,
277
277
  as soon as possible.
278
278
 
279
279
  > ℹ️ Note:
280
- > The header `Transfer-Encoding: chunked` will be added once you use
281
- > the trailer. It is a hard requirement for using trailer in Node.js.
280
+ > For HTTP/1 responses, the header `Transfer-Encoding: chunked` will be added
281
+ > once you use a trailer. HTTP/2 does not use chunked transfer encoding and
282
+ > sends trailers using its native trailing headers support.
282
283
 
283
284
  > ℹ️ Note:
284
285
  > Any error passed to `done` callback will be ignored. If you are interested
package/fastify.js CHANGED
@@ -1,6 +1,6 @@
1
1
  'use strict'
2
2
 
3
- const VERSION = '5.12.4'
3
+ const VERSION = '5.12.5'
4
4
 
5
5
  const Avvio = require('avvio')
6
6
  const http = require('node:http')
@@ -140,6 +140,7 @@ ContentTypeParser.prototype.getParser = function (contentType) {
140
140
  // with that type.
141
141
  parser = this.customParsers.get(contentType.mediaType)
142
142
  if (parser !== undefined) {
143
+ this.cache.set(ct, parser)
143
144
  return parser
144
145
  }
145
146
 
@@ -203,7 +204,12 @@ ContentTypeParser.prototype.run = function (contentType, handler, request, reply
203
204
  }
204
205
 
205
206
  const resource = new AsyncResource('content-type-parser:run', request)
206
- const done = resource.bind(onDone)
207
+
208
+ // Preserve the request's async context without the per-request function
209
+ // properties and wrappers created by AsyncResource.bind().
210
+ function done (error, body) {
211
+ resource.runInAsyncScope(onDone, undefined, error, body)
212
+ }
207
213
 
208
214
  if (parser.asString === true || parser.asBuffer === true) {
209
215
  rawBody(
package/lib/reply.js CHANGED
@@ -606,8 +606,11 @@ function onSendEnd (reply, payload) {
606
606
  header += trailerName
607
607
  }
608
608
  if (header !== '') {
609
- // it must be chunked for trailer to work
610
- reply.header('Transfer-Encoding', 'chunked')
609
+ // HTTP/1 must use chunked encoding for trailers. HTTP/2 forbids
610
+ // the transfer-encoding header and handles trailers natively.
611
+ if (!isHttp2Reply(reply)) {
612
+ reply.header('Transfer-Encoding', 'chunked')
613
+ }
611
614
  reply.header('Trailer', header.trim())
612
615
  } else {
613
616
  reply[kReplyTrailers] = null
package/lib/validation.js CHANGED
@@ -284,19 +284,21 @@ function compileSchemasForValidation (context, compile, isCustom) {
284
284
  }
285
285
 
286
286
  function validateParam (validatorFunction, request, paramName) {
287
- const isUndefined = request[paramName] === undefined
287
+ // The headers getter may allocate a new object, so only read it when needed.
288
+ if (validatorFunction == null) return false
289
+ const value = request[paramName]
288
290
  let ret
289
291
 
290
292
  try {
291
- const data = isUndefined ? null : request[paramName]
293
+ const data = value === undefined ? null : value
292
294
  // Ajv can only mutate a root value when its parent is provided.
293
- if (validatorFunction?.schemaEnv) {
295
+ if (validatorFunction.schemaEnv) {
294
296
  ret = validatorFunction(data, {
295
297
  parentData: request,
296
298
  parentDataProperty: paramName
297
299
  })
298
300
  } else {
299
- ret = validatorFunction?.(data)
301
+ ret = validatorFunction(data)
300
302
  }
301
303
  } catch (err) {
302
304
  // If validator throws synchronously, ensure it propagates as an internal error
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fastify",
3
- "version": "5.12.4",
3
+ "version": "5.12.5",
4
4
  "description": "Fast and low overhead web framework, for Node.js",
5
5
  "main": "fastify.js",
6
6
  "type": "commonjs",
@@ -0,0 +1,76 @@
1
+ 'use strict'
2
+
3
+ const { AsyncLocalStorage, AsyncResource, createHook, executionAsyncId } = require('node:async_hooks')
4
+ const { setImmediate: immediate } = require('node:timers/promises')
5
+ const { test } = require('node:test')
6
+ const Fastify = require('..')
7
+
8
+ test('content type parser callbacks restore request context and destroy their resources', async t => {
9
+ const storage = new AsyncLocalStorage()
10
+ const external = new AsyncResource('external-parser')
11
+ const resources = new Set()
12
+ const destroyed = new Set()
13
+ const hook = createHook({
14
+ init (id, type) {
15
+ if (type === 'content-type-parser:run') resources.add(id)
16
+ },
17
+ destroy (id) {
18
+ if (resources.has(id)) destroyed.add(id)
19
+ }
20
+ }).enable()
21
+ const app = Fastify()
22
+ t.after(async () => {
23
+ hook.disable()
24
+ external.emitDestroy()
25
+ storage.disable()
26
+ await app.close()
27
+ })
28
+
29
+ app.addHook('onRequest', (request, reply, done) => {
30
+ storage.run({ id: request.headers['x-request-id'] }, done)
31
+ })
32
+
33
+ app.addContentTypeParser('application/custom', { parseAs: 'string' }, (request, body, done) => {
34
+ external.runInAsyncScope(() => {
35
+ t.assert.strictEqual(storage.getStore(), undefined)
36
+ setImmediate(() => {
37
+ if (body === 'error') {
38
+ done(new Error('parser failed'))
39
+ } else {
40
+ done(null, body)
41
+ }
42
+ })
43
+ })
44
+ })
45
+
46
+ function assertContext (request) {
47
+ t.assert.deepStrictEqual(storage.getStore(), { id: request.headers['x-request-id'] })
48
+ t.assert.ok(resources.has(executionAsyncId()))
49
+ }
50
+
51
+ app.setErrorHandler((error, request, reply) => {
52
+ assertContext(request)
53
+ reply.code(400).send({ error: error.message, id: storage.getStore().id })
54
+ })
55
+ app.post('/', (request, reply) => {
56
+ assertContext(request)
57
+ return { body: request.body, id: storage.getStore().id }
58
+ })
59
+
60
+ const responses = await Promise.all(['first', 'second', 'error'].map(id => app.inject({
61
+ method: 'POST',
62
+ url: '/',
63
+ headers: { 'content-type': 'application/custom', 'x-request-id': id },
64
+ payload: id
65
+ })))
66
+
67
+ t.assert.deepStrictEqual(responses.map(response => response.statusCode), [200, 200, 400])
68
+ t.assert.deepStrictEqual(responses.map(response => response.json()), [
69
+ { body: 'first', id: 'first' },
70
+ { body: 'second', id: 'second' },
71
+ { error: 'parser failed', id: 'error' }
72
+ ])
73
+ await immediate()
74
+ t.assert.strictEqual(resources.size, 3)
75
+ t.assert.deepStrictEqual(destroyed, resources)
76
+ })
@@ -6,6 +6,46 @@ const jsonParser = require('fast-json-body')
6
6
 
7
7
  process.removeAllListeners('warning')
8
8
 
9
+ test('parameterized parser matches preserve precedence and encapsulation on repeated requests', async t => {
10
+ const fastify = Fastify()
11
+ t.after(() => fastify.close())
12
+
13
+ fastify.addContentTypeParser('application/custom', { parseAs: 'string' }, (request, body, done) => {
14
+ done(null, 'parent')
15
+ })
16
+ fastify.addContentTypeParser('application/custom; version=2', { parseAs: 'string' }, (request, body, done) => {
17
+ done(null, 'version 2')
18
+ })
19
+ fastify.post('/', request => request.body)
20
+
21
+ fastify.register(async child => {
22
+ child.removeContentTypeParser('application/custom')
23
+ child.addContentTypeParser('application/custom', { parseAs: 'string' }, (request, body, done) => {
24
+ done(null, 'child')
25
+ })
26
+ child.post('/child', request => request.body)
27
+ })
28
+
29
+ for (let i = 0; i < 3; i++) {
30
+ for (const [url, contentType, expected] of [
31
+ ['/', 'application/custom; charset=utf-8', 'parent'],
32
+ ['/child', 'application/custom; charset=utf-8', 'child'],
33
+ ['/', 'application/custom; version=2', 'version 2'],
34
+ ['/child', 'application/custom; version=2', 'version 2'],
35
+ ['/', 'Application/Custom ; Charset="utf-8"', 'parent']
36
+ ]) {
37
+ const response = await fastify.inject({
38
+ method: 'POST',
39
+ url,
40
+ headers: { 'content-type': contentType },
41
+ payload: 'body'
42
+ })
43
+ t.assert.strictEqual(response.statusCode, 200)
44
+ t.assert.strictEqual(response.body, expected)
45
+ }
46
+ }
47
+ })
48
+
9
49
  test('should prefer string content types over RegExp ones', async (t) => {
10
50
  t.plan(6)
11
51
  const fastify = Fastify()
@@ -68,6 +68,51 @@ test('http2 plain test', async t => {
68
68
  })
69
69
  })
70
70
 
71
+ test('http2 response trailers do not use transfer-encoding', async t => {
72
+ const fastify = Fastify({ http2: true })
73
+
74
+ fastify.get('/', async (request, reply) => {
75
+ reply.trailer('x-checksum', async () => 'abc')
76
+ return 'hello'
77
+ })
78
+
79
+ await fastify.listen({ port: 0, host: '127.0.0.1' })
80
+
81
+ const client = http2.connect(`http://127.0.0.1:${fastify.server.address().port}`)
82
+ t.after(() => {
83
+ client.close()
84
+ return fastify.close()
85
+ })
86
+
87
+ const response = await new Promise((resolve, reject) => {
88
+ const request = client.request({
89
+ [http2.constants.HTTP2_HEADER_METHOD]: http2.constants.HTTP2_METHOD_GET,
90
+ [http2.constants.HTTP2_HEADER_PATH]: '/'
91
+ })
92
+ const result = { body: '' }
93
+
94
+ request.setEncoding('utf8')
95
+ request.on('response', headers => {
96
+ result.headers = headers
97
+ })
98
+ request.on('trailers', trailers => {
99
+ result.trailers = trailers
100
+ })
101
+ request.on('data', chunk => {
102
+ result.body += chunk
103
+ })
104
+ request.on('error', reject)
105
+ request.on('end', () => resolve(result))
106
+ request.end()
107
+ })
108
+
109
+ t.assert.strictEqual(response.headers[':status'], 200)
110
+ t.assert.strictEqual(response.headers['transfer-encoding'], undefined)
111
+ t.assert.strictEqual(response.headers.trailer, 'x-checksum')
112
+ t.assert.strictEqual(response.body, 'hello')
113
+ t.assert.strictEqual(response.trailers['x-checksum'], 'abc')
114
+ })
115
+
71
116
  test('http2 large non-stream replies are sent completely', async t => {
72
117
  const modes = ['buffer', 'string']
73
118
 
@@ -10,6 +10,72 @@ const { normalizeSchema } = require('../../lib/schemas')
10
10
  const symbols = require('../../lib/validation').symbols
11
11
  const { kSchemaVisited } = require('../../lib/symbols')
12
12
 
13
+ for (const [part, symbol] of [
14
+ ['params', symbols.paramsSchema],
15
+ ['body', symbols.bodySchema],
16
+ ['query', symbols.querystringSchema],
17
+ ['headers', symbols.headersSchema]
18
+ ]) {
19
+ for (const async of [false, true]) {
20
+ test(`validate reads only the validated ${part} once (${async ? 'async' : 'sync'})`, async t => {
21
+ const request = {}
22
+ const value = { hello: 'world' }
23
+ let reads = 0
24
+ let calls = 0
25
+
26
+ for (const name of ['params', 'body', 'query', 'headers']) {
27
+ Object.defineProperty(request, name, {
28
+ get () {
29
+ t.assert.strictEqual(name, part, 'unvalidated request parts must not be read')
30
+ reads++
31
+ return value
32
+ }
33
+ })
34
+ }
35
+
36
+ const context = {
37
+ [symbol]: data => {
38
+ calls++
39
+ t.assert.strictEqual(data, value)
40
+ return async ? Promise.resolve(data) : true
41
+ }
42
+ }
43
+
44
+ t.assert.strictEqual(await validation.validate(context, request), false)
45
+ t.assert.strictEqual(reads, 1)
46
+ t.assert.strictEqual(calls, 1)
47
+ })
48
+ }
49
+ }
50
+
51
+ test('validate skips a body without a matching content-type schema', t => {
52
+ const context = { [symbols.bodySchema]: { 'application/json': () => true } }
53
+ const request = {
54
+ mediaType: 'text/plain',
55
+ get body () {
56
+ t.assert.fail('an unvalidated body must not be read')
57
+ }
58
+ }
59
+
60
+ t.assert.strictEqual(validation.validate(context, request), false)
61
+ })
62
+
63
+ test('validate passes null for an undefined request part and preserves other values', t => {
64
+ for (const value of [undefined, null, false, 0, '']) {
65
+ let calls = 0
66
+ const context = {
67
+ [symbols.bodySchema]: data => {
68
+ calls++
69
+ t.assert.strictEqual(data, value === undefined ? null : value)
70
+ return true
71
+ }
72
+ }
73
+
74
+ t.assert.strictEqual(validation.validate(context, { body: value }), false)
75
+ t.assert.strictEqual(calls, 1)
76
+ }
77
+ })
78
+
13
79
  test('Symbols', t => {
14
80
  t.plan(5)
15
81
  t.assert.strictEqual(typeof symbols.responseSchema, 'symbol')