@fastify/compress 9.0.0 → 9.1.0

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,52 @@ 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/*"
13
37
  open-pull-requests-limit: 10
38
+ groups:
39
+ # Production dependencies with breaking changes
40
+ dependencies:
41
+ dependency-type: "production"
42
+ # ESLint related dependencies
43
+ dev-dependencies-eslint:
44
+ patterns:
45
+ - "eslint"
46
+ - "neostandard"
47
+ - "@stylistic/*"
48
+ # TypeScript related dependencies
49
+ dev-dependencies-typescript:
50
+ patterns:
51
+ - "@types/*"
52
+ - "tstyche"
53
+ - "typescript"
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,19 @@ 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
+ createPeekTransform
22
+ } = require('./lib/utils')
15
23
 
16
24
  const InvalidRequestEncodingError = createError('FST_CP_ERR_INVALID_CONTENT_ENCODING', 'Unsupported Content-Encoding: %s', 415)
17
25
  const InvalidRequestCompressedPayloadError = createError('FST_CP_ERR_INVALID_CONTENT', 'Could not decompress the request payload using the provided encoding', 400)
@@ -258,6 +266,9 @@ function buildRouteCompress (_fastify, params, routeOptions, decorateOnly) {
258
266
  const noCompress =
259
267
  // don't compress on x-no-compression header
260
268
  (req.headers['x-no-compression'] !== undefined) ||
269
+ // don't compress partial content: Content-Range describes unencoded byte offsets
270
+ (reply.statusCode === 206) ||
271
+ (reply.getHeader('Content-Range') !== undefined) ||
261
272
  // don't compress if not one of the indicated compressible types
262
273
  (shouldCompress(reply.getHeader('Content-Type') || 'application/json', params.compressibleTypes) === false) ||
263
274
  // don't compress on missing or identity `accept-encoding` header
@@ -459,8 +470,12 @@ function onEnd (err) {
459
470
  // Client disconnection during streaming is expected and handled by Fastify.
460
471
  // Do not log "premature close" errors at error level since they are not
461
472
  // actual errors - they occur when clients disconnect mid-response.
473
+ // Node's native stream.pipeline emits ERR_STREAM_PREMATURE_CLOSE with the
474
+ // message 'Premature close', while the legacy pump/end-of-stream packages
475
+ // emit 'premature close' without a code, so both variants are checked.
462
476
  // See: https://github.com/fastify/fastify-compress/issues/382
463
- if (err && err.message !== 'premature close') {
477
+ // See: https://github.com/fastify/fastify-compress/issues/410
478
+ if (err && err.code !== 'ERR_STREAM_PREMATURE_CLOSE' && err.message !== 'premature close') {
464
479
  this.log.error(err)
465
480
  }
466
481
  }
@@ -547,26 +562,26 @@ function maybeUnzip (payload, serialize) {
547
562
  }
548
563
 
549
564
  function zipStream (deflate, encoding) {
550
- return peek({ newline: false, maxBuffer: 10 }, function (data, swap) {
565
+ return createPeekTransform(function (data) {
551
566
  switch (isCompressed(data)) {
552
- case 1: return swap(null, new Minipass())
553
- case 2: return swap(null, new Minipass())
567
+ case 1: return new Minipass()
568
+ case 2: return new Minipass()
554
569
  }
555
- return swap(null, deflate[encoding]())
570
+ return deflate[encoding]()
556
571
  })
557
572
  }
558
573
 
559
574
  function unzipStream (inflate, maxRecursion) {
560
575
  if (!(maxRecursion >= 0)) maxRecursion = 3
561
- return peek({ newline: false, maxBuffer: 10 }, function (data, swap) {
576
+ return createPeekTransform(function (data) {
562
577
  // This path is never taken, when `maxRecursion` < 0 it is automatically set back to 3
563
578
  /* c8 ignore next */
564
- if (maxRecursion < 0) return swap(new Error('Maximum recursion reached'))
579
+ if (maxRecursion < 0) throw new Error('Maximum recursion reached')
565
580
  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)))
581
+ case 1: return compose(inflate.gzip(), unzipStream(inflate, maxRecursion - 1))
582
+ case 2: return compose(inflate.deflate(), unzipStream(inflate, maxRecursion - 1))
568
583
  }
569
- return swap(null, new Minipass())
584
+ return new Minipass()
570
585
  })
571
586
  }
572
587
 
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) {
@@ -104,4 +105,92 @@ async function * intoAsyncIterator (payload) {
104
105
  yield payload
105
106
  }
106
107
 
107
- module.exports = { isZstd, isGzip, isDeflate, isStream, intoAsyncIterator, isWebReadableStream, isFetchResponse, webStreamToNodeReadable }
108
+ function createPeekTransform (swapStream, peekSize = 10) {
109
+ const buffers = []
110
+ let bufferSize = 0
111
+ let downstream = null
112
+ let flushCallback = null
113
+
114
+ const _transform = new Transform({
115
+ transform (chunk, encoding, callback) {
116
+ // when downstream already selected, directly passthrough
117
+ if (downstream !== null) return downstream.write(chunk, encoding, callback)
118
+
119
+ bufferSize += chunk.length
120
+ buffers.push(chunk)
121
+
122
+ // read more data when buffer size smaller than peeksize
123
+ if (bufferSize < peekSize) return callback()
124
+
125
+ wireDownStream(callback)
126
+ },
127
+ flush (callback) {
128
+ // used in "end" event to notify the transform finished flush
129
+ flushCallback = callback
130
+ if (downstream !== null) {
131
+ // all data should be flushed to downstream already
132
+ // we end the stream
133
+ downstream.end()
134
+ } else {
135
+ // flush internal buffers and end the downstream
136
+ wireDownStream((error) => {
137
+ if (error) {
138
+ flushCallback = null
139
+ callback(error)
140
+ } else {
141
+ downstream.end()
142
+ }
143
+ })
144
+ }
145
+ }
146
+ })
147
+
148
+ function onData (chunk) {
149
+ _transform.push(chunk)
150
+ }
151
+
152
+ function onError (error) {
153
+ _transform.destroy(error)
154
+ }
155
+
156
+ function onEnd () {
157
+ flushCallback && flushCallback()
158
+ // cleanup
159
+ downstream.off('data', onData)
160
+ downstream.off('error', onError)
161
+ downstream.off('end', onEnd)
162
+ downstream = null
163
+ flushCallback = null
164
+
165
+ _transform.push(null)
166
+ }
167
+
168
+ function wireDownStream (callback) {
169
+ const data = Buffer.concat(buffers)
170
+ try {
171
+ downstream = swapStream(data.subarray(0, peekSize))
172
+ downstream.on('data', onData)
173
+ downstream.on('error', onError)
174
+ downstream.on('end', onEnd)
175
+ downstream.write(data, callback)
176
+ // cleanup
177
+ buffers.length = 0
178
+ } catch (error) {
179
+ callback(error)
180
+ }
181
+ }
182
+
183
+ return _transform
184
+ }
185
+
186
+ module.exports = {
187
+ isZstd,
188
+ isGzip,
189
+ isDeflate,
190
+ isStream,
191
+ intoAsyncIterator,
192
+ isWebReadableStream,
193
+ isFetchResponse,
194
+ webStreamToNodeReadable,
195
+ createPeekTransform
196
+ }
package/package.json CHANGED
@@ -1,20 +1,19 @@
1
1
  {
2
2
  "name": "@fastify/compress",
3
- "version": "9.0.0",
3
+ "version": "9.1.0",
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",
16
+ "@types/node": "^26.0.1",
18
17
  "adm-zip": "^0.5.12",
19
18
  "c8": "^11.0.0",
20
19
  "eslint": "^9.17.0",
@@ -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",
@@ -682,6 +682,41 @@ describe('When a custom `zlib` option is provided, it should compress data :`',
682
682
  t.assert.equal(response.headers['content-encoding'], 'gzip')
683
683
  t.assert.equal(payload.toString('utf-8'), file)
684
684
  })
685
+
686
+ test('using the custom `createZstdCompress()` method', async (t) => {
687
+ if (typeof zlib.createZstdCompress !== 'function') {
688
+ t.skip('zstd not supported in this Node.js version')
689
+ return
690
+ }
691
+ t.plan(4)
692
+
693
+ let usedCustom = false
694
+ const customZlib = { createZstdCompress: () => (usedCustom = true) && zlib.createZstdCompress() }
695
+
696
+ const fastify = Fastify()
697
+ await fastify.register(compressPlugin, { global: true, zlib: customZlib })
698
+
699
+ fastify.get('/', (_request, reply) => {
700
+ reply
701
+ .type('text/plain')
702
+ .compress(createReadStream('./package.json'))
703
+ })
704
+
705
+ const response = await fastify.inject({
706
+ url: '/',
707
+ method: 'GET',
708
+ headers: {
709
+ 'accept-encoding': 'zstd'
710
+ }
711
+ })
712
+ t.assert.equal(usedCustom, true)
713
+
714
+ const file = readFileSync('./package.json', 'utf8')
715
+ const payload = zlib.zstdDecompressSync(response.rawPayload)
716
+ t.assert.equal(response.headers.vary, 'accept-encoding')
717
+ t.assert.equal(response.headers['content-encoding'], 'zstd')
718
+ t.assert.equal(payload.toString('utf-8'), file)
719
+ })
685
720
  })
686
721
 
687
722
  describe('When a malformed custom `zlib` option is provided, it should compress data :', async () => {
@@ -765,6 +800,37 @@ describe('When a malformed custom `zlib` option is provided, it should compress
765
800
  const payload = zlib.gunzipSync(response.rawPayload)
766
801
  t.assert.equal(payload.toString('utf-8'), 'hello')
767
802
  })
803
+
804
+ test('using the fallback default Node.js core `zlib.createZstdCompress()` method', async (t) => {
805
+ if (typeof zlib.createZstdCompress !== 'function') {
806
+ t.skip('zstd not supported in this Node.js version')
807
+ return
808
+ }
809
+ t.plan(1)
810
+
811
+ const fastify = Fastify()
812
+ await fastify.register(compressPlugin, {
813
+ global: true,
814
+ threshold: 0,
815
+ zlib: true // will trigger a fallback on the default zlib.createZstdCompress
816
+ })
817
+
818
+ fastify.get('/', (_request, reply) => {
819
+ reply
820
+ .type('text/plain')
821
+ .compress('hello')
822
+ })
823
+
824
+ const response = await fastify.inject({
825
+ url: '/',
826
+ method: 'GET',
827
+ headers: {
828
+ 'accept-encoding': 'zstd'
829
+ }
830
+ })
831
+ const payload = zlib.zstdDecompressSync(response.rawPayload)
832
+ t.assert.equal(payload.toString('utf-8'), 'hello')
833
+ })
768
834
  })
769
835
 
770
836
  describe('When `inflateIfDeflated` is `true` and `X-No-Compression` request header is `true` :', async () => {
@@ -3548,3 +3614,56 @@ test('It should demonstrate globalDecompression controls decompression independe
3548
3614
  t.assert.equal(response.statusCode, 400)
3549
3615
  t.assert.ok(response.body.includes('Content-Length') || response.body.includes('Bad Request'))
3550
3616
  })
3617
+
3618
+ describe('Range Responses', () => {
3619
+ test('it should not compress 206 Partial Content responses', async (t) => {
3620
+ t.plan(4)
3621
+ const fastify = Fastify()
3622
+ await fastify.register(compressPlugin, { threshold: 0 })
3623
+
3624
+ fastify.get('/partial', (request, reply) => {
3625
+ reply
3626
+ .status(206)
3627
+ .header('Content-Type', 'text/plain')
3628
+ .header('Content-Range', 'bytes 0-4/12')
3629
+ .header('Content-Length', '5')
3630
+ .send('hello')
3631
+ })
3632
+
3633
+ const response = await fastify.inject({
3634
+ url: '/partial',
3635
+ method: 'GET',
3636
+ headers: { 'accept-encoding': 'gzip' }
3637
+ })
3638
+
3639
+ t.assert.equal(response.statusCode, 206)
3640
+ t.assert.equal(response.headers['content-encoding'], undefined)
3641
+ t.assert.equal(response.headers['content-range'], 'bytes 0-4/12')
3642
+ t.assert.equal(response.body, 'hello')
3643
+ })
3644
+
3645
+ test('it should not compress responses carrying a Content-Range header', async (t) => {
3646
+ t.plan(4)
3647
+ const fastify = Fastify()
3648
+ await fastify.register(compressPlugin, { threshold: 0 })
3649
+
3650
+ fastify.get('/content-range', (request, reply) => {
3651
+ reply
3652
+ .header('Content-Type', 'text/plain')
3653
+ .header('Content-Range', 'bytes 0-4/12')
3654
+ .header('Content-Length', '5')
3655
+ .send('hello')
3656
+ })
3657
+
3658
+ const response = await fastify.inject({
3659
+ url: '/content-range',
3660
+ method: 'GET',
3661
+ headers: { 'accept-encoding': 'gzip' }
3662
+ })
3663
+
3664
+ t.assert.equal(response.statusCode, 200)
3665
+ t.assert.equal(response.headers['content-encoding'], undefined)
3666
+ t.assert.equal(response.headers['content-range'], 'bytes 0-4/12')
3667
+ t.assert.equal(response.body, 'hello')
3668
+ })
3669
+ })
@@ -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
+ })
@@ -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
+ })