@pronghorn/compress
v0.1.0
Published
Response compression middleware for Pronghorn, built on Bun's native CompressionStream. Negotiates brotli, gzip, and deflate against Accept-Encoding with zero external dependencies.
Downloads
26
Maintainers
Readme
Compress 🗜️
Compress is a lightweight, TypeScript-first response compression middleware built as an external plugin for Pronghorn. It negotiates brotli, gzip, and deflate against a request's Accept-Encoding header and streams the response body through Bun's native CompressionStream, with zero external dependencies.
Built as a standalone package (
@pronghorn/compress), the Pronghorn equivalent offastify-compress, powered entirely by Web-standard streams rather thanzlibbindings.
Why Compress
Pronghorn had no way to shrink response payloads before this. Bun v1.3.3 shipped native CompressionStream/DecompressionStream support for gzip, deflate, brotli, and zstd, which means compression can be implemented as pure stream-piping with no native bindings or third-party libraries at all.
- Negotiates the best encoding the client supports via
Accept-Encoding, preferring brotli, then gzip, then deflate. - Streams the response body through
CompressionStream, no buffering the whole payload in memory first. - Skips payloads below a configurable
threshold, compressing tiny responses wastes CPU for negligible savings. - Skips already-compressed or binary content types (images, video, zip) by default via a MIME allowlist.
- Automatically sets
Content-Encoding, removes the now-inaccurateContent-Length, and appendsVary: Accept-Encodingfor correct caching behavior. - Zero runtime dependencies,
pronghornis only a peer dependency for types.
Installation
bun add @pronghorn/compressRequires Bun >=1.3.3 (the version that introduced native CompressionStream support) and pronghorn >=0.1.2 as a peer dependency (used for typing the middleware only).
Quick Start
import { createApp } from 'pronghorn'
import { compress } from '@pronghorn/compress'
const app = createApp()
app.use(compress())
app.get('/data', context => context.json({ items: Array.from({ length: 500 }, (_, i) => i) }))
await app.listen(4000)Register compress last (or near-last) in the global middleware chain, after shield/cors, so it compresses the fully-formed response rather than an intermediate one.
Core Concepts
Encoding negotiation
Compress reads the request's Accept-Encoding header and picks the highest-priority encoding both the client accepts and the server allows. If the client sends *, the server's top preference is used; if no supported encoding is found, the response passes through uncompressed.
app.use(compress({ encodings: ['br', 'gzip'] })) // drop deflate entirelySize threshold
Very small responses (a short JSON error, a health check) rarely benefit from compression, the overhead can even make them larger. Compress skips any response reporting a Content-Length below the threshold.
app.use(compress({ threshold: 2048 })) // only compress responses ≥ 2KBResponses without a known Content-Length (e.g. already streamed) are still compressed, since their size can't be checked upfront.
MIME type filtering
By default, only text-based and structured formats are compressed: text/*, application/json, application/javascript, application/xml, and image/svg+xml. Binary formats like JPEG, PNG, or ZIP are already compressed and excluded automatically.
app.use(compress({
mimeTypes: ['text/', 'application/json', 'application/vnd.api+json']
}))Streaming, not buffering
The response body is piped directly through CompressionStream without ever being fully loaded into memory, keeping compression overhead flat regardless of payload size.
app.get('/export.csv', context => {
const stream = generateCsvStream() // any ReadableStream
return new Response(stream, { headers: { 'Content-Type': 'text/csv' } })
})
// compress() will transparently compress this if the client accepts itMiddleware Options
| Option | Type | Default | Description |
| --- | --- | --- | --- |
| threshold | number | 1024 | Minimum response size in bytes before compression is applied |
| encodings | Encoding[] | ['br', 'gzip', 'deflate'] | Allowed encodings, in priority order |
| mimeTypes | string[] | ['text/', 'application/json', 'application/javascript', 'application/xml', 'image/svg+xml'] | Content-Type substrings eligible for compression |
Encoding is 'br' | 'gzip' | 'deflate'.
API Reference
compress(options?: CompressOptions): Middleware - global middleware factory, register via app.use(compress(options)).
Lower-level negotiation helpers are also exported for advanced use outside the middleware:
import { negotiateEncoding, isCompressible } from '@pronghorn/compress'
const encoding = negotiateEncoding(request.headers.get('accept-encoding'), ['br', 'gzip'])
const shouldCompress = isCompressible(response.headers.get('content-type'), ['text/', 'application/json'])Architecture
Compress is split into two modules, each with a single responsibility.
| Module | Responsibility |
| --- | --- |
| negotiate.ts | Parses Accept-Encoding, resolves the best allowed encoding, and checks content-type eligibility |
| middleware.ts | Applies threshold/MIME filtering, pipes the body through CompressionStream, and rebuilds response headers |
Because a compressed stream's final byte length isn't known until the stream finishes, Content-Length is removed rather than recalculated, letting the underlying transport handle chunked delivery, the same approach fastify-compress uses for streamed responses.
Performance Notes
- Brotli generally compresses better than gzip for text content but is slightly more CPU-intensive; both are supported natively by Bun with no bindings overhead.
- Because compression is streamed rather than buffered, memory usage stays flat even for large responses, letting you compress exports or large JSON payloads safely.
- Skipping already-compressed MIME types (images, video, archives) avoids burning CPU cycles for zero size benefit, re-compressing binary formats can occasionally make them larger.
License
WTFPL (Do What the Fuck You Want to Public License), see LICENSE for details.
