diff --git a/.changeset/funny-rings-sit.md b/.changeset/funny-rings-sit.md
new file mode 100644
index 00000000..55b9a33a
--- /dev/null
+++ b/.changeset/funny-rings-sit.md
@@ -0,0 +1,5 @@
+---
+'@hono/bun-compress': minor
+---
+
+Created Bun Compress Middleware
diff --git a/packages/bun-compress/README.md b/packages/bun-compress/README.md
new file mode 100644
index 00000000..0611467b
--- /dev/null
+++ b/packages/bun-compress/README.md
@@ -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
+
+### 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.
+
+### threshold: `number`
+
+The minimum size in bytes to compress. Defaults to 1024 bytes.
diff --git a/packages/bun-compress/package.json b/packages/bun-compress/package.json
new file mode 100644
index 00000000..6fec3d56
--- /dev/null
+++ b/packages/bun-compress/package.json
@@ -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"
+ }
+}
diff --git a/packages/bun-compress/src/index.test.ts b/packages/bun-compress/src/index.test.ts
new file mode 100644
index 00000000..e7c190ce
--- /dev/null
+++ b/packages/bun-compress/src/index.test.ts
@@ -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 {
+ 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()
+}
diff --git a/packages/bun-compress/src/index.ts b/packages/bun-compress/src/index.ts
new file mode 100644
index 00000000..4ff5c86f
--- /dev/null
+++ b/packages/bun-compress/src/index.ts
@@ -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
+
+ // 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)
+}
diff --git a/packages/bun-compress/tsconfig.build.json b/packages/bun-compress/tsconfig.build.json
new file mode 100644
index 00000000..3f408d1d
--- /dev/null
+++ b/packages/bun-compress/tsconfig.build.json
@@ -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": []
+}
\ No newline at end of file
diff --git a/packages/bun-compress/tsconfig.json b/packages/bun-compress/tsconfig.json
new file mode 100644
index 00000000..d4d0929e
--- /dev/null
+++ b/packages/bun-compress/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "files": [],
+ "include": [],
+ "references": [
+ {
+ "path": "./tsconfig.build.json"
+ },
+ {
+ "path": "./tsconfig.spec.json"
+ }
+ ]
+}
diff --git a/packages/bun-compress/tsconfig.spec.json b/packages/bun-compress/tsconfig.spec.json
new file mode 100644
index 00000000..887243a2
--- /dev/null
+++ b/packages/bun-compress/tsconfig.spec.json
@@ -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"
+ }
+ ]
+}
diff --git a/packages/bun-compress/vitest.config.ts b/packages/bun-compress/vitest.config.ts
new file mode 100644
index 00000000..b678ce25
--- /dev/null
+++ b/packages/bun-compress/vitest.config.ts
@@ -0,0 +1,7 @@
+import { defineProject } from 'vitest/config.js'
+
+export default defineProject({
+ test: {
+ globals: true,
+ },
+})
diff --git a/yarn.lock b/yarn.lock
index b83e3373..30bd88f5 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -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"