@fastify/compress 9.1.1 → 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.
@@ -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@2073dc8e1f9e172bf42daa3843c9dbd31af1e8cb # v6.0.0
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@2073dc8e1f9e172bf42daa3843c9dbd31af1e8cb # v6.0.0
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,
@@ -22,6 +23,10 @@ const {
22
23
  createPeekTransform
23
24
  } = require('./lib/utils')
24
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
+
25
30
  const InvalidRequestEncodingError = createError('FST_CP_ERR_INVALID_CONTENT_ENCODING', 'Unsupported Content-Encoding: %s', 415)
26
31
  const InvalidRequestCompressedPayloadError = createError('FST_CP_ERR_INVALID_CONTENT', 'Could not decompress the request payload using the provided encoding', 400)
27
32
 
@@ -128,13 +133,20 @@ function processCompressParams (opts) {
128
133
  }
129
134
 
130
135
  params.removeContentLengthHeader = typeof opts.removeContentLengthHeader === 'boolean' ? opts.removeContentLengthHeader : true
131
- params.brotliOptions = params.global
132
- ? { ...recommendedDefaultBrotliOptions, ...opts.brotliOptions }
133
- : 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 }
134
140
  params.zlibOptions = opts.zlibOptions
135
141
  params.onUnsupportedEncoding = opts.onUnsupportedEncoding
136
142
  params.inflateIfDeflated = opts.inflateIfDeflated === true
137
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
138
150
  params.compressibleTypes = opts.customTypes instanceof RegExp
139
151
  ? opts.customTypes.test.bind(opts.customTypes)
140
152
  : typeof opts.customTypes === 'function'
@@ -148,6 +160,22 @@ function processCompressParams (opts) {
148
160
  if (typeof ((opts.zlib || zlib).createZstdCompress || zlib.createZstdCompress) === 'function') {
149
161
  params.compressStream.zstd = () => ((opts.zlib || zlib).createZstdCompress || zlib.createZstdCompress)(params.zlibOptions)
150
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
+
151
179
  params.uncompressStream = {
152
180
  // Currently params.uncompressStream.br() is never called as we do not have any way to autodetect brotli compression in `fastify-compress`
153
181
  // Brotli documentation reference: [RFC 7932](https://www.rfc-editor.org/rfc/rfc7932)
@@ -175,6 +203,54 @@ function processCompressParams (opts) {
175
203
  return params
176
204
  }
177
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
+
178
254
  function processDecompressParams (opts) {
179
255
  /* c8 ignore next 3 */
180
256
  if (!opts) {
@@ -253,14 +329,18 @@ function buildRouteCompress (_fastify, params, routeOptions, decorateOnly) {
253
329
  }
254
330
 
255
331
  function onSend (req, reply, payload, next) {
332
+ if (isFetchResponse(payload)) {
333
+ payload = unwrapFetchResponse(reply, payload)
334
+ }
335
+
256
336
  if (payload == null) {
257
- return next()
337
+ return next(null, payload)
258
338
  }
259
339
 
260
340
  const responseEncoding = reply.getHeader('Content-Encoding')
261
341
  if (responseEncoding && responseEncoding !== 'identity') {
262
342
  // response is already compressed
263
- return next()
343
+ return next(null, payload)
264
344
  }
265
345
 
266
346
  let stream, encoding
@@ -296,16 +376,30 @@ function buildRouteCompress (_fastify, params, routeOptions, decorateOnly) {
296
376
  }
297
377
 
298
378
  if (typeof payload.pipe !== 'function') {
299
- if (isFetchResponse(payload)) {
300
- payload = unwrapFetchResponse(reply, payload)
301
- }
302
-
303
379
  if (isWebReadableStream(payload)) {
304
380
  payload = webStreamToNodeReadable(payload)
305
381
  } else {
306
- if (Buffer.byteLength(payload) < params.threshold) {
382
+ const payloadSize = Buffer.byteLength(payload)
383
+ if (payloadSize < params.threshold) {
307
384
  return next()
308
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
+
309
403
  payload = Readable.from(intoAsyncIterator(payload))
310
404
  }
311
405
  }
@@ -393,6 +487,14 @@ function compress (params) {
393
487
  return
394
488
  }
395
489
 
490
+ if (isFetchResponse(payload)) {
491
+ if (payload.body === null) {
492
+ return this.send(payload)
493
+ }
494
+
495
+ payload = unwrapFetchResponse(this, payload)
496
+ }
497
+
396
498
  let stream, encoding
397
499
  const noCompress =
398
500
  // don't compress on x-no-compression header
@@ -425,10 +527,6 @@ function compress (params) {
425
527
  }
426
528
 
427
529
  if (typeof payload.pipe !== 'function') {
428
- if (isFetchResponse(payload)) {
429
- payload = unwrapFetchResponse(this, payload)
430
- }
431
-
432
530
  if (isWebReadableStream(payload)) {
433
531
  payload = webStreamToNodeReadable(payload)
434
532
  } else if (!Buffer.isBuffer(payload) && typeof payload !== 'string') {
@@ -437,9 +535,27 @@ function compress (params) {
437
535
  }
438
536
 
439
537
  if (typeof payload.pipe !== 'function') {
440
- if (Buffer.byteLength(payload) < params.threshold) {
538
+ const payloadSize = Buffer.byteLength(payload)
539
+ if (payloadSize < params.threshold) {
441
540
  return this.send(payload)
442
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
+
443
559
  payload = Readable.from(intoAsyncIterator(payload))
444
560
  }
445
561
 
package/lib/utils.js CHANGED
@@ -1,8 +1,48 @@
1
1
  'use strict'
2
2
 
3
3
  const { Readable: NodeReadable } = require('node:stream')
4
+ const { availableParallelism } = require('node:os')
4
5
  const { Transform } = require('readable-stream')
5
6
 
7
+ // Bounds of the computed default synchronous compression threshold
8
+ const MIN_SYNC_THRESHOLD = 4096
9
+ const MAX_SYNC_THRESHOLD = 65536
10
+ // libuv's threadpool defaults to 4 threads, so at most 4 stream compressions can make
11
+ // progress at once however many cores the host reports. `UV_THREADPOOL_SIZE` can raise
12
+ // it, but it is not something this default can rely on.
13
+ const MAX_USEFUL_PARALLELISM = 4
14
+
15
+ /**
16
+ * Compute the default upper size bound of the synchronous compression path.
17
+ *
18
+ * The stream path hands compression to the libuv threadpool, so it only pays off when
19
+ * there are spare cores to overlap that work with the event loop. The more cores are
20
+ * available for that overlap, the smaller the payload at which streaming starts winning:
21
+ * measured over real sockets, the crossover sits around 4 KiB with four or more cores,
22
+ * while on a single core the synchronous path stays ahead across the whole range.
23
+ *
24
+ * The threshold is therefore divided by 8 for every core beyond the first, and clamped.
25
+ * The divisor is deliberately steep because the two errors are not symmetric: settling
26
+ * too low only forgoes some of the throughput win, while settling too high costs far
27
+ * more than it ever gains.
28
+ *
29
+ * `availableParallelism()` accounts for a cgroup CPU quota since libuv 1.49 (Node 22.12),
30
+ * so a container limited to whole cores reports those rather than the host's. Two cases
31
+ * still read as a wide host and so land on the low end of the range:
32
+ *
33
+ * - Node.js before 22.12, where the quota is not consulted at all;
34
+ * - a quota below one full core, such as Kubernetes `cpu: 500m`, which floors to zero
35
+ * and falls back to the host's core count.
36
+ *
37
+ * Both err towards the small end, which is the safe direction: settling too low only
38
+ * forgoes some of the win. Such deployments can raise `syncThreshold` explicitly.
39
+ */
40
+ function computeSyncThreshold (parallelism = availableParallelism()) {
41
+ const usableCores = Math.min(parallelism, MAX_USEFUL_PARALLELISM)
42
+ const threshold = MAX_SYNC_THRESHOLD >> (3 * (usableCores - 1))
43
+ return Math.min(Math.max(threshold, MIN_SYNC_THRESHOLD), MAX_SYNC_THRESHOLD)
44
+ }
45
+
6
46
  // https://datatracker.ietf.org/doc/html/rfc8878#section-3.1.1
7
47
  function isZstd (buffer) {
8
48
  return (
@@ -192,6 +232,7 @@ function createPeekTransform (swapStream, peekSize = 10) {
192
232
  }
193
233
 
194
234
  module.exports = {
235
+ computeSyncThreshold,
195
236
  isZstd,
196
237
  isGzip,
197
238
  isDeflate,