@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 +2 -6
- package/.github/dependabot.yml +43 -2
- package/.github/workflows/ci.yml +1 -1
- package/.github/workflows/lock-threads.yml +1 -1
- package/README.md +2 -3
- package/index.js +30 -14
- package/lib/utils.js +99 -1
- package/package.json +6 -7
- package/test/global-compress.test.js +149 -0
- package/test/regression/issue-382.test.js +3 -3
- package/test/regression/issue-410.test.js +163 -0
- package/test/routes-compress.test.js +17 -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,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: "
|
|
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/*"
|
|
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"
|
package/.github/workflows/ci.yml
CHANGED
|
@@ -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
|
package/README.md
CHANGED
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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
566
|
+
return createPeekTransform(function (data) {
|
|
551
567
|
switch (isCompressed(data)) {
|
|
552
|
-
case 1: return
|
|
553
|
-
case 2: return
|
|
568
|
+
case 1: return new Minipass()
|
|
569
|
+
case 2: return new Minipass()
|
|
554
570
|
}
|
|
555
|
-
return
|
|
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
|
|
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)
|
|
580
|
+
if (maxRecursion < 0) throw new Error('Maximum recursion reached')
|
|
565
581
|
switch (isCompressed(data)) {
|
|
566
|
-
case 1: return
|
|
567
|
-
case 2: return
|
|
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
|
|
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
|
-
|
|
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.
|
|
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": "^
|
|
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": "^
|
|
18
|
-
"adm-zip": "^0.
|
|
19
|
-
"c8": "^
|
|
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 (
|
|
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()
|
|
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
|
+
})
|
|
@@ -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()
|
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
|
+
})
|