fastify 6.0.0-alpha.3 → 6.0.0-alpha.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.
Files changed (41) hide show
  1. package/SECURITY.md +6 -0
  2. package/SPONSORS.md +1 -0
  3. package/docs/Guides/Ecosystem.md +10 -53
  4. package/docs/Reference/Errors.md +0 -2
  5. package/docs/Reference/Reply.md +20 -2
  6. package/docs/Reference/Request.md +8 -0
  7. package/docs/Reference/Routes.md +6 -2
  8. package/docs/Reference/Server.md +2 -2
  9. package/docs/Reference/Validation-and-Serialization.md +33 -0
  10. package/docs/Reference/Warnings.md +2 -0
  11. package/fastify.js +1 -1
  12. package/lib/content-type-parser.js +28 -13
  13. package/lib/content-type.js +4 -4
  14. package/lib/errors.js +0 -5
  15. package/lib/handle-request.js +7 -20
  16. package/lib/reply.js +11 -4
  17. package/lib/request.js +9 -5
  18. package/lib/route.js +6 -2
  19. package/lib/validation.js +73 -14
  20. package/lib/warnings.js +10 -1
  21. package/package.json +1 -1
  22. package/test/child-logger-factory.test.js +25 -0
  23. package/test/content-parser.test.js +60 -0
  24. package/test/content-type-parser-async-context.test.js +76 -0
  25. package/test/custom-parser.4.test.js +40 -0
  26. package/test/http2/plain.test.js +45 -0
  27. package/test/internals/errors.test.js +1 -11
  28. package/test/internals/reply-serialize.test.js +20 -0
  29. package/test/internals/reply.test.js +30 -1
  30. package/test/internals/request.test.js +50 -0
  31. package/test/internals/validation.test.js +66 -0
  32. package/test/reply-media-type.test.js +57 -0
  33. package/test/rfc-10008.test.js +6 -9
  34. package/test/schema-feature.test.js +1 -1
  35. package/test/schema-validation.test.js +251 -1
  36. package/test/types/errors.tst.ts +0 -1
  37. package/test/types/fastify.tst.ts +5 -0
  38. package/test/types/route.tst.ts +21 -0
  39. package/types/errors.d.ts +0 -1
  40. package/types/instance.d.ts +4 -4
  41. package/types/route.d.ts +3 -2
package/lib/request.js CHANGED
@@ -122,8 +122,10 @@ function buildRequestWithTrustProxy (R, trustProxy) {
122
122
  host: {
123
123
  get () {
124
124
  const socket = this.raw.socket
125
- if (this.headers['x-forwarded-host'] && socket != null && proxyFn(socket.remoteAddress, 0)) {
126
- return getLastEntryInMultiHeaderValue(this.headers['x-forwarded-host'])
125
+ // The headers getter may allocate a new object, so read it once.
126
+ const headers = this.headers
127
+ if (headers['x-forwarded-host'] && socket != null && proxyFn(socket.remoteAddress, 0)) {
128
+ return getLastEntryInMultiHeaderValue(headers['x-forwarded-host'])
127
129
  }
128
130
  /**
129
131
  * The last fallback supports the following cases:
@@ -131,14 +133,16 @@ function buildRequestWithTrustProxy (R, trustProxy) {
131
133
  * 2. HTTP/1.0 without a Host Header
132
134
  * 3. Headers schema that may remove the Host Header
133
135
  */
134
- return this.headers.host ?? this.headers[':authority'] ?? ''
136
+ return headers.host ?? headers[':authority'] ?? ''
135
137
  }
136
138
  },
137
139
  protocol: {
138
140
  get () {
139
141
  const socket = this.raw.socket
140
- if (this.headers['x-forwarded-proto'] && socket != null && proxyFn(socket.remoteAddress, 0)) {
141
- return getLastEntryInMultiHeaderValue(this.headers['x-forwarded-proto'])
142
+ // The headers getter may allocate a new object, so read it once.
143
+ const headers = this.headers
144
+ if (headers['x-forwarded-proto'] && socket != null && proxyFn(socket.remoteAddress, 0)) {
145
+ return getLastEntryInMultiHeaderValue(headers['x-forwarded-proto'])
142
146
  }
143
147
  if (this.socket) {
144
148
  return this.socket.encrypted ? 'https' : 'http'
package/lib/route.js CHANGED
@@ -466,8 +466,12 @@ function buildRouting (options) {
466
466
  // the child logger creation altogether
467
467
  childLogger = logger
468
468
  } else {
469
- const loggerOpts = {
470
- level: context.logLevel
469
+ const loggerOpts = {}
470
+
471
+ // An empty level means "inherit". Passing it would make pino treat it
472
+ // as a level change and rebuild every log method of each request child.
473
+ if (context.logLevel) {
474
+ loggerOpts.level = context.logLevel
471
475
  }
472
476
 
473
477
  if (context.logSerializers) {
package/lib/validation.js CHANGED
@@ -13,7 +13,7 @@ const {
13
13
  FST_ERR_SCH_RESPONSE_SCHEMA_NOT_NESTED_2XX
14
14
  } = require('./errors')
15
15
 
16
- const { FSTWRN001, FSTSEC002 } = require('./warnings')
16
+ const { FSTWRN001, FSTSEC002, FSTSEC003 } = require('./warnings')
17
17
 
18
18
  function compileSchemasForSerialization (context, compile) {
19
19
  if (!context.schema || !context.schema.response) {
@@ -70,9 +70,14 @@ function compileSchemasForSerialization (context, compile) {
70
70
  // reachable through `allOf`, `anyOf`, `oneOf`, `not`, `if`/`then`/`else`, local
71
71
  // `$ref` definitions (`definitions`/`$defs`), and the other standard subschema
72
72
  // containers. The input schema is never mutated: a new schema tree is returned.
73
- function lowerCaseHeadersSchema (schema) {
73
+ //
74
+ // A `propertyNames` subschema validates each header name itself, so values held
75
+ // in `enum` and `const` (and in every subschema reachable from it) are header
76
+ // names rather than header values. `insidePropertyNames` tracks that context so
77
+ // those `enum`/`const` string values are lowercased there, and only there.
78
+ function lowerCaseHeadersSchema (schema, insidePropertyNames = false) {
74
79
  if (Array.isArray(schema)) {
75
- return schema.map(lowerCaseHeadersSchema)
80
+ return schema.map(item => lowerCaseHeadersSchema(item, insidePropertyNames))
76
81
  }
77
82
  if (schema === null || typeof schema !== 'object') {
78
83
  return schema
@@ -91,7 +96,7 @@ function lowerCaseHeadersSchema (schema) {
91
96
  }
92
97
  const normalized = {}
93
98
  for (const prop of Object.keys(value)) {
94
- normalized[prop.toLowerCase()] = lowerCaseHeadersSchema(value[prop])
99
+ normalized[prop.toLowerCase()] = lowerCaseHeadersSchema(value[prop], insidePropertyNames)
95
100
  }
96
101
  result.properties = normalized
97
102
  break
@@ -112,7 +117,7 @@ function lowerCaseHeadersSchema (schema) {
112
117
  if (Array.isArray(depValue)) {
113
118
  normalized[dep.toLowerCase()] = depValue.map(name => name.toLowerCase())
114
119
  } else {
115
- normalized[dep.toLowerCase()] = lowerCaseHeadersSchema(depValue)
120
+ normalized[dep.toLowerCase()] = lowerCaseHeadersSchema(depValue, insidePropertyNames)
116
121
  }
117
122
  }
118
123
  result.dependencies = normalized
@@ -125,7 +130,7 @@ function lowerCaseHeadersSchema (schema) {
125
130
  }
126
131
  const normalized = {}
127
132
  for (const dep of Object.keys(value)) {
128
- normalized[dep.toLowerCase()] = lowerCaseHeadersSchema(value[dep])
133
+ normalized[dep.toLowerCase()] = lowerCaseHeadersSchema(value[dep], insidePropertyNames)
129
134
  }
130
135
  result.dependentSchemas = normalized
131
136
  break
@@ -158,12 +163,21 @@ function lowerCaseHeadersSchema (schema) {
158
163
  case 'unevaluatedItems':
159
164
  case 'unevaluatedProperties':
160
165
  case 'contains':
161
- case 'propertyNames':
162
166
  case 'contentSchema':
163
- result[key] = lowerCaseHeadersSchema(value)
167
+ result[key] = lowerCaseHeadersSchema(value, insidePropertyNames)
168
+ break
169
+ case 'propertyNames':
170
+ result[key] = lowerCaseHeadersSchema(value, true)
164
171
  break
165
172
  // Maps of subschemas whose keys are not header names (definition names,
166
173
  // regex patterns): keep the keys, normalize the subschema values.
174
+ //
175
+ // `patternProperties` keys are regular expressions matched against the
176
+ // already-lowercased header names and are left as-is: a regular expression
177
+ // cannot be safely lowercased. The same applies to a `propertyNames`
178
+ // `pattern`. A header-name pattern written in canonical case will not match
179
+ // the lowercased request headers, so it must be authored to match
180
+ // lowercase. See docs/Reference/Validation-and-Serialization.md.
167
181
  case 'definitions':
168
182
  case '$defs':
169
183
  case 'patternProperties': {
@@ -173,13 +187,23 @@ function lowerCaseHeadersSchema (schema) {
173
187
  }
174
188
  const normalized = {}
175
189
  for (const k of Object.keys(value)) {
176
- normalized[k] = lowerCaseHeadersSchema(value[k])
190
+ normalized[k] = lowerCaseHeadersSchema(value[k], insidePropertyNames)
177
191
  }
178
192
  result[key] = normalized
179
193
  break
180
194
  }
181
195
  default:
182
- result[key] = value
196
+ // Inside a `propertyNames` subschema, `enum` and `const` values are the
197
+ // candidate header names themselves, so they must be lowercased to match
198
+ // the lowercased request headers. Everywhere else these hold header
199
+ // *values* (or arbitrary constant data) and must be left untouched.
200
+ if (insidePropertyNames && key === 'enum' && Array.isArray(value)) {
201
+ result[key] = value.map(item => typeof item === 'string' ? item.toLowerCase() : item)
202
+ } else if (insidePropertyNames && key === 'const' && typeof value === 'string') {
203
+ result[key] = value.toLowerCase()
204
+ } else {
205
+ result[key] = value
206
+ }
183
207
  }
184
208
  }
185
209
 
@@ -217,6 +241,35 @@ function findExternalHeaderRef (schema) {
217
241
  return undefined
218
242
  }
219
243
 
244
+ // A `$ref` reached inside a `propertyNames` subschema resolves to a definition
245
+ // that is normalized in its own, non-`propertyNames` context, so header names
246
+ // held in its `enum`/`const` keep their original case and a case-insensitive
247
+ // deny-list or allow-list never matches the lowercased request headers. We
248
+ // cannot safely normalize the referenced definition here because it may be
249
+ // shared with value contexts, so we detect the reference and warn instead.
250
+ // Returns the first local `$ref` found inside a `propertyNames` subschema, or
251
+ // undefined.
252
+ function findPropertyNamesRef (schema, insidePropertyNames = false) {
253
+ if (Array.isArray(schema)) {
254
+ for (const item of schema) {
255
+ const ref = findPropertyNamesRef(item, insidePropertyNames)
256
+ if (ref !== undefined) return ref
257
+ }
258
+ return undefined
259
+ }
260
+ if (schema === null || typeof schema !== 'object') {
261
+ return undefined
262
+ }
263
+ if (insidePropertyNames && typeof schema.$ref === 'string' && schema.$ref[0] === '#') {
264
+ return schema.$ref
265
+ }
266
+ for (const key of Object.keys(schema)) {
267
+ const ref = findPropertyNamesRef(schema[key], insidePropertyNames || key === 'propertyNames')
268
+ if (ref !== undefined) return ref
269
+ }
270
+ return undefined
271
+ }
272
+
220
273
  function compileSchemasForValidation (context, compile, isCustom) {
221
274
  const { schema } = context
222
275
  if (!schema) {
@@ -246,6 +299,10 @@ function compileSchemasForValidation (context, compile, isCustom) {
246
299
  if (externalRef !== undefined) {
247
300
  FSTSEC002(method, url, externalRef)
248
301
  }
302
+ const propertyNamesRef = findPropertyNamesRef(headers)
303
+ if (propertyNamesRef !== undefined) {
304
+ FSTSEC003(method, url, propertyNamesRef)
305
+ }
249
306
  context[headersSchema] = compile({ schema: headersSchemaLowerCase, method, url, httpPart: 'headers' })
250
307
  }
251
308
  } else if (Object.hasOwn(schema, 'headers')) {
@@ -284,19 +341,21 @@ function compileSchemasForValidation (context, compile, isCustom) {
284
341
  }
285
342
 
286
343
  function validateParam (validatorFunction, request, paramName) {
287
- const isUndefined = request[paramName] === undefined
344
+ // The headers getter may allocate a new object, so only read it when needed.
345
+ if (validatorFunction == null) return false
346
+ const value = request[paramName]
288
347
  let ret
289
348
 
290
349
  try {
291
- const data = isUndefined ? null : request[paramName]
350
+ const data = value === undefined ? null : value
292
351
  // Ajv can only mutate a root value when its parent is provided.
293
- if (validatorFunction?.schemaEnv) {
352
+ if (validatorFunction.schemaEnv) {
294
353
  ret = validatorFunction(data, {
295
354
  parentData: request,
296
355
  parentDataProperty: paramName
297
356
  })
298
357
  } else {
299
- ret = validatorFunction?.(data)
358
+ ret = validatorFunction(data)
300
359
  }
301
360
  } catch (err) {
302
361
  // If validator throws synchronously, ensure it propagates as an internal error
package/lib/warnings.js CHANGED
@@ -7,6 +7,7 @@ const { createWarning } = require('process-warning')
7
7
  * - FSTWRN001
8
8
  * - FSTSEC001
9
9
  * - FSTSEC002
10
+ * - FSTSEC003
10
11
  *
11
12
  * Deprecation Codes FSTDEP001 - FSTDEP021 were used by v4 and MUST NOT be reused.
12
13
  * - FSTDEP022 - FSTDEP025 are used by v5 and MUST NOT be reused.
@@ -41,9 +42,17 @@ const FSTSEC002 = createWarning({
41
42
  unlimited: true
42
43
  })
43
44
 
45
+ const FSTSEC003 = createWarning({
46
+ name: 'FastifySecurity',
47
+ code: 'FSTSEC003',
48
+ message: 'The headers schema for %s: %s uses a $ref (%s) inside propertyNames that is not case-normalized. Header names held in the referenced schema keep their original case and will not match the lowercased request headers, so a case-insensitive deny-list or allow-list on header names may not apply. Inline the propertyNames constraint instead of referencing it with a $ref.',
49
+ unlimited: true
50
+ })
51
+
44
52
  module.exports = {
45
53
  FSTWRN001,
46
54
  FSTWRN003,
47
55
  FSTSEC001,
48
- FSTSEC002
56
+ FSTSEC002,
57
+ FSTSEC003
49
58
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fastify",
3
- "version": "6.0.0-alpha.3",
3
+ "version": "6.0.0-alpha.5",
4
4
  "description": "Fast and low overhead web framework, for Node.js",
5
5
  "main": "fastify.js",
6
6
  "type": "commonjs",
@@ -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
+ })
@@ -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,62 @@ 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
+ })
@@ -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
 
@@ -5,7 +5,7 @@ const errors = require('../../lib/errors')
5
5
  const { readFileSync } = require('node:fs')
6
6
  const { resolve } = require('node:path')
7
7
 
8
- const expectedErrors = 95
8
+ const expectedErrors = 94
9
9
 
10
10
  test(`should expose ${expectedErrors} errors`, t => {
11
11
  t.plan(1)
@@ -790,16 +790,6 @@ test('FST_ERR_ROUTE_MISSING_CONTENT_TYPE', t => {
790
790
  t.assert.ok(error instanceof Error)
791
791
  })
792
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)
801
- })
802
-
803
793
  test('FST_ERR_REOPENED_CLOSE_SERVER', t => {
804
794
  t.plan(5)
805
795
  const error = new errors.FST_ERR_REOPENED_CLOSE_SERVER()
@@ -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