fastify 5.6.2 → 5.7.1

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 (51) hide show
  1. package/README.md +34 -33
  2. package/SECURITY.md +45 -7
  3. package/SPONSORS.md +1 -1
  4. package/build/build-validation.js +1 -2
  5. package/build/sync-version.js +0 -1
  6. package/docs/Guides/Detecting-When-Clients-Abort.md +1 -1
  7. package/docs/Guides/Ecosystem.md +12 -15
  8. package/docs/Guides/Migration-Guide-V4.md +2 -1
  9. package/docs/Guides/Migration-Guide-V5.md +9 -0
  10. package/docs/Guides/Serverless.md +25 -7
  11. package/docs/Guides/Testing.md +2 -2
  12. package/docs/Reference/Decorators.md +4 -3
  13. package/docs/Reference/Encapsulation.md +2 -2
  14. package/docs/Reference/Logging.md +4 -0
  15. package/docs/Reference/Plugins.md +2 -2
  16. package/docs/Reference/Principles.md +2 -2
  17. package/docs/Reference/Reply.md +3 -2
  18. package/docs/Reference/Server.md +35 -4
  19. package/docs/Reference/TypeScript.md +2 -1
  20. package/docs/Reference/Validation-and-Serialization.md +7 -1
  21. package/examples/benchmark/webstream.js +27 -0
  22. package/fastify.d.ts +16 -21
  23. package/fastify.js +14 -9
  24. package/lib/config-validator.js +189 -223
  25. package/lib/error-handler.js +2 -5
  26. package/lib/error-status.js +14 -0
  27. package/lib/four-oh-four.js +2 -1
  28. package/lib/handle-request.js +6 -1
  29. package/lib/reply.js +53 -3
  30. package/lib/route.js +26 -12
  31. package/lib/schema-controller.js +2 -2
  32. package/lib/wrap-thenable.js +3 -0
  33. package/package.json +4 -4
  34. package/test/404s.test.js +69 -0
  35. package/test/diagnostics-channel/error-status.test.js +84 -0
  36. package/test/internals/schema-controller-perf.test.js +40 -0
  37. package/test/issue-4959.test.js +34 -9
  38. package/test/listen.1.test.js +9 -1
  39. package/test/logger/logging.test.js +38 -1
  40. package/test/router-options.test.js +169 -0
  41. package/test/server.test.js +4 -1
  42. package/test/types/fastify.test-d.ts +28 -7
  43. package/test/types/instance.test-d.ts +29 -21
  44. package/test/types/reply.test-d.ts +55 -4
  45. package/test/types/type-provider.test-d.ts +6 -6
  46. package/test/web-api.test.js +136 -0
  47. package/types/instance.d.ts +1 -1
  48. package/types/reply.d.ts +2 -2
  49. package/types/type-provider.d.ts +16 -0
  50. package/.vscode/settings.json +0 -22
  51. package/test/decorator-namespace.test._js_ +0 -30
@@ -16,7 +16,7 @@ t.test('logging', { timeout: 60000 }, async (t) => {
16
16
  let localhost
17
17
  let localhostForURL
18
18
 
19
- t.plan(13)
19
+ t.plan(14)
20
20
 
21
21
  t.before(async function () {
22
22
  [localhost, localhostForURL] = await helper.getLoopbackHost()
@@ -282,6 +282,43 @@ t.test('logging', { timeout: 60000 }, async (t) => {
282
282
  t.assert.strictEqual(stream.readableLength, 0)
283
283
  })
284
284
 
285
+ await t.test('should log incoming request and outgoing response based on disableRequestLogging function', async (t) => {
286
+ const lines = [
287
+ 'incoming request',
288
+ 'request completed'
289
+ ]
290
+ t.plan(lines.length)
291
+
292
+ const stream = split(JSON.parse)
293
+ const loggerInstance = pino(stream)
294
+
295
+ const fastify = Fastify({
296
+ disableRequestLogging: (request) => {
297
+ return request.url !== '/not-logged'
298
+ },
299
+ loggerInstance
300
+ })
301
+ t.after(() => fastify.close())
302
+
303
+ fastify.get('/logged', (req, reply) => {
304
+ return reply.code(200).send({})
305
+ })
306
+
307
+ fastify.get('/not-logged', (req, reply) => {
308
+ return reply.code(200).send({})
309
+ })
310
+
311
+ await fastify.ready()
312
+
313
+ await fastify.inject({ method: 'GET', url: '/not-logged' })
314
+ await fastify.inject({ method: 'GET', url: '/logged' })
315
+
316
+ for await (const [line] of on(stream, 'data')) {
317
+ t.assert.strictEqual(line.msg, lines.shift())
318
+ if (lines.length === 0) break
319
+ }
320
+ })
321
+
285
322
  await t.test('defaults to info level', async (t) => {
286
323
  const lines = [
287
324
  { req: { method: 'GET' }, msg: 'incoming request' },
@@ -447,6 +447,157 @@ test('Should honor disableRequestLogging option in frameworkErrors wrapper - FST
447
447
  )
448
448
  })
449
449
 
450
+ test('Should honor disableRequestLogging function in frameworkErrors wrapper - FST_ERR_BAD_URL', (t, done) => {
451
+ t.plan(4)
452
+
453
+ let logCallCount = 0
454
+ const logStream = split(JSON.parse)
455
+
456
+ const fastify = Fastify({
457
+ disableRequestLogging: (req) => {
458
+ // Disable logging for URLs containing 'silent'
459
+ return req.url.includes('silent')
460
+ },
461
+ frameworkErrors: function (err, req, res) {
462
+ res.send(`${err.message} - ${err.code}`)
463
+ },
464
+ logger: {
465
+ stream: logStream,
466
+ level: 'info'
467
+ }
468
+ })
469
+
470
+ fastify.get('/test/:id', (req, res) => {
471
+ res.send('{ hello: \'world\' }')
472
+ })
473
+
474
+ logStream.on('data', (json) => {
475
+ if (json.msg === 'incoming request') {
476
+ logCallCount++
477
+ }
478
+ })
479
+
480
+ // First request: URL does not contain 'silent', so logging should happen
481
+ fastify.inject(
482
+ {
483
+ method: 'GET',
484
+ url: '/test/%world'
485
+ },
486
+ (err, res) => {
487
+ t.assert.ifError(err)
488
+ t.assert.strictEqual(res.body, '\'/test/%world\' is not a valid url component - FST_ERR_BAD_URL')
489
+
490
+ // Second request: URL contains 'silent', so logging should be disabled
491
+ fastify.inject(
492
+ {
493
+ method: 'GET',
494
+ url: '/silent/%world'
495
+ },
496
+ (err2, res2) => {
497
+ t.assert.ifError(err2)
498
+ // Give time for any potential log events
499
+ setImmediate(() => {
500
+ // Only the first request should have logged
501
+ t.assert.strictEqual(logCallCount, 1)
502
+ done()
503
+ })
504
+ }
505
+ )
506
+ }
507
+ )
508
+ })
509
+
510
+ test('Should honor disableRequestLogging function in frameworkErrors wrapper - FST_ERR_ASYNC_CONSTRAINT', (t, done) => {
511
+ t.plan(4)
512
+
513
+ let logCallCount = 0
514
+
515
+ const constraint = {
516
+ name: 'secret',
517
+ storage: function () {
518
+ const secrets = {}
519
+ return {
520
+ get: (secret) => { return secrets[secret] || null },
521
+ set: (secret, store) => { secrets[secret] = store }
522
+ }
523
+ },
524
+ deriveConstraint: (req, ctx, done) => {
525
+ done(Error('kaboom'))
526
+ },
527
+ validate () { return true }
528
+ }
529
+
530
+ const logStream = split(JSON.parse)
531
+
532
+ const fastify = Fastify({
533
+ constraints: { secret: constraint },
534
+ disableRequestLogging: (req) => {
535
+ // Disable logging for URLs containing 'silent'
536
+ return req.url.includes('silent')
537
+ },
538
+ frameworkErrors: function (err, req, res) {
539
+ res.send(`${err.message} - ${err.code}`)
540
+ },
541
+ logger: {
542
+ stream: logStream,
543
+ level: 'info'
544
+ }
545
+ })
546
+
547
+ fastify.route({
548
+ method: 'GET',
549
+ url: '/',
550
+ constraints: { secret: 'alpha' },
551
+ handler: (req, reply) => {
552
+ reply.send({ hello: 'from alpha' })
553
+ }
554
+ })
555
+
556
+ fastify.route({
557
+ method: 'GET',
558
+ url: '/silent',
559
+ constraints: { secret: 'alpha' },
560
+ handler: (req, reply) => {
561
+ reply.send({ hello: 'from alpha' })
562
+ }
563
+ })
564
+
565
+ logStream.on('data', (json) => {
566
+ if (json.msg === 'incoming request') {
567
+ logCallCount++
568
+ }
569
+ })
570
+
571
+ // First request: URL does not contain 'silent', so logging should happen
572
+ fastify.inject(
573
+ {
574
+ method: 'GET',
575
+ url: '/'
576
+ },
577
+ (err, res) => {
578
+ t.assert.ifError(err)
579
+ t.assert.strictEqual(res.body, 'Unexpected error from async constraint - FST_ERR_ASYNC_CONSTRAINT')
580
+
581
+ // Second request: URL contains 'silent', so logging should be disabled
582
+ fastify.inject(
583
+ {
584
+ method: 'GET',
585
+ url: '/silent'
586
+ },
587
+ (err2, res2) => {
588
+ t.assert.ifError(err2)
589
+ // Give time for any potential log events
590
+ setImmediate(() => {
591
+ // Only the first request should have logged
592
+ t.assert.strictEqual(logCallCount, 1)
593
+ done()
594
+ })
595
+ }
596
+ )
597
+ }
598
+ )
599
+ })
600
+
450
601
  test('Should honor routerOptions.defaultRoute', async t => {
451
602
  t.plan(3)
452
603
  const fastify = Fastify({
@@ -895,3 +1046,21 @@ test('Should honor routerOptions.useSemicolonDelimiter over useSemicolonDelimite
895
1046
  })
896
1047
  t.assert.strictEqual(res.statusCode, 200)
897
1048
  })
1049
+
1050
+ test('Should support extra find-my-way options', async t => {
1051
+ t.plan(1)
1052
+ // Use a real upstream option from find-my-way
1053
+ const fastify = Fastify({
1054
+ routerOptions: {
1055
+ buildPrettyMeta: (route) => {
1056
+ const cleanMeta = Object.assign({}, route.store)
1057
+ return cleanMeta
1058
+ }
1059
+ }
1060
+ })
1061
+
1062
+ await fastify.ready()
1063
+
1064
+ // Ensure the option is preserved after validation
1065
+ t.assert.strictEqual(typeof fastify.initialConfig.routerOptions.buildPrettyMeta, 'function')
1066
+ })
@@ -1,11 +1,14 @@
1
1
  'use strict'
2
2
 
3
3
  const dns = require('node:dns')
4
+ const { networkInterfaces } = require('node:os')
4
5
  const { test } = require('node:test')
5
6
  const Fastify = require('..')
6
7
  const undici = require('undici')
7
8
  const proxyquire = require('proxyquire')
8
9
 
10
+ const isIPv6Missing = !Object.values(networkInterfaces()).flat().some(({ family }) => family === 'IPv6')
11
+
9
12
  test('listen should accept null port', async t => {
10
13
  const fastify = Fastify()
11
14
  t.after(() => fastify.close())
@@ -81,7 +84,7 @@ test('Test for hostname and port', async (t) => {
81
84
  await fetch('http://localhost:8000/host')
82
85
  })
83
86
 
84
- test('Test for IPV6 port', async (t) => {
87
+ test('Test for IPV6 port', { skip: isIPv6Missing }, async (t) => {
85
88
  t.plan(3)
86
89
  const app = Fastify()
87
90
  t.after(() => app.close())
@@ -1,4 +1,4 @@
1
- import { ErrorObject as AjvErrorObject } from 'ajv'
1
+ import Ajv, { ErrorObject as AjvErrorObject } from 'ajv'
2
2
  import * as http from 'node:http'
3
3
  import * as http2 from 'node:http2'
4
4
  import * as https from 'node:https'
@@ -65,12 +65,12 @@ expectType<
65
65
  >(fastify({ http2: true, https: {}, http2SessionTimeout: 1000 }))
66
66
  expectType<LightMyRequestChain>(fastify({ http2: true, https: {} }).inject())
67
67
  expectType<
68
- FastifyInstance<https.Server, http.IncomingMessage, http.ServerResponse> &
69
- SafePromiseLike<FastifyInstance<https.Server, http.IncomingMessage, http.ServerResponse>>
68
+ FastifyInstance<http.Server, http.IncomingMessage, http.ServerResponse> &
69
+ SafePromiseLike<FastifyInstance<http.Server, http.IncomingMessage, http.ServerResponse>>
70
70
  >(fastify({ schemaController: {} }))
71
71
  expectType<
72
- FastifyInstance<https.Server, http.IncomingMessage, http.ServerResponse> &
73
- SafePromiseLike<FastifyInstance<https.Server, http.IncomingMessage, http.ServerResponse>>
72
+ FastifyInstance<http.Server, http.IncomingMessage, http.ServerResponse> &
73
+ SafePromiseLike<FastifyInstance<http.Server, http.IncomingMessage, http.ServerResponse>>
74
74
  >(
75
75
  fastify({
76
76
  schemaController: {
@@ -115,6 +115,7 @@ expectAssignable<FastifyInstance>(fastify({ pluginTimeout: 1000 }))
115
115
  expectAssignable<FastifyInstance>(fastify({ bodyLimit: 100 }))
116
116
  expectAssignable<FastifyInstance>(fastify({ maxParamLength: 100 }))
117
117
  expectAssignable<FastifyInstance>(fastify({ disableRequestLogging: true }))
118
+ expectAssignable<FastifyInstance>(fastify({ disableRequestLogging: (req) => req.url?.includes('/health') ?? false }))
118
119
  expectAssignable<FastifyInstance>(fastify({ requestIdLogLabel: 'request-id' }))
119
120
  expectAssignable<FastifyInstance>(fastify({ onProtoPoisoning: 'error' }))
120
121
  expectAssignable<FastifyInstance>(fastify({ onConstructorPoisoning: 'error' }))
@@ -232,12 +233,32 @@ expectAssignable<FastifyInstance>(fastify({
232
233
  customOptions: {
233
234
  removeAdditional: 'all'
234
235
  },
235
- plugins: [() => { }]
236
+ plugins: [(ajv: Ajv): Ajv => ajv]
237
+ }
238
+ }))
239
+ expectAssignable<FastifyInstance>(fastify({
240
+ ajv: {
241
+ plugins: [[(ajv: Ajv): Ajv => ajv, ['keyword1', 'keyword2']]]
242
+ }
243
+ }))
244
+ expectError(fastify({
245
+ ajv: {
246
+ customOptions: {
247
+ removeAdditional: 'all'
248
+ },
249
+ plugins: [
250
+ () => {
251
+ // error, plugins always return the Ajv instance fluently
252
+ }
253
+ ]
236
254
  }
237
255
  }))
238
256
  expectAssignable<FastifyInstance>(fastify({
239
257
  ajv: {
240
- plugins: [[() => { }, ['keyword1', 'keyword2']]]
258
+ onCreate: (ajvInstance) => {
259
+ expectType<Ajv>(ajvInstance)
260
+ return ajvInstance
261
+ }
241
262
  }
242
263
  }))
243
264
  expectAssignable<FastifyInstance>(fastify({ frameworkErrors: () => { } }))
@@ -1,9 +1,10 @@
1
- import { expectAssignable, expectError, expectNotDeprecated, expectType } from 'tsd'
1
+ import { expectAssignable, expectError, expectNotAssignable, expectNotDeprecated, expectType } from 'tsd'
2
2
  import fastify, {
3
3
  FastifyBaseLogger,
4
4
  FastifyBodyParser,
5
5
  FastifyError,
6
6
  FastifyInstance,
7
+ FastifyRouterOptions,
7
8
  RawReplyDefaultExpression,
8
9
  RawRequestDefaultExpression,
9
10
  RawServerDefault,
@@ -15,7 +16,7 @@ import { FastifyRequest } from '../../types/request'
15
16
  import { FastifySchemaControllerOptions, FastifySchemaCompiler, FastifySerializerCompiler } from '../../types/schema'
16
17
  import { AddressInfo } from 'node:net'
17
18
  import { Bindings, ChildLoggerOptions } from '../../types/logger'
18
- import { ConstraintStrategy } from 'find-my-way'
19
+ import { Config as FindMyWayConfig, ConstraintStrategy } from 'find-my-way'
19
20
  import { FindMyWayVersion } from '../../types/instance'
20
21
 
21
22
  const server = fastify()
@@ -92,12 +93,12 @@ interface ReplyPayload {
92
93
  // typed sync error handler
93
94
  server.setErrorHandler<CustomError, ReplyPayload>((error, request, reply) => {
94
95
  expectType<CustomError>(error)
95
- expectType<((payload?: ReplyPayload['Reply']) => FastifyReply<ReplyPayload, RawServerDefault, RawRequestDefaultExpression<RawServerDefault>, RawReplyDefaultExpression<RawServerDefault>>)>(reply.send)
96
+ expectType<((...args: [payload: ReplyPayload['Reply']]) => FastifyReply<ReplyPayload, RawServerDefault, RawRequestDefaultExpression<RawServerDefault>, RawReplyDefaultExpression<RawServerDefault>>)>(reply.send)
96
97
  })
97
98
  // typed async error handler send
98
99
  server.setErrorHandler<CustomError, ReplyPayload>(async (error, request, reply) => {
99
100
  expectType<CustomError>(error)
100
- expectType<((payload?: ReplyPayload['Reply']) => FastifyReply<ReplyPayload, RawServerDefault, RawRequestDefaultExpression<RawServerDefault>, RawReplyDefaultExpression<RawServerDefault>>)>(reply.send)
101
+ expectType<((...args: [payload: ReplyPayload['Reply']]) => FastifyReply<ReplyPayload, RawServerDefault, RawRequestDefaultExpression<RawServerDefault>, RawReplyDefaultExpression<RawServerDefault>>)>(reply.send)
101
102
  })
102
103
  // typed async error handler return
103
104
  server.setErrorHandler<CustomError, ReplyPayload>(async (error, request, reply) => {
@@ -323,7 +324,7 @@ type InitialConfig = Readonly<{
323
324
  https?: boolean | Readonly<{ allowHTTP1: boolean }>,
324
325
  ignoreTrailingSlash?: boolean,
325
326
  ignoreDuplicateSlashes?: boolean,
326
- disableRequestLogging?: boolean,
327
+ disableRequestLogging?: boolean | ((req: FastifyRequest) => boolean),
327
328
  maxParamLength?: number,
328
329
  onProtoPoisoning?: 'error' | 'remove' | 'ignore',
329
330
  onConstructorPoisoning?: 'error' | 'remove' | 'ignore',
@@ -332,25 +333,32 @@ type InitialConfig = Readonly<{
332
333
  requestIdLogLabel?: string,
333
334
  http2SessionTimeout?: number,
334
335
  useSemicolonDelimiter?: boolean,
335
- routerOptions?: {
336
- allowUnsafeRegex?: boolean,
337
- buildPrettyMeta?: (route: { [k: string]: unknown, store: { [k: string]: unknown } }) => object,
338
- caseSensitive?: boolean,
339
- constraints?: {
340
- [name: string]: ConstraintStrategy<FindMyWayVersion<RawServerDefault>, unknown>
341
- }
342
- defaultRoute?: (req: FastifyRequest, res: FastifyReply) => void,
343
- ignoreDuplicateSlashes?: boolean,
344
- ignoreTrailingSlash?: boolean,
345
- maxParamLength?: number,
346
- onBadUrl?: (path: string, req: FastifyRequest, res: FastifyReply) => void,
347
- querystringParser?: (str: string) => { [key: string]: unknown },
348
- useSemicolonDelimiter?: boolean,
349
- }
336
+ routerOptions?: FastifyRouterOptions<RawServerDefault>
350
337
  }>
351
338
 
352
339
  expectType<InitialConfig>(fastify().initialConfig)
353
340
 
341
+ const routerOptionsForFindMyWay = {} as FastifyRouterOptions<RawServerDefault>
342
+ expectAssignable<FindMyWayConfig<FindMyWayVersion<RawServerDefault>>>(routerOptionsForFindMyWay)
343
+
344
+ fastify({
345
+ routerOptions: {
346
+ defaultRoute: (req, res) => {
347
+ expectType<RawRequestDefaultExpression<RawServerDefault>>(req)
348
+ expectType<RawReplyDefaultExpression<RawServerDefault>>(res)
349
+ expectNotAssignable<FastifyReply>(res)
350
+ res.end('foo')
351
+ },
352
+ onBadUrl: (path, req, res) => {
353
+ expectType<string>(path)
354
+ expectType<RawRequestDefaultExpression<RawServerDefault>>(req)
355
+ expectType<RawReplyDefaultExpression<RawServerDefault>>(res)
356
+ expectNotAssignable<FastifyReply>(res)
357
+ res.end('foo')
358
+ }
359
+ }
360
+ })
361
+
354
362
  expectType<FastifyBodyParser<string>>(server.defaultTextParser)
355
363
 
356
364
  expectType<FastifyBodyParser<string>>(server.getDefaultJsonParser('ignore', 'error'))
@@ -562,7 +570,7 @@ expectError(server.decorateReply('typedTestReplyMethod', async function (x) {
562
570
  const foo = server.getDecorator<string>('foo')
563
571
  expectType<string>(foo)
564
572
 
565
- const versionConstraintStrategy = {
573
+ const versionConstraintStrategy: ConstraintStrategy<FindMyWayVersion<RawServerDefault>> = {
566
574
  name: 'version',
567
575
  storage: () => ({
568
576
  get: () => () => {},
@@ -16,14 +16,14 @@ const getHandler: RouteHandlerMethod = function (_request, reply) {
16
16
  expectType<FastifyRequest<RouteGenericInterface, RawServerDefault, RawRequestDefaultExpression>>(reply.request)
17
17
  expectType<<Code extends number>(statusCode: Code) => DefaultFastifyReplyWithCode<Code>>(reply.code)
18
18
  expectType<<Code extends number>(statusCode: Code) => DefaultFastifyReplyWithCode<Code>>(reply.status)
19
- expectType<(payload?: unknown) => FastifyReply>(reply.code(100 as number).send)
19
+ expectType<(...args: [payload?: unknown]) => FastifyReply>(reply.code(100 as number).send)
20
20
  expectType<number>(reply.elapsedTime)
21
21
  expectType<number>(reply.statusCode)
22
22
  expectType<boolean>(reply.sent)
23
23
  expectType<
24
24
  (hints: Record<string, string | string[]>, callback?: (() => void) | undefined) => void
25
25
  >(reply.writeEarlyHints)
26
- expectType<((payload?: unknown) => FastifyReply)>(reply.send)
26
+ expectType<((...args: [payload?: unknown]) => FastifyReply)>(reply.send)
27
27
  expectAssignable<(key: string, value: any) => FastifyReply>(reply.header)
28
28
  expectAssignable<(values: { [key: string]: any }) => FastifyReply>(reply.headers)
29
29
  expectAssignable<(key: string) => number | string | string[] | undefined>(reply.getHeader)
@@ -100,9 +100,29 @@ interface InvalidReplyHttpCodes {
100
100
  }
101
101
  }
102
102
 
103
+ interface ReplyVoid {
104
+ Reply: void;
105
+ }
106
+
107
+ interface ReplyUndefined {
108
+ Reply: undefined;
109
+ }
110
+
111
+ // Issue #5534 scenario: 204 No Content should allow empty send(), 201 Created should require payload
112
+ // Note: `204: undefined` gets converted to `unknown` via UndefinedToUnknown in type-provider.d.ts,
113
+ // meaning send() is optional but send({}) is also allowed. Use `void` instead of `undefined`
114
+ // if you want stricter "no payload allowed" semantics.
115
+ interface ReplyHttpCodesWithNoContent {
116
+ Reply: {
117
+ 201: { id: string };
118
+ 204: undefined;
119
+ }
120
+ }
121
+
103
122
  const typedHandler: RouteHandler<ReplyPayload> = async (request, reply) => {
104
- expectType<((payload?: ReplyPayload['Reply']) => FastifyReply<ReplyPayload, RawServerDefault, RawRequestDefaultExpression<RawServerDefault>, RawReplyDefaultExpression<RawServerDefault>>)>(reply.send)
105
- expectType<((payload?: ReplyPayload['Reply']) => FastifyReply<ReplyPayload, RawServerDefault, RawRequestDefaultExpression<RawServerDefault>, RawReplyDefaultExpression<RawServerDefault>>)>(reply.code(100).send)
123
+ // When Reply type is specified, send() requires a payload argument
124
+ expectType<((...args: [payload: ReplyPayload['Reply']]) => FastifyReply<ReplyPayload, RawServerDefault, RawRequestDefaultExpression<RawServerDefault>, RawReplyDefaultExpression<RawServerDefault>>)>(reply.send)
125
+ expectType<((...args: [payload: ReplyPayload['Reply']]) => FastifyReply<ReplyPayload, RawServerDefault, RawRequestDefaultExpression<RawServerDefault>, RawReplyDefaultExpression<RawServerDefault>>)>(reply.code(100).send)
106
126
  }
107
127
 
108
128
  const server = fastify()
@@ -111,6 +131,10 @@ server.get('/typed', typedHandler)
111
131
  server.get<ReplyPayload>('/get-generic-send', async function handler (request, reply) {
112
132
  reply.send({ test: true })
113
133
  })
134
+ // When Reply type is specified, send() requires a payload - calling without arguments should error
135
+ expectError(server.get<ReplyPayload>('/get-generic-send-missing-payload', async function handler (request, reply) {
136
+ reply.send()
137
+ }))
114
138
  server.get<ReplyPayload>('/get-generic-return', async function handler (request, reply) {
115
139
  return { test: false }
116
140
  })
@@ -201,3 +225,30 @@ const httpHeaderHandler: RouteHandlerMethod = function (_request, reply) {
201
225
  reply.headers({ 'x-fastify-test': 'test' })
202
226
  reply.removeHeader('x-fastify-test')
203
227
  }
228
+
229
+ // Test: send() without arguments is valid when no Reply type is specified (default unknown)
230
+ server.get('/get-no-type-send-empty', async function handler (request, reply) {
231
+ reply.send()
232
+ })
233
+
234
+ // Test: send() without arguments is valid when Reply type is void
235
+ server.get<ReplyVoid>('/get-void-send-empty', async function handler (request, reply) {
236
+ reply.send()
237
+ })
238
+
239
+ // Test: send() without arguments is valid when Reply type is undefined
240
+ server.get<ReplyUndefined>('/get-undefined-send-empty', async function handler (request, reply) {
241
+ reply.send()
242
+ })
243
+
244
+ // Issue #5534 scenario: HTTP status codes with 204 No Content
245
+ server.get<ReplyHttpCodesWithNoContent>('/get-http-codes-no-content', async function handler (request, reply) {
246
+ // 204 No Content - send() without payload is valid because Reply is undefined
247
+ reply.code(204).send()
248
+ // 201 Created - send() requires payload
249
+ reply.code(201).send({ id: '123' })
250
+ })
251
+ // 201 Created without payload should error
252
+ expectError(server.get<ReplyHttpCodesWithNoContent>('/get-http-codes-201-missing-payload', async function handler (request, reply) {
253
+ reply.code(201).send()
254
+ }))
@@ -479,9 +479,9 @@ expectAssignable(server.withTypeProvider<TypeBoxProvider>().get(
479
479
  res.send('hello')
480
480
  res.send(42)
481
481
  res.send({ error: 'error' })
482
- expectType<(payload?: string) => typeof res>(res.code(200).send)
483
- expectType<(payload?: number) => typeof res>(res.code(400).send)
484
- expectType<(payload?: { error: string }) => typeof res>(res.code(500).send)
482
+ expectType<((...args: [payload: string]) => typeof res)>(res.code(200).send)
483
+ expectType<((...args: [payload: number]) => typeof res)>(res.code(400).send)
484
+ expectType<((...args: [payload: { error: string }]) => typeof res)>(res.code(500).send)
485
485
  expectError<(payload?: unknown) => typeof res>(res.code(200).send)
486
486
  }
487
487
  ))
@@ -711,9 +711,9 @@ expectAssignable(server.withTypeProvider<JsonSchemaToTsProvider>().get(
711
711
  res.send('hello')
712
712
  res.send(42)
713
713
  res.send({ error: 'error' })
714
- expectType<(payload?: string) => typeof res>(res.code(200).send)
715
- expectType<(payload?: number) => typeof res>(res.code(400).send)
716
- expectType<(payload?: { [x: string]: unknown; error?: string }) => typeof res>(res.code(500).send)
714
+ expectType<((...args: [payload: string]) => typeof res)>(res.code(200).send)
715
+ expectType<((...args: [payload: number]) => typeof res)>(res.code(400).send)
716
+ expectType<((...args: [payload: { [x: string]: unknown; error?: string }]) => typeof res)>(res.code(500).send)
717
717
  expectError<(payload?: unknown) => typeof res>(res.code(200).send)
718
718
  }
719
719
  ))
@@ -5,6 +5,7 @@ const Fastify = require('../fastify')
5
5
  const fs = require('node:fs')
6
6
  const { Readable } = require('node:stream')
7
7
  const { fetch: undiciFetch } = require('undici')
8
+ const http = require('node:http')
8
9
 
9
10
  test('should response with a ReadableStream', async (t) => {
10
11
  t.plan(2)
@@ -330,3 +331,138 @@ test('allow to pipe with undici.fetch', async (t) => {
330
331
  t.assert.strictEqual(response.statusCode, 200)
331
332
  t.assert.deepStrictEqual(response.json(), { ok: true })
332
333
  })
334
+
335
+ test('WebStream error before headers sent should trigger error handler', async (t) => {
336
+ t.plan(2)
337
+
338
+ const fastify = Fastify()
339
+
340
+ fastify.get('/', function (request, reply) {
341
+ const stream = new ReadableStream({
342
+ start (controller) {
343
+ controller.error(new Error('stream error'))
344
+ }
345
+ })
346
+ reply.send(stream)
347
+ })
348
+
349
+ const response = await fastify.inject({ method: 'GET', path: '/' })
350
+
351
+ t.assert.strictEqual(response.statusCode, 500)
352
+ t.assert.strictEqual(response.json().message, 'stream error')
353
+ })
354
+
355
+ test('WebStream error after headers sent should destroy response', (t, done) => {
356
+ t.plan(2)
357
+
358
+ const fastify = Fastify()
359
+ t.after(() => fastify.close())
360
+
361
+ fastify.get('/', function (request, reply) {
362
+ const stream = new ReadableStream({
363
+ start (controller) {
364
+ controller.enqueue('hello')
365
+ },
366
+ pull (controller) {
367
+ setTimeout(() => {
368
+ controller.error(new Error('stream error'))
369
+ }, 10)
370
+ }
371
+ })
372
+ reply.header('content-type', 'text/plain').send(stream)
373
+ })
374
+
375
+ fastify.listen({ port: 0 }, err => {
376
+ t.assert.ifError(err)
377
+
378
+ let finished = false
379
+ http.get(`http://localhost:${fastify.server.address().port}`, (res) => {
380
+ res.on('close', () => {
381
+ if (!finished) {
382
+ finished = true
383
+ t.assert.ok('response closed')
384
+ done()
385
+ }
386
+ })
387
+ res.resume()
388
+ })
389
+ })
390
+ })
391
+
392
+ test('WebStream should cancel reader when response is destroyed', (t, done) => {
393
+ t.plan(2)
394
+
395
+ const fastify = Fastify()
396
+ t.after(() => fastify.close())
397
+
398
+ let readerCancelled = false
399
+
400
+ fastify.get('/', function (request, reply) {
401
+ const stream = new ReadableStream({
402
+ start (controller) {
403
+ controller.enqueue('hello')
404
+ },
405
+ pull (controller) {
406
+ return new Promise(() => {})
407
+ },
408
+ cancel () {
409
+ readerCancelled = true
410
+ }
411
+ })
412
+ reply.header('content-type', 'text/plain').send(stream)
413
+ })
414
+
415
+ fastify.listen({ port: 0 }, err => {
416
+ t.assert.ifError(err)
417
+
418
+ const req = http.get(`http://localhost:${fastify.server.address().port}`, (res) => {
419
+ res.once('data', () => {
420
+ req.destroy()
421
+ setTimeout(() => {
422
+ t.assert.strictEqual(readerCancelled, true)
423
+ done()
424
+ }, 50)
425
+ })
426
+ })
427
+ })
428
+ })
429
+
430
+ test('WebStream should warn when headers already sent', async (t) => {
431
+ t.plan(2)
432
+
433
+ let warnCalled = false
434
+ const spyLogger = {
435
+ level: 'warn',
436
+ fatal: () => { },
437
+ error: () => { },
438
+ warn: (msg) => {
439
+ if (typeof msg === 'string' && msg.includes('use res.writeHead in stream mode')) {
440
+ warnCalled = true
441
+ }
442
+ },
443
+ info: () => { },
444
+ debug: () => { },
445
+ trace: () => { },
446
+ child: () => spyLogger
447
+ }
448
+
449
+ const fastify = Fastify({ loggerInstance: spyLogger })
450
+ t.after(() => fastify.close())
451
+
452
+ fastify.get('/', function (request, reply) {
453
+ reply.raw.writeHead(200, { 'content-type': 'text/plain' })
454
+ const stream = new ReadableStream({
455
+ start (controller) {
456
+ controller.enqueue('hello')
457
+ controller.close()
458
+ }
459
+ })
460
+ reply.send(stream)
461
+ })
462
+
463
+ await fastify.listen({ port: 0 })
464
+
465
+ const response = await fetch(`http://localhost:${fastify.server.address().port}/`)
466
+ t.assert.strictEqual(response.status, 200)
467
+ t.assert.strictEqual(warnCalled, true)
468
+ })