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.
- package/README.md +1 -0
- package/build/build-validation.js +2 -0
- package/docs/Guides/Ecosystem.md +4 -0
- package/docs/Guides/Migration-Guide-V5.md +2 -2
- package/docs/Guides/Serverless.md +33 -10
- package/docs/Reference/Errors.md +13 -0
- package/docs/Reference/Hooks.md +13 -0
- package/docs/Reference/Reply.md +786 -697
- package/docs/Reference/Request.md +303 -107
- package/docs/Reference/Server.md +39 -2
- package/docs/Reference/Validation-and-Serialization.md +15 -1
- package/fastify.d.ts +1 -0
- package/fastify.js +35 -1
- package/lib/config-validator.js +102 -73
- package/lib/content-type-parser.js +14 -2
- 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 -9
- package/lib/reply.js +12 -11
- package/lib/request.js +3 -2
- package/lib/route.js +34 -5
- package/lib/schemas.js +44 -32
- package/package.json +8 -3
- package/test/close-disposable.test.js +154 -0
- package/test/constrained-routes.test.js +4 -2
- package/test/content-parser.test.js +39 -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 +18 -9
- package/test/internals/initial-config.test.js +2 -0
- package/test/internals/server.test.js +6 -4
- package/test/reply-error.test.js +82 -0
- package/test/reply-trailers.test.js +159 -1
- package/test/router-compile.test.js +241 -0
- package/test/schema-serialization.test.js +120 -7
- package/test/types/fastify.tst.ts +4 -0
- package/test/types/instance.tst.ts +1 -1
- package/test/types/route.tst.ts +16 -0
- package/test/types/using.tst.ts +20 -0
- package/test/validation-error-handling.test.js +21 -1
- package/types/instance.d.ts +5 -1
- package/types/route.d.ts +1 -0
|
@@ -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
|
}
|
|
@@ -151,53 +151,58 @@ test('FST_ERR_CTP_INVALID_PARSE_TYPE', t => {
|
|
|
151
151
|
})
|
|
152
152
|
|
|
153
153
|
test('FST_ERR_CTP_BODY_TOO_LARGE', t => {
|
|
154
|
-
t.plan(
|
|
154
|
+
t.plan(6)
|
|
155
155
|
const error = new errors.FST_ERR_CTP_BODY_TOO_LARGE()
|
|
156
156
|
t.assert.strictEqual(error.name, 'FastifyError')
|
|
157
157
|
t.assert.strictEqual(error.code, 'FST_ERR_CTP_BODY_TOO_LARGE')
|
|
158
158
|
t.assert.strictEqual(error.message, 'Request body is too large')
|
|
159
159
|
t.assert.strictEqual(error.statusCode, 413)
|
|
160
160
|
t.assert.ok(error instanceof RangeError)
|
|
161
|
+
t.assert.strictEqual(error.stack, undefined)
|
|
161
162
|
})
|
|
162
163
|
|
|
163
164
|
test('FST_ERR_CTP_INVALID_MEDIA_TYPE', t => {
|
|
164
|
-
t.plan(
|
|
165
|
+
t.plan(6)
|
|
165
166
|
const error = new errors.FST_ERR_CTP_INVALID_MEDIA_TYPE()
|
|
166
167
|
t.assert.strictEqual(error.name, 'FastifyError')
|
|
167
168
|
t.assert.strictEqual(error.code, 'FST_ERR_CTP_INVALID_MEDIA_TYPE')
|
|
168
169
|
t.assert.strictEqual(error.message, 'Unsupported Media Type')
|
|
169
170
|
t.assert.strictEqual(error.statusCode, 415)
|
|
170
171
|
t.assert.ok(error instanceof Error)
|
|
172
|
+
t.assert.strictEqual(error.stack, undefined)
|
|
171
173
|
})
|
|
172
174
|
|
|
173
175
|
test('FST_ERR_CTP_INVALID_CONTENT_LENGTH', t => {
|
|
174
|
-
t.plan(
|
|
176
|
+
t.plan(6)
|
|
175
177
|
const error = new errors.FST_ERR_CTP_INVALID_CONTENT_LENGTH()
|
|
176
178
|
t.assert.strictEqual(error.name, 'FastifyError')
|
|
177
179
|
t.assert.strictEqual(error.code, 'FST_ERR_CTP_INVALID_CONTENT_LENGTH')
|
|
178
180
|
t.assert.strictEqual(error.message, 'Request body size did not match Content-Length')
|
|
179
181
|
t.assert.strictEqual(error.statusCode, 400)
|
|
180
182
|
t.assert.ok(error instanceof RangeError)
|
|
183
|
+
t.assert.strictEqual(error.stack, undefined)
|
|
181
184
|
})
|
|
182
185
|
|
|
183
186
|
test('FST_ERR_CTP_EMPTY_JSON_BODY', t => {
|
|
184
|
-
t.plan(
|
|
187
|
+
t.plan(6)
|
|
185
188
|
const error = new errors.FST_ERR_CTP_EMPTY_JSON_BODY()
|
|
186
189
|
t.assert.strictEqual(error.name, 'FastifyError')
|
|
187
190
|
t.assert.strictEqual(error.code, 'FST_ERR_CTP_EMPTY_JSON_BODY')
|
|
188
191
|
t.assert.strictEqual(error.message, "Body cannot be empty when content-type is set to 'application/json'")
|
|
189
192
|
t.assert.strictEqual(error.statusCode, 400)
|
|
190
193
|
t.assert.ok(error instanceof Error)
|
|
194
|
+
t.assert.strictEqual(error.stack, undefined)
|
|
191
195
|
})
|
|
192
196
|
|
|
193
197
|
test('FST_ERR_CTP_INVALID_JSON_BODY', t => {
|
|
194
|
-
t.plan(
|
|
198
|
+
t.plan(6)
|
|
195
199
|
const error = new errors.FST_ERR_CTP_INVALID_JSON_BODY()
|
|
196
200
|
t.assert.strictEqual(error.name, 'FastifyError')
|
|
197
201
|
t.assert.strictEqual(error.code, 'FST_ERR_CTP_INVALID_JSON_BODY')
|
|
198
202
|
t.assert.strictEqual(error.message, "Body is not valid JSON but content-type is set to 'application/json'")
|
|
199
203
|
t.assert.strictEqual(error.statusCode, 400)
|
|
200
204
|
t.assert.ok(error instanceof Error)
|
|
205
|
+
t.assert.strictEqual(error.stack, undefined)
|
|
201
206
|
})
|
|
202
207
|
|
|
203
208
|
test('FST_ERR_CTP_INSTANCE_ALREADY_STARTED', t => {
|
|
@@ -621,23 +626,25 @@ test('FST_ERR_DUPLICATED_ROUTE', t => {
|
|
|
621
626
|
})
|
|
622
627
|
|
|
623
628
|
test('FST_ERR_BAD_URL', t => {
|
|
624
|
-
t.plan(
|
|
629
|
+
t.plan(6)
|
|
625
630
|
const error = new errors.FST_ERR_BAD_URL()
|
|
626
631
|
t.assert.strictEqual(error.name, 'FastifyError')
|
|
627
632
|
t.assert.strictEqual(error.code, 'FST_ERR_BAD_URL')
|
|
628
633
|
t.assert.strictEqual(error.message, "'%s' is not a valid url component")
|
|
629
634
|
t.assert.strictEqual(error.statusCode, 400)
|
|
630
635
|
t.assert.ok(error instanceof Error)
|
|
636
|
+
t.assert.strictEqual(error.stack, undefined)
|
|
631
637
|
})
|
|
632
638
|
|
|
633
639
|
test('FST_ERR_MAX_PARAM_LENGTH', t => {
|
|
634
|
-
t.plan(
|
|
640
|
+
t.plan(6)
|
|
635
641
|
const error = new errors.FST_ERR_MAX_PARAM_LENGTH()
|
|
636
642
|
t.assert.strictEqual(error.name, 'FastifyError')
|
|
637
643
|
t.assert.strictEqual(error.code, 'FST_ERR_MAX_PARAM_LENGTH')
|
|
638
644
|
t.assert.strictEqual(error.message, "'%s' is exceeding the max param length")
|
|
639
645
|
t.assert.strictEqual(error.statusCode, 414)
|
|
640
646
|
t.assert.ok(error instanceof Error)
|
|
647
|
+
t.assert.strictEqual(error.stack, undefined)
|
|
641
648
|
})
|
|
642
649
|
|
|
643
650
|
test('FST_ERR_ASYNC_CONSTRAINT', t => {
|
|
@@ -781,13 +788,14 @@ test('FST_ERR_ROUTE_REWRITE_NOT_STR', t => {
|
|
|
781
788
|
})
|
|
782
789
|
|
|
783
790
|
test('FST_ERR_ROUTE_MISSING_CONTENT_TYPE', t => {
|
|
784
|
-
t.plan(
|
|
791
|
+
t.plan(6)
|
|
785
792
|
const error = new errors.FST_ERR_ROUTE_MISSING_CONTENT_TYPE()
|
|
786
793
|
t.assert.strictEqual(error.name, 'FastifyError')
|
|
787
794
|
t.assert.strictEqual(error.code, 'FST_ERR_ROUTE_MISSING_CONTENT_TYPE')
|
|
788
795
|
t.assert.strictEqual(error.message, "Method '%s' must provide a 'Content-Type' header.")
|
|
789
796
|
t.assert.strictEqual(error.statusCode, 400)
|
|
790
797
|
t.assert.ok(error instanceof Error)
|
|
798
|
+
t.assert.strictEqual(error.stack, undefined)
|
|
791
799
|
})
|
|
792
800
|
|
|
793
801
|
test('FST_ERR_REOPENED_CLOSE_SERVER', t => {
|
|
@@ -911,13 +919,14 @@ test('FST_ERR_PLUGIN_TIMEOUT', t => {
|
|
|
911
919
|
})
|
|
912
920
|
|
|
913
921
|
test('FST_ERR_VALIDATION', t => {
|
|
914
|
-
t.plan(
|
|
922
|
+
t.plan(6)
|
|
915
923
|
const error = new errors.FST_ERR_VALIDATION()
|
|
916
924
|
t.assert.strictEqual(error.name, 'FastifyError')
|
|
917
925
|
t.assert.strictEqual(error.code, 'FST_ERR_VALIDATION')
|
|
918
926
|
t.assert.strictEqual(error.message, '%s')
|
|
919
927
|
t.assert.strictEqual(error.statusCode, 400)
|
|
920
928
|
t.assert.ok(error instanceof Error)
|
|
929
|
+
t.assert.strictEqual(error.stack, undefined)
|
|
921
930
|
})
|
|
922
931
|
|
|
923
932
|
test('FST_ERR_LISTEN_OPTIONS_INVALID', t => {
|
|
@@ -45,6 +45,7 @@ test('without options passed to Fastify, initialConfig should expose default val
|
|
|
45
45
|
routerOptions: {
|
|
46
46
|
allowUnsafeRegex: false,
|
|
47
47
|
caseSensitive: true,
|
|
48
|
+
compile: false,
|
|
48
49
|
constraints: undefined,
|
|
49
50
|
ignoreTrailingSlash: false,
|
|
50
51
|
ignoreDuplicateSlashes: false,
|
|
@@ -302,6 +303,7 @@ test('Should not have issues when passing stream options to Pino.js', (t, done)
|
|
|
302
303
|
routerOptions: {
|
|
303
304
|
allowUnsafeRegex: false,
|
|
304
305
|
caseSensitive: true,
|
|
306
|
+
compile: false,
|
|
305
307
|
constraints: undefined,
|
|
306
308
|
ignoreTrailingSlash: true,
|
|
307
309
|
ignoreDuplicateSlashes: false,
|
|
@@ -12,13 +12,15 @@ const handler = (req, res) => {
|
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
test('start listening', async t => {
|
|
15
|
+
t.plan(1)
|
|
15
16
|
const { server, listen } = createServer({}, handler)
|
|
17
|
+
t.after(() => server.close())
|
|
16
18
|
await listen.call(Fastify(), { port: 0, host: 'localhost' })
|
|
17
|
-
server.
|
|
18
|
-
t.assert.ok(true, 'server started')
|
|
19
|
+
t.assert.ok(server.listening, 'the server is listening')
|
|
19
20
|
})
|
|
20
21
|
|
|
21
22
|
test('DNS errors does not stop the main server on localhost - promise interface', async t => {
|
|
23
|
+
t.plan(1)
|
|
22
24
|
const { createServer } = proxyquire('../../lib/server', {
|
|
23
25
|
'node:dns': {
|
|
24
26
|
lookup: (hostname, options, cb) => {
|
|
@@ -27,9 +29,9 @@ test('DNS errors does not stop the main server on localhost - promise interface'
|
|
|
27
29
|
}
|
|
28
30
|
})
|
|
29
31
|
const { server, listen } = createServer({}, handler)
|
|
32
|
+
t.after(() => server.close())
|
|
30
33
|
await listen.call(Fastify(), { port: 0, host: 'localhost' })
|
|
31
|
-
server.
|
|
32
|
-
t.assert.ok(true, 'server started')
|
|
34
|
+
t.assert.ok(server.listening, 'the DNS error did not stop the server listening')
|
|
33
35
|
})
|
|
34
36
|
|
|
35
37
|
test('DNS errors does not stop the main server on localhost - callback interface', (t, done) => {
|
package/test/reply-error.test.js
CHANGED
|
@@ -245,6 +245,35 @@ test('Error status code below 400 defaults to 500', (t, testDone) => {
|
|
|
245
245
|
})
|
|
246
246
|
})
|
|
247
247
|
|
|
248
|
+
test('Error status code above 599 defaults to 500', (t, testDone) => {
|
|
249
|
+
t.plan(3)
|
|
250
|
+
const fastify = Fastify()
|
|
251
|
+
t.after(() => fastify.close())
|
|
252
|
+
const err = new Error('winter is coming')
|
|
253
|
+
err.statusCode = 600
|
|
254
|
+
|
|
255
|
+
fastify.get('/', () => {
|
|
256
|
+
return Promise.reject(err)
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
fastify.inject({
|
|
260
|
+
method: 'GET',
|
|
261
|
+
url: '/'
|
|
262
|
+
}, (error, res) => {
|
|
263
|
+
t.assert.ifError(error)
|
|
264
|
+
t.assert.strictEqual(res.statusCode, 500)
|
|
265
|
+
t.assert.deepStrictEqual(
|
|
266
|
+
{
|
|
267
|
+
error: statusCodes['500'],
|
|
268
|
+
message: err.message,
|
|
269
|
+
statusCode: 500
|
|
270
|
+
},
|
|
271
|
+
JSON.parse(res.payload)
|
|
272
|
+
)
|
|
273
|
+
testDone()
|
|
274
|
+
})
|
|
275
|
+
})
|
|
276
|
+
|
|
248
277
|
test('Error.status property support', (t, testDone) => {
|
|
249
278
|
t.plan(3)
|
|
250
279
|
const fastify = Fastify()
|
|
@@ -848,3 +877,56 @@ test('should catch error when setting invalid header on async onSend hook', asyn
|
|
|
848
877
|
app.get('/', async () => ({ ok: true }))
|
|
849
878
|
await app.inject({ method: 'GET', path: '/' })
|
|
850
879
|
})
|
|
880
|
+
|
|
881
|
+
// The fallback error handler must not try to write a new response once the
|
|
882
|
+
// current one has started transmitting: that would raise an uncaught
|
|
883
|
+
// ERR_HTTP_HEADERS_SENT and terminate the process. It aborts the response.
|
|
884
|
+
test('fallback error handler aborts the response when headers were already sent', async (t) => {
|
|
885
|
+
t.plan(2)
|
|
886
|
+
|
|
887
|
+
const app = Fastify()
|
|
888
|
+
app.addHook('onSend', async (request, reply) => {
|
|
889
|
+
if (reply.raw.headersSent === false) {
|
|
890
|
+
reply.raw.writeHead(200, { 'content-type': 'text/plain' })
|
|
891
|
+
reply.raw.write('partial')
|
|
892
|
+
}
|
|
893
|
+
throw new Error('kaboom')
|
|
894
|
+
})
|
|
895
|
+
app.get('/', async () => 'ok')
|
|
896
|
+
|
|
897
|
+
await app.listen({ port: 0 })
|
|
898
|
+
t.after(() => app.close())
|
|
899
|
+
|
|
900
|
+
const base = `http://127.0.0.1:${app.server.address().port}`
|
|
901
|
+
await t.assert.rejects(async () => {
|
|
902
|
+
const res = await fetch(`${base}/`)
|
|
903
|
+
await res.text()
|
|
904
|
+
})
|
|
905
|
+
|
|
906
|
+
// The server must not have crashed.
|
|
907
|
+
t.assert.strictEqual(app.server.listening, true)
|
|
908
|
+
})
|
|
909
|
+
|
|
910
|
+
test('fallback error handler aborts the response when writeHead throws ERR_HTTP_HEADERS_SENT', async (t) => {
|
|
911
|
+
t.plan(2)
|
|
912
|
+
|
|
913
|
+
const app = Fastify()
|
|
914
|
+
app.addHook('onSend', async (request, reply, payload) => {
|
|
915
|
+
reply.raw.writeHead = function () {
|
|
916
|
+
const err = new Error('Cannot write headers after they are sent to the client')
|
|
917
|
+
err.code = 'ERR_HTTP_HEADERS_SENT'
|
|
918
|
+
throw err
|
|
919
|
+
}
|
|
920
|
+
return payload
|
|
921
|
+
})
|
|
922
|
+
app.get('/', async () => 'ok')
|
|
923
|
+
|
|
924
|
+
await app.listen({ port: 0 })
|
|
925
|
+
t.after(() => app.close())
|
|
926
|
+
|
|
927
|
+
const base = `http://127.0.0.1:${app.server.address().port}`
|
|
928
|
+
await t.assert.rejects(fetch(`${base}/`))
|
|
929
|
+
|
|
930
|
+
// The server must not have crashed.
|
|
931
|
+
t.assert.strictEqual(app.server.listening, true)
|
|
932
|
+
})
|
|
@@ -4,7 +4,28 @@ const { test, describe } = require('node:test')
|
|
|
4
4
|
const Fastify = require('..')
|
|
5
5
|
const { Readable } = require('node:stream')
|
|
6
6
|
const { createHash } = require('node:crypto')
|
|
7
|
-
const { sleep } = require('./helper')
|
|
7
|
+
const { sleep, getServerUrl } = require('./helper')
|
|
8
|
+
const http = require('node:http')
|
|
9
|
+
|
|
10
|
+
function getWithTrailers (url) {
|
|
11
|
+
return new Promise((resolve, reject) => {
|
|
12
|
+
const req = http.request(url, (res) => {
|
|
13
|
+
const chunks = []
|
|
14
|
+
res.on('data', (chunk) => chunks.push(chunk))
|
|
15
|
+
res.on('end', () => {
|
|
16
|
+
resolve({
|
|
17
|
+
statusCode: res.statusCode,
|
|
18
|
+
headers: res.headers,
|
|
19
|
+
trailers: res.trailers,
|
|
20
|
+
body: Buffer.concat(chunks).toString()
|
|
21
|
+
})
|
|
22
|
+
})
|
|
23
|
+
res.on('error', reject)
|
|
24
|
+
})
|
|
25
|
+
req.on('error', reject)
|
|
26
|
+
req.end()
|
|
27
|
+
})
|
|
28
|
+
}
|
|
8
29
|
|
|
9
30
|
test('send trailers when payload is empty string', (t, testDone) => {
|
|
10
31
|
t.plan(5)
|
|
@@ -142,6 +163,143 @@ test('send trailers when payload is stream', (t, testDone) => {
|
|
|
142
163
|
})
|
|
143
164
|
})
|
|
144
165
|
|
|
166
|
+
// fastify.inject() never touches a real socket, so it cannot observe
|
|
167
|
+
// the res.end() race between Readable#pipe() and the trailer flush.
|
|
168
|
+
describe('trailers over a real socket', () => {
|
|
169
|
+
test('send trailers when payload is a Readable stream', async (t) => {
|
|
170
|
+
const fastify = Fastify()
|
|
171
|
+
|
|
172
|
+
fastify.get('/', function (request, reply) {
|
|
173
|
+
reply.trailer('ETag', function (reply, payload, done) {
|
|
174
|
+
done(null, 'custom-etag')
|
|
175
|
+
})
|
|
176
|
+
const stream = Readable.from([JSON.stringify({ hello: 'world' })])
|
|
177
|
+
reply.send(stream)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
await fastify.listen({ port: 0 })
|
|
181
|
+
t.after(() => fastify.close())
|
|
182
|
+
|
|
183
|
+
const res = await getWithTrailers(getServerUrl(fastify))
|
|
184
|
+
t.assert.strictEqual(res.statusCode, 200)
|
|
185
|
+
t.assert.strictEqual(res.headers['transfer-encoding'], 'chunked')
|
|
186
|
+
t.assert.strictEqual(res.headers.trailer, 'etag')
|
|
187
|
+
t.assert.strictEqual(res.trailers.etag, 'custom-etag')
|
|
188
|
+
t.assert.strictEqual(res.body, JSON.stringify({ hello: 'world' }))
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
test('send trailers when payload is a web ReadableStream', async (t) => {
|
|
192
|
+
const fastify = Fastify()
|
|
193
|
+
|
|
194
|
+
fastify.get('/', function (request, reply) {
|
|
195
|
+
reply.trailer('ETag', function (reply, payload, done) {
|
|
196
|
+
done(null, 'custom-etag')
|
|
197
|
+
})
|
|
198
|
+
const webStream = new ReadableStream({
|
|
199
|
+
start (controller) {
|
|
200
|
+
controller.enqueue(Buffer.from(JSON.stringify({ hello: 'world' })))
|
|
201
|
+
controller.close()
|
|
202
|
+
}
|
|
203
|
+
})
|
|
204
|
+
reply.send(webStream)
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
await fastify.listen({ port: 0 })
|
|
208
|
+
t.after(() => fastify.close())
|
|
209
|
+
|
|
210
|
+
const res = await getWithTrailers(getServerUrl(fastify))
|
|
211
|
+
t.assert.strictEqual(res.statusCode, 200)
|
|
212
|
+
t.assert.strictEqual(res.headers.trailer, 'etag')
|
|
213
|
+
t.assert.strictEqual(res.trailers.etag, 'custom-etag')
|
|
214
|
+
t.assert.strictEqual(res.body, JSON.stringify({ hello: 'world' }))
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
test('send trailers when payload is a pre-ended Readable stream', async (t) => {
|
|
218
|
+
const fastify = Fastify()
|
|
219
|
+
|
|
220
|
+
// drain the stream and let 'end' pass before it is ever handed to
|
|
221
|
+
// send(), so sendStream() attaches its listeners after the stream
|
|
222
|
+
// is already done. the route handler stays synchronous so fastify's
|
|
223
|
+
// async "did the handler forget to send" rescue in wrap-thenable
|
|
224
|
+
// never gets a chance to paper over a stuck response.
|
|
225
|
+
const stream = Readable.from([JSON.stringify({ hello: 'world' })])
|
|
226
|
+
stream.resume()
|
|
227
|
+
await new Promise((resolve) => stream.on('end', resolve))
|
|
228
|
+
|
|
229
|
+
fastify.get('/', function (request, reply) {
|
|
230
|
+
reply.trailer('ETag', function (reply, payload, done) {
|
|
231
|
+
done(null, 'custom-etag')
|
|
232
|
+
})
|
|
233
|
+
reply.send(stream)
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
await fastify.listen({ port: 0 })
|
|
237
|
+
t.after(() => fastify.close())
|
|
238
|
+
|
|
239
|
+
const res = await Promise.race([
|
|
240
|
+
getWithTrailers(getServerUrl(fastify)),
|
|
241
|
+
new Promise((resolve, reject) => {
|
|
242
|
+
const hangTimer = setTimeout(() => reject(new Error('response hung')), 2000)
|
|
243
|
+
t.after(() => clearTimeout(hangTimer))
|
|
244
|
+
})
|
|
245
|
+
])
|
|
246
|
+
t.assert.strictEqual(res.statusCode, 200)
|
|
247
|
+
t.assert.strictEqual(res.headers.trailer, 'etag')
|
|
248
|
+
t.assert.strictEqual(res.trailers.etag, 'custom-etag')
|
|
249
|
+
t.assert.strictEqual(res.body, '')
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
test('stream response with no trailers registered ends normally', async (t) => {
|
|
253
|
+
const fastify = Fastify()
|
|
254
|
+
|
|
255
|
+
fastify.get('/', function (request, reply) {
|
|
256
|
+
const stream = Readable.from(['no-trailer-payload'])
|
|
257
|
+
reply.send(stream)
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
await fastify.listen({ port: 0 })
|
|
261
|
+
t.after(() => fastify.close())
|
|
262
|
+
|
|
263
|
+
const res = await getWithTrailers(getServerUrl(fastify))
|
|
264
|
+
t.assert.strictEqual(res.statusCode, 200)
|
|
265
|
+
t.assert.deepStrictEqual(res.trailers, {})
|
|
266
|
+
t.assert.strictEqual(res.body, 'no-trailer-payload')
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
test('stream error before end does not hang the response', async (t) => {
|
|
270
|
+
const fastify = Fastify({ logger: false })
|
|
271
|
+
|
|
272
|
+
fastify.get('/', function (request, reply) {
|
|
273
|
+
reply.trailer('ETag', function (reply, payload, done) {
|
|
274
|
+
done(null, 'custom-etag')
|
|
275
|
+
})
|
|
276
|
+
const stream = new Readable({
|
|
277
|
+
read () {
|
|
278
|
+
this.push('partial-data')
|
|
279
|
+
process.nextTick(() => this.destroy(new Error('boom')))
|
|
280
|
+
}
|
|
281
|
+
})
|
|
282
|
+
reply.send(stream)
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
await fastify.listen({ port: 0 })
|
|
286
|
+
t.after(() => fastify.close())
|
|
287
|
+
|
|
288
|
+
await new Promise((resolve, reject) => {
|
|
289
|
+
const req = http.request(getServerUrl(fastify), (res) => {
|
|
290
|
+
res.on('data', () => {})
|
|
291
|
+
res.on('error', () => resolve())
|
|
292
|
+
res.on('close', () => resolve())
|
|
293
|
+
res.on('end', () => resolve())
|
|
294
|
+
})
|
|
295
|
+
req.on('error', () => resolve())
|
|
296
|
+
req.end()
|
|
297
|
+
const hangTimer = setTimeout(() => reject(new Error('response hung')), 2000)
|
|
298
|
+
t.after(() => clearTimeout(hangTimer))
|
|
299
|
+
})
|
|
300
|
+
})
|
|
301
|
+
})
|
|
302
|
+
|
|
145
303
|
test('remove trailer while stream is being consumed', (t, testDone) => {
|
|
146
304
|
t.plan(5)
|
|
147
305
|
|