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"