@fastify/compress 8.3.1 → 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.
@@ -0,0 +1,193 @@
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
+ test('should not log "premature close" errors on client disconnect (global compression)', async (t) => {
11
+ let prematureCloseCount = 0
12
+
13
+ const fastify = Fastify({
14
+ logger: {
15
+ level: 'error',
16
+ stream: new Writable({
17
+ write (chunk, encoding, callback) {
18
+ if (/premature close/i.test(chunk.toString())) {
19
+ prematureCloseCount++
20
+ }
21
+ callback()
22
+ }
23
+ })
24
+ }
25
+ })
26
+
27
+ await fastify.register(fastifyCompress, {
28
+ global: true,
29
+ encodings: ['gzip'],
30
+ threshold: 1024
31
+ })
32
+
33
+ // Streaming endpoint that takes time to produce data
34
+ fastify.get('/stream', async (_request, reply) => {
35
+ let chunks = 0
36
+ const stream = new (require('node:stream').Readable)({
37
+ read () {
38
+ if (chunks >= 20) {
39
+ this.push(null)
40
+ return
41
+ }
42
+ setTimeout(() => {
43
+ this.push(JSON.stringify({ id: chunks, data: 'x'.repeat(1000) }) + '\n')
44
+ chunks++
45
+ }, 50)
46
+ }
47
+ })
48
+ reply.type('text/plain')
49
+ return stream
50
+ })
51
+
52
+ await fastify.listen({ port: 0 })
53
+ const port = fastify.server.address().port
54
+
55
+ // Simulate clients that disconnect before the full response is sent
56
+ for (let i = 0; i < 10; i++) {
57
+ await new Promise((_resolve) => {
58
+ const sock = net.connect(port, '127.0.0.1', () => {
59
+ sock.write('GET /stream HTTP/1.1\r\nHost: localhost\r\nAccept-Encoding: gzip\r\n\r\n')
60
+ // Disconnect after receiving partial response (100ms)
61
+ setTimeout(() => {
62
+ sock.destroy()
63
+ _resolve()
64
+ }, 100)
65
+ })
66
+ sock.on('error', () => _resolve())
67
+ })
68
+ }
69
+
70
+ // Allow pending callbacks to fire
71
+ await sleep(2000)
72
+ await fastify.close()
73
+
74
+ t.assert.equal(
75
+ prematureCloseCount,
76
+ 0,
77
+ `Expected no "premature close" errors, but got ${prematureCloseCount} errors from 10 client disconnects`
78
+ )
79
+ })
80
+
81
+ test('should not log "premature close" errors on client disconnect (reply.compress path)', async (t) => {
82
+ let prematureCloseCount = 0
83
+
84
+ const fastify = Fastify({
85
+ logger: {
86
+ level: 'error',
87
+ stream: new Writable({
88
+ write (chunk, encoding, callback) {
89
+ if (/premature close/i.test(chunk.toString())) {
90
+ prematureCloseCount++
91
+ }
92
+ callback()
93
+ }
94
+ })
95
+ }
96
+ })
97
+
98
+ await fastify.register(fastifyCompress, {
99
+ global: false,
100
+ encodings: ['gzip']
101
+ })
102
+
103
+ // Streaming endpoint using reply.compress
104
+ fastify.get('/stream', async (_request, reply) => {
105
+ let chunks = 0
106
+ const stream = new (require('node:stream').Readable)({
107
+ read () {
108
+ if (chunks >= 20) {
109
+ this.push(null)
110
+ return
111
+ }
112
+ setTimeout(() => {
113
+ this.push(JSON.stringify({ id: chunks, data: 'x'.repeat(1000) }) + '\n')
114
+ chunks++
115
+ }, 50)
116
+ }
117
+ })
118
+ reply.type('text/plain')
119
+ return reply.compress(stream)
120
+ })
121
+
122
+ await fastify.listen({ port: 0 })
123
+ const port = fastify.server.address().port
124
+
125
+ // Simulate clients that disconnect before the full response is sent
126
+ for (let i = 0; i < 10; i++) {
127
+ await new Promise((_resolve) => {
128
+ const sock = net.connect(port, '127.0.0.1', () => {
129
+ sock.write('GET /stream HTTP/1.1\r\nHost: localhost\r\nAccept-Encoding: gzip\r\n\r\n')
130
+ // Disconnect after receiving partial response (100ms)
131
+ setTimeout(() => {
132
+ sock.destroy()
133
+ _resolve()
134
+ }, 100)
135
+ })
136
+ sock.on('error', () => _resolve())
137
+ })
138
+ }
139
+
140
+ // Allow pending callbacks to fire
141
+ await sleep(2000)
142
+ await fastify.close()
143
+
144
+ t.assert.equal(
145
+ prematureCloseCount,
146
+ 0,
147
+ `Expected no "premature close" errors, but got ${prematureCloseCount} errors from 10 client disconnects`
148
+ )
149
+ })
150
+
151
+ test('should still log actual errors (not premature close)', async (t) => {
152
+ const errors = []
153
+
154
+ const fastify = Fastify({
155
+ logger: {
156
+ level: 'error',
157
+ stream: new Writable({
158
+ write (chunk, encoding, callback) {
159
+ const msg = chunk.toString()
160
+ if (/premature close/i.test(msg)) {
161
+ // Ignore premature close errors
162
+ } else {
163
+ errors.push(msg)
164
+ }
165
+ callback()
166
+ }
167
+ })
168
+ }
169
+ })
170
+
171
+ await fastify.register(fastifyCompress, {
172
+ global: true,
173
+ encodings: ['gzip']
174
+ })
175
+
176
+ // Route that throws an error
177
+ fastify.get('/error', async () => {
178
+ throw new Error('Test error')
179
+ })
180
+
181
+ await fastify.inject({
182
+ method: 'GET',
183
+ url: '/error',
184
+ headers: { 'accept-encoding': 'gzip' }
185
+ })
186
+
187
+ await sleep(500)
188
+
189
+ t.assert.ok(
190
+ errors.length > 0,
191
+ 'Expected actual errors to still be logged'
192
+ )
193
+ })
@@ -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
+ })
@@ -330,95 +330,6 @@ describe('When `compress.removeContentLengthHeader` is `false`, it should not re
330
330
  })
331
331
  })
332
332
 
333
- describe('When using the old routes `{ config: compress }` option :', async () => {
334
- test('it should compress data using the route custom provided `createGzip` method', async (t) => {
335
- t.plan(10)
336
- const equal = t.assert.equal
337
-
338
- let usedCustomGlobal = false
339
- let usedCustom = false
340
- const customZlibGlobal = { createGzip: () => (usedCustomGlobal = true) && zlib.createGzip() }
341
- const customZlib = { createGzip: () => (usedCustom = true) && zlib.createGzip() }
342
-
343
- const fastify = Fastify()
344
- await fastify.register(compressPlugin, { global: false, zlib: customZlibGlobal })
345
-
346
- fastify.get('/', (_request, reply) => {
347
- reply
348
- .type('text/plain')
349
- .compress(createReadStream('./package.json'))
350
- })
351
-
352
- fastify.get('/custom', {
353
- config: {
354
- compress: { zlib: customZlib }
355
- }
356
- }, (_request, reply) => {
357
- reply
358
- .type('text/plain')
359
- .compress(createReadStream('./package.json'))
360
- })
361
-
362
- await fastify.inject({
363
- url: '/',
364
- method: 'GET',
365
- headers: {
366
- 'accept-encoding': 'gzip'
367
- }
368
- }).then((response) => {
369
- equal(usedCustom, false)
370
- equal(usedCustomGlobal, true)
371
-
372
- const file = readFileSync('./package.json', 'utf8')
373
- const payload = zlib.gunzipSync(response.rawPayload)
374
- equal(response.headers.vary, 'accept-encoding')
375
- equal(response.headers['content-encoding'], 'gzip')
376
- equal(payload.toString('utf-8'), file)
377
-
378
- usedCustom = false
379
- usedCustomGlobal = false
380
- })
381
-
382
- const response = await fastify.inject({
383
- url: '/custom',
384
- method: 'GET',
385
- headers: {
386
- 'accept-encoding': 'gzip'
387
- }
388
- })
389
- equal(usedCustom, true)
390
- equal(usedCustomGlobal, false)
391
-
392
- const file = readFileSync('./package.json', 'utf8')
393
- const payload = zlib.gunzipSync(response.rawPayload)
394
- equal(response.headers.vary, 'accept-encoding')
395
- equal(response.headers['content-encoding'], 'gzip')
396
- equal(payload.toString('utf-8'), file)
397
- })
398
-
399
- test('it should use the old routes `{ config: compress }` options over routes `compress` options', async (t) => {
400
- t.plan(1)
401
-
402
- const fastify = Fastify()
403
- await fastify.register(compressPlugin, { global: false })
404
-
405
- try {
406
- fastify.get('/', {
407
- compress: {
408
- zlib: { createGzip: () => zlib.createGzip() }
409
- },
410
- config: {
411
- compress: 'bad config'
412
- }
413
- }, (_request, reply) => {
414
- reply.send('')
415
- })
416
- } catch (err) {
417
- t.assert.equal(err.message, 'Unknown value for route compress configuration')
418
- }
419
- })
420
- })
421
-
422
333
  test('It should avoid to trigger `onSend` hook twice', async (t) => {
423
334
  t.plan(1)
424
335
 
@@ -2,9 +2,9 @@
2
2
 
3
3
  const { test, describe } = require('node:test')
4
4
  const { createReadStream } = require('node:fs')
5
+ const { pipeline } = require('node:stream')
5
6
  const path = require('node:path')
6
7
  const zlib = require('node:zlib')
7
- const pump = require('pump')
8
8
  const Fastify = require('fastify')
9
9
  const compressPlugin = require('../index')
10
10
 
@@ -12,7 +12,9 @@ function createPayload (compressor) {
12
12
  let payload = createReadStream(path.resolve(__dirname, '../package.json'))
13
13
 
14
14
  if (compressor) {
15
- payload = pump(payload, compressor())
15
+ const compressed = compressor()
16
+ pipeline(payload, compressed, () => {})
17
+ payload = compressed
16
18
  }
17
19
 
18
20
  return payload
@@ -231,88 +233,3 @@ describe('When using routes `decompress` settings :', async () => {
231
233
  }
232
234
  })
233
235
  })
234
-
235
- describe('When using the old routes `{ config: decompress }` option :', async () => {
236
- test('it should decompress data using the route custom provided `createGunzip` method', async (t) => {
237
- t.plan(8)
238
- const equal = t.assert.equal
239
-
240
- let usedCustomGlobal = false
241
- let usedCustom = false
242
- const customZlibGlobal = { createGunzip: () => (usedCustomGlobal = true) && zlib.createGunzip() }
243
- const customZlib = { createGunzip: () => (usedCustom = true) && zlib.createGunzip() }
244
-
245
- const fastify = Fastify()
246
- await fastify.register(compressPlugin, { zlib: customZlibGlobal })
247
-
248
- fastify.post('/', (request, reply) => {
249
- reply.send(request.body.name)
250
- })
251
-
252
- fastify.post('/custom', {
253
- config: {
254
- decompress: {
255
- zlib: customZlib
256
- }
257
- }
258
- }, (request, reply) => {
259
- reply.send(request.body.name)
260
- })
261
-
262
- await fastify.inject({
263
- url: '/',
264
- method: 'POST',
265
- headers: {
266
- 'content-type': 'application/json',
267
- 'content-encoding': 'gzip'
268
- },
269
- payload: createPayload(zlib.createGzip)
270
- }).then((response) => {
271
- equal(usedCustom, false)
272
- equal(usedCustomGlobal, true)
273
-
274
- equal(response.statusCode, 200)
275
- equal(response.body, '@fastify/compress')
276
-
277
- usedCustom = false
278
- usedCustomGlobal = false
279
- })
280
-
281
- const response = await fastify.inject({
282
- url: '/custom',
283
- method: 'POST',
284
- headers: {
285
- 'content-type': 'application/json',
286
- 'content-encoding': 'gzip'
287
- },
288
- payload: createPayload(zlib.createGzip)
289
- })
290
- equal(usedCustom, true)
291
- equal(usedCustomGlobal, false)
292
-
293
- equal(response.statusCode, 200)
294
- equal(response.body, '@fastify/compress')
295
- })
296
-
297
- test('it should use the old routes `{ config: decompress }` options over routes `decompress` options', async (t) => {
298
- t.plan(1)
299
-
300
- const fastify = Fastify()
301
- await fastify.register(compressPlugin, { global: false })
302
-
303
- try {
304
- fastify.post('/', {
305
- decompress: {
306
- zlib: { createGunzip: () => zlib.createGunzip() }
307
- },
308
- config: {
309
- decompress: 'bad config'
310
- }
311
- }, (request, reply) => {
312
- reply.send(request.body.name)
313
- })
314
- } catch (err) {
315
- t.assert.equal(err.message, 'Unknown value for route decompress configuration')
316
- }
317
- })
318
- })
@@ -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
+ })