@fastify/compress 8.1.0 → 8.2.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.
@@ -14,6 +14,11 @@ on:
14
14
  - 'docs/**'
15
15
  - '*.md'
16
16
 
17
+ # This allows a subsequently queued workflow run to interrupt previous runs
18
+ concurrency:
19
+ group: "${{ github.workflow }}-${{ github.event.pull_request.head.label || github.head_ref || github.ref }}"
20
+ cancel-in-progress: true
21
+
17
22
  permissions:
18
23
  contents: read
19
24
 
package/README.md CHANGED
@@ -106,6 +106,40 @@ app.get('/', (req, reply) => {
106
106
  await app.listen({ port: 3000 })
107
107
  ```
108
108
 
109
+ It's also possible to pass a Fetch API `Response` object or a Web `ReadableStream`. The plugin will automatically extract the body stream from the `Response` or convert the Web stream to a Node.js `Readable` behind the scenes.
110
+
111
+ ```js
112
+ import fastify from 'fastify'
113
+
114
+ const app = fastify()
115
+ await app.register(import('@fastify/compress'), { global: true })
116
+
117
+ app.get('/', async (req, reply) => {
118
+ const resp = new Response('Hello from Fetch Response')
119
+ reply.compress(resp)
120
+ })
121
+ ```
122
+
123
+ ```js
124
+ app.get('/', async (req, reply) => {
125
+ return new Response('Hello from Fetch Response')
126
+ })
127
+ ```
128
+
129
+ ```js
130
+ app.get('/', (req, reply) => {
131
+ const stream = new ReadableStream({
132
+ start (controller) {
133
+ controller.enqueue(new TextEncoder().encode('Hello from Web ReadableStream'))
134
+ controller.close()
135
+ }
136
+ })
137
+
138
+ reply.header('content-type', 'text/plain')
139
+ reply.compress(stream)
140
+ })
141
+ ```
142
+
109
143
  ## Compress Options
110
144
 
111
145
  ### threshold
@@ -222,15 +256,24 @@ This plugin adds a `preParsing` hook to decompress the request payload based on
222
256
 
223
257
  Currently, the following encoding tokens are supported:
224
258
 
225
- 1. `zstd` (Node.js 22.15+/23.8+)
226
- 2. `br`
227
- 3. `gzip`
228
- 4. `deflate`
259
+ - `zstd` (Node.js 22.15+/23.8+)
260
+ - `br`
261
+ - `gzip`
262
+ - `deflate`
229
263
 
230
264
  If an unsupported encoding or invalid payload is received, the plugin throws an error.
231
265
 
232
266
  If the request header is missing, the plugin yields to the next hook.
233
267
 
268
+ ### Supported payload types
269
+
270
+ The plugin supports compressing the following payload types:
271
+
272
+ - Strings and Buffers
273
+ - Node.js streams
274
+ - Response objects (from the Fetch API)
275
+ - ReadableStream objects (from the Web Streams API)
276
+
234
277
  ### Global hook
235
278
 
236
279
  The global request decompression hook is enabled by default. To disable it, pass `{ global: false }`:
package/index.js CHANGED
@@ -12,7 +12,7 @@ const { Minipass } = require('minipass')
12
12
  const pumpify = require('pumpify')
13
13
  const { Readable } = require('readable-stream')
14
14
 
15
- const { isStream, isGzip, isDeflate, intoAsyncIterator } = require('./lib/utils')
15
+ const { isStream, isGzip, isDeflate, intoAsyncIterator, isWebReadableStream, isFetchResponse, webStreamToNodeReadable } = require('./lib/utils')
16
16
 
17
17
  const InvalidRequestEncodingError = createError('FST_CP_ERR_INVALID_CONTENT_ENCODING', 'Unsupported Content-Encoding: %s', 415)
18
18
  const InvalidRequestCompressedPayloadError = createError('FST_CP_ERR_INVALID_CONTENT', 'Could not decompress the request payload using the provided encoding', 400)
@@ -254,6 +254,7 @@ function buildRouteCompress (_fastify, params, routeOptions, decorateOnly) {
254
254
  if (payload == null) {
255
255
  return next()
256
256
  }
257
+
257
258
  const responseEncoding = reply.getHeader('Content-Encoding')
258
259
  if (responseEncoding && responseEncoding !== 'identity') {
259
260
  // response is already compressed
@@ -290,10 +291,18 @@ function buildRouteCompress (_fastify, params, routeOptions, decorateOnly) {
290
291
  }
291
292
 
292
293
  if (typeof payload.pipe !== 'function') {
293
- if (Buffer.byteLength(payload) < params.threshold) {
294
- return next()
294
+ if (isFetchResponse(payload)) {
295
+ payload = payload.body
296
+ }
297
+
298
+ if (isWebReadableStream(payload)) {
299
+ payload = webStreamToNodeReadable(payload)
300
+ } else {
301
+ if (Buffer.byteLength(payload) < params.threshold) {
302
+ return next()
303
+ }
304
+ payload = Readable.from(intoAsyncIterator(payload))
295
305
  }
296
- payload = Readable.from(intoAsyncIterator(payload))
297
306
  }
298
307
 
299
308
  setVaryHeader(reply)
@@ -408,7 +417,13 @@ function compress (params) {
408
417
  }
409
418
 
410
419
  if (typeof payload.pipe !== 'function') {
411
- if (!Buffer.isBuffer(payload) && typeof payload !== 'string') {
420
+ if (isFetchResponse(payload)) {
421
+ payload = payload.body
422
+ }
423
+
424
+ if (isWebReadableStream(payload)) {
425
+ payload = webStreamToNodeReadable(payload)
426
+ } else if (!Buffer.isBuffer(payload) && typeof payload !== 'string') {
412
427
  payload = this.serialize(payload)
413
428
  }
414
429
  }
package/lib/utils.js CHANGED
@@ -1,5 +1,7 @@
1
1
  'use strict'
2
2
 
3
+ const { Readable: NodeReadable } = require('node:stream')
4
+
3
5
  // https://datatracker.ietf.org/doc/html/rfc8878#section-3.1.1
4
6
  function isZstd (buffer) {
5
7
  return (
@@ -49,6 +51,18 @@ function isStream (stream) {
49
51
  return stream !== null && typeof stream === 'object' && typeof stream.pipe === 'function'
50
52
  }
51
53
 
54
+ function isWebReadableStream (obj) {
55
+ return obj instanceof ReadableStream
56
+ }
57
+
58
+ function isFetchResponse (obj) {
59
+ return obj instanceof Response
60
+ }
61
+
62
+ function webStreamToNodeReadable (webStream) {
63
+ return NodeReadable.fromWeb(webStream)
64
+ }
65
+
52
66
  /**
53
67
  * Provide a async iteratable for Readable.from
54
68
  */
@@ -90,4 +104,4 @@ async function * intoAsyncIterator (payload) {
90
104
  yield payload
91
105
  }
92
106
 
93
- module.exports = { isZstd, isGzip, isDeflate, isStream, intoAsyncIterator }
107
+ module.exports = { isZstd, isGzip, isDeflate, isStream, intoAsyncIterator, isWebReadableStream, isFetchResponse, webStreamToNodeReadable }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fastify/compress",
3
- "version": "8.1.0",
3
+ "version": "8.2.0",
4
4
  "description": "Fastify compression utils",
5
5
  "main": "index.js",
6
6
  "type": "commonjs",
@@ -16,16 +16,15 @@
16
16
  "readable-stream": "^4.5.2"
17
17
  },
18
18
  "devDependencies": {
19
- "@fastify/pre-commit": "^2.1.0",
20
- "@types/node": "^22.0.0",
19
+ "@types/node": "^24.0.8",
21
20
  "adm-zip": "^0.5.12",
22
21
  "c8": "^10.1.2",
23
22
  "eslint": "^9.17.0",
24
23
  "fastify": "^5.0.0",
25
24
  "jsonstream": "^1.0.3",
26
25
  "neostandard": "^0.12.0",
27
- "tsd": "^0.32.0",
28
- "typescript": "~5.8.3"
26
+ "tsd": "^0.33.0",
27
+ "typescript": "~5.9.2"
29
28
  },
30
29
  "scripts": {
31
30
  "lint": "eslint",
@@ -84,7 +83,7 @@
84
83
  "url": "git+https://github.com/fastify/fastify-compress.git"
85
84
  },
86
85
  "tsd": {
87
- "directory": "test/types"
86
+ "directory": "types"
88
87
  },
89
88
  "publishConfig": {
90
89
  "access": "public"
@@ -9,6 +9,7 @@ const JSONStream = require('jsonstream')
9
9
  const Fastify = require('fastify')
10
10
  const compressPlugin = require('../index')
11
11
  const { once } = require('node:events')
12
+ const { ReadableStream: WebReadableStream, Response } = globalThis
12
13
 
13
14
  describe('When `global` is not set, it is `true` by default :', async () => {
14
15
  test('it should compress Buffer data using brotli when `Accept-Encoding` request header is `br`', async (t) => {
@@ -265,6 +266,54 @@ describe('When `global` is not set, it is `true` by default :', async () => {
265
266
  const payload = zlib.gunzipSync(response.rawPayload)
266
267
  t.assert.equal(payload.toString('utf-8'), 'hello')
267
268
  })
269
+
270
+ test('it should compress a Fetch API Response body', async (t) => {
271
+ t.plan(1)
272
+
273
+ const fastify = Fastify()
274
+ await fastify.register(compressPlugin, { threshold: 0 })
275
+
276
+ const body = 'hello from fetch response'
277
+ fastify.get('/fetch-resp', (_request, reply) => {
278
+ const resp = new Response(body, { headers: { 'content-type': 'text/plain' } })
279
+ reply.send(resp)
280
+ })
281
+
282
+ const response = await fastify.inject({
283
+ url: '/fetch-resp',
284
+ method: 'GET',
285
+ headers: { 'accept-encoding': 'gzip' }
286
+ })
287
+ const payload = zlib.gunzipSync(response.rawPayload)
288
+ t.assert.equal(payload.toString('utf-8'), body)
289
+ })
290
+
291
+ test('it should compress a Web ReadableStream body', async (t) => {
292
+ t.plan(1)
293
+
294
+ const fastify = Fastify()
295
+ await fastify.register(compressPlugin, { threshold: 0 })
296
+
297
+ const body = 'hello from web stream'
298
+ fastify.get('/web-stream', (_request, reply) => {
299
+ const stream = new WebReadableStream({
300
+ start (controller) {
301
+ controller.enqueue(Buffer.from(body))
302
+ controller.close()
303
+ }
304
+ })
305
+ reply.header('content-type', 'text/plain')
306
+ reply.send(stream)
307
+ })
308
+
309
+ const response = await fastify.inject({
310
+ url: '/web-stream',
311
+ method: 'GET',
312
+ headers: { 'accept-encoding': 'gzip' }
313
+ })
314
+ const payload = zlib.gunzipSync(response.rawPayload)
315
+ t.assert.equal(payload.toString('utf-8'), body)
316
+ })
268
317
  })
269
318
 
270
319
  describe('It should send compressed Stream data when `global` is `true` :', async () => {
@@ -440,3 +440,33 @@ test('It should avoid to trigger `onSend` hook twice', async (t) => {
440
440
  })
441
441
  t.assert.deepEqual(JSON.parse(zlib.brotliDecompressSync(response.rawPayload)), { hi: true })
442
442
  })
443
+
444
+ test('reply.compress should handle Fetch Response', async (t) => {
445
+ t.plan(1)
446
+ const fastify = Fastify()
447
+ await fastify.register(compressPlugin, { global: true, threshold: 0 })
448
+ fastify.get('/', (_req, reply) => {
449
+ const r = new Response('from reply.compress', { headers: { 'content-type': 'text/plain' } })
450
+ reply.compress(r)
451
+ })
452
+ const res = await fastify.inject({ url: '/', method: 'GET', headers: { 'accept-encoding': 'gzip' } })
453
+ t.assert.equal(zlib.gunzipSync(res.rawPayload).toString('utf8'), 'from reply.compress')
454
+ })
455
+
456
+ test('reply.compress should handle Web ReadableStream', async (t) => {
457
+ t.plan(1)
458
+ const fastify = Fastify()
459
+ await fastify.register(compressPlugin, { global: true, threshold: 0 })
460
+ fastify.get('/', (_req, reply) => {
461
+ const stream = new ReadableStream({
462
+ start (controller) {
463
+ controller.enqueue(Buffer.from('from webstream'))
464
+ controller.close()
465
+ }
466
+ })
467
+ reply.header('content-type', 'text/plain')
468
+ reply.compress(stream)
469
+ })
470
+ const res = await fastify.inject({ url: '/', method: 'GET', headers: { 'accept-encoding': 'gzip' } })
471
+ t.assert.equal(zlib.gunzipSync(res.rawPayload).toString('utf8'), 'from webstream')
472
+ })
package/types/index.d.ts CHANGED
@@ -26,7 +26,7 @@ declare module 'fastify' {
26
26
  }
27
27
 
28
28
  interface FastifyReply {
29
- compress(input: Stream | Input): void;
29
+ compress(input: Stream | Input | Response | ReadableStream): void;
30
30
  }
31
31
 
32
32
  export interface RouteOptions {
@@ -132,3 +132,18 @@ expectError(appThatTriggerAnError.register(fastifyCompress, {
132
132
  global: true,
133
133
  thisOptionDoesNotExist: 'trigger a typescript error'
134
134
  }))
135
+
136
+ app.get('/ts-fetch-response', async (_request, reply) => {
137
+ const resp = new Response('ok', { headers: { 'content-type': 'text/plain' } })
138
+ expectType<void>(reply.compress(resp))
139
+ })
140
+
141
+ app.get('/ts-web-readable-stream', async (_request, reply) => {
142
+ const stream = new ReadableStream({
143
+ start (controller) {
144
+ controller.enqueue(new Uint8Array([1, 2, 3]))
145
+ controller.close()
146
+ }
147
+ })
148
+ expectType<void>(reply.compress(stream))
149
+ })
@@ -1,8 +0,0 @@
1
- {
2
- "permissions": {
3
- "allow": [
4
- "mcp__ripgrep__search"
5
- ],
6
- "deny": []
7
- }
8
- }
package/CLAUDE.md DELETED
@@ -1,64 +0,0 @@
1
- # CLAUDE.md
2
-
3
- This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
-
5
- ## Development Commands
6
-
7
- ### Testing
8
- - `npm run test` - Run both unit and TypeScript tests
9
- - `npm run test:unit` - Run unit tests with Node.js test runner
10
- - `npm run test:coverage` - Run tests with coverage reporting
11
- - `npm run lint` - Run ESLint for code style checking
12
- - `npm run lint:fix` - Auto-fix linting issues
13
-
14
- ### Running Single Tests
15
- Use Node.js test runner with file path:
16
- ```bash
17
- node --test test/global-compress.test.js
18
- ```
19
-
20
- ## Architecture Overview
21
-
22
- This is a Fastify plugin (`@fastify/compress`) that provides compression and decompression capabilities for HTTP requests and responses.
23
-
24
- ### Core Components
25
-
26
- **Main Plugin (`index.js`)**: 574-line plugin implementation using Fastify's hook system:
27
- - `onSend` hook for response compression
28
- - `preParsing` hook for request decompression
29
- - `onRoute` hook for route-level configuration
30
-
31
- **Utilities (`lib/utils.js`)**: Core compression detection and stream handling:
32
- - `isZstd()` / `isGzip()` / `isDeflate()` - Magic byte detection (RFC 8878/1952/1950)
33
- - `isStream()` - Stream type checking
34
- - `intoAsyncIterator()` - Payload conversion utilities
35
-
36
- **TypeScript Support (`types/index.d.ts`)**: Full type definitions with Fastify module augmentation
37
-
38
- ### Compression System
39
-
40
- **Supported Encodings**: zstd (Node.js 22.15+/23.8+), brotli (`br`), gzip, deflate, identity
41
- **Priority Order**: zstd > br > gzip > deflate > * (defaults to gzip) > identity
42
- **Threshold-based**: Default 1024 bytes minimum for compression
43
- **Content-Type Aware**: Uses `mime-db` for compressible content detection
44
-
45
- ### Configuration Levels
46
- 1. **Global**: Applied to all routes via plugin options
47
- 2. **Route-level**: Override global settings in route config
48
- 3. **Runtime**: Manual compression via `reply.compress()`
49
-
50
- ### Key Dependencies
51
- - `@fastify/accept-negotiator` - Content encoding negotiation
52
- - `fastify-plugin` - Plugin registration
53
- - `mime-db` - MIME type database
54
- - `minipass`, `pump`, `pumpify` - Stream processing
55
-
56
- ### Testing Structure
57
- Uses Node.js built-in test runner with describe/test pattern. Test files are organized by functionality:
58
- - `global-compress.test.js` / `global-decompress.test.js` - Global functionality
59
- - `routes-compress.test.js` / `routes-decompress.test.js` - Route-level functionality
60
- - `utils.test.js` - Utility functions
61
- - `regression/` - Regression tests
62
-
63
- ### Code Style
64
- Uses `neostandard` (ESLint-based) with TypeScript support. Configuration in `eslint.config.js`.