@fastify/compress 9.1.0 → 9.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.
@@ -34,6 +34,7 @@ updates:
34
34
  - dependency-name: "eslint"
35
35
  - dependency-name: "neostandard"
36
36
  - dependency-name: "@stylistic/*"
37
+ - dependency-name: "typescript"
37
38
  open-pull-requests-limit: 10
38
39
  groups:
39
40
  # Production dependencies with breaking changes
@@ -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@ef591e2186785d5ab36b9fe6a79c7ce2f1d94e57 # v7.0.0
31
31
  with:
32
32
  license-check: true
33
33
  lint: true
@@ -16,4 +16,4 @@ jobs:
16
16
  permissions:
17
17
  issues: write
18
18
  pull-requests: write
19
- uses: fastify/workflows/.github/workflows/lock-threads.yml@v6
19
+ uses: fastify/workflows/.github/workflows/lock-threads.yml@ef591e2186785d5ab36b9fe6a79c7ce2f1d94e57 # v7.0.0
package/README.md CHANGED
@@ -167,6 +167,84 @@ await fastify.register(
167
167
  { threshold: 2048 }
168
168
  )
169
169
  ```
170
+ ### syncThreshold
171
+ The maximum byte size for compressing a response synchronously. Defaults to a value derived
172
+ from `os.availableParallelism()`, between `4096` and `65536`.
173
+
174
+ Payloads that are already fully in memory (a `string` or a `Buffer`) and are not larger than
175
+ `syncThreshold` are compressed in one call instead of being pushed through a compression
176
+ stream. This is faster for small responses — a per-response zlib stream costs more in setup
177
+ and scheduling than the compression itself at these sizes — and it uses much less memory
178
+ while the response is in flight, since no zlib context is allocated and the source payload
179
+ is released immediately.
180
+
181
+ Larger payloads, streams, [web `ReadableStream`s](#supported-payload-types) and fetch
182
+ `Response`s always use the streaming path, so the event loop is never held for long. Set
183
+ `syncThreshold` to `0` to compress every response through the streaming path.
184
+
185
+ ```js
186
+ await fastify.register(
187
+ import('@fastify/compress'),
188
+ { syncThreshold: 65536 }
189
+ )
190
+ ```
191
+
192
+ #### How the default is chosen
193
+
194
+ The streaming path hands compression to libuv's threadpool, so it only pays off when there
195
+ are spare cores to overlap that work with the event loop. The more cores are available for
196
+ that overlap, the smaller the payload at which streaming starts to win, so the default
197
+ shrinks as the host gets wider:
198
+
199
+ | cores reported | default |
200
+ | --- | --- |
201
+ | 1 | `65536` |
202
+ | 2 | `8192` |
203
+ | 3 or more | `4096` |
204
+
205
+ The scale stops at four because libuv's threadpool defaults to four threads: beyond that,
206
+ extra cores cannot compress any more responses in parallel. The value is computed once at
207
+ startup and is always overridden by an explicit `syncThreshold`.
208
+
209
+ The memory saving does not depend on payload size — it comes from not allocating a zlib
210
+ context per in-flight response — so it is retained in full even at the lowest default.
211
+
212
+ #### Raising it on CPU-constrained deployments
213
+
214
+ Since libuv 1.49 (Node.js 22.12), `os.availableParallelism()` accounts for a cgroup CPU
215
+ quota, so a container limited to whole cores reports those rather than the host's and gets
216
+ an appropriate default on its own. Two cases still read as a wide host and so get the
217
+ smallest default:
218
+
219
+ | deployment | reported | why |
220
+ | --- | --- | --- |
221
+ | Node.js before 22.12 | host cores | the quota is not consulted at all |
222
+ | a quota below one core, e.g. Kubernetes `cpu: 500m` | host cores | the quota floors to zero and falls back to the host count |
223
+
224
+ Both are precisely the deployments that benefit most from compressing synchronously: with
225
+ little or no spare CPU, there is nothing for the streaming path to overlap with. Quotas are
226
+ also floored rather than rounded, so `cpu: 2500m` reports two cores.
227
+
228
+ If either applies to you, set `syncThreshold` explicitly:
229
+
230
+ ```js
231
+ await fastify.register(
232
+ import('@fastify/compress'),
233
+ // a container pinned to ~1 CPU behaves like a single core host
234
+ { syncThreshold: 65536 }
235
+ )
236
+ ```
237
+
238
+ The default is biased towards the low end on purpose: choosing too low a value only forgoes
239
+ part of the possible throughput gain, while choosing too high a value costs considerably
240
+ more than it can ever return.
241
+
242
+ Because the compressed size is known upfront, responses compressed synchronously carry an
243
+ accurate `Content-Length` instead of being sent with chunked transfer encoding. Replies that
244
+ already carry a `Content-Length` the plugin has been asked to keep
245
+ (see [removeContentLengthHeader](#manage-content-length-header-removal-with-removecontentlengthheader))
246
+ use the streaming path so the provided value is preserved.
247
+
170
248
  ### customTypes
171
249
  [mime-db](https://github.com/jshttp/mime-db) determines if a `content-type` should be compressed. Additional content types can be compressed via regex or a function.
172
250
 
@@ -268,6 +346,12 @@ By default, `@fastify/compress` removes the reply `Content-Length` header. Chang
268
346
  )
269
347
  ```
270
348
 
349
+ This option exists because the streaming path cannot know the compressed size upfront, so
350
+ the reply `Content-Length` would otherwise describe the uncompressed payload. Responses
351
+ taking the [synchronous path](#syncthreshold) do know the compressed size and always send an
352
+ accurate `Content-Length`; a reply that already carries a `Content-Length` and sets
353
+ `removeContentLengthHeader` to `false` is streamed instead, so the provided value is kept.
354
+
271
355
  ## Usage - Decompress request payloads
272
356
  This plugin adds a `preParsing` hook to decompress the request payload based on the `content-encoding` request header.
273
357
 
@@ -0,0 +1,282 @@
1
+ // Measures the synchronous compression path (`syncThreshold`) against the stream
2
+ // pipeline it replaces for small buffered payloads.
3
+ //
4
+ // node benchmark/sync-compression.mjs
5
+ // node benchmark/sync-compression.mjs --conns 100 --reps 5 --duration 8000
6
+ //
7
+ // Both configurations run against the same build: the baseline registers the plugin
8
+ // with `syncThreshold: 0`, which always streams, and the candidate uses the default.
9
+ //
10
+ // A few things this harness deliberately does, because getting any of them wrong
11
+ // produces numbers that look meaningful but are not:
12
+ //
13
+ // * The server runs in a forked process. Compressing synchronously occupies the
14
+ // event loop, so a client sharing that loop would measure itself as much as
15
+ // the server.
16
+ // * Requests go over real sockets with real concurrency. `inject()` has neither,
17
+ // and cannot observe that the stream path hands work to the libuv threadpool
18
+ // and so overlaps with other cores.
19
+ // * Every size is measured several times, alternating between the two
20
+ // configurations, and reported as a median with its spread.
21
+ // * A control payload larger than the threshold is always included. Both
22
+ // configurations stream it, so its result must come out near zero. When it
23
+ // does not, the machine was too noisy and the whole run is rejected.
24
+
25
+ import { fork } from 'node:child_process'
26
+ import { createRequire } from 'node:module'
27
+ import { availableParallelism, loadavg } from 'node:os'
28
+ import { gzipSync } from 'node:zlib'
29
+ import http from 'node:http'
30
+ import { performance } from 'node:perf_hooks'
31
+
32
+ const require = createRequire(import.meta.url)
33
+
34
+ // Payload sizes must straddle the threshold: the last one is the control.
35
+ const CONTROL_SIZE = 131072
36
+ const DEFAULT_SIZES = [1024, 2048, 4096, 8192, CONTROL_SIZE]
37
+ // Above this, the control row is too far off for the run to mean anything.
38
+ const CONTROL_TOLERANCE = 0.1
39
+ const TARGET_RATIO = 8
40
+
41
+ function parseArgs (argv) {
42
+ const args = {
43
+ conns: 50,
44
+ duration: 5000,
45
+ reps: 3,
46
+ warmup: 1000,
47
+ sizes: DEFAULT_SIZES
48
+ }
49
+ for (let i = 0; i < argv.length; i += 2) {
50
+ const key = argv[i].replace(/^--/, '')
51
+ const value = argv[i + 1]
52
+ if (!(key in args)) throw new Error(`Unknown option: ${argv[i]}`)
53
+ args[key] = key === 'sizes'
54
+ ? value.split(',').map(Number)
55
+ : Number(value)
56
+ }
57
+ return args
58
+ }
59
+
60
+ // Deterministic payload with a realistic compression ratio. Content structure drives
61
+ // compression cost far more than length does, so a fixed ratio keeps sizes comparable.
62
+ function makePayload (byteCount, randomFraction) {
63
+ const data = Buffer.alloc(byteCount)
64
+ let state = 0x12345678
65
+ for (let i = 0; i < byteCount; i++) {
66
+ state = (Math.imul(state, 1664525) + 1013904223) >>> 0
67
+ if (state / 0x100000000 < randomFraction) data[i] = state >>> 24
68
+ }
69
+ return data
70
+ }
71
+
72
+ function makePayloadAtRatio (byteCount, ratio) {
73
+ let low = 0
74
+ let high = 1
75
+ let best
76
+ for (let i = 0; i < 20; i++) {
77
+ const fraction = (low + high) / 2
78
+ const payload = makePayload(byteCount, fraction)
79
+ const actual = byteCount / gzipSync(payload).byteLength
80
+ if (!best || Math.abs(actual - ratio) < Math.abs(best.ratio - ratio)) {
81
+ best = { payload, ratio: actual }
82
+ }
83
+ if (actual > ratio) low = fraction
84
+ else high = fraction
85
+ }
86
+ return best
87
+ }
88
+
89
+ /* ------------------------------------------------------------------ server role */
90
+
91
+ if (process.env.BENCHMARK_ROLE === 'server') {
92
+ const Fastify = require('fastify')
93
+ const compress = require('../index.js')
94
+
95
+ const size = Number(process.env.BENCHMARK_SIZE)
96
+ const syncThreshold = process.env.BENCHMARK_SYNC_THRESHOLD === 'default'
97
+ ? undefined
98
+ : Number(process.env.BENCHMARK_SYNC_THRESHOLD)
99
+
100
+ const { payload } = makePayloadAtRatio(size, TARGET_RATIO)
101
+ const body = payload.toString('latin1')
102
+
103
+ const app = Fastify()
104
+ const options = { global: true }
105
+ if (syncThreshold !== undefined) options.syncThreshold = syncThreshold
106
+ await app.register(compress, options)
107
+
108
+ app.get('/payload', async (_request, reply) => {
109
+ reply.type('text/plain')
110
+ return body
111
+ })
112
+
113
+ const address = await app.listen({ port: 0, host: '127.0.0.1' })
114
+ const port = Number(new URL(address).port)
115
+
116
+ process.on('message', (message) => {
117
+ if (message === 'rss') process.send({ rssMiB: process.memoryUsage().rss / 1048576 })
118
+ })
119
+ process.send({ ready: true, port })
120
+ } else {
121
+ await main()
122
+ }
123
+
124
+ /* ------------------------------------------------------------------ client role */
125
+
126
+ function startServer (size, syncThreshold) {
127
+ return new Promise((resolve, reject) => {
128
+ const child = fork(import.meta.filename, [], {
129
+ env: {
130
+ ...process.env,
131
+ BENCHMARK_ROLE: 'server',
132
+ BENCHMARK_SIZE: String(size),
133
+ BENCHMARK_SYNC_THRESHOLD: String(syncThreshold)
134
+ },
135
+ stdio: ['ignore', 'ignore', 'inherit', 'ipc']
136
+ })
137
+ const onMessage = (message) => {
138
+ if (message.ready) {
139
+ child.off('message', onMessage)
140
+ resolve({ child, port: message.port })
141
+ }
142
+ }
143
+ child.on('message', onMessage)
144
+ child.once('error', reject)
145
+ child.once('exit', (code) => reject(new Error(`benchmark server exited early (${code})`)))
146
+ })
147
+ }
148
+
149
+ function rssOf (child) {
150
+ return new Promise((resolve) => {
151
+ const onMessage = (message) => {
152
+ if (message.rssMiB !== undefined) {
153
+ child.off('message', onMessage)
154
+ resolve(message.rssMiB)
155
+ }
156
+ }
157
+ child.on('message', onMessage)
158
+ child.send('rss')
159
+ })
160
+ }
161
+
162
+ function get (port, agent) {
163
+ return new Promise((resolve, reject) => {
164
+ const request = http.get(
165
+ { host: '127.0.0.1', port, path: '/payload', agent, headers: { 'accept-encoding': 'gzip' } },
166
+ (response) => {
167
+ response.resume()
168
+ response.once('end', () => resolve(response.headers['content-encoding']))
169
+ response.once('error', reject)
170
+ }
171
+ )
172
+ request.once('error', reject)
173
+ })
174
+ }
175
+
176
+ async function measure (port, { conns, duration, warmup }) {
177
+ const agent = new http.Agent({ keepAlive: true, maxSockets: conns })
178
+ const countFrom = performance.now() + warmup
179
+ const stopAt = countFrom + duration
180
+ let completed = 0
181
+ let encoding
182
+
183
+ // Requests keep flowing across the warmup boundary, so the measured window sees a
184
+ // server already at steady state rather than one still filling its connection pool.
185
+ const workers = Array.from({ length: conns }, async () => {
186
+ while (performance.now() < stopAt) {
187
+ encoding = await get(port, agent)
188
+ if (performance.now() >= countFrom) completed++
189
+ }
190
+ })
191
+
192
+ await Promise.all(workers)
193
+ agent.destroy()
194
+
195
+ return { reqPerSec: completed / (duration / 1000), encoding }
196
+ }
197
+
198
+ async function runCase (size, syncThreshold, args) {
199
+ const { child, port } = await startServer(size, syncThreshold)
200
+ try {
201
+ const result = await measure(port, args)
202
+ return { ...result, rssMiB: await rssOf(child) }
203
+ } finally {
204
+ child.kill('SIGKILL')
205
+ }
206
+ }
207
+
208
+ // Declared as a function so it is hoisted above the top-level `main()` call.
209
+ function median (values) {
210
+ const sorted = [...values].sort((a, b) => a - b)
211
+ return sorted[Math.floor(sorted.length / 2)]
212
+ }
213
+
214
+ function formatSize (bytes) {
215
+ return bytes >= 1024 ? `${bytes / 1024} KiB` : `${bytes} B`
216
+ }
217
+
218
+ async function main () {
219
+ const args = parseArgs(process.argv.slice(2))
220
+ const { computeSyncThreshold } = require('../lib/utils.js')
221
+ const cores = availableParallelism()
222
+ const computed = computeSyncThreshold()
223
+
224
+ console.log('@fastify/compress — synchronous compression path')
225
+ console.log(`node ${process.version} ${process.platform}/${process.arch} ${cores} cores`)
226
+ console.log(`computed default syncThreshold: ${computed} bytes`)
227
+ console.log(`${args.conns} connections, ${args.duration} ms x ${args.reps} reps, target ratio ${TARGET_RATIO}:1\n`)
228
+
229
+ const load = loadavg()[0]
230
+ if (load > cores / 4) {
231
+ console.log(`WARNING: 1-minute load average is ${load.toFixed(2)} on ${cores} cores.`)
232
+ console.log('Results from a busy machine are not meaningful. Wait for it to settle.\n')
233
+ }
234
+
235
+ const results = new Map(args.sizes.map((size) => [size, { baseline: [], candidate: [] }]))
236
+
237
+ for (let rep = 1; rep <= args.reps; rep++) {
238
+ for (const size of args.sizes) {
239
+ // Alternate within a rep so slow drift hits both configurations equally.
240
+ const baseline = await runCase(size, 0, args)
241
+ const candidate = await runCase(size, 'default', args)
242
+ if (baseline.encoding !== 'gzip' || candidate.encoding !== 'gzip') {
243
+ throw new Error(`payload of ${size} B was not compressed — check the plugin threshold`)
244
+ }
245
+ results.get(size).baseline.push(baseline)
246
+ results.get(size).candidate.push(candidate)
247
+ }
248
+ console.log(`rep ${rep}/${args.reps} done`)
249
+ }
250
+
251
+ console.log('\npayload streamed r/s default r/s change streamed RSS default RSS')
252
+ let controlChange = 0
253
+ for (const size of args.sizes) {
254
+ const { baseline, candidate } = results.get(size)
255
+ const base = median(baseline.map((r) => r.reqPerSec))
256
+ const cand = median(candidate.map((r) => r.reqPerSec))
257
+ const change = cand / base - 1
258
+ const isControl = size > Math.max(computed, ...args.sizes.filter((s) => s <= computed))
259
+ if (isControl) controlChange = Math.max(controlChange, Math.abs(change))
260
+ console.log(
261
+ formatSize(size).padEnd(12) +
262
+ base.toFixed(0).padStart(12) +
263
+ cand.toFixed(0).padStart(14) +
264
+ `${(change * 100).toFixed(0)}%`.padStart(11) +
265
+ `${median(baseline.map((r) => r.rssMiB)).toFixed(0)} MiB`.padStart(15) +
266
+ `${median(candidate.map((r) => r.rssMiB)).toFixed(0)} MiB`.padStart(14) +
267
+ (isControl ? ' <- control, expect ~0%' : '')
268
+ )
269
+ }
270
+
271
+ // The control streams in both configurations, so anything other than a near-zero
272
+ // result means the measurement environment, not the plugin, moved the numbers.
273
+ console.log('')
274
+ if (controlChange > CONTROL_TOLERANCE) {
275
+ console.log(`REJECTED: control moved by ${(controlChange * 100).toFixed(0)}%, above the ${CONTROL_TOLERANCE * 100}% tolerance.`)
276
+ console.log('Both configurations stream that payload, so the difference is machine noise.')
277
+ console.log('Re-run on an idle machine, or raise --reps, before drawing conclusions.')
278
+ process.exitCode = 1
279
+ } else {
280
+ console.log(`Control within ${(CONTROL_TOLERANCE * 100).toFixed(0)}% — run looks valid.`)
281
+ }
282
+ }
package/index.js CHANGED
@@ -11,6 +11,7 @@ const { Minipass } = require('minipass')
11
11
  const { Readable } = require('readable-stream')
12
12
 
13
13
  const {
14
+ computeSyncThreshold,
14
15
  isStream,
15
16
  isGzip,
16
17
  isDeflate,
@@ -18,9 +19,14 @@ const {
18
19
  isWebReadableStream,
19
20
  isFetchResponse,
20
21
  webStreamToNodeReadable,
22
+ unwrapFetchResponse,
21
23
  createPeekTransform
22
24
  } = require('./lib/utils')
23
25
 
26
+ // The default depends on how much parallelism the host offers, which cannot change while
27
+ // the process runs, so it is computed once here rather than per registration or per request
28
+ const defaultSyncThreshold = computeSyncThreshold()
29
+
24
30
  const InvalidRequestEncodingError = createError('FST_CP_ERR_INVALID_CONTENT_ENCODING', 'Unsupported Content-Encoding: %s', 415)
25
31
  const InvalidRequestCompressedPayloadError = createError('FST_CP_ERR_INVALID_CONTENT', 'Could not decompress the request payload using the provided encoding', 400)
26
32
 
@@ -127,13 +133,20 @@ function processCompressParams (opts) {
127
133
  }
128
134
 
129
135
  params.removeContentLengthHeader = typeof opts.removeContentLengthHeader === 'boolean' ? opts.removeContentLengthHeader : true
130
- params.brotliOptions = params.global
131
- ? { ...recommendedDefaultBrotliOptions, ...opts.brotliOptions }
132
- : opts.brotliOptions
136
+ // The recommended defaults apply whatever the value of `global` is: with
137
+ // `global: false` the compression still runs, just through `reply.compress()`,
138
+ // and falling back to zlib's default quality of 11 there is needlessly slow.
139
+ params.brotliOptions = { ...recommendedDefaultBrotliOptions, ...opts.brotliOptions }
133
140
  params.zlibOptions = opts.zlibOptions
134
141
  params.onUnsupportedEncoding = opts.onUnsupportedEncoding
135
142
  params.inflateIfDeflated = opts.inflateIfDeflated === true
136
143
  params.threshold = typeof opts.threshold === 'number' ? opts.threshold : 1024
144
+ // Upper size bound of the synchronous compression path. Unlike `threshold`, which is a
145
+ // lower bound, this one is a ceiling: bigger payloads keep using the stream pipeline.
146
+ // The default scales with the parallelism the host can offer the stream path, see
147
+ // `computeSyncThreshold()`. An explicit value is always used as is, and `0` disables
148
+ // the synchronous path entirely.
149
+ params.syncThreshold = typeof opts.syncThreshold === 'number' ? opts.syncThreshold : defaultSyncThreshold
137
150
  params.compressibleTypes = opts.customTypes instanceof RegExp
138
151
  ? opts.customTypes.test.bind(opts.customTypes)
139
152
  : typeof opts.customTypes === 'function'
@@ -147,6 +160,22 @@ function processCompressParams (opts) {
147
160
  if (typeof ((opts.zlib || zlib).createZstdCompress || zlib.createZstdCompress) === 'function') {
148
161
  params.compressStream.zstd = () => ((opts.zlib || zlib).createZstdCompress || zlib.createZstdCompress)(params.zlibOptions)
149
162
  }
163
+ // Synchronous counterparts of `params.compressStream`, used for small buffered payloads.
164
+ // Encodings without a usable synchronous method are left out of the map so that they
165
+ // keep flowing through the stream pipeline.
166
+ params.compressSync = {}
167
+ for (const [encoding, streamMethod, syncMethod, options] of [
168
+ ['br', 'createBrotliCompress', 'brotliCompressSync', params.brotliOptions],
169
+ ['gzip', 'createGzip', 'gzipSync', params.zlibOptions],
170
+ ['deflate', 'createDeflate', 'deflateSync', params.zlibOptions],
171
+ ['zstd', 'createZstdCompress', 'zstdCompressSync', params.zlibOptions]
172
+ ]) {
173
+ const compressSync = resolveSyncCompressor(opts.zlib, streamMethod, syncMethod, options)
174
+ if (compressSync !== undefined) {
175
+ params.compressSync[encoding] = compressSync
176
+ }
177
+ }
178
+
150
179
  params.uncompressStream = {
151
180
  // Currently params.uncompressStream.br() is never called as we do not have any way to autodetect brotli compression in `fastify-compress`
152
181
  // Brotli documentation reference: [RFC 7932](https://www.rfc-editor.org/rfc/rfc7932)
@@ -174,6 +203,54 @@ function processCompressParams (opts) {
174
203
  return params
175
204
  }
176
205
 
206
+ // Resolve the synchronous counterpart of a stream compressor.
207
+ // A synchronous method is only picked when it comes from the same `zlib` implementation the
208
+ // stream path would have used, so a custom `zlib` exposing just the stream constructor is
209
+ // never bypassed: its encoding simply keeps using the stream pipeline.
210
+ function resolveSyncCompressor (customZlib, streamMethod, syncMethod, options) {
211
+ let compressSync
212
+
213
+ if (customZlib != null && typeof customZlib[syncMethod] === 'function') {
214
+ compressSync = customZlib[syncMethod]
215
+ } else if ((customZlib == null || typeof customZlib[streamMethod] !== 'function') && typeof zlib[syncMethod] === 'function') {
216
+ // `zlib.zstdCompressSync` is only available on Node.js 22.15+/23.8+
217
+ compressSync = zlib[syncMethod]
218
+ } else {
219
+ return undefined
220
+ }
221
+
222
+ return (payload) => compressSync(payload, options)
223
+ }
224
+
225
+ // Compress an already buffered payload inline, bypassing the stream pipeline.
226
+ // Small payloads are much cheaper to compress this way: no zlib context and no stream
227
+ // pipeline are allocated per response, and the source payload is released immediately
228
+ // instead of being pinned for as long as the response is in flight.
229
+ // Returns `null` when the payload is not eligible and must be streamed instead.
230
+ function compressPayloadSync (params, reply, encoding, payload, payloadSize) {
231
+ if (params.syncThreshold <= 0 || payloadSize > params.syncThreshold) return null
232
+
233
+ const compressSync = params.compressSync[encoding]
234
+ if (compressSync === undefined) return null
235
+
236
+ // Only strings and Buffers are fully buffered and can be compressed in one go.
237
+ // Fastify already turns every other buffered payload type into one of those two
238
+ // before this point, so this is a safety net rather than a reachable path.
239
+ /* c8 ignore next */
240
+ if (typeof payload !== 'string' && !Buffer.isBuffer(payload)) return null
241
+
242
+ // Fastify recomputes `content-length` for buffered payloads, so a caller provided value
243
+ // that the plugin has been asked to keep can only be honoured by the stream path
244
+ if (params.removeContentLengthHeader === false && reply.getHeader('content-length') !== undefined) return null
245
+
246
+ const buffer = Buffer.isBuffer(payload) ? payload : Buffer.from(payload)
247
+
248
+ // Mirror `zipStream()` and pass already gzip or deflate compressed payloads through as is
249
+ if (isCompressed(buffer) !== 0) return buffer
250
+
251
+ return compressSync(buffer)
252
+ }
253
+
177
254
  function processDecompressParams (opts) {
178
255
  /* c8 ignore next 3 */
179
256
  if (!opts) {
@@ -252,14 +329,18 @@ function buildRouteCompress (_fastify, params, routeOptions, decorateOnly) {
252
329
  }
253
330
 
254
331
  function onSend (req, reply, payload, next) {
332
+ if (isFetchResponse(payload)) {
333
+ payload = unwrapFetchResponse(reply, payload)
334
+ }
335
+
255
336
  if (payload == null) {
256
- return next()
337
+ return next(null, payload)
257
338
  }
258
339
 
259
340
  const responseEncoding = reply.getHeader('Content-Encoding')
260
341
  if (responseEncoding && responseEncoding !== 'identity') {
261
342
  // response is already compressed
262
- return next()
343
+ return next(null, payload)
263
344
  }
264
345
 
265
346
  let stream, encoding
@@ -295,16 +376,30 @@ function buildRouteCompress (_fastify, params, routeOptions, decorateOnly) {
295
376
  }
296
377
 
297
378
  if (typeof payload.pipe !== 'function') {
298
- if (isFetchResponse(payload)) {
299
- payload = payload.body
300
- }
301
-
302
379
  if (isWebReadableStream(payload)) {
303
380
  payload = webStreamToNodeReadable(payload)
304
381
  } else {
305
- if (Buffer.byteLength(payload) < params.threshold) {
382
+ const payloadSize = Buffer.byteLength(payload)
383
+ if (payloadSize < params.threshold) {
306
384
  return next()
307
385
  }
386
+
387
+ let compressedPayload = null
388
+ try {
389
+ compressedPayload = compressPayloadSync(params, reply, encoding, payload, payloadSize)
390
+ } catch (err) {
391
+ return next(err)
392
+ }
393
+
394
+ if (compressedPayload !== null) {
395
+ setVaryHeader(reply)
396
+ reply.header('Content-Encoding', encoding)
397
+ // The compressed size is known on this path, so an accurate `content-length` is
398
+ // set instead of dropping the header and falling back to a chunked response
399
+ reply.header('Content-Length', compressedPayload.length)
400
+ return next(null, compressedPayload)
401
+ }
402
+
308
403
  payload = Readable.from(intoAsyncIterator(payload))
309
404
  }
310
405
  }
@@ -392,6 +487,14 @@ function compress (params) {
392
487
  return
393
488
  }
394
489
 
490
+ if (isFetchResponse(payload)) {
491
+ if (payload.body === null) {
492
+ return this.send(payload)
493
+ }
494
+
495
+ payload = unwrapFetchResponse(this, payload)
496
+ }
497
+
395
498
  let stream, encoding
396
499
  const noCompress =
397
500
  // don't compress on x-no-compression header
@@ -424,10 +527,6 @@ function compress (params) {
424
527
  }
425
528
 
426
529
  if (typeof payload.pipe !== 'function') {
427
- if (isFetchResponse(payload)) {
428
- payload = payload.body
429
- }
430
-
431
530
  if (isWebReadableStream(payload)) {
432
531
  payload = webStreamToNodeReadable(payload)
433
532
  } else if (!Buffer.isBuffer(payload) && typeof payload !== 'string') {
@@ -436,9 +535,27 @@ function compress (params) {
436
535
  }
437
536
 
438
537
  if (typeof payload.pipe !== 'function') {
439
- if (Buffer.byteLength(payload) < params.threshold) {
538
+ const payloadSize = Buffer.byteLength(payload)
539
+ if (payloadSize < params.threshold) {
440
540
  return this.send(payload)
441
541
  }
542
+
543
+ let compressedPayload = null
544
+ try {
545
+ compressedPayload = compressPayloadSync(params, this, encoding, payload, payloadSize)
546
+ } catch (err) {
547
+ return this.send(err)
548
+ }
549
+
550
+ if (compressedPayload !== null) {
551
+ setVaryHeader(this)
552
+ this.header('Content-Encoding', encoding)
553
+ // The compressed size is known on this path, so an accurate `content-length` is
554
+ // set instead of dropping the header and falling back to a chunked response
555
+ this.header('Content-Length', compressedPayload.length)
556
+ return this.send(compressedPayload)
557
+ }
558
+
442
559
  payload = Readable.from(intoAsyncIterator(payload))
443
560
  }
444
561