@fastify/compress 9.0.0 → 9.1.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.
package/.c8rc CHANGED
@@ -1,14 +1,10 @@
1
1
  {
2
2
  "all": true,
3
- "exclude": [
4
- "test/",
5
- "coverage/",
6
- "types/"
7
- ],
3
+ "exclude": ["test/", "coverage/", "types/", "eslint.config.js"],
8
4
  "clean": true,
9
5
  "check-coverage": true,
10
6
  "branches": 100,
11
7
  "lines": 100,
12
8
  "functions": 100,
13
9
  "statements": 100
14
- }
10
+ }
@@ -2,12 +2,53 @@ version: 2
2
2
  updates:
3
3
  - package-ecosystem: "github-actions"
4
4
  directory: "/"
5
+ commit-message:
6
+ # Prefix all commit messages with "chore: "
7
+ prefix: "chore"
5
8
  schedule:
6
- interval: "monthly"
9
+ interval: "weekly"
10
+ cooldown:
11
+ default-days: 7
12
+ allow:
13
+ - dependency-name: "*"
14
+ update-types:
15
+ - "version-update:semver-major"
7
16
  open-pull-requests-limit: 10
8
17
 
9
18
  - package-ecosystem: "npm"
10
19
  directory: "/"
20
+ commit-message:
21
+ # Prefix all commit messages with "chore: "
22
+ prefix: "chore"
11
23
  schedule:
12
- interval: "monthly"
24
+ interval: "weekly"
25
+ cooldown:
26
+ default-days: 7
27
+ versioning-strategy: "increase-if-necessary"
28
+ allow:
29
+ - dependency-name: "*"
30
+ update-types:
31
+ - "version-update:semver-major"
32
+ ignore:
33
+ # TODO: remove ignore until neostandard support ESLint 10
34
+ - dependency-name: "eslint"
35
+ - dependency-name: "neostandard"
36
+ - dependency-name: "@stylistic/*"
37
+ - dependency-name: "typescript"
13
38
  open-pull-requests-limit: 10
39
+ groups:
40
+ # Production dependencies with breaking changes
41
+ dependencies:
42
+ dependency-type: "production"
43
+ # ESLint related dependencies
44
+ dev-dependencies-eslint:
45
+ patterns:
46
+ - "eslint"
47
+ - "neostandard"
48
+ - "@stylistic/*"
49
+ # TypeScript related dependencies
50
+ dev-dependencies-typescript:
51
+ patterns:
52
+ - "@types/*"
53
+ - "tstyche"
54
+ - "typescript"
@@ -27,7 +27,7 @@ jobs:
27
27
  permissions:
28
28
  contents: write
29
29
  pull-requests: write
30
- uses: fastify/workflows/.github/workflows/plugins-ci.yml@v6
30
+ uses: fastify/workflows/.github/workflows/plugins-ci.yml@2073dc8e1f9e172bf42daa3843c9dbd31af1e8cb # v6.0.0
31
31
  with:
32
32
  license-check: true
33
33
  lint: true
@@ -16,4 +16,4 @@ jobs:
16
16
  permissions:
17
17
  issues: write
18
18
  pull-requests: write
19
- uses: fastify/workflows/.github/workflows/lock-threads.yml@v6
19
+ uses: fastify/workflows/.github/workflows/lock-threads.yml@2073dc8e1f9e172bf42daa3843c9dbd31af1e8cb # v6.0.0
package/README.md CHANGED
@@ -396,9 +396,8 @@ await fastify.register(
396
396
 
397
397
  ## Acknowledgments
398
398
 
399
- Past sponsors:
400
-
401
- - [LetzDoIt](http://www.letzdoitapp.com/)
399
+ Past sp
400
+ - LetzDoIt
402
401
 
403
402
  ## License
404
403
 
package/index.js CHANGED
@@ -7,11 +7,20 @@ const { pipeline, compose } = require('node:stream')
7
7
  const fp = require('fastify-plugin')
8
8
  const encodingNegotiator = require('@fastify/accept-negotiator')
9
9
  const mimedb = require('mime-db')
10
- const peek = require('peek-stream')
11
10
  const { Minipass } = require('minipass')
12
11
  const { Readable } = require('readable-stream')
13
12
 
14
- const { isStream, isGzip, isDeflate, intoAsyncIterator, isWebReadableStream, isFetchResponse, webStreamToNodeReadable } = require('./lib/utils')
13
+ const {
14
+ isStream,
15
+ isGzip,
16
+ isDeflate,
17
+ intoAsyncIterator,
18
+ isWebReadableStream,
19
+ isFetchResponse,
20
+ webStreamToNodeReadable,
21
+ unwrapFetchResponse,
22
+ createPeekTransform
23
+ } = require('./lib/utils')
15
24
 
16
25
  const InvalidRequestEncodingError = createError('FST_CP_ERR_INVALID_CONTENT_ENCODING', 'Unsupported Content-Encoding: %s', 415)
17
26
  const InvalidRequestCompressedPayloadError = createError('FST_CP_ERR_INVALID_CONTENT', 'Could not decompress the request payload using the provided encoding', 400)
@@ -258,6 +267,9 @@ function buildRouteCompress (_fastify, params, routeOptions, decorateOnly) {
258
267
  const noCompress =
259
268
  // don't compress on x-no-compression header
260
269
  (req.headers['x-no-compression'] !== undefined) ||
270
+ // don't compress partial content: Content-Range describes unencoded byte offsets
271
+ (reply.statusCode === 206) ||
272
+ (reply.getHeader('Content-Range') !== undefined) ||
261
273
  // don't compress if not one of the indicated compressible types
262
274
  (shouldCompress(reply.getHeader('Content-Type') || 'application/json', params.compressibleTypes) === false) ||
263
275
  // don't compress on missing or identity `accept-encoding` header
@@ -285,7 +297,7 @@ function buildRouteCompress (_fastify, params, routeOptions, decorateOnly) {
285
297
 
286
298
  if (typeof payload.pipe !== 'function') {
287
299
  if (isFetchResponse(payload)) {
288
- payload = payload.body
300
+ payload = unwrapFetchResponse(reply, payload)
289
301
  }
290
302
 
291
303
  if (isWebReadableStream(payload)) {
@@ -414,7 +426,7 @@ function compress (params) {
414
426
 
415
427
  if (typeof payload.pipe !== 'function') {
416
428
  if (isFetchResponse(payload)) {
417
- payload = payload.body
429
+ payload = unwrapFetchResponse(this, payload)
418
430
  }
419
431
 
420
432
  if (isWebReadableStream(payload)) {
@@ -459,8 +471,12 @@ function onEnd (err) {
459
471
  // Client disconnection during streaming is expected and handled by Fastify.
460
472
  // Do not log "premature close" errors at error level since they are not
461
473
  // actual errors - they occur when clients disconnect mid-response.
474
+ // Node's native stream.pipeline emits ERR_STREAM_PREMATURE_CLOSE with the
475
+ // message 'Premature close', while the legacy pump/end-of-stream packages
476
+ // emit 'premature close' without a code, so both variants are checked.
462
477
  // See: https://github.com/fastify/fastify-compress/issues/382
463
- if (err && err.message !== 'premature close') {
478
+ // See: https://github.com/fastify/fastify-compress/issues/410
479
+ if (err && err.code !== 'ERR_STREAM_PREMATURE_CLOSE' && err.message !== 'premature close') {
464
480
  this.log.error(err)
465
481
  }
466
482
  }
@@ -547,26 +563,26 @@ function maybeUnzip (payload, serialize) {
547
563
  }
548
564
 
549
565
  function zipStream (deflate, encoding) {
550
- return peek({ newline: false, maxBuffer: 10 }, function (data, swap) {
566
+ return createPeekTransform(function (data) {
551
567
  switch (isCompressed(data)) {
552
- case 1: return swap(null, new Minipass())
553
- case 2: return swap(null, new Minipass())
568
+ case 1: return new Minipass()
569
+ case 2: return new Minipass()
554
570
  }
555
- return swap(null, deflate[encoding]())
571
+ return deflate[encoding]()
556
572
  })
557
573
  }
558
574
 
559
575
  function unzipStream (inflate, maxRecursion) {
560
576
  if (!(maxRecursion >= 0)) maxRecursion = 3
561
- return peek({ newline: false, maxBuffer: 10 }, function (data, swap) {
577
+ return createPeekTransform(function (data) {
562
578
  // This path is never taken, when `maxRecursion` < 0 it is automatically set back to 3
563
579
  /* c8 ignore next */
564
- if (maxRecursion < 0) return swap(new Error('Maximum recursion reached'))
580
+ if (maxRecursion < 0) throw new Error('Maximum recursion reached')
565
581
  switch (isCompressed(data)) {
566
- case 1: return swap(null, compose(inflate.gzip(), unzipStream(inflate, maxRecursion - 1)))
567
- case 2: return swap(null, compose(inflate.deflate(), unzipStream(inflate, maxRecursion - 1)))
582
+ case 1: return compose(inflate.gzip(), unzipStream(inflate, maxRecursion - 1))
583
+ case 2: return compose(inflate.deflate(), unzipStream(inflate, maxRecursion - 1))
568
584
  }
569
- return swap(null, new Minipass())
585
+ return new Minipass()
570
586
  })
571
587
  }
572
588
 
package/lib/utils.js CHANGED
@@ -1,6 +1,7 @@
1
1
  'use strict'
2
2
 
3
3
  const { Readable: NodeReadable } = require('node:stream')
4
+ const { Transform } = require('readable-stream')
4
5
 
5
6
  // https://datatracker.ietf.org/doc/html/rfc8878#section-3.1.1
6
7
  function isZstd (buffer) {
@@ -63,6 +64,14 @@ function webStreamToNodeReadable (webStream) {
63
64
  return NodeReadable.fromWeb(webStream)
64
65
  }
65
66
 
67
+ function unwrapFetchResponse (reply, response) {
68
+ reply.code(response.status)
69
+ for (const [name, value] of response.headers) {
70
+ reply.header(name, value)
71
+ }
72
+ return response.body
73
+ }
74
+
66
75
  /**
67
76
  * Provide a async iteratable for Readable.from
68
77
  */
@@ -104,4 +113,93 @@ async function * intoAsyncIterator (payload) {
104
113
  yield payload
105
114
  }
106
115
 
107
- module.exports = { isZstd, isGzip, isDeflate, isStream, intoAsyncIterator, isWebReadableStream, isFetchResponse, webStreamToNodeReadable }
116
+ function createPeekTransform (swapStream, peekSize = 10) {
117
+ const buffers = []
118
+ let bufferSize = 0
119
+ let downstream = null
120
+ let flushCallback = null
121
+
122
+ const _transform = new Transform({
123
+ transform (chunk, encoding, callback) {
124
+ // when downstream already selected, directly passthrough
125
+ if (downstream !== null) return downstream.write(chunk, encoding, callback)
126
+
127
+ bufferSize += chunk.length
128
+ buffers.push(chunk)
129
+
130
+ // read more data when buffer size smaller than peeksize
131
+ if (bufferSize < peekSize) return callback()
132
+
133
+ wireDownStream(callback)
134
+ },
135
+ flush (callback) {
136
+ // used in "end" event to notify the transform finished flush
137
+ flushCallback = callback
138
+ if (downstream !== null) {
139
+ // all data should be flushed to downstream already
140
+ // we end the stream
141
+ downstream.end()
142
+ } else {
143
+ // flush internal buffers and end the downstream
144
+ wireDownStream((error) => {
145
+ if (error) {
146
+ flushCallback = null
147
+ callback(error)
148
+ } else {
149
+ downstream.end()
150
+ }
151
+ })
152
+ }
153
+ }
154
+ })
155
+
156
+ function onData (chunk) {
157
+ _transform.push(chunk)
158
+ }
159
+
160
+ function onError (error) {
161
+ _transform.destroy(error)
162
+ }
163
+
164
+ function onEnd () {
165
+ flushCallback && flushCallback()
166
+ // cleanup
167
+ downstream.off('data', onData)
168
+ downstream.off('error', onError)
169
+ downstream.off('end', onEnd)
170
+ downstream = null
171
+ flushCallback = null
172
+
173
+ _transform.push(null)
174
+ }
175
+
176
+ function wireDownStream (callback) {
177
+ const data = Buffer.concat(buffers)
178
+ try {
179
+ downstream = swapStream(data.subarray(0, peekSize))
180
+ downstream.on('data', onData)
181
+ downstream.on('error', onError)
182
+ downstream.on('end', onEnd)
183
+ downstream.write(data, callback)
184
+ // cleanup
185
+ buffers.length = 0
186
+ } catch (error) {
187
+ callback(error)
188
+ }
189
+ }
190
+
191
+ return _transform
192
+ }
193
+
194
+ module.exports = {
195
+ isZstd,
196
+ isGzip,
197
+ isDeflate,
198
+ isStream,
199
+ intoAsyncIterator,
200
+ isWebReadableStream,
201
+ isFetchResponse,
202
+ webStreamToNodeReadable,
203
+ unwrapFetchResponse,
204
+ createPeekTransform
205
+ }
package/package.json CHANGED
@@ -1,22 +1,21 @@
1
1
  {
2
2
  "name": "@fastify/compress",
3
- "version": "9.0.0",
3
+ "version": "9.1.1",
4
4
  "description": "Fastify compression utils",
5
5
  "main": "index.js",
6
6
  "type": "commonjs",
7
7
  "types": "types/index.d.ts",
8
8
  "dependencies": {
9
9
  "@fastify/accept-negotiator": "^2.0.0",
10
- "fastify-plugin": "^5.0.0",
10
+ "fastify-plugin": "^6.0.0",
11
11
  "mime-db": "^1.52.0",
12
12
  "minipass": "^7.0.4",
13
- "peek-stream": "^1.1.3",
14
13
  "readable-stream": "^4.5.2"
15
14
  },
16
15
  "devDependencies": {
17
- "@types/node": "^25.0.3",
18
- "adm-zip": "^0.5.12",
19
- "c8": "^11.0.0",
16
+ "@types/node": "^26.0.1",
17
+ "adm-zip": "^0.6.0",
18
+ "c8": "^12.0.0",
20
19
  "eslint": "^9.17.0",
21
20
  "fastify": "^5.0.0",
22
21
  "jsonstream": "^1.0.3",
@@ -41,7 +40,7 @@
41
40
  "brotli",
42
41
  "zstd"
43
42
  ],
44
- "author": "Tomas Della Vedova - @delvedor (http://delved.org)",
43
+ "author": "Tomas Della Vedova - @delvedor (https://delvedor.dev)",
45
44
  "contributors": [
46
45
  {
47
46
  "name": "Matteo Collina",
@@ -288,6 +288,36 @@ describe('When `global` is not set, it is `true` by default :', async () => {
288
288
  t.assert.equal(payload.toString('utf-8'), body)
289
289
  })
290
290
 
291
+ test('it should preserve a Fetch API Response status and headers', async (t) => {
292
+ t.plan(5)
293
+
294
+ const fastify = Fastify()
295
+ await fastify.register(compressPlugin, { threshold: 0 })
296
+
297
+ const body = 'hello from fetch response'
298
+ fastify.get('/fetch-resp', (_request, reply) => {
299
+ reply.send(new Response(body, {
300
+ status: 201,
301
+ headers: {
302
+ 'content-type': 'text/plain',
303
+ 'cache-control': 'public, max-age=120',
304
+ 'set-cookie': 'a=b; HttpOnly'
305
+ }
306
+ }))
307
+ })
308
+
309
+ const response = await fastify.inject({
310
+ url: '/fetch-resp',
311
+ method: 'GET',
312
+ headers: { 'accept-encoding': 'gzip' }
313
+ })
314
+ t.assert.equal(response.statusCode, 201)
315
+ t.assert.equal(response.headers['content-type'], 'text/plain')
316
+ t.assert.equal(response.headers['cache-control'], 'public, max-age=120')
317
+ t.assert.equal(response.headers['set-cookie'], 'a=b; HttpOnly')
318
+ t.assert.equal(zlib.gunzipSync(response.rawPayload).toString('utf-8'), body)
319
+ })
320
+
291
321
  test('it should compress a Web ReadableStream body', async (t) => {
292
322
  t.plan(1)
293
323
 
@@ -682,6 +712,41 @@ describe('When a custom `zlib` option is provided, it should compress data :`',
682
712
  t.assert.equal(response.headers['content-encoding'], 'gzip')
683
713
  t.assert.equal(payload.toString('utf-8'), file)
684
714
  })
715
+
716
+ test('using the custom `createZstdCompress()` method', async (t) => {
717
+ if (typeof zlib.createZstdCompress !== 'function') {
718
+ t.skip('zstd not supported in this Node.js version')
719
+ return
720
+ }
721
+ t.plan(4)
722
+
723
+ let usedCustom = false
724
+ const customZlib = { createZstdCompress: () => (usedCustom = true) && zlib.createZstdCompress() }
725
+
726
+ const fastify = Fastify()
727
+ await fastify.register(compressPlugin, { global: true, zlib: customZlib })
728
+
729
+ fastify.get('/', (_request, reply) => {
730
+ reply
731
+ .type('text/plain')
732
+ .compress(createReadStream('./package.json'))
733
+ })
734
+
735
+ const response = await fastify.inject({
736
+ url: '/',
737
+ method: 'GET',
738
+ headers: {
739
+ 'accept-encoding': 'zstd'
740
+ }
741
+ })
742
+ t.assert.equal(usedCustom, true)
743
+
744
+ const file = readFileSync('./package.json', 'utf8')
745
+ const payload = zlib.zstdDecompressSync(response.rawPayload)
746
+ t.assert.equal(response.headers.vary, 'accept-encoding')
747
+ t.assert.equal(response.headers['content-encoding'], 'zstd')
748
+ t.assert.equal(payload.toString('utf-8'), file)
749
+ })
685
750
  })
686
751
 
687
752
  describe('When a malformed custom `zlib` option is provided, it should compress data :', async () => {
@@ -765,6 +830,37 @@ describe('When a malformed custom `zlib` option is provided, it should compress
765
830
  const payload = zlib.gunzipSync(response.rawPayload)
766
831
  t.assert.equal(payload.toString('utf-8'), 'hello')
767
832
  })
833
+
834
+ test('using the fallback default Node.js core `zlib.createZstdCompress()` method', async (t) => {
835
+ if (typeof zlib.createZstdCompress !== 'function') {
836
+ t.skip('zstd not supported in this Node.js version')
837
+ return
838
+ }
839
+ t.plan(1)
840
+
841
+ const fastify = Fastify()
842
+ await fastify.register(compressPlugin, {
843
+ global: true,
844
+ threshold: 0,
845
+ zlib: true // will trigger a fallback on the default zlib.createZstdCompress
846
+ })
847
+
848
+ fastify.get('/', (_request, reply) => {
849
+ reply
850
+ .type('text/plain')
851
+ .compress('hello')
852
+ })
853
+
854
+ const response = await fastify.inject({
855
+ url: '/',
856
+ method: 'GET',
857
+ headers: {
858
+ 'accept-encoding': 'zstd'
859
+ }
860
+ })
861
+ const payload = zlib.zstdDecompressSync(response.rawPayload)
862
+ t.assert.equal(payload.toString('utf-8'), 'hello')
863
+ })
768
864
  })
769
865
 
770
866
  describe('When `inflateIfDeflated` is `true` and `X-No-Compression` request header is `true` :', async () => {
@@ -3548,3 +3644,56 @@ test('It should demonstrate globalDecompression controls decompression independe
3548
3644
  t.assert.equal(response.statusCode, 400)
3549
3645
  t.assert.ok(response.body.includes('Content-Length') || response.body.includes('Bad Request'))
3550
3646
  })
3647
+
3648
+ describe('Range Responses', () => {
3649
+ test('it should not compress 206 Partial Content responses', async (t) => {
3650
+ t.plan(4)
3651
+ const fastify = Fastify()
3652
+ await fastify.register(compressPlugin, { threshold: 0 })
3653
+
3654
+ fastify.get('/partial', (request, reply) => {
3655
+ reply
3656
+ .status(206)
3657
+ .header('Content-Type', 'text/plain')
3658
+ .header('Content-Range', 'bytes 0-4/12')
3659
+ .header('Content-Length', '5')
3660
+ .send('hello')
3661
+ })
3662
+
3663
+ const response = await fastify.inject({
3664
+ url: '/partial',
3665
+ method: 'GET',
3666
+ headers: { 'accept-encoding': 'gzip' }
3667
+ })
3668
+
3669
+ t.assert.equal(response.statusCode, 206)
3670
+ t.assert.equal(response.headers['content-encoding'], undefined)
3671
+ t.assert.equal(response.headers['content-range'], 'bytes 0-4/12')
3672
+ t.assert.equal(response.body, 'hello')
3673
+ })
3674
+
3675
+ test('it should not compress responses carrying a Content-Range header', async (t) => {
3676
+ t.plan(4)
3677
+ const fastify = Fastify()
3678
+ await fastify.register(compressPlugin, { threshold: 0 })
3679
+
3680
+ fastify.get('/content-range', (request, reply) => {
3681
+ reply
3682
+ .header('Content-Type', 'text/plain')
3683
+ .header('Content-Range', 'bytes 0-4/12')
3684
+ .header('Content-Length', '5')
3685
+ .send('hello')
3686
+ })
3687
+
3688
+ const response = await fastify.inject({
3689
+ url: '/content-range',
3690
+ method: 'GET',
3691
+ headers: { 'accept-encoding': 'gzip' }
3692
+ })
3693
+
3694
+ t.assert.equal(response.statusCode, 200)
3695
+ t.assert.equal(response.headers['content-encoding'], undefined)
3696
+ t.assert.equal(response.headers['content-range'], 'bytes 0-4/12')
3697
+ t.assert.equal(response.body, 'hello')
3698
+ })
3699
+ })
@@ -15,7 +15,7 @@ test('should not log "premature close" errors on client disconnect (global compr
15
15
  level: 'error',
16
16
  stream: new Writable({
17
17
  write (chunk, encoding, callback) {
18
- if (chunk.toString().includes('premature close')) {
18
+ if (/premature close/i.test(chunk.toString())) {
19
19
  prematureCloseCount++
20
20
  }
21
21
  callback()
@@ -86,7 +86,7 @@ test('should not log "premature close" errors on client disconnect (reply.compre
86
86
  level: 'error',
87
87
  stream: new Writable({
88
88
  write (chunk, encoding, callback) {
89
- if (chunk.toString().includes('premature close')) {
89
+ if (/premature close/i.test(chunk.toString())) {
90
90
  prematureCloseCount++
91
91
  }
92
92
  callback()
@@ -157,7 +157,7 @@ test('should still log actual errors (not premature close)', async (t) => {
157
157
  stream: new Writable({
158
158
  write (chunk, encoding, callback) {
159
159
  const msg = chunk.toString()
160
- if (msg.includes('premature close')) {
160
+ if (/premature close/i.test(msg)) {
161
161
  // Ignore premature close errors
162
162
  } else {
163
163
  errors.push(msg)
@@ -0,0 +1,163 @@
1
+ 'use strict'
2
+
3
+ const { test } = require('node:test')
4
+ const { setTimeout: sleep } = require('node:timers/promises')
5
+ const net = require('node:net')
6
+ const { Writable } = require('node:stream')
7
+ const Fastify = require('fastify')
8
+ const fastifyCompress = require('../..')
9
+
10
+ // Node's native stream.pipeline emits ERR_STREAM_PREMATURE_CLOSE whose message
11
+ // is 'Premature close' (capital P), unlike the legacy pump/end-of-stream
12
+ // message 'premature close'. The onEnd guard must suppress both variants.
13
+ // See: https://github.com/fastify/fastify-compress/issues/410
14
+
15
+ function buildFastify (onErrorLog) {
16
+ return Fastify({
17
+ logger: {
18
+ level: 'error',
19
+ stream: new Writable({
20
+ write (chunk, encoding, callback) {
21
+ onErrorLog(chunk.toString())
22
+ callback()
23
+ }
24
+ })
25
+ }
26
+ })
27
+ }
28
+
29
+ function streamRoute (reply, ReadableCtor) {
30
+ let chunks = 0
31
+ const stream = new ReadableCtor({
32
+ read () {
33
+ if (chunks >= 20) {
34
+ this.push(null)
35
+ return
36
+ }
37
+ setTimeout(() => {
38
+ this.push(JSON.stringify({ id: chunks, data: 'x'.repeat(1000) }) + '\n')
39
+ chunks++
40
+ }, 50)
41
+ }
42
+ })
43
+ reply.type('text/plain')
44
+ return stream
45
+ }
46
+
47
+ async function abortClients (port) {
48
+ // Simulate clients that disconnect before the full response is sent
49
+ for (let i = 0; i < 10; i++) {
50
+ await new Promise((_resolve) => {
51
+ const sock = net.connect(port, '127.0.0.1', () => {
52
+ sock.write('GET /stream HTTP/1.1\r\nHost: localhost\r\nAccept-Encoding: gzip\r\n\r\n')
53
+ // Disconnect after receiving partial response (100ms)
54
+ setTimeout(() => {
55
+ sock.destroy()
56
+ _resolve()
57
+ }, 100)
58
+ })
59
+ sock.on('error', () => _resolve())
60
+ })
61
+ }
62
+ }
63
+
64
+ test('should not log native pipeline "Premature close" (ERR_STREAM_PREMATURE_CLOSE) on client disconnect (global compression)', async (t) => {
65
+ let prematureCloseCount = 0
66
+
67
+ const fastify = buildFastify((msg) => {
68
+ if (/premature close/i.test(msg)) {
69
+ prematureCloseCount++
70
+ }
71
+ })
72
+
73
+ await fastify.register(fastifyCompress, {
74
+ global: true,
75
+ encodings: ['gzip'],
76
+ threshold: 1024
77
+ })
78
+
79
+ fastify.get('/stream', async (_request, reply) => {
80
+ return streamRoute(reply, require('node:stream').Readable)
81
+ })
82
+
83
+ await fastify.listen({ port: 0 })
84
+ const port = fastify.server.address().port
85
+
86
+ await abortClients(port)
87
+
88
+ // Allow pending callbacks to fire
89
+ await sleep(2000)
90
+ await fastify.close()
91
+
92
+ t.assert.equal(
93
+ prematureCloseCount,
94
+ 0,
95
+ `Expected no premature close errors (any casing), but got ${prematureCloseCount} errors from 10 client disconnects`
96
+ )
97
+ })
98
+
99
+ test('should not log native pipeline "Premature close" (ERR_STREAM_PREMATURE_CLOSE) on client disconnect (reply.compress path)', async (t) => {
100
+ let prematureCloseCount = 0
101
+
102
+ const fastify = buildFastify((msg) => {
103
+ if (/premature close/i.test(msg)) {
104
+ prematureCloseCount++
105
+ }
106
+ })
107
+
108
+ await fastify.register(fastifyCompress, {
109
+ global: false,
110
+ encodings: ['gzip']
111
+ })
112
+
113
+ fastify.get('/stream', async (_request, reply) => {
114
+ return reply.compress(streamRoute(reply, require('node:stream').Readable))
115
+ })
116
+
117
+ await fastify.listen({ port: 0 })
118
+ const port = fastify.server.address().port
119
+
120
+ await abortClients(port)
121
+
122
+ // Allow pending callbacks to fire
123
+ await sleep(2000)
124
+ await fastify.close()
125
+
126
+ t.assert.equal(
127
+ prematureCloseCount,
128
+ 0,
129
+ `Expected no premature close errors (any casing), but got ${prematureCloseCount} errors from 10 client disconnects`
130
+ )
131
+ })
132
+
133
+ test('should still log errors that are not premature close', async (t) => {
134
+ const errors = []
135
+
136
+ const fastify = buildFastify((msg) => {
137
+ if (!/premature close/i.test(msg)) {
138
+ errors.push(msg)
139
+ }
140
+ })
141
+
142
+ await fastify.register(fastifyCompress, {
143
+ global: true,
144
+ encodings: ['gzip']
145
+ })
146
+
147
+ fastify.get('/error', async () => {
148
+ throw new Error('Test error')
149
+ })
150
+
151
+ await fastify.inject({
152
+ method: 'GET',
153
+ url: '/error',
154
+ headers: { 'accept-encoding': 'gzip' }
155
+ })
156
+
157
+ await sleep(500)
158
+
159
+ t.assert.ok(
160
+ errors.length > 0,
161
+ 'Expected actual errors to still be logged'
162
+ )
163
+ })
@@ -364,6 +364,23 @@ test('reply.compress should handle Fetch Response', async (t) => {
364
364
  t.assert.equal(zlib.gunzipSync(res.rawPayload).toString('utf8'), 'from reply.compress')
365
365
  })
366
366
 
367
+ test('reply.compress should preserve a Fetch Response status and headers', async (t) => {
368
+ t.plan(4)
369
+ const fastify = Fastify()
370
+ await fastify.register(compressPlugin, { global: true, threshold: 0 })
371
+ fastify.get('/', (_req, reply) => {
372
+ reply.compress(new Response('from reply.compress', {
373
+ status: 418,
374
+ headers: { 'content-type': 'text/plain', 'x-custom': 'kept' }
375
+ }))
376
+ })
377
+ const res = await fastify.inject({ url: '/', method: 'GET', headers: { 'accept-encoding': 'gzip' } })
378
+ t.assert.equal(res.statusCode, 418)
379
+ t.assert.equal(res.headers['content-type'], 'text/plain')
380
+ t.assert.equal(res.headers['x-custom'], 'kept')
381
+ t.assert.equal(zlib.gunzipSync(res.rawPayload).toString('utf8'), 'from reply.compress')
382
+ })
383
+
367
384
  test('reply.compress should handle Web ReadableStream', async (t) => {
368
385
  t.plan(1)
369
386
  const fastify = Fastify()
@@ -3,8 +3,9 @@
3
3
  const { createReadStream } = require('node:fs')
4
4
  const { Socket } = require('node:net')
5
5
  const { Duplex, PassThrough, Readable, Stream, Transform, Writable } = require('node:stream')
6
+ const { finished } = require('node:stream/promises')
6
7
  const { test } = require('node:test')
7
- const { isStream, isZstd, isDeflate, isGzip, intoAsyncIterator } = require('../lib/utils')
8
+ const { isStream, isZstd, isDeflate, isGzip, intoAsyncIterator, createPeekTransform } = require('../lib/utils')
8
9
 
9
10
  test('isStream() utility should be able to detect Streams', async (t) => {
10
11
  t.plan(12)
@@ -124,3 +125,139 @@ test('intoAsyncIterator() utility should handle different data', async (t) => {
124
125
  equal(chunk, obj)
125
126
  }
126
127
  })
128
+
129
+ test('createPeekTransform', async (t) => {
130
+ class UpperCaseTransform extends Transform {
131
+ _transform (chunk, encoding, callback) {
132
+ callback(null, chunk.toString().toUpperCase())
133
+ }
134
+ }
135
+
136
+ await t.test('uppercase', async (t) => {
137
+ t.plan(2)
138
+
139
+ const transform = createPeekTransform(function (data) {
140
+ t.assert.strictEqual(data.toString(), 'hello\nworl')
141
+ return new UpperCaseTransform()
142
+ })
143
+
144
+ let data = ''
145
+ transform.on('data', (chunk) => { data += chunk.toString() })
146
+ transform.write('hello\n')
147
+ transform.write('world\n')
148
+ transform.end()
149
+
150
+ await finished(transform)
151
+
152
+ t.assert.strictEqual(data, 'HELLO\nWORLD\n')
153
+ })
154
+
155
+ await t.test('uppercase no newline', async (t) => {
156
+ t.plan(2)
157
+
158
+ const transform = createPeekTransform(function (data) {
159
+ t.assert.strictEqual(data.toString(), 'helloworld')
160
+ return new UpperCaseTransform()
161
+ })
162
+
163
+ let data = ''
164
+ transform.on('data', (chunk) => { data += chunk.toString() })
165
+ transform.write('hello')
166
+ transform.write('world')
167
+ transform.end()
168
+
169
+ await finished(transform)
170
+
171
+ t.assert.strictEqual(data, 'HELLOWORLD')
172
+ })
173
+
174
+ await t.test('error', async (t) => {
175
+ t.plan(2)
176
+
177
+ const transform = createPeekTransform(function (data) {
178
+ throw new Error('boom')
179
+ })
180
+
181
+ transform.on('data', (chunk) => { t.assert.fail('should not reach') })
182
+ transform.on('error', (error) => { t.assert.ok(error) })
183
+ transform.write('hello')
184
+ transform.write('world')
185
+ transform.end()
186
+
187
+ await t.assert.rejects(finished(transform), new Error('boom'))
188
+ })
189
+
190
+ await t.test('peekSize = 5', async (t) => {
191
+ t.plan(2)
192
+
193
+ const transform = createPeekTransform(function (data) {
194
+ t.assert.strictEqual(data.toString(), 'hello')
195
+ return new UpperCaseTransform()
196
+ }, 5)
197
+
198
+ let data = ''
199
+ transform.on('data', (chunk) => { data += chunk.toString() })
200
+ transform.write('hello\n')
201
+ transform.write('world\n')
202
+ transform.end()
203
+
204
+ await finished(transform)
205
+
206
+ t.assert.strictEqual(data, 'HELLO\nWORLD\n')
207
+ })
208
+
209
+ await t.test('data size < peekSize', async (t) => {
210
+ t.plan(2)
211
+
212
+ const transform = createPeekTransform(function (data) {
213
+ t.assert.strictEqual(data.toString(), 'helloworld')
214
+ return new UpperCaseTransform()
215
+ }, 100)
216
+
217
+ let data = ''
218
+ transform.on('data', (chunk) => { data += chunk.toString() })
219
+ transform.write('hello')
220
+ transform.write('world')
221
+ transform.end()
222
+
223
+ await finished(transform)
224
+
225
+ t.assert.strictEqual(data, 'HELLOWORLD')
226
+ })
227
+
228
+ await t.test('error when data size < peekSize', async (t) => {
229
+ t.plan(2)
230
+
231
+ const transform = createPeekTransform(function (data) {
232
+ throw new Error('boom')
233
+ }, 100)
234
+
235
+ transform.on('data', (chunk) => { t.assert.fail('should not reach') })
236
+ transform.on('error', (error) => { t.assert.ok(error) })
237
+ transform.write('hello')
238
+ transform.write('world')
239
+ transform.end()
240
+
241
+ await t.assert.rejects(finished(transform), new Error('boom'))
242
+ })
243
+
244
+ await t.test('error inside downstream', async (t) => {
245
+ t.plan(2)
246
+
247
+ const transform = createPeekTransform(function (data) {
248
+ return new Transform({
249
+ transform (chunk, encoding, callback) {
250
+ callback(new Error('boom'))
251
+ }
252
+ })
253
+ })
254
+
255
+ transform.on('data', (chunk) => { t.assert.fail('should not reach') })
256
+ transform.on('error', (error) => { t.assert.ok(error) })
257
+ transform.write('hello')
258
+ transform.write('world')
259
+ transform.end()
260
+
261
+ await t.assert.rejects(finished(transform), new Error('boom'))
262
+ })
263
+ })