@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 +2 -6
- package/.github/dependabot.yml +42 -2
- package/README.md +2 -3
- package/index.js +27 -12
- package/lib/utils.js +90 -1
- package/package.json +4 -5
- package/test/global-compress.test.js +119 -0
- package/test/regression/issue-382.test.js +3 -3
- package/test/regression/issue-410.test.js +163 -0
- package/test/utils.test.js +138 -1
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
|
+
}
|
package/.github/dependabot.yml
CHANGED
|
@@ -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: "
|
|
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: "
|
|
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
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 {
|
|
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
|
-
|
|
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
|
|
565
|
+
return createPeekTransform(function (data) {
|
|
551
566
|
switch (isCompressed(data)) {
|
|
552
|
-
case 1: return
|
|
553
|
-
case 2: return
|
|
567
|
+
case 1: return new Minipass()
|
|
568
|
+
case 2: return new Minipass()
|
|
554
569
|
}
|
|
555
|
-
return
|
|
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
|
|
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)
|
|
579
|
+
if (maxRecursion < 0) throw new Error('Maximum recursion reached')
|
|
565
580
|
switch (isCompressed(data)) {
|
|
566
|
-
case 1: return
|
|
567
|
-
case 2: return
|
|
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
|
|
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
|
-
|
|
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.
|
|
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": "^
|
|
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": "^
|
|
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 (
|
|
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()
|
|
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()
|
|
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 (
|
|
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
|
+
})
|
package/test/utils.test.js
CHANGED
|
@@ -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
|
+
})
|