@fastify/compress 9.1.1 → 9.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.c8rc CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "all": true,
3
- "exclude": ["test/", "coverage/", "types/", "eslint.config.js"],
3
+ "exclude": ["test/", "coverage/", "types/", "benchmark/", "eslint.config.js"],
4
4
  "clean": true,
5
5
  "check-coverage": true,
6
6
  "branches": 100,
@@ -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
@@ -0,0 +1,24 @@
1
+ name: Release the package
2
+
3
+ on:
4
+ workflow_dispatch:
5
+ inputs:
6
+ semver:
7
+ description: 'Release bump type'
8
+ required: true
9
+ type: choice
10
+ options:
11
+ - patch
12
+ - minor
13
+ - major
14
+
15
+ permissions: {}
16
+
17
+ jobs:
18
+ release:
19
+ permissions:
20
+ id-token: write # required for npm provenance via OIDC
21
+ contents: write # required to push the release commit and the tag
22
+ uses: fastify/workflows/.github/workflows/reusable-release.yml@b1a11bce713bc857e52b2231fec3c89071ad3135
23
+ with:
24
+ semver: ${{ inputs.semver }}
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
 
@@ -396,7 +480,7 @@ await fastify.register(
396
480
 
397
481
  ## Acknowledgments
398
482
 
399
- Past sp
483
+ Past sponsors:
400
484
  - LetzDoIt
401
485
 
402
486
  ## License
@@ -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
+ }