@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.
- package/.github/workflows/ci.yml +1 -1
- package/.github/workflows/lock-threads.yml +1 -1
- package/README.md +84 -0
- package/benchmark/sync-compression.mjs +282 -0
- package/index.js +131 -15
- package/lib/utils.js +41 -0
- package/package.json +2 -1
- package/test/global-compress.test.js +161 -2
- package/test/regression/issue-350.test.js +3 -1
- package/test/routes-compress.test.js +26 -0
- package/test/sync-compress.test.js +741 -0
- package/test/utils.test.js +35 -1
- package/types/index.d.ts +2 -0
- package/types/index.tst.ts +3 -1
package/.github/workflows/ci.yml
CHANGED
|
@@ -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@
|
|
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@
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|