feat(bun-compress): Created a compress middleware that supports Bun (#1153)

* feat(bun-compress): Created a compress middlware that supports Bun

* fix(bun-compress): spelling mistake in readme

* fix(bun-compress): package description

* fixed package.json
pull/1158/head
Nicholas Dobie 2025-05-12 17:15:11 -05:00 committed by GitHub
parent 026e1deac3
commit 7717755dd3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 525 additions and 0 deletions

View File

@ -0,0 +1,5 @@
---
'@hono/bun-compress': minor
---
Created Bun Compress Middleware

View File

@ -0,0 +1,30 @@
# Bun Compress Middleware for Hono
Bun does not currently support the [CompressionStream API](https://developer.mozilla.org/en-US/docs/Web/API/CompressionStream) so this middleware replicates the behavior of [`hono/compress`](https://hono.dev/docs/middleware/builtin/compress) using the Zlib library. This middleware will be deprecated once [Bun adds support for `CompressionStream`](https://github.com/oven-sh/bun/issues/1723).
This middleware will use `hono/compress` if CompressionStream is available so you can use this middleware in Bun and Node.js without any changes.
## Import
```ts
import { Hono } from 'hono'
import { compress } from '@hono/bun-compress'
```
## Usage
```ts
const app = new Hono()
app.use(compress())
```
## Options
### <Badge type="info" text="optional" /> encoding: `'gzip'` | `'deflate'`
The compression scheme to allow for response compression. Either `gzip` or `deflate`. If not defined, both are allowed and will be used based on the `Accept-Encoding` header. `gzip` is prioritized if this option is not provided and the client provides both in the `Accept-Encoding` header.
### <Badge type="info" text="optional" /> threshold: `number`
The minimum size in bytes to compress. Defaults to 1024 bytes.

View File

@ -0,0 +1,53 @@
{
"name": "@hono/bun-compress",
"version": "0.0.0",
"description": "A Hono middleware for compressing responses using Bun's built-in compression.",
"type": "module",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"build": "tsup ./src/index.ts",
"prepack": "yarn build",
"publint": "attw --pack && publint",
"typecheck": "tsc -b tsconfig.json",
"test": "bun test"
},
"exports": {
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
}
},
"license": "MIT",
"publishConfig": {
"registry": "https://registry.npmjs.org",
"access": "public"
},
"repository": {
"type": "git",
"url": "git+https://github.com/honojs/middleware.git",
"directory": "packages/bun-compress"
},
"homepage": "https://github.com/honojs/middleware",
"peerDependencies": {
"hono": "*"
},
"devDependencies": {
"@arethetypeswrong/cli": "^0.17.4",
"@types/bun": "^1.2.12",
"@types/node": "^22.15.15",
"publint": "^0.3.9",
"tsup": "^8.4.0",
"typescript": "^5.8.2",
"vitest": "^3.0.8"
}
}

View File

@ -0,0 +1,231 @@
import { Hono } from 'hono'
import { stream, streamSSE } from 'hono/streaming'
import { Readable } from 'node:stream'
import type { ReadableStream } from 'node:stream/web'
import { createGunzip } from 'node:zlib'
import { compress } from '.'
describe('Bun Compress Middleware', () => {
const app = new Hono()
// Apply compress middleware to all routes
app.use('*', compress())
// Test routes
app.get('/small', (c) => {
c.header('Content-Type', 'text/plain')
c.header('Content-Length', '5')
return c.text('small')
})
app.get('/large', (c) => {
c.header('Content-Type', 'text/plain')
c.header('Content-Length', '1024')
return c.text('a'.repeat(1024))
})
app.get('/small-json', (c) => {
c.header('Content-Type', 'application/json')
c.header('Content-Length', '26')
return c.json({ message: 'Hello, World!' })
})
app.get('/large-json', (c) => {
c.header('Content-Type', 'application/json')
c.header('Content-Length', '1024')
return c.json({ data: 'a'.repeat(1024), message: 'Large JSON' })
})
app.get('/no-transform', (c) => {
c.header('Content-Type', 'text/plain')
c.header('Content-Length', '1024')
c.header('Cache-Control', 'no-transform')
return c.text('a'.repeat(1024))
})
app.get('/jpeg-image', (c) => {
c.header('Content-Type', 'image/jpeg')
c.header('Content-Length', '1024')
return c.body(new Uint8Array(1024)) // Simulated JPEG data
})
app.get('/already-compressed', (c) => {
c.header('Content-Type', 'application/octet-stream')
c.header('Content-Encoding', 'br')
c.header('Content-Length', '1024')
return c.body(new Uint8Array(1024)) // Simulated compressed data
})
app.get('/transfer-encoding-deflate', (c) => {
c.header('Content-Type', 'application/octet-stream')
c.header('Transfer-Encoding', 'deflate')
c.header('Content-Length', '1024')
return c.body(new Uint8Array(1024)) // Simulated deflate data
})
app.get('/chunked', (c) => {
c.header('Content-Type', 'application/octet-stream')
c.header('Transfer-Encoding', 'chunked')
c.header('Content-Length', '1024')
return c.body(new Uint8Array(1024)) // Simulated chunked data
})
app.get('/stream', (c) =>
stream(c, async (stream) => {
c.header('Content-Type', 'text/plain')
// 60000 bytes
for (let i = 0; i < 10000; i++) {
await stream.write('chunk ')
}
})
)
app.get('/already-compressed-stream', (c) =>
stream(c, async (stream) => {
c.header('Content-Type', 'text/plain')
c.header('Content-Encoding', 'br')
// 60000 bytes
for (let i = 0; i < 10000; i++) {
await stream.write(new Uint8Array([0, 1, 2, 3, 4, 5])) // Simulated compressed data
}
})
)
app.get('/sse', (c) =>
streamSSE(c, async (stream) => {
for (let i = 0; i < 1000; i++) {
await stream.writeSSE({ data: 'chunk' })
}
})
)
app.notFound((c) => c.text('Custom NotFound', 404))
const testCompression = async (
path: string,
acceptEncoding: string,
expectedEncoding: string | null
) => {
const req = new Request(`http://localhost${path}`, {
method: 'GET',
headers: new Headers({ 'Accept-Encoding': acceptEncoding }),
})
const res = await app.request(req)
expect(res.headers.get('Content-Encoding')).toBe(expectedEncoding)
return res
}
describe('Compression Behavior', () => {
it('should compress large responses with gzip', async () => {
const res = await testCompression('/large', 'gzip', 'gzip')
expect(res.headers.get('Content-Length')).toBeNull()
expect((await res.arrayBuffer()).byteLength).toBeLessThan(1024)
})
it('should compress large responses with deflate', async () => {
const res = await testCompression('/large', 'deflate', 'deflate')
expect((await res.arrayBuffer()).byteLength).toBeLessThan(1024)
})
it('should prioritize gzip over deflate when both are accepted', async () => {
await testCompression('/large', 'gzip, deflate', 'gzip')
})
it('should not compress small responses', async () => {
const res = await testCompression('/small', 'gzip, deflate', null)
expect(res.headers.get('Content-Length')).toBe('5')
})
it('should not compress when no Accept-Encoding is provided', async () => {
await testCompression('/large', '', null)
})
it('should not compress images', async () => {
const res = await testCompression('/jpeg-image', 'gzip', null)
expect(res.headers.get('Content-Type')).toBe('image/jpeg')
expect(res.headers.get('Content-Length')).toBe('1024')
})
it('should not compress already compressed responses', async () => {
const res = await testCompression('/already-compressed', 'gzip', 'br')
expect(res.headers.get('Content-Length')).toBe('1024')
})
it('should remove Content-Length when compressing', async () => {
const res = await testCompression('/large', 'gzip', 'gzip')
expect(res.headers.get('Content-Length')).toBeNull()
})
it('should not remove Content-Length when not compressing', async () => {
const res = await testCompression('/jpeg-image', 'gzip', null)
expect(res.headers.get('Content-Length')).toBeDefined()
})
it('should not compress transfer-encoding: deflate', async () => {
const res = await testCompression('/transfer-encoding-deflate', 'gzip', null)
expect(res.headers.get('Content-Length')).toBe('1024')
expect(res.headers.get('Transfer-Encoding')).toBe('deflate')
})
it('should not compress transfer-encoding: chunked', async () => {
const res = await testCompression('/chunked', 'gzip', null)
expect(res.headers.get('Content-Length')).toBe('1024')
expect(res.headers.get('Transfer-Encoding')).toBe('chunked')
})
})
describe('JSON Handling', () => {
it('should not compress small JSON responses', async () => {
const res = await testCompression('/small-json', 'gzip', null)
expect(res.headers.get('Content-Length')).toBe('26')
})
it('should compress large JSON responses', async () => {
const res = await testCompression('/large-json', 'gzip', 'gzip')
expect(res.headers.get('Content-Length')).toBeNull()
const decompressed = await decompressResponse(res)
const json = JSON.parse(decompressed)
expect(json.data.length).toBe(1024)
expect(json.message).toBe('Large JSON')
})
})
describe('Streaming Responses', () => {
it('should compress streaming responses written in multiple chunks', async () => {
const res = await testCompression('/stream', 'gzip', 'gzip')
const decompressed = await decompressResponse(res)
expect(decompressed.length).toBe(60000)
})
it('should not compress already compressed streaming responses', async () => {
const res = await testCompression('/already-compressed-stream', 'gzip', 'br')
expect((await res.arrayBuffer()).byteLength).toBe(60000)
})
it('should not compress server-sent events', async () => {
const res = await testCompression('/sse', 'gzip', null)
expect((await res.arrayBuffer()).byteLength).toBe(13000)
})
})
describe('Edge Cases', () => {
it('should not compress responses with Cache-Control: no-transform', async () => {
await testCompression('/no-transform', 'gzip', null)
})
it('should handle HEAD requests without compression', async () => {
const req = new Request('http://localhost/large', {
method: 'HEAD',
headers: new Headers({ 'Accept-Encoding': 'gzip' }),
})
const res = await app.request(req)
expect(res.headers.get('Content-Encoding')).toBeNull()
})
it('should compress custom 404 Not Found responses', async () => {
const res = await testCompression('/not-found', 'gzip', 'gzip')
expect(res.status).toBe(404)
const decompressed = await decompressResponse(res)
expect(decompressed).toBe('Custom NotFound')
})
})
})
async function decompressResponse(res: Response): Promise<string> {
const resBody = res.body as ReadableStream
const readableStream = Readable.fromWeb(resBody)
const decompressedStream = readableStream.pipe(createGunzip())
const decompressedReadableStream = Readable.toWeb(decompressedStream)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const decompressedResponse = new Response(decompressedReadableStream as any)
return await decompressedResponse.text()
}

View File

@ -0,0 +1,103 @@
/**
* @module
* Compress Middleware for Hono.
*/
import type { MiddlewareHandler } from 'hono'
import { compress as originalCompress } from 'hono/compress'
import { COMPRESSIBLE_CONTENT_TYPE_REGEX } from 'hono/utils/compress'
import { Readable } from 'node:stream'
import type { ReadableStream } from 'node:stream/web'
import { createDeflate, createGzip } from 'node:zlib'
const ENCODING_TYPES = ['gzip', 'deflate'] as const
const cacheControlNoTransformRegExp = /(?:^|,)\s*?no-transform\s*?(?:,|$)/i
interface CompressionOptions {
encoding?: (typeof ENCODING_TYPES)[number]
threshold?: number
}
/**
* Compress Middleware for Hono on Bun.
*
* Bun does not currently support CompressionStream, so this uses the zlib module to compress the response body.
*
* @see {@link https://hono.dev/docs/middleware/builtin/compress}
* @see {@link https://github.com/oven-sh/bun/issues/1723}
*
* @param {CompressionOptions} [options] - The options for the compress middleware.
* @param {'gzip' | 'deflate'} [options.encoding] - The compression scheme to allow for response compression. Either 'gzip' or 'deflate'. If not defined, both are allowed and will be used based on the Accept-Encoding header. 'gzip' is prioritized if this option is not provided and the client provides both in the Accept-Encoding header.
* @param {number} [options.threshold=1024] - The minimum size in bytes to compress. Defaults to 1024 bytes.
* @returns {MiddlewareHandler} The middleware handler function.
*
* @example
* ```ts
* const app = new Hono()
*
* app.use(bunCompress())
* ```
*/
export const compress = (options?: CompressionOptions): MiddlewareHandler => {
// Check CompressionStream support
if (typeof CompressionStream !== 'undefined') {
return originalCompress(options)
}
const threshold = options?.threshold ?? 1024
return async function compress(ctx, next) {
await next()
const contentLength = ctx.res.headers.get('Content-Length')
// Check if response should be compressed
if (
ctx.res.headers.has('Content-Encoding') || // already encoded
ctx.res.headers.has('Transfer-Encoding') || // already encoded or chunked
ctx.req.method === 'HEAD' || // HEAD request
(contentLength && Number(contentLength) < threshold) || // content-length below threshold
!shouldCompress(ctx.res) || // not compressible type
!shouldTransform(ctx.res) // cache-control: no-transform
) {
return
}
const accepted = ctx.req.header('Accept-Encoding')
const encoding =
options?.encoding ?? ENCODING_TYPES.find((encoding) => accepted?.includes(encoding))
if (!encoding || !ctx.res.body) {
return
}
// Compress the response
try {
const compressedStream = encoding === 'gzip' ? createGzip() : createDeflate()
const readableBody = ctx.res.body as ReadableStream
const readableStream = Readable.fromWeb(readableBody)
const compressedBody = readableStream.pipe(compressedStream)
const compressedReadableStream = Readable.toWeb(compressedBody) as ReadableStream<Uint8Array>
// Create a new response with the compressed body
// eslint-disable-next-line @typescript-eslint/no-explicit-any
ctx.res = new Response(compressedReadableStream as any, ctx.res)
ctx.res.headers.delete('Content-Length')
ctx.res.headers.set('Content-Encoding', encoding)
} catch (error) {
console.error('Compression error:', error)
}
}
}
const shouldCompress = (res: Response) => {
const type = res.headers.get('Content-Type')
return type && COMPRESSIBLE_CONTENT_TYPE_REGEX.test(type)
}
const shouldTransform = (res: Response) => {
const cacheControl = res.headers.get('Cache-Control')
// Don't compress for Cache-Control: no-transform
// https://tools.ietf.org/html/rfc7234#section-5.2.2.4
return !cacheControl || !cacheControlNoTransformRegExp.test(cacheControl)
}

View File

@ -0,0 +1,20 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"tsBuildInfoFile": "dist/tsconfig.build.tsbuildinfo",
"emitDeclarationOnly": false,
"types": [
"node",
"bun"
],
},
"include": [
"src/**/*.ts"
],
"exclude": [
"**/*.test.ts"
],
"references": []
}

View File

@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.build.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

View File

@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc/packages/hello",
"types": ["vitest/globals"]
},
"include": ["**/*.test.ts", "vitest.config.ts"],
"references": [
{
"path": "./tsconfig.build.json"
}
]
}

View File

@ -0,0 +1,7 @@
import { defineProject } from 'vitest/config.js'
export default defineProject({
test: {
globals: true,
},
})

View File

@ -1845,6 +1845,22 @@ __metadata:
languageName: unknown
linkType: soft
"@hono/bun-compress@workspace:packages/bun-compress":
version: 0.0.0-use.local
resolution: "@hono/bun-compress@workspace:packages/bun-compress"
dependencies:
"@arethetypeswrong/cli": "npm:^0.17.4"
"@types/bun": "npm:^1.2.12"
"@types/node": "npm:^22.15.15"
publint: "npm:^0.3.9"
tsup: "npm:^8.4.0"
typescript: "npm:^5.8.2"
vitest: "npm:^3.0.8"
peerDependencies:
hono: "*"
languageName: unknown
linkType: soft
"@hono/bun-transpiler@workspace:packages/bun-transpiler":
version: 0.0.0-use.local
resolution: "@hono/bun-transpiler@workspace:packages/bun-transpiler"
@ -3776,6 +3792,15 @@ __metadata:
languageName: node
linkType: hard
"@types/bun@npm:^1.2.12":
version: 1.2.12
resolution: "@types/bun@npm:1.2.12"
dependencies:
bun-types: "npm:1.2.12"
checksum: 168578a09e9408792c44363957b45d6f574b684a87cfb1ec350131a868e5001b6ce5006a1503846ff1afb7cd827911c5538ec27ba24cbc287e5f200b748ebe30
languageName: node
linkType: hard
"@types/caseless@npm:*":
version: 0.12.5
resolution: "@types/caseless@npm:0.12.5"
@ -3917,6 +3942,15 @@ __metadata:
languageName: node
linkType: hard
"@types/node@npm:^22.15.15":
version: 22.15.15
resolution: "@types/node@npm:22.15.15"
dependencies:
undici-types: "npm:~6.21.0"
checksum: 3b0c12531c9057ddcbe1cdd869ca6c2f3ea753f1cb3de0a482ca70c3d86f0dbb1d28339aecd8a0c133febda4f4355c012ed9be6ab866297fc23db360fa218dde
languageName: node
linkType: hard
"@types/prop-types@npm:*":
version: 15.7.14
resolution: "@types/prop-types@npm:15.7.14"
@ -5001,6 +5035,15 @@ __metadata:
languageName: node
linkType: hard
"bun-types@npm:1.2.12":
version: 1.2.12
resolution: "bun-types@npm:1.2.12"
dependencies:
"@types/node": "npm:*"
checksum: 9e6f421f82164f39ada25f202969f267609e477286817395420cf2b0f4d9e98d2243ec5e73f564a4448941d6706a53329e2fbdbdac7e2e6a32f6882ec63eddae
languageName: node
linkType: hard
"bun-types@npm:1.2.5":
version: 1.2.5
resolution: "bun-types@npm:1.2.5"
@ -14038,6 +14081,13 @@ __metadata:
languageName: node
linkType: hard
"undici-types@npm:~6.21.0":
version: 6.21.0
resolution: "undici-types@npm:6.21.0"
checksum: c01ed51829b10aa72fc3ce64b747f8e74ae9b60eafa19a7b46ef624403508a54c526ffab06a14a26b3120d055e1104d7abe7c9017e83ced038ea5cf52f8d5e04
languageName: node
linkType: hard
"undici@npm:*":
version: 7.5.0
resolution: "undici@npm:7.5.0"