From 0f8a9afcd0061f2ab21dcf67b8a96e165da2e5d7 Mon Sep 17 00:00:00 2001 From: parkrevil Date: Sun, 29 Mar 2026 20:51:09 +0900 Subject: [PATCH] feat(compression): add HTTP response compression middleware Add @zipbul/compression package with support for gzip, deflate, and brotli encoding, including content negotiation, threshold-based compression, and integration with the HTTP adapter pipeline. Co-Authored-By: Claude Opus 4.6 (1M context) --- bun.lock | 48 +- packages/compression/COMPRESSION.md | 483 +++++++++ packages/compression/HTTP-ADAPTER-REQUEST.md | 202 ++++ packages/compression/index.ts | 6 + packages/compression/package.json | 64 ++ packages/compression/src/compressors.spec.ts | 86 ++ packages/compression/src/compressors.ts | 23 + packages/compression/src/constants.spec.ts | 123 +++ packages/compression/src/constants.ts | 21 + packages/compression/src/encoding.spec.ts | 295 ++++++ packages/compression/src/encoding.ts | 82 ++ packages/compression/src/enums.ts | 14 + packages/compression/src/htb.spec.ts | 213 ++++ packages/compression/src/htb.ts | 113 ++ packages/compression/src/interfaces.ts | 28 + packages/compression/src/middleware.ts | 123 +++ packages/compression/src/options.spec.ts | 330 ++++++ packages/compression/src/options.ts | 94 ++ packages/compression/src/types.ts | 10 + .../test/e2e/compression-server.test.ts | 142 +++ .../test/integration/compression.test.ts | 961 ++++++++++++++++++ packages/compression/tsconfig.build.json | 12 + packages/compression/tsconfig.json | 3 + packages/shared/src/enums/http-header.ts | 6 + packages/shared/src/enums/http-status.ts | 2 + 25 files changed, 3477 insertions(+), 7 deletions(-) create mode 100644 packages/compression/COMPRESSION.md create mode 100644 packages/compression/HTTP-ADAPTER-REQUEST.md create mode 100644 packages/compression/index.ts create mode 100644 packages/compression/package.json create mode 100644 packages/compression/src/compressors.spec.ts create mode 100644 packages/compression/src/compressors.ts create mode 100644 packages/compression/src/constants.spec.ts create mode 100644 packages/compression/src/constants.ts create mode 100644 packages/compression/src/encoding.spec.ts create mode 100644 packages/compression/src/encoding.ts create mode 100644 packages/compression/src/enums.ts create mode 100644 packages/compression/src/htb.spec.ts create mode 100644 packages/compression/src/htb.ts create mode 100644 packages/compression/src/interfaces.ts create mode 100644 packages/compression/src/middleware.ts create mode 100644 packages/compression/src/options.spec.ts create mode 100644 packages/compression/src/options.ts create mode 100644 packages/compression/src/types.ts create mode 100644 packages/compression/test/e2e/compression-server.test.ts create mode 100644 packages/compression/test/integration/compression.test.ts create mode 100644 packages/compression/tsconfig.build.json create mode 100644 packages/compression/tsconfig.json diff --git a/bun.lock b/bun.lock index 1a1201d..52f4386 100644 --- a/bun.lock +++ b/bun.lock @@ -13,9 +13,25 @@ "typescript": "^5", }, }, + "packages/compression": { + "name": "@zipbul/compression", + "version": "0.0.1", + "dependencies": { + "@zipbul/result": "workspace:*", + "@zipbul/shared": "workspace:*", + }, + "devDependencies": { + "@zipbul/common": "^0.1.0", + "@zipbul/http-adapter": "^0.1.0", + }, + "peerDependencies": { + "@zipbul/common": "^0.1.0", + "@zipbul/http-adapter": "^0.1.0", + }, + }, "packages/cors": { "name": "@zipbul/cors", - "version": "0.1.0", + "version": "0.1.3", "dependencies": { "@zipbul/result": "workspace:*", "@zipbul/shared": "workspace:*", @@ -24,7 +40,7 @@ }, "packages/multipart": { "name": "@zipbul/multipart", - "version": "0.0.0", + "version": "0.1.0", "dependencies": { "@zipbul/result": "workspace:*", "@zipbul/shared": "workspace:*", @@ -35,7 +51,7 @@ }, "packages/query-parser": { "name": "@zipbul/query-parser", - "version": "0.2.0", + "version": "0.2.3", "dependencies": { "@zipbul/result": "workspace:*", }, @@ -47,7 +63,7 @@ }, "packages/rate-limiter": { "name": "@zipbul/rate-limiter", - "version": "0.2.0", + "version": "0.2.3", "dependencies": { "@zipbul/result": "workspace:*", }, @@ -57,11 +73,11 @@ }, "packages/result": { "name": "@zipbul/result", - "version": "0.1.4", + "version": "0.1.7", }, "packages/router": { "name": "@zipbul/router", - "version": "0.1.0", + "version": "0.2.2", "dependencies": { "@zipbul/result": "workspace:*", "@zipbul/shared": "workspace:*", @@ -79,7 +95,7 @@ }, "packages/shared": { "name": "@zipbul/shared", - "version": "0.0.8", + "version": "0.0.11", }, }, "packages": { @@ -169,8 +185,20 @@ "@types/serve-static": ["@types/serve-static@2.2.0", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*" } }, "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ=="], + "@zipbul/baker": ["@zipbul/baker@0.1.2", "", { "dependencies": { "@zipbul/result": "^0.1.4" } }, "sha512-EA78997tOvRm3outpSd4kVrOL+n/NxfdDn9uM9DQL4iwRJFZvK6Qa/vasvbnda5iujyVA8HOS61w0cZkA3Kgaw=="], + + "@zipbul/common": ["@zipbul/common@0.1.1", "", { "dependencies": { "@zipbul/baker": "^0.1.0", "@zipbul/result": "^0.1.4" } }, "sha512-z/MDUSQr4BUkNDzhw+zmZSSnD5W4VbN0+jtyuYx5qRdTp1+2OZajvg+QUUeNSun4IyasvJhPTWrHL9w+cCgnSw=="], + + "@zipbul/compression": ["@zipbul/compression@workspace:packages/compression"], + + "@zipbul/core": ["@zipbul/core@0.1.1", "", { "dependencies": { "@zipbul/baker": "^0.1.0", "@zipbul/common": "0.1.1", "@zipbul/logger": "0.1.1", "dotenv": "^16.4.5", "exponential-backoff": "^3.1.2" } }, "sha512-D1zBxaNB0YDDn2sgPMxsB3Zz5SxH780zTAbURDFKxKeLWXxJIsGusHwYoMRzszDTBi4OpRLLaDqyENQ4dNHCPw=="], + "@zipbul/cors": ["@zipbul/cors@workspace:packages/cors"], + "@zipbul/http-adapter": ["@zipbul/http-adapter@0.1.1", "", { "dependencies": { "http-status-codes": "^2.3" }, "peerDependencies": { "@zipbul/baker": "^0.1.0", "@zipbul/common": "0.1.1", "@zipbul/core": "0.1.1", "@zipbul/logger": "0.1.1" } }, "sha512-xM2sqWgRSF+D2kQVBmtTea/vxN/E9h7g3/G/c3oVhLApGhBKogu9fcjD/4vp/04qgKSI8eKkEIK8jZi7aXz+dA=="], + + "@zipbul/logger": ["@zipbul/logger@0.1.1", "", { "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-shnaZ5WA5Q/RkIcqDluK/SB88KCXPtv1h3dCx5gQMvRBNC0LnBocVP9aBAdAaiwlOqLqrEH2hAm6u0BAL0voVw=="], + "@zipbul/multipart": ["@zipbul/multipart@workspace:packages/multipart"], "@zipbul/query-parser": ["@zipbul/query-parser@workspace:packages/query-parser"], @@ -217,6 +245,8 @@ "dir-glob": ["dir-glob@3.0.1", "", { "dependencies": { "path-type": "^4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="], + "dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "enquirer": ["enquirer@2.4.1", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="], @@ -229,6 +259,8 @@ "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], + "exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="], + "extendable-error": ["extendable-error@0.1.7", "", {}, "sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg=="], "fast-check": ["fast-check@4.5.3", "", { "dependencies": { "pure-rand": "^7.0.0" } }, "sha512-IE9csY7lnhxBnA8g/WI5eg/hygA6MGWJMSNfFRrBlXUciADEhS1EDB0SIsMSvzubzIlOBbVITSsypCsW717poA=="], @@ -271,6 +303,8 @@ "hono": ["hono@4.12.3", "", {}, "sha512-SFsVSjp8sj5UumXOOFlkZOG6XS9SJDKw0TbwFeV+AJ8xlST8kxK5Z/5EYa111UY8732lK2S/xB653ceuaoGwpg=="], + "http-status-codes": ["http-status-codes@2.3.0", "", {}, "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA=="], + "human-id": ["human-id@4.1.3", "", { "bin": { "human-id": "dist/cli.js" } }, "sha512-tsYlhAYpjCKa//8rXZ9DqKEawhPoSytweBC2eNvcaDK+57RZLHGqNs3PZTQO6yekLFSuvA6AlnAfrw1uBvtb+Q=="], "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], diff --git a/packages/compression/COMPRESSION.md b/packages/compression/COMPRESSION.md new file mode 100644 index 0000000..5156d98 --- /dev/null +++ b/packages/compression/COMPRESSION.md @@ -0,0 +1,483 @@ +# Compression Middleware — 심층 분석 + +## 1. 아키텍처 개요 + +compression 미들웨어는 Zipbul HTTP 어댑터의 응답 파이프라인에서 동작하는 **동기 버퍼 기반** 압축 모듈이다. + +``` +[Handler] → body (object/string/Uint8Array/...) + ↓ +[compressionMiddleware] + ├─ RFC 9110 precondition checks + ├─ Accept-Encoding negotiation + ├─ serializeBody() → Uint8Array + ├─ BUFFER_COMPRESSORS[enc] → Uint8Array (동기 압축) + ├─ injectPadding (optional) → Uint8Array (BREACH 완화) + └─ response.setBody() → 압축된 바이너리로 교체 + ↓ +[HttpResponse.build()] → Bun Response +``` + +요청 하나에 최소 **3개의 full-size 버퍼**가 동시에 메모리에 존재한다: + +1. 원본 body (object → `JSON.stringify` → string 중간체) +2. `serializeBody` 결과 (`TextEncoder.encode` → `Uint8Array`) +3. 압축 결과 (`Uint8Array`) + +BREACH 패딩 적용 시 4번째 할당이 추가된다. + +--- + +## 2. 모듈별 라인 분석 + +### 2.1 `compressors.ts` — 동기 전용 압축기 + +```typescript +// compressors.ts:10-23 +BUFFER_COMPRESSORS: Record = { + [Encoding.Gzip]: (data, level) => Bun.gzipSync(data, { level }), + [Encoding.Deflate]: (data, level) => deflateSync(data, { level }), + [Encoding.Brotli]: (data, level) => brotliCompressSync(data, { params }), + [Encoding.Zstd]: (data, level) => Bun.zstdCompressSync(data, { level }), +} +``` + +**현재 런타임 사용 현황:** + +| 인코딩 | 버퍼 압축 API | 런타임 | +|--------|--------------|--------| +| Gzip | `Bun.gzipSync()` | Bun native | +| Deflate | `node:zlib deflateSync()` | Node 호환 레이어 | +| Brotli | `node:zlib brotliCompressSync()` | Node 호환 레이어 | +| Zstd | `Bun.zstdCompressSync()` | Bun native | + +Deflate가 `node:zlib`을 사용하는 이유: `Bun.deflateSync()`는 RFC 1951 raw deflate를 생성하지만, +HTTP `Content-Encoding: deflate`는 RFC 1950 zlib-wrapped format을 요구한다. (compressors.ts:13-14 주석) + +### 2.2 `middleware.ts` — 핵심 로직 + +#### 2.2.1 `serializeBody` (line 17-22) + +```typescript +function serializeBody(body: string | number | boolean | Uint8Array | ArrayBuffer | object): Uint8Array { + if (body instanceof Uint8Array) return body; + if (body instanceof ArrayBuffer) return new Uint8Array(body); + if (typeof body === 'string') return encoder.encode(body); + return encoder.encode(JSON.stringify(body)); // object 경로 +} +``` + +object body의 직렬화 경로: `object → JSON.stringify() → string → TextEncoder.encode() → Uint8Array` + +`JSON.stringify`가 string을 반환하고, 다시 `encode`로 Uint8Array로 변환한다. +대형 JSON body에서 이 string 중간체가 GC 압박을 준다. + +`HttpResponse.build()`도 JSON body에 대해 `JSON.stringify()`를 수행하지만, compression 미들웨어가 +body를 `Uint8Array`로 교체하므로 build() 단계에서의 재직렬화는 발생하지 않는다. + +#### 2.2.2 RFC 9110 프리컨디션 체크 (line 54-68) + +```typescript +// RFC 9110 §15: body가 없어야 하는 상태 코드 +const status = response.getStatus() as number; +if (status < 200 || status === 204 || status === 205 || status === 304) return; + +// RFC 9110 §9.3.2: HEAD는 content 없음 +if (request.httpMethod === 'HEAD') return; + +// body 없음 또는 이미 인코딩됨 +const body = response.getBody(); +if (body === undefined || body === null) return; +if (response.getHeader(HttpHeader.ContentEncoding) !== null) return; +``` + +body가 null이거나 상태 코드가 body 금지인 경우 **Vary 헤더를 설정하지 않고** 즉시 리턴한다. +RFC 9110 §12.5.1에 의해 content negotiation이 발생하지 않는 경우 Vary를 설정하지 않는 것이 올바르다. + +#### 2.2.3 Vary 헤더 처리 (line 70-75) + +```typescript +const existingVary = response.getHeader(HttpHeader.Vary); +if (existingVary === null || !hasVaryEncoding(existingVary)) { + response.appendHeader(HttpHeader.Vary, HttpHeader.AcceptEncoding); +} +``` + +Accept-Encoding을 고려한 이상, 실제 압축 여부와 무관하게 `Vary: Accept-Encoding`을 설정한다. +이는 캐시 무효화를 올바르게 처리하기 위한 것이다. + +이 시점 이후의 모든 early return (no-transform, content-type 필터, threshold 미달)에서도 Vary는 이미 설정된 상태다. + +#### 2.2.4 negotiation 선행, 직렬화 후행 (line 84-94) + +```typescript +// Accept-Encoding과 negotiation을 먼저 수행 (line 84-91) +const acceptHeader = request.headers.get(HttpHeader.AcceptEncoding); +if (acceptHeader === null || acceptHeader === '') return; + +const clientPrefs = parseAcceptEncoding(acceptHeader); +const encoding = negotiateEncoding(effectiveEncodings, clientPrefs); +if (encoding === null) return; + +// negotiation 성공 후에만 body 직렬화 (line 93-94) +const bytes = serializeBody(body); +if (bytes.byteLength < resolved.threshold) return; +``` + +encoding negotiation을 body 직렬화 **이전**에 수행하여, 매칭되는 인코딩이 없을 때 +불필요한 `JSON.stringify` + `TextEncoder.encode`를 회피한다. + +그러나 threshold 체크(line 94)는 직렬화 **이후**에 수행된다. +body가 object일 때 `JSON.stringify` 비용이 이미 발생한 후 threshold 미달로 폐기되는 경로가 존재한다. + +#### 2.2.5 압축 실행 및 에러 처리 (line 96-101) + +```typescript +let compressed: Uint8Array; +try { + compressed = BUFFER_COMPRESSORS[encoding](bytes, resolved.level[encoding]); +} catch { + return; // silent return — body 변경 없음 +} +``` + +`*Sync` 호출이므로 이벤트 루프를 블로킹한다. +Brotli quality 4 + 100KB body 기준 수 밀리초, 고 트래픽 시 tail latency에 영향. + +에러 발생 시 silent return하며, 이미 직렬화된 `bytes`는 response에 반영되지 않는다. +원본 object body가 그대로 유지되어 `HttpResponse.build()`가 재직렬화한다. + +#### 2.2.6 Content-Length 처리 (line 112-115) + +```typescript +response + .setBody(compressed) + .setHeader(HttpHeader.ContentEncoding, encoding) + .removeHeader(HttpHeader.ContentLength); +``` + +`Content-Length`를 삭제하지만, 압축 후 `compressed.byteLength`를 알 수 있음에도 재설정하지 않는다. + +버퍼 기반 응답에서 `Content-Length`가 없으면: +- HTTP/1.1: `Transfer-Encoding: chunked`로 전송됨 (Bun 자동 처리) +- 클라이언트가 다운로드 진행률을 알 수 없음 +- keep-alive 연결 효율이 저하될 수 있음 + +스트리밍 응답에서는 `Content-Length` 생략이 정당하지만, 버퍼 기반에서는 설정하는 것이 올바르다. + +#### 2.2.7 ETag 약화 (line 117-121) + +```typescript +const etag = response.getHeader(HttpHeader.ETag); +if (etag !== null) { + response.setHeader(HttpHeader.ETag, weakenETag(etag)); +} +``` + +RFC 9110 §8.8.1: content transformation 후 strong ETag를 유지하면 바이트 동일성 보장이 깨진다. +`W/` prefix를 붙여 weak ETag로 변환한다. 이미 weak인 경우 그대로 유지. + +### 2.3 `encoding.ts` — Accept-Encoding 파싱 및 협상 + +#### 2.3.1 `parseAcceptEncoding` (line 15-46) + +RFC 9110 §12.5.3 준수. `x-gzip` → `gzip`, `x-compress` → `compress` 에일리어스 처리. +quality value 파싱 후 quality 내림차순 정렬. + +#### 2.3.2 `negotiateEncoding` (line 55-82) + +```typescript +for (const encoding of serverEncodings) { + const quality = clientMap.get(encoding) ?? wildcardQuality; + if (quality > 0 && quality > bestQuality) { + best = encoding; + bestQuality = quality; + } +} +``` + +서버 인코딩 목록을 순회하며 클라이언트 quality가 가장 높은 것을 선택한다. +동일 quality일 때 `quality > bestQuality` (strict greater-than)이므로 +첫 번째 매칭이 유지된다. 이는 서버 선호 순서를 tie-breaker로 사용하는 의도된 동작이다. + +wildcard `*`는 명시적으로 지정되지 않은 인코딩에 대한 fallback quality로 사용된다. +`*;q=0`은 "명시되지 않은 모든 인코딩 거부"를 의미한다. + +### 2.4 `htb.ts` — BREACH 완화 + +#### 2.4.1 Gzip 패딩 (line 40-87) + +RFC 1952 §2.3.1의 FEXTRA 필드에 `"ZP"` (0x5a50) 서브필드 식별자로 랜덤 패딩을 주입한다. + +두 가지 경로: +- **기존 FEXTRA 있음** (line 45-69): 기존 XLEN을 읽고, 새 서브필드를 뒤에 append + - XLEN 오버플로우 시 (>0xFFFF) 패딩 없이 원본 복사 반환 +- **기존 FEXTRA 없음** (line 72-86): FLG에 FEXTRA 플래그 설정, XLEN + 서브필드 삽입 + +패딩 데이터는 `Uint8Array` 생성자의 zero-fill에 의존한다 (랜덤 바이트 불필요, 크기만 랜덤이면 됨). + +#### 2.4.2 Zstd 패딩 (line 91-113) + +RFC 8878 §3.1.2의 Skippable Frame을 압축 데이터 **앞에** prepend한다. + +``` +[4B magic: 0x184D2A50] [4B frame_size] [padLen bytes padding] [compressed data] +``` + +RFC 호환 디컴프레서는 Skippable Frame을 무시하므로 투명하다. + +#### 2.4.3 CSPRNG (line 21-30) + +```typescript +function randomPadLen(maxPadding: number): number { + const limit = 0x100000000 - (0x100000000 % maxPadding); + let value: number; + do { + crypto.getRandomValues(rngBuf); + value = rngBuf[0]!; + } while (value >= limit); + return 1 + (value % maxPadding); +} +``` + +rejection sampling으로 [1, maxPadding] 범위의 bias-free 랜덤 정수를 생성한다. +`rngBuf`가 모듈 레벨 `Uint32Array(1)`로 재사용되어 할당을 최소화한다. + +### 2.5 `options.ts` — 설정 해석 및 검증 + +#### 2.5.1 기본값 (constants.ts) + +| 설정 | 기본값 | +|------|--------| +| threshold | 1024 bytes | +| encodings | `[Brotli, Gzip]` | +| level (Brotli) | 4 | +| level (Gzip) | 6 | +| level (Deflate) | 6 | +| level (Zstd) | 3 | +| filter | `text/*` (event-stream 제외), `application/json`, `application/xml` 계열, `image/svg+xml` | + +#### 2.5.2 레벨 범위 + +| 인코딩 | 최소 | 최대 | +|--------|------|------| +| Gzip | 1 | 9 | +| Deflate | 1 | 9 | +| Brotli | 0 | 11 | +| Zstd | 1 | 19 | + +Zstd max=19는 RFC 9659의 8MB window size 제한을 반영한다 (level 20+은 128MB window 요구). + +#### 2.5.3 BREACH 검증 + +BREACH 패딩은 Gzip과 Zstd만 지원한다 (`BREACH_SAFE_ENCODINGS`). +Brotli/Deflate는 포맷 레벨 패딩을 안전하게 주입할 수 있는 필드가 없다. + +BREACH 활성화 시 `effectiveEncodings`에서 non-safe 인코딩을 필터링한다 (middleware.ts:46-48). + +### 2.6 `constants.ts` — 필터 패턴 + +```typescript +const COMPRESSIBLE_PATTERN = + /^text\/(?!event-stream\b)|^application\/(?:json|javascript|xml|...)|^image\/svg\+xml/i; +``` + +`text/event-stream`을 negative lookahead로 제외한다. +SSE 스트림은 실시간 전송이므로 버퍼링/압축이 부적절하다. + +--- + +## 3. 스트리밍 압축 — Bun Web API + +### 3.1 `CompressionStream` (Bun v1.3.3+) + +Bun v1.3.3 (2025-11)부터 Web API 표준 `CompressionStream` / `DecompressionStream`을 네이티브로 지원한다. +내부적으로 zlib 네이티브 바인딩을 사용하며, `node:zlib` 호환 레이어를 거치지 않는다. + +**지원 포맷:** + +| 포맷 문자열 | 인코딩 | 표준 여부 | +|-------------|--------|-----------| +| `"gzip"` | Gzip (RFC 1952) | Web API 표준 | +| `"deflate"` | Deflate zlib-wrapped (RFC 1950) | Web API 표준 | +| `"deflate-raw"` | Deflate raw (RFC 1951) | Web API 표준 | +| `"brotli"` | Brotli (RFC 7932) | Bun 확장 | +| `"zstd"` | Zstandard (RFC 8878) | Bun 확장 | + +**Zstd 포함 전 인코딩이 스트리밍 가능하다.** `node:zlib`이 불필요하다. + +### 3.2 사용 패턴 + +```typescript +// ReadableStream을 CompressionStream으로 파이프 +const compressed: ReadableStream = body + .pipeThrough(new CompressionStream("gzip")); + +// Bun.serve()에서 스트리밍 응답 +return new Response(compressed, { + headers: { "Content-Encoding": "gzip" }, +}); +``` + +`CompressionStream`은 `TransformStream`을 구현한다: +- `writable`: 비압축 데이터를 쓰는 `WritableStream` +- `readable`: 압축된 데이터를 읽는 `ReadableStream` + +청크 단위로 압축하며, 전체 body를 버퍼링하지 않는다. + +### 3.3 현재 버퍼 API vs 스트리밍 API 비교 + +| | 버퍼 모드 (현재) | 스트리밍 모드 (목표) | +|---|---|---| +| **API** | `Bun.gzipSync()` / `Bun.zstdCompressSync()` / `node:zlib *Sync` | `CompressionStream` (Web API) | +| **런타임** | Bun native + node:zlib 혼용 | Bun native 단일 | +| **이벤트 루프** | 블로킹 | 논블로킹 | +| **메모리** | 전체 body 3~4회 할당 | 청크 단위, 상수 메모리 | +| **body 타입** | `Uint8Array` | `ReadableStream` | +| **Content-Length** | 압축 후 알 수 있음 (현재 미설정) | 알 수 없음 (chunked) | +| **BREACH 패딩** | 압축 후 바이너리 조작 | 별도 전략 필요 | +| **최소 Bun** | >=1.2.0 | >=1.3.3 | + +### 3.4 `CompressionStream` 포맷 문자열과 `Encoding` enum 매핑 + +```typescript +// 현재 Encoding enum +enum Encoding { + Brotli = 'br', + Zstd = 'zstd', + Gzip = 'gzip', + Deflate = 'deflate', +} + +// CompressionStream 포맷 문자열 매핑 +const STREAM_FORMAT: Record = { + [Encoding.Gzip]: 'gzip', + [Encoding.Deflate]: 'deflate', // zlib-wrapped (RFC 1950) — Web API 표준 동작 + [Encoding.Brotli]: 'brotli', // Bun 확장 + [Encoding.Zstd]: 'zstd', // Bun 확장 +}; +``` + +Deflate: `CompressionStream("deflate")`는 RFC 1950 zlib-wrapped format을 생성한다. +현재 `node:zlib deflateSync()`을 사용하는 것과 동일한 포맷이므로 호환성 문제 없음. + +--- + +## 4. HTTP 어댑터 제약사항 — 스트리밍의 구조적 장벽 + +### 4.1 현재 타입 시스템 + +```typescript +// http-adapter/src/types.ts +type ResponseBodyValue = RequestBodyValue | string | Uint8Array | ArrayBuffer | null; +// → ReadableStream이 없다 +``` + +`HttpResponse.setBody()`는 `ResponseBodyValue | undefined`만 수용한다. + +### 4.2 응답 정규화 + +`normalizeWorkerBody()` (http-response.ts)는 다음 타입만 처리한다: +- `string` → passthrough +- `Uint8Array` → passthrough +- `ArrayBuffer` → passthrough +- `number | boolean` → `String()` 변환 +- `null | undefined` → null + +`ReadableStream`이 들어오면 **처리할 경로가 없다**. + +### 4.3 Response 이스케이프 해치의 한계 + +핸들러가 raw `Response` 객체를 반환할 때: + +```typescript +// http-adapter.ts writeSuccessResponse (line 392) +const arrayBuffer = await result.arrayBuffer(); +if (arrayBuffer.byteLength > 0) { + res.setBody(new Uint8Array(arrayBuffer)); +} +``` + +`ReadableStream` body를 가진 `Response`도 **전체를 `arrayBuffer()`로 버퍼링**한다. +스트리밍의 의미가 완전히 무효화된다. + +### 4.4 Bun 런타임 수준의 지원 + +`Bun.serve()`의 `fetch` 핸들러는 `new Response(ReadableStream)`을 네이티브로 지원한다. +**Bun은 스트리밍을 지원하지만, Zipbul의 HttpResponse 추상화가 이를 차단하고 있다.** + +어댑터에 필요한 변경 사항은 별도 문서 참고: [HTTP-ADAPTER-REQUEST.md](./HTTP-ADAPTER-REQUEST.md) + +--- + +## 5. 스트리밍 구현을 위한 설계 결정 + +### 5.1 Threshold 전략 + +버퍼 모드에서는 `bytes.byteLength < threshold`로 판단한다. +스트리밍에서는 전체 크기를 사전에 알 수 없다. + +선택지: +- **A**: `Content-Length` 헤더가 있으면 그것으로 판단, 없으면 항상 압축 +- **B**: 첫 N bytes를 내부 버퍼에 모아 threshold 도달 여부 확인 후 결정 + - 도달하면 버퍼 + 이후 청크를 압축 스트림으로 전달 + - 미달하면 버퍼를 그대로 flush (압축 없이) +- **C**: 스트리밍 모드에서는 threshold를 무시 (body가 스트림이면 항상 압축 대상으로 간주) + +### 5.2 BREACH 패딩과 스트리밍 + +**Gzip**: `CompressionStream("gzip")`이 gzip 헤더를 자동 생성한다. +패딩을 주입하려면: +- **방법 A**: 첫 청크를 가로채서 gzip 헤더의 FLG/XLEN/FEXTRA를 수정 +- **방법 B**: `CompressionStream("deflate-raw")` + 직접 gzip 헤더 생성 (FEXTRA 포함) + trailer 생성 +- **방법 C**: 스트리밍 모드에서는 BREACH 패딩을 비활성화 + +**Zstd**: Skippable Frame을 첫 번째 청크로 전송하고, 이후 `CompressionStream("zstd")` 출력을 이으면 된다. +`ReadableStream` 두 개를 concat하는 방식으로 구현 가능하다. + +### 5.3 Content-Length 처리 + +버퍼 모드: 압축 후 `compressed.byteLength`를 `Content-Length`로 설정해야 한다. (현재 미설정) +스트리밍 모드: `Content-Length`를 설정할 수 없다 (`Transfer-Encoding: chunked` 사용). + +### 5.4 `CompressionStream`의 레벨 설정 + +Web API `CompressionStream`은 **압축 레벨 파라미터를 받지 않는다**. +생성자 시그니처: `new CompressionStream(format: string)` + +현재 `options.level`에서 인코딩별 레벨을 설정하는 기능이 있지만, +`CompressionStream`에서는 런타임 기본 레벨이 사용된다. + +선택지: +- **A**: 스트리밍 모드에서는 레벨 설정을 무시 (런타임 기본값 사용) +- **B**: `node:zlib` Transform stream을 사용하여 레벨 설정 유지 (Bun-first 원칙 위반) +- **C**: 레벨 설정이 필요한 경우 버퍼 모드로 fallback + +--- + +## 6. 스트리밍 가용 런타임 요약 + +| 인코딩 | 버퍼 압축 | 스트리밍 압축 | 비고 | +|--------|-----------|---------------|------| +| Gzip | `Bun.gzipSync()` | `CompressionStream("gzip")` | Bun native | +| Deflate | `node:zlib deflateSync()` | `CompressionStream("deflate")` | 스트리밍은 Bun native로 전환 가능 | +| Brotli | `node:zlib brotliCompressSync()` | `CompressionStream("brotli")` | 스트리밍은 Bun native로 전환 가능 | +| Zstd | `Bun.zstdCompressSync()` | `CompressionStream("zstd")` | Bun native | + +**Bun v1.3.3+ 기준 전 인코딩이 스트리밍 가능. `node:zlib` 의존 없이 Web API 단일 경로.** + +--- + +## 7. 구현 우선순위 + +| 순위 | 항목 | 위치 | 영향 | +|------|------|------|------| +| P0 | HTTP 어댑터 스트리밍 지원 (별도 요청서 참고) | http-adapter | 스트리밍 자체가 불가능 | +| P1 | `CompressionStream` 기반 스트림 압축기 | compression | Bun native 스트리밍 | +| P1 | 미들웨어 `ReadableStream` body 분기 | compression/middleware.ts | 스트림 body 감지 → 스트림 압축 경로 | +| P2 | 버퍼 모드 `Content-Length` 재설정 | compression/middleware.ts:115 | HTTP/1.1 효율 | +| P2 | `serializeBody` 중간 string 할당 최적화 | compression/middleware.ts:21 | 대형 JSON GC 압박 | +| P2 | `engines.bun` 버전 범프 `>=1.2.0` → `>=1.3.3` | compression/package.json | `CompressionStream` 최소 요구 | +| P3 | 스트리밍 threshold 전략 결정 | compression | 설계 결정 | +| P3 | 스트리밍 BREACH 패딩 전략 결정 | compression/htb.ts | 구현 복잡도 | +| P3 | `CompressionStream` 레벨 미지원 대응 전략 | compression | 설계 결정 | diff --git a/packages/compression/HTTP-ADAPTER-REQUEST.md b/packages/compression/HTTP-ADAPTER-REQUEST.md new file mode 100644 index 0000000..ffed08b --- /dev/null +++ b/packages/compression/HTTP-ADAPTER-REQUEST.md @@ -0,0 +1,202 @@ +# HTTP Adapter 스트리밍 지원 요청서 + +> **From**: `@zipbul/compression` +> **To**: `@zipbul/http-adapter` +> **목적**: 응답 스트리밍 압축을 위한 `ReadableStream` body 지원 + +--- + +## 배경 + +`@zipbul/compression`은 현재 동기 버퍼 기반으로 동작한다. +모든 body를 `Uint8Array`로 직렬화한 뒤 `*Sync` API로 압축한다. + +Bun v1.3.3+는 `CompressionStream` Web API를 네이티브로 제공하며, +gzip, deflate, brotli, zstd 전 인코딩에 대한 **스트리밍 압축**을 지원한다. + +```typescript +const compressed = readableStream.pipeThrough(new CompressionStream("gzip")); +return new Response(compressed); // Bun.serve()가 네이티브로 스트리밍 전송 +``` + +그러나 현재 `@zipbul/http-adapter`의 `HttpResponse` 추상화가 +`ReadableStream`을 body 타입으로 수용하지 않아 **스트리밍 경로 자체가 존재하지 않는다**. + +--- + +## 현재 문제점 + +### 1. `ResponseBodyValue`에 `ReadableStream` 없음 + +```typescript +// http-adapter/src/types.ts (현재) +type ResponseBodyValue = RequestBodyValue | string | Uint8Array | ArrayBuffer | null; +``` + +`HttpResponse.setBody()`가 `ReadableStream`을 받을 수 없다. +미들웨어가 body를 `ReadableStream`으로 교체할 방법이 없다. + +### 2. `normalizeWorkerBody()`에 `ReadableStream` 경로 없음 + +```typescript +// http-response.ts normalizeWorkerBody (현재) +// string, Uint8Array, ArrayBuffer, number, boolean, null만 처리 +// ReadableStream이 들어오면 처리 불가 +``` + +### 3. `writeSuccessResponse()`가 `Response` body를 전체 버퍼링 + +```typescript +// http-adapter.ts (현재) +const arrayBuffer = await result.arrayBuffer(); +if (arrayBuffer.byteLength > 0) { + res.setBody(new Uint8Array(arrayBuffer)); +} +``` + +핸들러가 `new Response(ReadableStream)`을 반환해도, +어댑터가 `arrayBuffer()`로 전체를 메모리에 읽어들인다. +스트리밍의 의미가 완전히 무효화된다. + +### 4. `HttpResponse.build()`의 JSON 직렬화가 스트림과 충돌 + +`build()`는 body가 object면 `JSON.stringify()`를 수행한다. +`ReadableStream`은 object이므로, build()가 이를 JSON으로 직렬화하려 시도할 수 있다. + +--- + +## 요청 사항 + +### R1. `ResponseBodyValue`에 `ReadableStream` 추가 + +```typescript +// 변경 요청 +type ResponseBodyValue = + | RequestBodyValue + | string + | Uint8Array + | ArrayBuffer + | ReadableStream // 추가 + | null; +``` + +### R2. `HttpResponse.setBody()` / `getBody()`가 `ReadableStream` 수용 + +`setBody()`가 `ReadableStream`를 저장할 수 있어야 한다. +`getBody()`가 `ReadableStream`를 반환할 수 있어야 한다. + +### R3. `normalizeWorkerBody()`에 `ReadableStream` passthrough 추가 + +```typescript +// 변경 요청 +function normalizeWorkerBody(body: ResponseBodyValue | undefined): BodyInit | null { + // ... 기존 로직 ... + if (body instanceof ReadableStream) return body; // passthrough + // ... +} +``` + +`ReadableStream`은 Bun의 `new Response()` 생성자가 네이티브로 수용하는 `BodyInit` 타입이다. +변환 없이 그대로 전달하면 된다. + +### R4. `HttpResponse.build()`에서 스트림 body 분기 + +`build()` 내부에서 body가 `ReadableStream`인 경우: +- `JSON.stringify()` 직렬화를 **스킵**해야 한다 (스트림은 이미 직렬화된 바이트 청크) +- `Content-Type` 자동 추론을 **스킵**해야 한다 (미들웨어가 이미 설정) +- `Content-Length`를 **설정하지 않아야** 한다 (크기를 알 수 없음, chunked 전송) + +### R5. `writeSuccessResponse()`에서 `Response(ReadableStream)` 보존 + +핸들러가 `Response` 객체를 반환했을 때, body가 `ReadableStream`이면 +`arrayBuffer()`로 버퍼링하지 않고 스트림 자체를 `HttpResponse`에 전달해야 한다. + +```typescript +// 변경 요청 +if (result instanceof Response) { + res.setStatus(result.status); + for (const [key, value] of result.headers.entries()) { + res.setHeader(key, value); + } + + if (result.body instanceof ReadableStream) { + // 스트림은 버퍼링하지 않고 그대로 전달 + res.setBody(result.body); + } else { + const arrayBuffer = await result.arrayBuffer(); + if (arrayBuffer.byteLength > 0) { + res.setBody(new Uint8Array(arrayBuffer)); + } + } + return; +} +``` + +### R6. 스트림 body에 대한 `isSent()` 의미론 정의 + +버퍼 body는 `end()` 호출 시점에 전송이 완료되지만, +스트림 body는 `end()` 호출 후에도 청크가 계속 전송된다. + +`isSent()`가 "응답 헤더가 전송 시작되었는가"를 의미하는지, +"body 전체가 전송 완료되었는가"를 의미하는지 정의가 필요하다. + +--- + +## 영향 범위 + +### 변경이 필요한 파일 + +| 파일 | 변경 내용 | +|------|-----------| +| `types.ts` | `ResponseBodyValue`에 `ReadableStream` 추가 | +| `http-response.ts` | `setBody`, `normalizeWorkerBody`, `build`에 스트림 분기 | +| `http-adapter.ts` | `writeSuccessResponse`에서 스트림 body 보존 | + +### 하위 호환성 + +- `ResponseBodyValue` 유니온 타입 확장은 **하위 호환**이다 (기존 코드는 새 타입을 사용하지 않으면 영향 없음) +- `getBody()` 반환 타입이 확장되므로, 반환값을 타입 가드 없이 `Uint8Array`로 가정하는 코드는 수정 필요 +- `normalizeWorkerBody()`의 passthrough 추가는 기존 경로에 영향 없음 + +### 미들웨어 영향 + +이 변경이 완료되면 `@zipbul/compression`이 다음과 같이 동작할 수 있다: + +```typescript +// 미들웨어 내부 (스트리밍 경로) +const body = response.getBody(); + +if (body instanceof ReadableStream) { + const compressed = body.pipeThrough(new CompressionStream(encoding)); + response + .setBody(compressed) + .setHeader(HttpHeader.ContentEncoding, encoding) + .removeHeader(HttpHeader.ContentLength); + return; +} + +// 기존 버퍼 경로 유지 +``` + +--- + +## Bun 런타임 근거 + +`Bun.serve()`의 `fetch` 핸들러는 이미 `new Response(ReadableStream)`을 네이티브로 지원한다. + +```typescript +Bun.serve({ + fetch() { + const stream = new ReadableStream({ /* ... */ }); + return new Response(stream); // 네이티브 스트리밍 전송 + }, +}); +``` + +Bun은 `ReadableStream` body에 대해: +- `Transfer-Encoding: chunked`를 자동 적용 (HTTP/1.1) +- HTTP/2에서는 프레임 단위 전송 +- 소켓 write buffer 기반 backpressure 관리 +- 클라이언트 disconnect 시 abort signal 전파 + +**어댑터가 할 일은 `ReadableStream`을 `new Response()`에 그대로 넘기는 것뿐이다.** diff --git a/packages/compression/index.ts b/packages/compression/index.ts new file mode 100644 index 0000000..eb37913 --- /dev/null +++ b/packages/compression/index.ts @@ -0,0 +1,6 @@ +export { compressionMiddleware } from './src/middleware.ts'; +export { CompressionError } from './src/interfaces.ts'; +export type { CompressionOptions, CompressionErrorData, BreachOptions } from './src/interfaces.ts'; +export { Encoding, CompressionErrorReason } from './src/enums.ts'; +export { parseAcceptEncoding, negotiateEncoding } from './src/encoding.ts'; +export type { EncodingPreference } from './src/encoding.ts'; diff --git a/packages/compression/package.json b/packages/compression/package.json new file mode 100644 index 0000000..12e3939 --- /dev/null +++ b/packages/compression/package.json @@ -0,0 +1,64 @@ +{ + "name": "@zipbul/compression", + "version": "0.0.1", + "description": "Bun-native HTTP response compression middleware for standard Web APIs", + "license": "MIT", + "author": "Junhyung Park (https://github.com/parkrevil)", + "repository": { + "type": "git", + "url": "https://github.com/zipbul/toolkit", + "directory": "packages/compression" + }, + "bugs": "https://github.com/zipbul/toolkit/issues", + "homepage": "https://github.com/zipbul/toolkit/tree/main/packages/compression#readme", + "keywords": [ + "compression", + "gzip", + "brotli", + "zstd", + "deflate", + "http", + "fetch", + "web-api", + "bun", + "middleware", + "typescript", + "zipbul" + ], + "engines": { + "bun": ">=1.2.0" + }, + "type": "module", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "sideEffects": false, + "publishConfig": { + "provenance": true + }, + "scripts": { + "build": "bun build index.ts --outdir dist --target bun --format esm --packages external --production && tsc -p tsconfig.build.json", + "test": "bun test", + "coverage": "bun test --coverage" + }, + "dependencies": { + "@zipbul/result": "workspace:*", + "@zipbul/shared": "workspace:*" + }, + "peerDependencies": { + "@zipbul/common": "^0.1.0", + "@zipbul/http-adapter": "^0.1.0" + }, + "devDependencies": { + "@zipbul/common": "^0.1.0", + "@zipbul/http-adapter": "^0.1.0" + } +} diff --git a/packages/compression/src/compressors.spec.ts b/packages/compression/src/compressors.spec.ts new file mode 100644 index 0000000..e4a6078 --- /dev/null +++ b/packages/compression/src/compressors.spec.ts @@ -0,0 +1,86 @@ +import { describe, expect, it, spyOn } from 'bun:test'; +import * as zlib from 'node:zlib'; + +import { BUFFER_COMPRESSORS } from './compressors.ts'; +import { Encoding } from './enums.ts'; + +describe('BUFFER_COMPRESSORS', () => { + it('should call Bun.gzipSync with data and level when compressing with gzip', () => { + const data = new Uint8Array([1, 2, 3]); + const fakeResult = new Uint8Array([4, 5]); + const spy = spyOn(Bun, 'gzipSync').mockReturnValue(fakeResult); + + const result = BUFFER_COMPRESSORS[Encoding.Gzip](data, 6); + + expect(spy).toHaveBeenCalledWith(data, { level: 6 }); + expect(result).toBe(fakeResult); + spy.mockRestore(); + }); + + it('should call node:zlib deflateSync with data and level when compressing with deflate', () => { + const data = new Uint8Array([1, 2, 3]); + const fakeResult = Buffer.from([4, 5]); + const spy = spyOn(zlib, 'deflateSync').mockReturnValue(fakeResult); + + const result = BUFFER_COMPRESSORS[Encoding.Deflate](data, 6); + + expect(spy).toHaveBeenCalledWith(data, { level: 6 }); + expect(result).toEqual(fakeResult); + spy.mockRestore(); + }); + + it('should call brotliCompressSync with data and BROTLI_PARAM_QUALITY when compressing with brotli', () => { + const data = new Uint8Array([1, 2, 3]); + const level = 5; + const fakeResult = Buffer.from([10, 20]); + const spy = spyOn(zlib, 'brotliCompressSync').mockReturnValue(fakeResult); + + const result = BUFFER_COMPRESSORS[Encoding.Brotli](data, level); + + expect(spy).toHaveBeenCalledWith(data, { + params: { [zlib.constants.BROTLI_PARAM_QUALITY]: level }, + }); + expect(result).toEqual(fakeResult); + spy.mockRestore(); + }); + + it('should call Bun.zstdCompressSync with data and level when compressing with zstd', () => { + const data = new Uint8Array([1, 2, 3]); + const fakeResult = new Uint8Array([7, 8, 9]); + const spy = spyOn(Bun, 'zstdCompressSync').mockReturnValue(fakeResult); + + const result = BUFFER_COMPRESSORS[Encoding.Zstd](data, 6); + + expect(spy).toHaveBeenCalledWith(data, { level: 6 }); + expect(result).toBe(fakeResult); + spy.mockRestore(); + }); + + it('should handle empty Uint8Array input when compressing', () => { + const data = new Uint8Array(0); + const fakeResult = new Uint8Array([]); + const spy = spyOn(Bun, 'gzipSync').mockReturnValue(fakeResult); + + const result = BUFFER_COMPRESSORS[Encoding.Gzip](data, 6); + + expect(spy).toHaveBeenCalledWith(data, { level: 6 }); + expect(result).toBe(fakeResult); + spy.mockRestore(); + }); + + it('should produce same output for same input and level when called twice', () => { + const data = new Uint8Array([1, 2, 3]); + const fakeResult = new Uint8Array([4, 5, 6]); + const spy = spyOn(Bun, 'gzipSync').mockReturnValue(fakeResult); + + const result1 = BUFFER_COMPRESSORS[Encoding.Gzip](data, 6); + const result2 = BUFFER_COMPRESSORS[Encoding.Gzip](data, 6); + + expect(spy).toHaveBeenCalledTimes(2); + expect(spy).toHaveBeenNthCalledWith(1, data, { level: 6 }); + expect(spy).toHaveBeenNthCalledWith(2, data, { level: 6 }); + expect(result1).toBe(fakeResult); + expect(result2).toBe(fakeResult); + spy.mockRestore(); + }); +}); diff --git a/packages/compression/src/compressors.ts b/packages/compression/src/compressors.ts new file mode 100644 index 0000000..747a8d9 --- /dev/null +++ b/packages/compression/src/compressors.ts @@ -0,0 +1,23 @@ +import { brotliCompressSync, deflateSync, constants as zlibConstants } from 'node:zlib'; +import { Encoding } from './enums.ts'; +import type { BufferCompressFn } from './types.ts'; + +// Bun native APIs require Uint8Array (excludes SharedArrayBuffer) +type BunSafeArray = Uint8Array; +// Bun ZlibCompressionOptions.level is a literal union; runtime-validated by options.ts +type BunZlibLevel = -1 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9; + +export const BUFFER_COMPRESSORS: Record = { + [Encoding.Gzip]: (data, level) => + Bun.gzipSync(data as BunSafeArray, { level: level as BunZlibLevel }), + // RFC 1950: HTTP Content-Encoding: deflate requires zlib-wrapped format. + // Bun.deflateSync produces raw deflate (RFC 1951), so we use node:zlib instead. + [Encoding.Deflate]: (data, level) => + deflateSync(data, { level }), + [Encoding.Brotli]: (data, level) => + brotliCompressSync(data, { + params: { [zlibConstants.BROTLI_PARAM_QUALITY]: level }, + }), + [Encoding.Zstd]: (data, level) => + Bun.zstdCompressSync(data, { level }), +}; diff --git a/packages/compression/src/constants.spec.ts b/packages/compression/src/constants.spec.ts new file mode 100644 index 0000000..bc27690 --- /dev/null +++ b/packages/compression/src/constants.spec.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from 'bun:test'; +import { DEFAULT_FILTER } from './constants.ts'; + +describe('DEFAULT_FILTER', () => { + it('should return true for text/html when given text/html', () => { + const contentType = 'text/html'; + const result = DEFAULT_FILTER(contentType); + expect(result).toBe(true); + }); + + it('should return true for application/json when given application/json', () => { + const contentType = 'application/json'; + const result = DEFAULT_FILTER(contentType); + expect(result).toBe(true); + }); + + it('should return true for application/javascript when given application/javascript', () => { + const contentType = 'application/javascript'; + const result = DEFAULT_FILTER(contentType); + expect(result).toBe(true); + }); + + it('should return true for image/svg+xml when given image/svg+xml', () => { + const contentType = 'image/svg+xml'; + const result = DEFAULT_FILTER(contentType); + expect(result).toBe(true); + }); + + it('should return true for application/ld+json when given application/ld+json', () => { + const contentType = 'application/ld+json'; + const result = DEFAULT_FILTER(contentType); + expect(result).toBe(true); + }); + + it('should return true for application/vnd.api+json when given application/vnd.api+json', () => { + const contentType = 'application/vnd.api+json'; + const result = DEFAULT_FILTER(contentType); + expect(result).toBe(true); + }); + + it('should return false for image/png when given image/png', () => { + const contentType = 'image/png'; + const result = DEFAULT_FILTER(contentType); + expect(result).toBe(false); + }); + + it('should return false for application/octet-stream when given application/octet-stream', () => { + const contentType = 'application/octet-stream'; + const result = DEFAULT_FILTER(contentType); + expect(result).toBe(false); + }); + + it('should return false for video/mp4 when given video/mp4', () => { + const contentType = 'video/mp4'; + const result = DEFAULT_FILTER(contentType); + expect(result).toBe(false); + }); + + it('should return false for empty string when given empty string', () => { + const contentType = ''; + const result = DEFAULT_FILTER(contentType); + expect(result).toBe(false); + }); + + it('should return true for content-type with charset params when given text/html; charset=utf-8', () => { + const contentType = 'text/html; charset=utf-8'; + const result = DEFAULT_FILTER(contentType); + expect(result).toBe(true); + }); + + it('should return true for uppercase content-type when given TEXT/HTML', () => { + const contentType = 'TEXT/HTML'; + const result = DEFAULT_FILTER(contentType); + expect(result).toBe(true); + }); + + it('should return true for mixed-case Application/JSON', () => { + expect(DEFAULT_FILTER('Application/JSON')).toBe(true); + }); + + it('should return true for application/atom+xml', () => { + expect(DEFAULT_FILTER('application/atom+xml')).toBe(true); + }); + + it('should return true for application/rss+xml', () => { + expect(DEFAULT_FILTER('application/rss+xml')).toBe(true); + }); + + it('should return false for content-type with leading space when given text/html', () => { + const contentType = ' text/html'; + const result = DEFAULT_FILTER(contentType); + expect(result).toBe(false); + }); + + it('should return true for image/svg+xml but false for image/svg when given both', () => { + expect(DEFAULT_FILTER('image/svg+xml')).toBe(true); + expect(DEFAULT_FILTER('image/svg')).toBe(false); + }); + + it('should return false for text/event-stream (SSE)', () => { + expect(DEFAULT_FILTER('text/event-stream')).toBe(false); + }); + + it('should return false for text/event-stream with charset param', () => { + expect(DEFAULT_FILTER('text/event-stream; charset=utf-8')).toBe(false); + }); + + it('should return false for TEXT/EVENT-STREAM (case-insensitive)', () => { + expect(DEFAULT_FILTER('TEXT/EVENT-STREAM')).toBe(false); + }); + + it('should return true for text/plain (other text/* types still match)', () => { + expect(DEFAULT_FILTER('text/plain')).toBe(true); + }); + + it('should return true for text/css', () => { + expect(DEFAULT_FILTER('text/css')).toBe(true); + }); + + it('should return true for text/xml', () => { + expect(DEFAULT_FILTER('text/xml')).toBe(true); + }); +}); diff --git a/packages/compression/src/constants.ts b/packages/compression/src/constants.ts new file mode 100644 index 0000000..8d3531e --- /dev/null +++ b/packages/compression/src/constants.ts @@ -0,0 +1,21 @@ +import { Encoding } from './enums.ts'; + +export const DEFAULT_THRESHOLD = 1024; + +export const DEFAULT_ENCODINGS: Encoding[] = [ + Encoding.Brotli, + Encoding.Gzip, +]; + +export const DEFAULT_LEVELS: Record = { + [Encoding.Brotli]: 4, + [Encoding.Gzip]: 6, + [Encoding.Deflate]: 6, + [Encoding.Zstd]: 3, +}; + +const COMPRESSIBLE_PATTERN = + /^text\/(?!event-stream\b)|^application\/(?:json|javascript|xml|xhtml\+xml|ecmascript|graphql|ld\+json|manifest\+json|vnd\.api\+json|.+\+xml|.+\+json)|^image\/svg\+xml/i; + +export const DEFAULT_FILTER = (contentType: string): boolean => + COMPRESSIBLE_PATTERN.test(contentType); diff --git a/packages/compression/src/encoding.spec.ts b/packages/compression/src/encoding.spec.ts new file mode 100644 index 0000000..300f621 --- /dev/null +++ b/packages/compression/src/encoding.spec.ts @@ -0,0 +1,295 @@ +import { describe, expect, it } from 'bun:test'; +import { parseAcceptEncoding, negotiateEncoding } from './encoding.ts'; +import { Encoding } from './enums.ts'; + +describe('parseAcceptEncoding', () => { + it('should parse single encoding without quality as quality 1.0 when given plain encoding', () => { + const header = 'gzip'; + const result = parseAcceptEncoding(header); + expect(result).toEqual([{ encoding: 'gzip', quality: 1.0 }]); + }); + + it('should parse single encoding with explicit quality value when given q param', () => { + const header = 'br;q=0.9'; + const result = parseAcceptEncoding(header); + expect(result).toEqual([{ encoding: 'br', quality: 0.9 }]); + }); + + it('should parse multiple encodings and sort by quality descending when given comma-separated list', () => { + const header = 'identity;q=0.5, gzip;q=1.0, deflate;q=0.8'; + const result = parseAcceptEncoding(header); + expect(result).toEqual([ + { encoding: 'gzip', quality: 1.0 }, + { encoding: 'deflate', quality: 0.8 }, + { encoding: 'identity', quality: 0.5 }, + ]); + }); + + it('should parse wildcard * as encoding name when given in header', () => { + const header = '*;q=0.3'; + const result = parseAcceptEncoding(header); + expect(result).toEqual([{ encoding: '*', quality: 0.3 }]); + }); + + it('should lowercase encoding names when given mixed case', () => { + const header = 'GZIP, Br'; + const result = parseAcceptEncoding(header); + expect(result).toEqual([ + { encoding: 'gzip', quality: 1.0 }, + { encoding: 'br', quality: 1.0 }, + ]); + }); + + it('should return empty array when given empty string', () => { + const header = ''; + const result = parseAcceptEncoding(header); + expect(result).toEqual([]); + }); + + it('should default quality to 1.0 when q value is non-numeric', () => { + const header = 'gzip;q=abc'; + const result = parseAcceptEncoding(header); + expect(result).toEqual([{ encoding: 'gzip', quality: 1.0 }]); + }); + + it('should default quality to 1.0 when q value exceeds 1', () => { + const header = 'gzip;q=1.5'; + const result = parseAcceptEncoding(header); + expect(result).toEqual([{ encoding: 'gzip', quality: 1.0 }]); + }); + + it('should default quality to 1.0 when q value is negative', () => { + const header = 'gzip;q=-0.1'; + const result = parseAcceptEncoding(header); + expect(result).toEqual([{ encoding: 'gzip', quality: 1.0 }]); + }); + + it('should default quality to 1.0 when q param has no value', () => { + const header = 'gzip;q='; + const result = parseAcceptEncoding(header); + expect(result).toEqual([{ encoding: 'gzip', quality: 1.0 }]); + }); + + it('should skip empty parts when given commas with empty segments', () => { + const header = 'gzip,,deflate'; + const result = parseAcceptEncoding(header); + expect(result).toEqual([ + { encoding: 'gzip', quality: 1.0 }, + { encoding: 'deflate', quality: 1.0 }, + ]); + }); + + it('should return empty array when given single comma', () => { + const header = ','; + const result = parseAcceptEncoding(header); + expect(result).toEqual([]); + }); + + it('should parse quality exactly 0 when q=0 is specified', () => { + const header = 'gzip;q=0'; + const result = parseAcceptEncoding(header); + expect(result).toEqual([{ encoding: 'gzip', quality: 0 }]); + }); + + it('should skip encoding name when it is empty after trim', () => { + const header = ' ;q=0.5'; + const result = parseAcceptEncoding(header); + expect(result).toEqual([]); + }); + + it('should preserve duplicate encodings when same encoding appears multiple times', () => { + const header = 'gzip;q=0.8, gzip;q=0.6'; + const result = parseAcceptEncoding(header); + expect(result).toEqual([ + { encoding: 'gzip', quality: 0.8 }, + { encoding: 'gzip', quality: 0.6 }, + ]); + }); + + it('should handle uppercase Q param key when q is uppercase', () => { + const header = 'gzip;Q=0.7'; + const result = parseAcceptEncoding(header); + expect(result).toEqual([{ encoding: 'gzip', quality: 0.7 }]); + }); + + it('should handle whitespace when given around semicolons and equals', () => { + const header = 'gzip ; q = 0.6'; + const result = parseAcceptEncoding(header); + expect(result).toEqual([{ encoding: 'gzip', quality: 0.6 }]); + }); + + it('should use last q value when multiple q params exist in same part', () => { + const header = 'gzip;q=0.5;q=0.9'; + const result = parseAcceptEncoding(header); + expect(result).toEqual([{ encoding: 'gzip', quality: 0.9 }]); + }); + + it('should return same result when given same input multiple times', () => { + const header = 'gzip;q=0.8, br;q=0.9'; + const a = parseAcceptEncoding(header); + const b = parseAcceptEncoding(header); + expect(a).toEqual(b); + }); + + it('should maintain stable order when quality values are equal', () => { + const header = 'gzip, deflate, br'; + const result = parseAcceptEncoding(header); + expect(result[0].encoding).toBe('gzip'); + expect(result[1].encoding).toBe('deflate'); + expect(result[2].encoding).toBe('br'); + }); + + // RFC 9110 §8.4.1: encoding alias normalization + it('should normalize x-gzip to gzip (RFC 9110 §8.4.1.3)', () => { + const result = parseAcceptEncoding('x-gzip;q=0.8'); + expect(result).toEqual([{ encoding: 'gzip', quality: 0.8 }]); + }); + + it('should normalize x-compress to compress (RFC 9110 §8.4.1.1)', () => { + const result = parseAcceptEncoding('x-compress'); + expect(result).toEqual([{ encoding: 'compress', quality: 1.0 }]); + }); + + it('should normalize x-gzip among mixed encodings', () => { + const result = parseAcceptEncoding('x-gzip;q=0.5, br;q=1.0'); + expect(result).toEqual([ + { encoding: 'br', quality: 1.0 }, + { encoding: 'gzip', quality: 0.5 }, + ]); + }); +}); + +describe('negotiateEncoding', () => { + it('should return server-preferred encoding when client supports it', () => { + const serverEncodings = [Encoding.Brotli, Encoding.Gzip]; + const clientPreferences = [{ encoding: 'br', quality: 1.0 }]; + const result = negotiateEncoding(serverEncodings, clientPreferences); + expect(result).toBe(Encoding.Brotli); + }); + + it('should select encoding with highest client quality when multiple matches exist', () => { + const serverEncodings = [Encoding.Gzip, Encoding.Deflate, Encoding.Brotli]; + const clientPreferences = [ + { encoding: 'gzip', quality: 0.5 }, + { encoding: 'deflate', quality: 0.9 }, + { encoding: 'br', quality: 0.3 }, + ]; + const result = negotiateEncoding(serverEncodings, clientPreferences); + expect(result).toBe(Encoding.Deflate); + }); + + it('should match via wildcard when no specific match exists', () => { + const serverEncodings = [Encoding.Zstd]; + const clientPreferences = [{ encoding: '*', quality: 0.5 }]; + const result = negotiateEncoding(serverEncodings, clientPreferences); + expect(result).toBe(Encoding.Zstd); + }); + + it('should prefer specific match over wildcard when specific has higher quality', () => { + const serverEncodings = [Encoding.Gzip]; + const clientPreferences = [ + { encoding: 'gzip', quality: 0.9 }, + { encoding: '*', quality: 0.5 }, + ]; + const result = negotiateEncoding(serverEncodings, clientPreferences); + expect(result).toBe(Encoding.Gzip); + }); + + it('should return null when server encodings is empty', () => { + const serverEncodings: Encoding[] = []; + const clientPreferences = [{ encoding: 'gzip', quality: 1.0 }]; + const result = negotiateEncoding(serverEncodings, clientPreferences); + expect(result).toBeNull(); + }); + + it('should return null when client preferences is empty', () => { + const serverEncodings = [Encoding.Gzip]; + const clientPreferences: { encoding: string; quality: number }[] = []; + const result = negotiateEncoding(serverEncodings, clientPreferences); + expect(result).toBeNull(); + }); + + it('should return null when all client qualities are 0', () => { + const serverEncodings = [Encoding.Gzip]; + const clientPreferences = [{ encoding: 'gzip', quality: 0 }]; + const result = negotiateEncoding(serverEncodings, clientPreferences); + expect(result).toBeNull(); + }); + + it('should return null when no overlap exists between server and client', () => { + const serverEncodings = [Encoding.Brotli]; + const clientPreferences = [{ encoding: 'gzip', quality: 1.0 }]; + const result = negotiateEncoding(serverEncodings, clientPreferences); + expect(result).toBeNull(); + }); + + it('should return null when quality is exactly 0', () => { + const serverEncodings = [Encoding.Gzip]; + const clientPreferences = [{ encoding: 'gzip', quality: 0 }]; + const result = negotiateEncoding(serverEncodings, clientPreferences); + expect(result).toBeNull(); + }); + + it('should return encoding when quality is just above 0', () => { + const serverEncodings = [Encoding.Gzip]; + const clientPreferences = [{ encoding: 'gzip', quality: 0.001 }]; + const result = negotiateEncoding(serverEncodings, clientPreferences); + expect(result).toBe(Encoding.Gzip); + }); + + it('should use wildcard quality when encoding not in client map', () => { + const serverEncodings = [Encoding.Zstd]; + const clientPreferences = [{ encoding: '*', quality: 0.4 }]; + const result = negotiateEncoding(serverEncodings, clientPreferences); + expect(result).toBe(Encoding.Zstd); + }); + + it('should use specific quality over wildcard even when specific is q=0', () => { + const serverEncodings = [Encoding.Gzip]; + const clientPreferences = [ + { encoding: 'gzip', quality: 0 }, + { encoding: '*', quality: 0.5 }, + ]; + const result = negotiateEncoding(serverEncodings, clientPreferences); + expect(result).toBeNull(); + }); + + it('should use last wildcard quality when multiple wildcards present', () => { + const serverEncodings = [Encoding.Zstd]; + const clientPreferences = [ + { encoding: '*', quality: 0.1 }, + { encoding: '*', quality: 0.6 }, + ]; + const result = negotiateEncoding(serverEncodings, clientPreferences); + expect(result).toBe(Encoding.Zstd); + }); + + it('should return same result when given same input multiple times', () => { + const serverEncodings = [Encoding.Gzip]; + const clientPreferences = [{ encoding: 'gzip', quality: 0.8 }]; + const a = negotiateEncoding(serverEncodings, clientPreferences); + const b = negotiateEncoding(serverEncodings, clientPreferences); + expect(a).toBe(b); + }); + + it('should break quality ties by server encoding order when client qualities are equal', () => { + const serverEncodings = [Encoding.Brotli, Encoding.Gzip, Encoding.Deflate]; + const clientPreferences = [ + { encoding: 'br', quality: 0.5 }, + { encoding: 'gzip', quality: 0.5 }, + { encoding: 'deflate', quality: 0.5 }, + ]; + const result = negotiateEncoding(serverEncodings, clientPreferences); + expect(result).toBe(Encoding.Brotli); + }); + + it('should let higher quality override server order when qualities differ', () => { + const serverEncodings = [Encoding.Brotli, Encoding.Gzip]; + const clientPreferences = [ + { encoding: 'br', quality: 0.3 }, + { encoding: 'gzip', quality: 0.9 }, + ]; + const result = negotiateEncoding(serverEncodings, clientPreferences); + expect(result).toBe(Encoding.Gzip); + }); +}); diff --git a/packages/compression/src/encoding.ts b/packages/compression/src/encoding.ts new file mode 100644 index 0000000..7784373 --- /dev/null +++ b/packages/compression/src/encoding.ts @@ -0,0 +1,82 @@ +import { Encoding } from './enums.ts'; + +export interface EncodingPreference { + encoding: string; + quality: number; +} + +/** + * Parses an Accept-Encoding header value into a sorted list of preferences. + * Follows RFC 9110 §12.5.3 (quality values). + * + * @example parseAcceptEncoding('gzip;q=1.0, br;q=0.8, identity;q=0.5') + * // [{ encoding: 'gzip', quality: 1.0 }, { encoding: 'br', quality: 0.8 }, ...] + */ +export function parseAcceptEncoding(header: string): EncodingPreference[] { + const preferences: EncodingPreference[] = []; + + for (const part of header.split(',')) { + const trimmed = part.trim(); + if (trimmed === '') continue; + + const [encoding, ...params] = trimmed.split(';'); + let name = encoding!.trim().toLowerCase(); + if (name === '') continue; + + // RFC 9110 §8.4.1: x-gzip is equivalent to gzip, x-compress to compress + if (name === 'x-gzip') name = 'gzip'; + else if (name === 'x-compress') name = 'compress'; + + let quality = 1.0; + for (const param of params) { + const [key, value] = param.split('='); + if (key?.trim().toLowerCase() === 'q' && value !== undefined) { + const parsed = Number.parseFloat(value.trim()); + if (Number.isFinite(parsed) && parsed >= 0 && parsed <= 1) { + quality = parsed; + } + } + } + + preferences.push({ encoding: name, quality }); + } + + preferences.sort((a, b) => b.quality - a.quality); + return preferences; +} + +/** + * Negotiates the best encoding based on server preferences and client Accept-Encoding. + * + * Strategy: among server-supported encodings that the client also accepts (q > 0), + * pick the one with the highest client quality value. Ties are broken by server + * preference order (earlier = higher priority). + */ +export function negotiateEncoding( + serverEncodings: Encoding[], + clientPreferences: EncodingPreference[], +): Encoding | null { + const clientMap = new Map(); + let wildcardQuality = -1; + + for (const pref of clientPreferences) { + if (pref.encoding === '*') { + wildcardQuality = pref.quality; + } else { + clientMap.set(pref.encoding, pref.quality); + } + } + + let best: Encoding | null = null; + let bestQuality = 0; + + for (const encoding of serverEncodings) { + const quality = clientMap.get(encoding) ?? wildcardQuality; + if (quality > 0 && quality > bestQuality) { + best = encoding; + bestQuality = quality; + } + } + + return best; +} diff --git a/packages/compression/src/enums.ts b/packages/compression/src/enums.ts new file mode 100644 index 0000000..fed2275 --- /dev/null +++ b/packages/compression/src/enums.ts @@ -0,0 +1,14 @@ +export enum Encoding { + Brotli = 'br', + Zstd = 'zstd', + Gzip = 'gzip', + Deflate = 'deflate', +} + +export enum CompressionErrorReason { + InvalidThreshold = 'invalid_threshold', + InvalidEncodings = 'invalid_encodings', + InvalidLevel = 'invalid_level', + EmptyEncodings = 'empty_encodings', + InvalidBreach = 'invalid_breach', +} diff --git a/packages/compression/src/htb.spec.ts b/packages/compression/src/htb.spec.ts new file mode 100644 index 0000000..8370987 --- /dev/null +++ b/packages/compression/src/htb.spec.ts @@ -0,0 +1,213 @@ +import { describe, expect, it } from 'bun:test'; +import { injectGzipPadding, injectZstdPadding } from './htb.ts'; + +describe('injectGzipPadding (HTB - Heal the BREACH)', () => { + const data = new TextEncoder().encode('Hello, World!'.repeat(100)); + + function compress(input: Uint8Array): Uint8Array { + return Bun.gzipSync(input); + } + + // --- Correctness --- + + it('should produce output that decompresses to the same data as the original', () => { + const compressed = compress(data); + const padded = injectGzipPadding(compressed, 32); + const decompressed = Bun.gunzipSync(padded); + expect(Buffer.from(decompressed).toString()).toBe(Buffer.from(data).toString()); + }); + + it('should set the FEXTRA flag (bit 2) in the gzip header', () => { + const compressed = compress(data); + const padded = injectGzipPadding(compressed, 32); + expect(padded[3] & 0x04).toBe(0x04); + }); + + it('should produce output longer than the original compressed data', () => { + const compressed = compress(data); + const padded = injectGzipPadding(compressed, 32); + expect(padded.byteLength).toBeGreaterThan(compressed.byteLength); + }); + + it('should insert XLEN + RFC 1952 subfield after the 10-byte gzip header', () => { + const compressed = compress(data); + const padded = injectGzipPadding(compressed, 16); + // Bytes 10-11 should be XLEN (little-endian) + const xlen = padded[10] | (padded[11] << 8); + // XLEN = 4 (subfield header: SI1+SI2+LEN) + padLen (1..16) + expect(xlen).toBeGreaterThanOrEqual(5); // 4 + 1 + expect(xlen).toBeLessThanOrEqual(20); // 4 + 16 + // Subfield header: SI1='Z'(0x5a), SI2='P'(0x50) + expect(padded[12]).toBe(0x5a); + expect(padded[13]).toBe(0x50); + // Subfield LEN = padLen = xlen - 4 + const subfieldLen = padded[14] | (padded[15] << 8); + expect(subfieldLen).toBe(xlen - 4); + // Total size = original + 2 (XLEN field) + xlen (subfield) + expect(padded.byteLength).toBe(compressed.byteLength + 2 + xlen); + }); + + // --- Randomness --- + + it('should produce varying output sizes across multiple calls', () => { + const compressed = compress(data); + const sizes = new Set(); + for (let i = 0; i < 50; i++) { + sizes.add(injectGzipPadding(compressed, 64).byteLength); + } + // With maxPadding=64, 50 iterations should yield multiple distinct sizes + expect(sizes.size).toBeGreaterThan(1); + }); + + // --- Boundary --- + + it('should work with maxPadding=1 (always adds exactly 1 byte of padding data)', () => { + const compressed = compress(data); + const padded = injectGzipPadding(compressed, 1); + const xlen = padded[10] | (padded[11] << 8); + expect(xlen).toBe(5); // 4 (subfield header) + 1 (padding data) + expect(padded.byteLength).toBe(compressed.byteLength + 7); // +2 XLEN field + 5 subfield + expect(Buffer.from(Bun.gunzipSync(padded)).toString()).toBe(Buffer.from(data).toString()); + }); + + it('should work with large maxPadding=256', () => { + const compressed = compress(data); + const padded = injectGzipPadding(compressed, 256); + const xlen = padded[10] | (padded[11] << 8); + // xlen = 4 (subfield header) + padLen (1..256) + expect(xlen).toBeGreaterThanOrEqual(5); + expect(xlen).toBeLessThanOrEqual(260); + expect(Buffer.from(Bun.gunzipSync(padded)).toString()).toBe(Buffer.from(data).toString()); + }); + + // --- Preserves original header fields --- + + it('should preserve ID1, ID2, CM, MTIME, XFL, OS from the original header', () => { + const compressed = compress(data); + const padded = injectGzipPadding(compressed, 32); + // ID1, ID2, CM unchanged + expect(padded[0]).toBe(0x1f); + expect(padded[1]).toBe(0x8b); + expect(padded[2]).toBe(0x08); + // MTIME (bytes 4-7), XFL (byte 8), OS (byte 9) unchanged + for (const i of [4, 5, 6, 7, 8, 9]) { + expect(padded[i]).toBe(compressed[i]); + } + }); + + // --- XLEN overflow guard --- + + it('should return original compressed data when XLEN would overflow 16-bit max', () => { + const compressed = compress(data); + // Create a gzip with FEXTRA already at near-max XLEN (65530) + const bigXlen = 65530; + const fakeGzip = new Uint8Array(compressed.length + 2 + bigXlen); + fakeGzip.set(compressed.subarray(0, 10)); + fakeGzip[3] = compressed[3]! | 0x04; // FEXTRA flag + fakeGzip[10] = bigXlen & 0xff; + fakeGzip[11] = (bigXlen >> 8) & 0xff; + fakeGzip.set(compressed.subarray(10), 12 + bigXlen); + + const result = injectGzipPadding(fakeGzip, 100); + // Should return a copy (not mutated) since 65530 + padding > 65535 + expect(result).not.toBe(fakeGzip); + expect(result).toEqual(fakeGzip); + }); + + // --- Existing FEXTRA --- + + it('should handle input that already has FEXTRA by appending a new subfield', () => { + const compressed = compress(data); + // Manually inject a small FEXTRA with one valid subfield (SI1=0xAA, SI2=0xBB, LEN=0) + const existingExtra = new Uint8Array(compressed.length + 6); + existingExtra.set(compressed.subarray(0, 10)); + existingExtra[3] = compressed[3] | 0x04; + existingExtra[10] = 4; // XLEN = 4 (one subfield: SI1+SI2+LEN, no data) + existingExtra[11] = 0; + existingExtra[12] = 0xAA; // SI1 + existingExtra[13] = 0xBB; // SI2 + existingExtra[14] = 0x00; // LEN lo = 0 + existingExtra[15] = 0x00; // LEN hi = 0 + existingExtra.set(compressed.subarray(10), 16); + + const padded = injectGzipPadding(existingExtra, 32); + // Must still decompress correctly + expect(Buffer.from(Bun.gunzipSync(padded)).toString()).toBe(Buffer.from(data).toString()); + // New XLEN should be old XLEN (4) + 4 (subfield header) + padLen (1..32) + const newXlen = padded[10] | (padded[11] << 8); + expect(newXlen).toBeGreaterThanOrEqual(9); // 4 + 4 + 1 + expect(newXlen).toBeLessThanOrEqual(40); // 4 + 4 + 32 + // Original subfield should be preserved + expect(padded[12]).toBe(0xAA); + expect(padded[13]).toBe(0xBB); + }); +}); + +describe('injectZstdPadding (HTB - Skippable Frame)', () => { + const data = new TextEncoder().encode('Hello, World!'.repeat(100)); + + function compress(input: Uint8Array): Uint8Array { + return new Uint8Array(Bun.zstdCompressSync(input, { level: 3 })); + } + + it('should produce output that decompresses to the same data', () => { + const compressed = compress(data); + const padded = injectZstdPadding(compressed, 32); + const decompressed = Bun.zstdDecompressSync(padded); + expect(Buffer.from(decompressed).toString()).toBe(Buffer.from(data).toString()); + }); + + it('should prepend a Skippable Frame with correct magic number', () => { + const compressed = compress(data); + const padded = injectZstdPadding(compressed, 32); + // Magic: 0x184D2A50 in little-endian + expect(padded[0]).toBe(0x50); + expect(padded[1]).toBe(0x2a); + expect(padded[2]).toBe(0x4d); + expect(padded[3]).toBe(0x18); + }); + + it('should produce output longer than the original', () => { + const compressed = compress(data); + const padded = injectZstdPadding(compressed, 32); + // At least 8 bytes overhead (magic + size) + 1 byte padding + expect(padded.byteLength).toBeGreaterThan(compressed.byteLength + 8); + }); + + it('should encode frame size correctly in little-endian', () => { + const compressed = compress(data); + const padded = injectZstdPadding(compressed, 16); + const frameSize = padded[4] | (padded[5] << 8) | (padded[6] << 16) | (padded[7] << 24); + expect(frameSize).toBeGreaterThanOrEqual(1); + expect(frameSize).toBeLessThanOrEqual(16); + // Total = 8 (header) + frameSize (padding) + compressed.length + expect(padded.byteLength).toBe(8 + frameSize + compressed.byteLength); + }); + + it('should produce varying output sizes across multiple calls', () => { + const compressed = compress(data); + const sizes = new Set(); + for (let i = 0; i < 50; i++) { + sizes.add(injectZstdPadding(compressed, 64).byteLength); + } + expect(sizes.size).toBeGreaterThan(1); + }); + + it('should work with maxPadding=1 (always adds exactly 1 byte)', () => { + const compressed = compress(data); + const padded = injectZstdPadding(compressed, 1); + const frameSize = padded[4]; + expect(frameSize).toBe(1); + expect(padded.byteLength).toBe(8 + 1 + compressed.byteLength); + expect(Buffer.from(Bun.zstdDecompressSync(padded)).toString()).toBe(Buffer.from(data).toString()); + }); + + it('should work with large maxPadding=256', () => { + const compressed = compress(data); + const padded = injectZstdPadding(compressed, 256); + const frameSize = padded[4] | (padded[5] << 8); + expect(frameSize).toBeGreaterThanOrEqual(1); + expect(frameSize).toBeLessThanOrEqual(256); + expect(Buffer.from(Bun.zstdDecompressSync(padded)).toString()).toBe(Buffer.from(data).toString()); + }); +}); diff --git a/packages/compression/src/htb.ts b/packages/compression/src/htb.ts new file mode 100644 index 0000000..a44d2a4 --- /dev/null +++ b/packages/compression/src/htb.ts @@ -0,0 +1,113 @@ +/** + * Heal-the-BREACH (HTB) — injects random-length padding into compressed + * output so that sizes vary between requests, defeating BREACH oracle attacks. + * + * Gzip: padding in FEXTRA field (RFC 1952 §2.3.1). + * Zstd: prepended Skippable Frame (RFC 8878 §3.1.2). + * + * The padding is transparent to any RFC-compliant decompressor. + */ + +const GZIP_HEADER_SIZE = 10; +const FEXTRA_FLAG = 0x04; +const MAX_XLEN = 0xffff; // 16-bit max for gzip XLEN field + +// RFC 1952 §2.3.1: FEXTRA subfield header (SI1, SI2, LEN) +const SUBFIELD_HEADER_SIZE = 4; // SI1(1) + SI2(1) + LEN(2) +const SUBFIELD_SI1 = 0x5a; // 'Z' +const SUBFIELD_SI2 = 0x50; // 'P' — "ZP" identifies zipbul padding + +/** Bias-free CSPRNG integer in [1, maxPadding] via rejection sampling. */ +const rngBuf = new Uint32Array(1); +function randomPadLen(maxPadding: number): number { + const limit = 0x100000000 - (0x100000000 % maxPadding); + let value: number; + do { + crypto.getRandomValues(rngBuf); + value = rngBuf[0]!; + } while (value >= limit); + return 1 + (value % maxPadding); +} + +/** Writes a proper RFC 1952 subfield: SI1 SI2 LEN(le16) data. */ +function writeSubfield(target: Uint8Array, offset: number, dataLen: number): void { + target[offset] = SUBFIELD_SI1; + target[offset + 1] = SUBFIELD_SI2; + target[offset + 2] = dataLen & 0xff; + target[offset + 3] = (dataLen >> 8) & 0xff; +} + +export function injectGzipPadding(compressed: Uint8Array, maxPadding: number): Uint8Array { + const padLen = randomPadLen(maxPadding); + const subfieldTotal = SUBFIELD_HEADER_SIZE + padLen; // total bytes added to extra field + const hasFExtra = (compressed[3]! & FEXTRA_FLAG) !== 0; + + if (hasFExtra) { + // Existing FEXTRA: read current XLEN, append a new subfield + const existingXlen = compressed[GZIP_HEADER_SIZE]! | (compressed[GZIP_HEADER_SIZE + 1]! << 8); + const newXlen = existingXlen + subfieldTotal; + + // XLEN is a 16-bit field; if subfield would overflow, return unmodified copy + if (newXlen > MAX_XLEN) return compressed.slice(); + + const result = new Uint8Array(compressed.length + subfieldTotal); + // Header (10 bytes) + result.set(compressed.subarray(0, GZIP_HEADER_SIZE)); + // New XLEN + result[GZIP_HEADER_SIZE] = newXlen & 0xff; + result[GZIP_HEADER_SIZE + 1] = (newXlen >> 8) & 0xff; + // Original extra data + const extraStart = GZIP_HEADER_SIZE + 2; + result.set(compressed.subarray(extraStart, extraStart + existingXlen), extraStart); + // New subfield (SI1 SI2 LEN data) after existing extra data + writeSubfield(result, extraStart + existingXlen, padLen); + // Padding data (zero-filled by Uint8Array constructor) + // Rest of compressed data + const afterExtra = extraStart + existingXlen; + result.set(compressed.subarray(afterExtra), extraStart + newXlen); + + return result; + } + + // No existing FEXTRA: insert XLEN + subfield after header + const result = new Uint8Array(compressed.length + 2 + subfieldTotal); + // Header (10 bytes), set FEXTRA flag + result.set(compressed.subarray(0, GZIP_HEADER_SIZE)); + result[3] = compressed[3]! | FEXTRA_FLAG; + // XLEN (little-endian) + result[GZIP_HEADER_SIZE] = subfieldTotal & 0xff; + result[GZIP_HEADER_SIZE + 1] = (subfieldTotal >> 8) & 0xff; + // Subfield header + writeSubfield(result, GZIP_HEADER_SIZE + 2, padLen); + // Padding data (zero-filled by Uint8Array constructor) + // Rest of compressed data + result.set(compressed.subarray(GZIP_HEADER_SIZE), GZIP_HEADER_SIZE + 2 + subfieldTotal); + + return result; +} + +const ZSTD_SKIPPABLE_MAGIC = 0x184d2a50; + +export function injectZstdPadding(compressed: Uint8Array, maxPadding: number): Uint8Array { + const padLen = randomPadLen(maxPadding); + const frameOverhead = 8; // 4 bytes magic + 4 bytes frame size + const result = new Uint8Array(frameOverhead + padLen + compressed.length); + + // Skippable Frame magic number (little-endian) + result[0] = ZSTD_SKIPPABLE_MAGIC & 0xff; + result[1] = (ZSTD_SKIPPABLE_MAGIC >> 8) & 0xff; + result[2] = (ZSTD_SKIPPABLE_MAGIC >> 16) & 0xff; + result[3] = (ZSTD_SKIPPABLE_MAGIC >> 24) & 0xff; + + // User_Data size (little-endian) + result[4] = padLen & 0xff; + result[5] = (padLen >> 8) & 0xff; + result[6] = (padLen >> 16) & 0xff; + result[7] = (padLen >> 24) & 0xff; + + // Padding bytes (zero-filled by Uint8Array constructor) + // Actual compressed frame follows + result.set(compressed, frameOverhead + padLen); + + return result; +} diff --git a/packages/compression/src/interfaces.ts b/packages/compression/src/interfaces.ts new file mode 100644 index 0000000..f5a9458 --- /dev/null +++ b/packages/compression/src/interfaces.ts @@ -0,0 +1,28 @@ +import type { CompressionErrorReason, Encoding } from './enums.ts'; + +export interface BreachOptions { + maxPadding: number; +} + +export interface CompressionOptions { + encodings?: Encoding[]; + threshold?: number; + filter?: (contentType: string) => boolean; + level?: Partial>; + breach?: BreachOptions; +} + +export interface CompressionErrorData { + reason: CompressionErrorReason; + message: string; +} + +export class CompressionError extends Error { + public readonly reason: CompressionErrorReason; + + constructor(data: CompressionErrorData) { + super(data.message); + this.name = 'CompressionError'; + this.reason = data.reason; + } +} diff --git a/packages/compression/src/middleware.ts b/packages/compression/src/middleware.ts new file mode 100644 index 0000000..ec234f3 --- /dev/null +++ b/packages/compression/src/middleware.ts @@ -0,0 +1,123 @@ +import { defineMiddleware } from '@zipbul/common'; +import type { MiddlewareDefinition } from '@zipbul/common'; +import { HttpAdapter, HttpContext } from '@zipbul/http-adapter'; +import { err, isErr } from '@zipbul/result'; +import type { Result } from '@zipbul/result'; +import { HttpHeader, HttpStatus } from '@zipbul/shared'; + +import { BUFFER_COMPRESSORS } from './compressors.ts'; +import { Encoding } from './enums.ts'; +import { injectGzipPadding, injectZstdPadding } from './htb.ts'; +import type { BreachOptions, CompressionErrorData, CompressionOptions } from './interfaces.ts'; +import { BREACH_SAFE_ENCODINGS, resolveCompressionOptions, validateCompressionOptions } from './options.ts'; +import { negotiateEncoding, parseAcceptEncoding } from './encoding.ts'; + +const encoder = new TextEncoder(); + +function serializeBody(body: string | number | boolean | Uint8Array | ArrayBuffer | object): Uint8Array { + if (body instanceof Uint8Array) return body; + if (body instanceof ArrayBuffer) return new Uint8Array(body); + if (typeof body === 'string') return encoder.encode(body); + return encoder.encode(JSON.stringify(body)); +} + +function hasVaryEncoding(header: string): boolean { + return header.split(',').some((v) => v.trim().toLowerCase() === 'accept-encoding'); +} + +function hasNoTransform(cacheControl: string): boolean { + return cacheControl.split(',').some((d) => d.trim().toLowerCase() === 'no-transform'); +} + +function weakenETag(etag: string): string { + return etag.startsWith('W/') ? etag : `W/${etag}`; +} + +export function compressionMiddleware( + opts?: CompressionOptions, +): Result { + const resolved = resolveCompressionOptions(opts); + const breach: BreachOptions | undefined = opts?.breach; + + const validation = validateCompressionOptions(resolved, breach); + if (isErr(validation)) return err(validation.data); + + // When BREACH mitigation is enabled, restrict to encodings with safe padding + const effectiveEncodings = breach !== undefined + ? resolved.encodings.filter((e) => BREACH_SAFE_ENCODINGS.has(e)) + : resolved.encodings; + + return defineMiddleware([HttpAdapter], (ctx) => { + const http = ctx.to(HttpContext); + const { request, response } = http; + + // RFC 9110 §15: skip responses that MUST NOT have a body + const status = response.getStatus() as number; + if ( + status < 200 + || status === HttpStatus.NoContent + || status === HttpStatus.ResetContent + || status === HttpStatus.NotModified + ) return; + + // RFC 9110 §9.3.2: HEAD responses MUST NOT have content + if (request.httpMethod === 'HEAD') return; + + const body = response.getBody(); + if (body === undefined || body === null) return; + if (response.getHeader(HttpHeader.ContentEncoding) !== null) return; + + // RFC 9110 §12.5.1: Vary must be set whenever Accept-Encoding is considered, + // regardless of whether compression is actually applied. + const existingVary = response.getHeader(HttpHeader.Vary); + if (existingVary === null || !hasVaryEncoding(existingVary)) { + response.appendHeader(HttpHeader.Vary, HttpHeader.AcceptEncoding); + } + + // RFC 9110 §7.7 + RFC 9111 §5.2.2.6: no-transform prohibits compression + const cacheControl = response.getHeader(HttpHeader.CacheControl); + if (cacheControl !== null && hasNoTransform(cacheControl)) return; + + const contentType = response.getContentType(); + if (contentType !== null && !resolved.filter(contentType)) return; + + // Check Accept-Encoding and negotiate before serializing body (avoids + // wasteful JSON.stringify + TextEncoder.encode when no encoding matches). + const acceptHeader = request.headers.get(HttpHeader.AcceptEncoding); + if (acceptHeader === null || acceptHeader === '') return; + + const clientPrefs = parseAcceptEncoding(acceptHeader); + const encoding = negotiateEncoding(effectiveEncodings, clientPrefs); + if (encoding === null) return; + + const bytes = serializeBody(body); + if (bytes.byteLength < resolved.threshold) return; + + let compressed: Uint8Array; + try { + compressed = BUFFER_COMPRESSORS[encoding](bytes, resolved.level[encoding]); + } catch { + return; + } + + // BREACH mitigation: inject format-level padding + if (breach !== undefined) { + if (encoding === Encoding.Gzip) { + compressed = injectGzipPadding(compressed, breach.maxPadding); + } else if (encoding === Encoding.Zstd) { + compressed = injectZstdPadding(compressed, breach.maxPadding); + } + } + + response + .setBody(compressed) + .setHeader(HttpHeader.ContentEncoding, encoding) + .removeHeader(HttpHeader.ContentLength); + + // RFC 9110 §8.8.1: strong ETag must be weakened after content transformation + const etag = response.getHeader(HttpHeader.ETag); + if (etag !== null) { + response.setHeader(HttpHeader.ETag, weakenETag(etag)); + } + }); +} diff --git a/packages/compression/src/options.spec.ts b/packages/compression/src/options.spec.ts new file mode 100644 index 0000000..8684bb4 --- /dev/null +++ b/packages/compression/src/options.spec.ts @@ -0,0 +1,330 @@ +import { describe, expect, it } from 'bun:test'; +import { isErr } from '@zipbul/result'; +import { resolveCompressionOptions, validateCompressionOptions } from './options.ts'; +import { DEFAULT_ENCODINGS, DEFAULT_FILTER, DEFAULT_LEVELS, DEFAULT_THRESHOLD } from './constants.ts'; +import { CompressionErrorReason, Encoding } from './enums.ts'; +import type { ResolvedCompressionOptions } from './types.ts'; + +function makeResolved(overrides?: Partial): ResolvedCompressionOptions { + return { + encodings: [Encoding.Gzip], + threshold: 1024, + filter: () => true, + level: { ...DEFAULT_LEVELS }, + ...overrides, + }; +} + +describe('resolveCompressionOptions', () => { + it('should return all defaults when options is undefined', () => { + const result = resolveCompressionOptions(undefined); + + expect(result.encodings).toEqual(DEFAULT_ENCODINGS); + expect(result.threshold).toBe(DEFAULT_THRESHOLD); + expect(result.filter).toBe(DEFAULT_FILTER); + expect(result.level).toEqual(DEFAULT_LEVELS); + }); + + it('should return all defaults when options is empty object', () => { + const result = resolveCompressionOptions({}); + + expect(result.encodings).toEqual(DEFAULT_ENCODINGS); + expect(result.threshold).toBe(DEFAULT_THRESHOLD); + expect(result.filter).toBe(DEFAULT_FILTER); + expect(result.level).toEqual(DEFAULT_LEVELS); + }); + + it('should use provided values when all fields specified', () => { + const filter = (ct: string) => ct === 'text/plain'; + const result = resolveCompressionOptions({ + encodings: [Encoding.Zstd], + threshold: 512, + filter, + level: { [Encoding.Brotli]: 8, [Encoding.Gzip]: 3, [Encoding.Deflate]: 2, [Encoding.Zstd]: 10 }, + }); + + expect(result.encodings).toEqual([Encoding.Zstd]); + expect(result.threshold).toBe(512); + expect(result.filter).toBe(filter); + expect(result.level).toEqual({ [Encoding.Brotli]: 8, [Encoding.Gzip]: 3, [Encoding.Deflate]: 2, [Encoding.Zstd]: 10 }); + }); + + it('should merge partial level with defaults via spread', () => { + const result = resolveCompressionOptions({ level: { [Encoding.Gzip]: 1 } }); + + expect(result.level[Encoding.Gzip]).toBe(1); + expect(result.level[Encoding.Brotli]).toBe(DEFAULT_LEVELS[Encoding.Brotli]); + expect(result.level[Encoding.Deflate]).toBe(DEFAULT_LEVELS[Encoding.Deflate]); + expect(result.level[Encoding.Zstd]).toBe(DEFAULT_LEVELS[Encoding.Zstd]); + }); + + it('should pass through empty encodings array without validation', () => { + const result = resolveCompressionOptions({ encodings: [] }); + + expect(result.encodings).toEqual([]); + }); + + it('should pass through NaN threshold without validation', () => { + const result = resolveCompressionOptions({ threshold: NaN }); + + expect(result.threshold).toBeNaN(); + }); + + it('should not default threshold=0 when given falsy but not nullish value', () => { + const result = resolveCompressionOptions({ threshold: 0 }); + + expect(result.threshold).toBe(0); + }); + + it('should produce equal result for undefined and empty object', () => { + const fromUndefined = resolveCompressionOptions(undefined); + const fromEmpty = resolveCompressionOptions({}); + + expect(fromUndefined.encodings).toEqual(fromEmpty.encodings); + expect(fromUndefined.threshold).toBe(fromEmpty.threshold); + expect(fromUndefined.filter).toBe(fromEmpty.filter); + expect(fromUndefined.level).toEqual(fromEmpty.level); + }); + + it('should override only specified levels via spread when partial level provided', () => { + const result = resolveCompressionOptions({ level: { [Encoding.Zstd]: 15 } }); + + expect(result.level[Encoding.Zstd]).toBe(15); + expect(result.level[Encoding.Gzip]).toBe(DEFAULT_LEVELS[Encoding.Gzip]); + }); + + it('should return same result when given same input', () => { + const opts = { encodings: [Encoding.Gzip], threshold: 100 }; + const a = resolveCompressionOptions(opts); + const b = resolveCompressionOptions(opts); + + expect(a.encodings).toEqual(b.encodings); + expect(a.threshold).toBe(b.threshold); + expect(a.level).toEqual(b.level); + }); +}); + +describe('validateCompressionOptions', () => { + it('should return undefined when all options are valid', () => { + const resolved = makeResolved(); + const result = validateCompressionOptions(resolved); + expect(result).toBeUndefined(); + }); + + it('should accept gzip level at min boundary when level is 1', () => { + const resolved = makeResolved({ level: { ...DEFAULT_LEVELS, [Encoding.Gzip]: 1 } }); + const result = validateCompressionOptions(resolved); + expect(result).toBeUndefined(); + }); + + it('should accept gzip level at max boundary when level is 9', () => { + const resolved = makeResolved({ level: { ...DEFAULT_LEVELS, [Encoding.Gzip]: 9 } }); + const result = validateCompressionOptions(resolved); + expect(result).toBeUndefined(); + }); + + it('should accept brotli level at min boundary when level is 0', () => { + const resolved = makeResolved({ level: { ...DEFAULT_LEVELS, [Encoding.Brotli]: 0 } }); + const result = validateCompressionOptions(resolved); + expect(result).toBeUndefined(); + }); + + it('should accept brotli level at max boundary when level is 11', () => { + const resolved = makeResolved({ level: { ...DEFAULT_LEVELS, [Encoding.Brotli]: 11 } }); + const result = validateCompressionOptions(resolved); + expect(result).toBeUndefined(); + }); + + it('should accept zstd level boundaries when level is 1 and 19', () => { + const minResult = validateCompressionOptions( + makeResolved({ level: { ...DEFAULT_LEVELS, [Encoding.Zstd]: 1 } }), + ); + const maxResult = validateCompressionOptions( + makeResolved({ level: { ...DEFAULT_LEVELS, [Encoding.Zstd]: 19 } }), + ); + expect(minResult).toBeUndefined(); + expect(maxResult).toBeUndefined(); + }); + + it('should accept deflate level boundaries when level is 1 and 9', () => { + const minResult = validateCompressionOptions( + makeResolved({ level: { ...DEFAULT_LEVELS, [Encoding.Deflate]: 1 } }), + ); + const maxResult = validateCompressionOptions( + makeResolved({ level: { ...DEFAULT_LEVELS, [Encoding.Deflate]: 9 } }), + ); + expect(minResult).toBeUndefined(); + expect(maxResult).toBeUndefined(); + }); + + it('should return EmptyEncodings when encodings is empty', () => { + const resolved = makeResolved({ encodings: [] }); + const result = validateCompressionOptions(resolved); + + expect(isErr(result)).toBe(true); + expect(result!.data.reason).toBe(CompressionErrorReason.EmptyEncodings); + }); + + it('should return InvalidEncodings when unknown encoding provided', () => { + const resolved = makeResolved({ encodings: ['lz4' as Encoding] }); + const result = validateCompressionOptions(resolved); + + expect(isErr(result)).toBe(true); + expect(result!.data.reason).toBe(CompressionErrorReason.InvalidEncodings); + }); + + it('should return InvalidThreshold when threshold is negative', () => { + const resolved = makeResolved({ threshold: -1 }); + const result = validateCompressionOptions(resolved); + + expect(isErr(result)).toBe(true); + expect(result!.data.reason).toBe(CompressionErrorReason.InvalidThreshold); + }); + + it('should return InvalidThreshold when threshold is NaN', () => { + const resolved = makeResolved({ threshold: NaN }); + const result = validateCompressionOptions(resolved); + + expect(isErr(result)).toBe(true); + expect(result!.data.reason).toBe(CompressionErrorReason.InvalidThreshold); + }); + + it('should return InvalidLevel when level exceeds max', () => { + const resolved = makeResolved({ level: { ...DEFAULT_LEVELS, [Encoding.Gzip]: 10 } }); + const result = validateCompressionOptions(resolved); + + expect(isErr(result)).toBe(true); + expect(result!.data.reason).toBe(CompressionErrorReason.InvalidLevel); + }); + + it('should return InvalidLevel when level is below min', () => { + const resolved = makeResolved({ level: { ...DEFAULT_LEVELS, [Encoding.Gzip]: 0 } }); + const result = validateCompressionOptions(resolved); + + expect(isErr(result)).toBe(true); + expect(result!.data.reason).toBe(CompressionErrorReason.InvalidLevel); + }); + + it('should return InvalidLevel when level is fractional', () => { + const resolved = makeResolved({ level: { ...DEFAULT_LEVELS, [Encoding.Gzip]: 5.5 } }); + const result = validateCompressionOptions(resolved); + + expect(isErr(result)).toBe(true); + expect(result!.data.reason).toBe(CompressionErrorReason.InvalidLevel); + }); + + it('should accept threshold=0 as valid when threshold is exactly 0', () => { + const resolved = makeResolved({ threshold: 0 }); + const result = validateCompressionOptions(resolved); + expect(result).toBeUndefined(); + }); + + it('should accept threshold=-0 as valid when threshold is negative zero', () => { + const resolved = makeResolved({ threshold: -0 }); + const result = validateCompressionOptions(resolved); + expect(result).toBeUndefined(); + }); + + it('should skip level validation when level is undefined for an encoding', () => { + const resolved = makeResolved({ + level: { + [Encoding.Gzip]: 6, + [Encoding.Deflate]: 6, + } as Record, + }); + const result = validateCompressionOptions(resolved); + expect(result).toBeUndefined(); + }); + + it('should check empty encodings before threshold when both are invalid', () => { + const resolved = makeResolved({ encodings: [], threshold: -1 }); + const result = validateCompressionOptions(resolved); + + expect(isErr(result)).toBe(true); + expect(result!.data.reason).toBe(CompressionErrorReason.EmptyEncodings); + }); + + it('should check invalid threshold before levels when both are invalid', () => { + const resolved = makeResolved({ + threshold: NaN, + level: { ...DEFAULT_LEVELS, [Encoding.Gzip]: 100 }, + }); + const result = validateCompressionOptions(resolved); + + expect(isErr(result)).toBe(true); + expect(result!.data.reason).toBe(CompressionErrorReason.InvalidThreshold); + }); + + it('should validate level for all Encoding enum values regardless of encodings array', () => { + const resolved = makeResolved({ + encodings: [Encoding.Gzip], + level: { ...DEFAULT_LEVELS, [Encoding.Brotli]: 99 }, + }); + const result = validateCompressionOptions(resolved); + + expect(isErr(result)).toBe(true); + expect(result!.data.reason).toBe(CompressionErrorReason.InvalidLevel); + }); + + // --- Breach validation --- + + it('should accept valid breach options', () => { + const resolved = makeResolved({ encodings: [Encoding.Gzip] }); + const result = validateCompressionOptions(resolved, { maxPadding: 32 }); + expect(result).toBeUndefined(); + }); + + it('should return InvalidBreach when maxPadding is 0', () => { + const resolved = makeResolved({ encodings: [Encoding.Gzip] }); + const result = validateCompressionOptions(resolved, { maxPadding: 0 }); + expect(isErr(result)).toBe(true); + expect(result!.data.reason).toBe(CompressionErrorReason.InvalidBreach); + }); + + it('should return InvalidBreach when maxPadding is negative', () => { + const resolved = makeResolved({ encodings: [Encoding.Gzip] }); + const result = validateCompressionOptions(resolved, { maxPadding: -1 }); + expect(isErr(result)).toBe(true); + expect(result!.data.reason).toBe(CompressionErrorReason.InvalidBreach); + }); + + it('should return InvalidBreach when maxPadding is fractional', () => { + const resolved = makeResolved({ encodings: [Encoding.Gzip] }); + const result = validateCompressionOptions(resolved, { maxPadding: 1.5 }); + expect(isErr(result)).toBe(true); + expect(result!.data.reason).toBe(CompressionErrorReason.InvalidBreach); + }); + + it('should return InvalidBreach when maxPadding exceeds 4096', () => { + const resolved = makeResolved({ encodings: [Encoding.Gzip] }); + const result = validateCompressionOptions(resolved, { maxPadding: 5000 }); + expect(isErr(result)).toBe(true); + expect(result!.data.reason).toBe(CompressionErrorReason.InvalidBreach); + }); + + it('should return InvalidBreach when maxPadding is NaN', () => { + const resolved = makeResolved({ encodings: [Encoding.Gzip] }); + const result = validateCompressionOptions(resolved, { maxPadding: NaN }); + expect(isErr(result)).toBe(true); + expect(result!.data.reason).toBe(CompressionErrorReason.InvalidBreach); + }); + + it('should return InvalidBreach when no BREACH-safe encoding available', () => { + const resolved = makeResolved({ encodings: [Encoding.Brotli] }); + const result = validateCompressionOptions(resolved, { maxPadding: 32 }); + expect(isErr(result)).toBe(true); + expect(result!.data.reason).toBe(CompressionErrorReason.InvalidBreach); + }); + + it('should accept breach when at least one BREACH-safe encoding exists', () => { + const resolved = makeResolved({ encodings: [Encoding.Brotli, Encoding.Zstd] }); + const result = validateCompressionOptions(resolved, { maxPadding: 32 }); + expect(result).toBeUndefined(); + }); + + it('should skip breach validation when breach is undefined', () => { + const resolved = makeResolved({ encodings: [Encoding.Brotli] }); + const result = validateCompressionOptions(resolved); + expect(result).toBeUndefined(); + }); +}); diff --git a/packages/compression/src/options.ts b/packages/compression/src/options.ts new file mode 100644 index 0000000..6fbf30b --- /dev/null +++ b/packages/compression/src/options.ts @@ -0,0 +1,94 @@ +import { err } from '@zipbul/result'; +import type { Result } from '@zipbul/result'; +import { CompressionErrorReason, Encoding } from './enums.ts'; +import { + DEFAULT_ENCODINGS, + DEFAULT_FILTER, + DEFAULT_LEVELS, + DEFAULT_THRESHOLD, +} from './constants.ts'; +import type { BreachOptions, CompressionErrorData, CompressionOptions } from './interfaces.ts'; +import type { ResolvedCompressionOptions } from './types.ts'; + +const VALID_ENCODINGS = new Set(Object.values(Encoding)); + +const LEVEL_RANGES: Record = { + [Encoding.Gzip]: { min: 1, max: 9 }, + [Encoding.Deflate]: { min: 1, max: 9 }, + [Encoding.Brotli]: { min: 0, max: 11 }, + [Encoding.Zstd]: { min: 1, max: 19 }, +}; + +/** Encodings with safe format-level padding for BREACH mitigation. */ +export const BREACH_SAFE_ENCODINGS = new Set([Encoding.Gzip, Encoding.Zstd]); + +export function resolveCompressionOptions( + options?: CompressionOptions, +): ResolvedCompressionOptions { + return { + encodings: options?.encodings ?? DEFAULT_ENCODINGS, + threshold: options?.threshold ?? DEFAULT_THRESHOLD, + filter: options?.filter ?? DEFAULT_FILTER, + level: { ...DEFAULT_LEVELS, ...options?.level }, + }; +} + +export function validateCompressionOptions( + resolved: ResolvedCompressionOptions, + breach?: BreachOptions, +): Result { + if (resolved.encodings.length === 0) { + return err({ + reason: CompressionErrorReason.EmptyEncodings, + message: 'encodings must not be empty', + }); + } + + for (const encoding of resolved.encodings) { + if (!VALID_ENCODINGS.has(encoding)) { + return err({ + reason: CompressionErrorReason.InvalidEncodings, + message: `unknown encoding: ${encoding}`, + }); + } + } + + if (!Number.isFinite(resolved.threshold) || resolved.threshold < 0) { + return err({ + reason: CompressionErrorReason.InvalidThreshold, + message: 'threshold must be a non-negative finite number', + }); + } + + for (const encoding of Object.values(Encoding)) { + const level = resolved.level[encoding]; + if (level === undefined) continue; + + const range = LEVEL_RANGES[encoding]; + if (!Number.isInteger(level) || level < range.min || level > range.max) { + return err({ + reason: CompressionErrorReason.InvalidLevel, + message: `${encoding} level must be an integer between ${range.min} and ${range.max}, got ${level}`, + }); + } + } + + if (breach !== undefined) { + if (!Number.isInteger(breach.maxPadding) || breach.maxPadding < 1 || breach.maxPadding > 4096) { + return err({ + reason: CompressionErrorReason.InvalidBreach, + message: 'breach.maxPadding must be an integer between 1 and 4096', + }); + } + + const hasSafeEncoding = resolved.encodings.some((e) => BREACH_SAFE_ENCODINGS.has(e)); + if (!hasSafeEncoding) { + return err({ + reason: CompressionErrorReason.InvalidBreach, + message: 'breach requires at least one BREACH-safe encoding (gzip or zstd)', + }); + } + } + + return undefined; +} diff --git a/packages/compression/src/types.ts b/packages/compression/src/types.ts new file mode 100644 index 0000000..1417322 --- /dev/null +++ b/packages/compression/src/types.ts @@ -0,0 +1,10 @@ +import type { Encoding } from './enums.ts'; + +export type ResolvedCompressionOptions = { + encodings: Encoding[]; + threshold: number; + filter: (contentType: string) => boolean; + level: Record; +}; + +export type BufferCompressFn = (data: Uint8Array, level: number) => Uint8Array; diff --git a/packages/compression/test/e2e/compression-server.test.ts b/packages/compression/test/e2e/compression-server.test.ts new file mode 100644 index 0000000..dc786d9 --- /dev/null +++ b/packages/compression/test/e2e/compression-server.test.ts @@ -0,0 +1,142 @@ +import { afterAll, beforeAll, describe, expect, it } from 'bun:test'; +import { isErr } from '@zipbul/result'; +import { compressionMiddleware, Encoding } from '../../index.ts'; +import { + HttpRequest, + HttpResponse, + HttpContextAdapter, + HttpContext, +} from '@zipbul/http-adapter'; +import type { HttpMethod } from '@zipbul/http-adapter'; +import type { Server } from 'bun'; + +let BASE: string; + +const LARGE_DATA = JSON.stringify({ data: 'x'.repeat(2048) }); + +const result = compressionMiddleware({ + encodings: [Encoding.Brotli, Encoding.Gzip], +}); +if (isErr(result)) throw new Error(`setup failed: ${result.data.message}`); +const middleware = result; + +let server: Server; + +beforeAll(() => { + server = Bun.serve({ + port: 0, + fetch(req) { + const url = new URL(req.url); + + const httpReq = new HttpRequest({ + url: req.url, + httpMethod: req.method as HttpMethod, + headers: req.headers, + }); + const httpRes = new HttpResponse(httpReq, new Headers()); + + if (url.pathname === '/json') { + httpRes.setBody(LARGE_DATA).setContentType('application/json'); + } else if (url.pathname === '/image') { + httpRes.setBody(new Uint8Array(2048)).setContentType('image/png'); + } else if (url.pathname === '/small') { + httpRes.setBody('hi').setContentType('text/plain'); + } else { + return new Response('not found', { status: 404 }); + } + + const adapter = new HttpContextAdapter(httpReq, httpRes); + const ctx = new HttpContext(adapter); + middleware.handler(ctx); + + // Build response directly from HttpResponse state to preserve binary body + const body = httpRes.getBody(); + const headers = new Headers(); + const contentType = httpRes.getContentType(); + if (contentType) headers.set('content-type', contentType); + + const encoding = httpRes.getHeader('content-encoding'); + if (encoding) headers.set('content-encoding', encoding); + + const vary = httpRes.getHeader('vary'); + if (vary) headers.set('vary', vary); + + return new Response(body as BodyInit | null, { headers }); + }, + }); + BASE = `http://localhost:${server.port}`; +}); + +afterAll(() => { + server.stop(true); +}); + +describe('Compression E2E with Bun.serve', () => { + it('should return gzip compressed response with correct headers', async () => { + const resp = await fetch(`${BASE}/json`, { + headers: { 'accept-encoding': 'gzip' }, + }); + + // Bun auto-decompresses, so content-encoding is stripped by fetch + // Verify the response body is decompressed correctly instead + const text = await resp.text(); + expect(text).toBe(LARGE_DATA); + }); + + it('should return brotli compressed response for Accept-Encoding: br', async () => { + const resp = await fetch(`${BASE}/json`, { + headers: { 'accept-encoding': 'br' }, + }); + + const text = await resp.text(); + expect(text).toBe(LARGE_DATA); + }); + + it('should roundtrip gzip (compress → auto-decompress → original)', async () => { + const resp = await fetch(`${BASE}/json`, { + headers: { 'accept-encoding': 'gzip' }, + }); + + const text = await resp.text(); + expect(text).toBe(LARGE_DATA); + expect(JSON.parse(text)).toEqual({ data: 'x'.repeat(2048) }); + }); + + it('should roundtrip brotli (compress → auto-decompress → original)', async () => { + const resp = await fetch(`${BASE}/json`, { + headers: { 'accept-encoding': 'br' }, + }); + + const text = await resp.text(); + expect(text).toBe(LARGE_DATA); + expect(JSON.parse(text)).toEqual({ data: 'x'.repeat(2048) }); + }); + + it('should skip compression for image/png', async () => { + const resp = await fetch(`${BASE}/image`, { + headers: { 'accept-encoding': 'gzip' }, + }); + + expect(resp.headers.has('content-encoding')).toBe(false); + }); + + it('should skip compression when body is below threshold', async () => { + const resp = await fetch(`${BASE}/small`, { + headers: { 'accept-encoding': 'gzip' }, + }); + + const text = await resp.text(); + expect(text).toBe('hi'); + expect(resp.headers.has('content-encoding')).toBe(false); + }); + + it('should skip compression when no Accept-Encoding header', async () => { + const resp = await fetch(`${BASE}/json`, { + headers: { 'accept-encoding': '' }, + }); + + expect(resp.headers.has('content-encoding')).toBe(false); + const text = await resp.text(); + expect(text).toBe(LARGE_DATA); + }); +}); diff --git a/packages/compression/test/integration/compression.test.ts b/packages/compression/test/integration/compression.test.ts new file mode 100644 index 0000000..771b0dc --- /dev/null +++ b/packages/compression/test/integration/compression.test.ts @@ -0,0 +1,961 @@ +import { describe, expect, it } from 'bun:test'; +import { gunzipSync, inflateSync, brotliDecompressSync } from 'node:zlib'; +import { isErr } from '@zipbul/result'; +import { + compressionMiddleware, + Encoding, + CompressionErrorReason, +} from '../../index.ts'; + +interface MockHeaders { + get(name: string): string | null; +} + +interface MockResponse { + getBody(): unknown; + setBody(data: unknown): MockResponse; + getHeader(name: string): string | null; + setHeader(name: string, value: string): MockResponse; + removeHeader(name: string): MockResponse; + appendHeader(name: string, value: string): MockResponse; + getContentType(): string | null; + getStatus(): number; +} + +function mockHttpResponse(opts: { + body?: unknown; + headers?: Record; + contentType?: string | null; + status?: number; +} = {}): MockResponse { + let body = opts.body; + const status = opts.status ?? 200; + const headers = new Map(); + if (opts.headers) { + for (const [k, v] of Object.entries(opts.headers)) { + headers.set(k.toLowerCase(), v); + } + } + const contentType = opts.contentType !== undefined ? opts.contentType : (headers.get('content-type') ?? null); + + const self: MockResponse = { + getBody: () => body, + setBody: (data) => { body = data; return self; }, + getHeader: (name) => headers.get(name.toLowerCase()) ?? null, + setHeader: (name, value) => { headers.set(name.toLowerCase(), value); return self; }, + removeHeader: (name) => { headers.delete(name.toLowerCase()); return self; }, + appendHeader: (name, value) => { + const existing = headers.get(name.toLowerCase()); + headers.set(name.toLowerCase(), existing ? `${existing}, ${value}` : value); + return self; + }, + getContentType: () => contentType, + getStatus: () => status, + }; + return self; +} + +function mockContext( + request: { headers: MockHeaders; httpMethod?: string }, + response: MockResponse, +) { + const req = { httpMethod: 'GET', ...request }; + return { + getType: () => 'http', + get: () => undefined, + to: () => ({ request: req, response }), + } as any; +} + +function makeRequestHeaders(acceptEncoding?: string): MockHeaders { + const h = new Headers(); + if (acceptEncoding !== undefined) { + h.set('accept-encoding', acceptEncoding); + } + return h; +} + +function largeBody(sizeBytes: number): string { + return 'a'.repeat(sizeBytes); +} + +/** Unwrap a successful Result — fails the test if it's an Err. */ +function unwrap(result: T) { + if (isErr(result)) { + throw new Error(`unexpected Err: ${result.data.message}`); + } + return result; +} + +const LARGE_JSON = JSON.stringify({ data: largeBody(2048) }); +const LARGE_BODY_OBJ = { data: largeBody(2048) }; + +describe('compressionMiddleware', () => { + const middleware = unwrap(compressionMiddleware()); + + // --- HP: Happy Path --- + + describe('gzip compression', () => { + it('should compress JSON body when Accept-Encoding: gzip', () => { + const response = mockHttpResponse({ body: LARGE_BODY_OBJ, contentType: 'application/json' }); + const ctx = mockContext({ headers: makeRequestHeaders('gzip') }, response); + middleware.handler(ctx); + expect(response.getHeader('content-encoding')).toBe('gzip'); + expect(response.getBody()).toBeInstanceOf(Uint8Array); + }); + + it('should roundtrip gzip (compress → decompress → original)', () => { + const response = mockHttpResponse({ body: LARGE_BODY_OBJ, contentType: 'application/json' }); + const ctx = mockContext({ headers: makeRequestHeaders('gzip') }, response); + middleware.handler(ctx); + const compressed = response.getBody() as Uint8Array; + const decompressed = gunzipSync(compressed).toString('utf-8'); + expect(decompressed).toBe(LARGE_JSON); + }); + + it('should roundtrip large body (100KB+) gzip', () => { + const original = largeBody(120_000); + const response = mockHttpResponse({ body: original, contentType: 'text/plain' }); + const ctx = mockContext({ headers: makeRequestHeaders('gzip') }, response); + middleware.handler(ctx); + const compressed = response.getBody() as Uint8Array; + const decompressed = gunzipSync(compressed).toString('utf-8'); + expect(decompressed).toBe(original); + }); + }); + + describe('brotli compression', () => { + it('should compress JSON body when Accept-Encoding: br', () => { + const response = mockHttpResponse({ body: LARGE_BODY_OBJ, contentType: 'application/json' }); + const ctx = mockContext({ headers: makeRequestHeaders('br') }, response); + middleware.handler(ctx); + expect(response.getHeader('content-encoding')).toBe('br'); + expect(response.getBody()).toBeInstanceOf(Uint8Array); + }); + + it('should roundtrip brotli', () => { + const response = mockHttpResponse({ body: LARGE_BODY_OBJ, contentType: 'application/json' }); + const ctx = mockContext({ headers: makeRequestHeaders('br') }, response); + middleware.handler(ctx); + const compressed = response.getBody() as Uint8Array; + const decompressed = brotliDecompressSync(compressed).toString('utf-8'); + expect(decompressed).toBe(LARGE_JSON); + }); + }); + + describe('deflate compression', () => { + it('should compress JSON body when Accept-Encoding: deflate', () => { + const m = unwrap(compressionMiddleware({ encodings: [Encoding.Deflate] })); + const response = mockHttpResponse({ body: LARGE_BODY_OBJ, contentType: 'application/json' }); + const ctx = mockContext({ headers: makeRequestHeaders('deflate') }, response); + m.handler(ctx); + expect(response.getHeader('content-encoding')).toBe('deflate'); + }); + + it('should roundtrip deflate (zlib-wrapped per RFC 1950)', () => { + const m = unwrap(compressionMiddleware({ encodings: [Encoding.Deflate] })); + const response = mockHttpResponse({ body: LARGE_BODY_OBJ, contentType: 'application/json' }); + const ctx = mockContext({ headers: makeRequestHeaders('deflate') }, response); + m.handler(ctx); + const compressed = response.getBody() as Uint8Array; + // node:zlib inflateSync handles zlib-wrapped format (RFC 1950) + const decompressed = inflateSync(compressed); + expect(new TextDecoder().decode(decompressed)).toBe(LARGE_JSON); + }); + + it('should produce zlib-wrapped output (starts with 0x78 CMF byte)', () => { + const m = unwrap(compressionMiddleware({ encodings: [Encoding.Deflate] })); + const response = mockHttpResponse({ body: LARGE_BODY_OBJ, contentType: 'application/json' }); + const ctx = mockContext({ headers: makeRequestHeaders('deflate') }, response); + m.handler(ctx); + const compressed = response.getBody() as Uint8Array; + // RFC 1950: zlib header starts with CMF byte where CM=8 (deflate), so CMF & 0x0f === 8 + expect(compressed[0]! & 0x0f).toBe(8); + }); + }); + + describe('zstd compression', () => { + it('should compress JSON body when Accept-Encoding: zstd', () => { + const m = unwrap(compressionMiddleware({ encodings: [Encoding.Zstd] })); + const response = mockHttpResponse({ body: LARGE_BODY_OBJ, contentType: 'application/json' }); + const ctx = mockContext({ headers: makeRequestHeaders('zstd') }, response); + m.handler(ctx); + expect(response.getHeader('content-encoding')).toBe('zstd'); + }); + + it('should roundtrip zstd', () => { + const m = unwrap(compressionMiddleware({ encodings: [Encoding.Zstd] })); + const response = mockHttpResponse({ body: LARGE_BODY_OBJ, contentType: 'application/json' }); + const ctx = mockContext({ headers: makeRequestHeaders('zstd') }, response); + m.handler(ctx); + const compressed = response.getBody() as Uint8Array; + const decompressed = Bun.zstdDecompressSync(Buffer.from(compressed)); + expect(new TextDecoder().decode(decompressed)).toBe(LARGE_JSON); + }); + }); + + describe('response headers after compression', () => { + it('should set Content-Encoding, remove Content-Length, add Vary', () => { + const response = mockHttpResponse({ + body: LARGE_BODY_OBJ, + contentType: 'application/json', + headers: { 'content-length': String(new TextEncoder().encode(LARGE_JSON).byteLength) }, + }); + const ctx = mockContext({ headers: makeRequestHeaders('gzip') }, response); + middleware.handler(ctx); + expect(response.getHeader('content-encoding')).toBe('gzip'); + expect(response.getHeader('content-length')).toBeNull(); + expect(response.getHeader('vary')).toContain('accept-encoding'); + }); + }); + + // --- NE: Negative/Error --- + + describe('encoding negotiation', () => { + it('should select server-preferred encoding when client supports both (br > gzip)', () => { + const response = mockHttpResponse({ body: LARGE_BODY_OBJ, contentType: 'application/json' }); + const ctx = mockContext({ headers: makeRequestHeaders('gzip, br') }, response); + middleware.handler(ctx); + expect(response.getHeader('content-encoding')).toBe('br'); + }); + + it('should respect quality values (gzip;q=1 > br;q=0.5)', () => { + const m = unwrap(compressionMiddleware({ encodings: [Encoding.Brotli, Encoding.Gzip] })); + const response = mockHttpResponse({ body: LARGE_BODY_OBJ, contentType: 'application/json' }); + const ctx = mockContext({ headers: makeRequestHeaders('gzip;q=1, br;q=0.5') }, response); + m.handler(ctx); + expect(response.getHeader('content-encoding')).toBe('gzip'); + }); + + it('should handle wildcard Accept-Encoding: * → server first', () => { + const response = mockHttpResponse({ body: LARGE_BODY_OBJ, contentType: 'application/json' }); + const ctx = mockContext({ headers: makeRequestHeaders('*') }, response); + middleware.handler(ctx); + expect(response.getHeader('content-encoding')).toBe('br'); + }); + + it('should skip when client does not support server-preferred encoding', () => { + const m = unwrap(compressionMiddleware({ encodings: [Encoding.Gzip] })); + const response = mockHttpResponse({ body: LARGE_BODY_OBJ, contentType: 'application/json' }); + const ctx = mockContext({ headers: makeRequestHeaders('br') }, response); + m.handler(ctx); + expect(response.getHeader('content-encoding')).toBeNull(); + }); + }); + + describe('create validation errors', () => { + it('should return Err with EmptyEncodings when encodings=[]', () => { + const result = compressionMiddleware({ encodings: [] }); + expect(isErr(result)).toBe(true); + if (isErr(result)) { + expect(result.data.reason).toBe(CompressionErrorReason.EmptyEncodings); + } + }); + + it('should return Err with InvalidEncodings for unknown encoding', () => { + const result = compressionMiddleware({ encodings: ['lz4' as Encoding] }); + expect(isErr(result)).toBe(true); + if (isErr(result)) { + expect(result.data.reason).toBe(CompressionErrorReason.InvalidEncodings); + } + }); + + it('should return Err with InvalidThreshold for negative threshold', () => { + const result = compressionMiddleware({ threshold: -1 }); + expect(isErr(result)).toBe(true); + if (isErr(result)) { + expect(result.data.reason).toBe(CompressionErrorReason.InvalidThreshold); + } + }); + + it('should return Err with InvalidThreshold for NaN threshold', () => { + const result = compressionMiddleware({ threshold: NaN }); + expect(isErr(result)).toBe(true); + if (isErr(result)) { + expect(result.data.reason).toBe(CompressionErrorReason.InvalidThreshold); + } + }); + + it('should return Err with InvalidLevel for gzip level=10', () => { + const result = compressionMiddleware({ level: { [Encoding.Gzip]: 10 } }); + expect(isErr(result)).toBe(true); + if (isErr(result)) { + expect(result.data.reason).toBe(CompressionErrorReason.InvalidLevel); + } + }); + + it('should return Err with InvalidLevel for brotli level=12', () => { + const result = compressionMiddleware({ level: { [Encoding.Brotli]: 12 } }); + expect(isErr(result)).toBe(true); + if (isErr(result)) { + expect(result.data.reason).toBe(CompressionErrorReason.InvalidLevel); + } + }); + + it('should return Err with InvalidLevel for fractional level', () => { + const result = compressionMiddleware({ level: { [Encoding.Gzip]: 5.5 } }); + expect(isErr(result)).toBe(true); + if (isErr(result)) { + expect(result.data.reason).toBe(CompressionErrorReason.InvalidLevel); + } + }); + }); + + describe('custom options', () => { + it('should apply custom filter', () => { + const m = unwrap(compressionMiddleware({ filter: (ct) => ct.includes('custom'), threshold: 0 })); + const response = mockHttpResponse({ body: 'hello', contentType: 'text/custom' }); + const ctx = mockContext({ headers: makeRequestHeaders('gzip') }, response); + m.handler(ctx); + expect(response.getHeader('content-encoding')).toBe('gzip'); + }); + + it('should apply custom threshold=0 → compress all', () => { + const m = unwrap(compressionMiddleware({ threshold: 0 })); + const response = mockHttpResponse({ body: { a: 1 }, contentType: 'application/json' }); + const ctx = mockContext({ headers: makeRequestHeaders('gzip') }, response); + m.handler(ctx); + expect(response.getHeader('content-encoding')).toBe('gzip'); + }); + + it('should apply custom level', () => { + const m = unwrap(compressionMiddleware({ level: { [Encoding.Gzip]: 1 } })); + const response = mockHttpResponse({ body: { data: largeBody(2048) }, contentType: 'application/json' }); + const ctx = mockContext({ headers: makeRequestHeaders('gzip') }, response); + m.handler(ctx); + const compressed = response.getBody() as Uint8Array; + const decompressed = gunzipSync(compressed).toString('utf-8'); + expect(JSON.parse(decompressed)).toEqual({ data: largeBody(2048) }); + }); + + it('should filter default compressible types: text/html, application/json, image/svg+xml', () => { + const types = ['text/html', 'application/json', 'image/svg+xml']; + for (const ct of types) { + const m = unwrap(compressionMiddleware()); + const response = mockHttpResponse({ body: largeBody(2048), contentType: ct }); + const ctx = mockContext({ headers: makeRequestHeaders('gzip') }, response); + m.handler(ctx); + expect(response.getHeader('content-encoding')).toBe('gzip'); + } + }); + }); + + // --- ED: Edge --- + + describe('edge cases', () => { + it('should skip when body is undefined → no changes', () => { + const response = mockHttpResponse({ body: undefined }); + const ctx = mockContext({ headers: makeRequestHeaders('gzip') }, response); + middleware.handler(ctx); + expect(response.getHeader('content-encoding')).toBeNull(); + }); + + it('should skip when body is null → no changes', () => { + const response = mockHttpResponse({ body: null }); + const ctx = mockContext({ headers: makeRequestHeaders('gzip') }, response); + middleware.handler(ctx); + expect(response.getHeader('content-encoding')).toBeNull(); + }); + + it('should compress when body size equals threshold (not skip)', () => { + const m = unwrap(compressionMiddleware({ threshold: 10 })); + const body = 'a'.repeat(10); + const response = mockHttpResponse({ body, contentType: 'text/plain' }); + const ctx = mockContext({ headers: makeRequestHeaders('gzip') }, response); + m.handler(ctx); + expect(response.getHeader('content-encoding')).toBe('gzip'); + }); + + it('should skip when body is 1 byte below threshold', () => { + const m = unwrap(compressionMiddleware({ threshold: 10 })); + const body = 'a'.repeat(9); + const response = mockHttpResponse({ body, contentType: 'text/plain' }); + const ctx = mockContext({ headers: makeRequestHeaders('gzip') }, response); + m.handler(ctx); + expect(response.getHeader('content-encoding')).toBeNull(); + }); + + it('should attempt compression when Content-Type is absent', () => { + const m = unwrap(compressionMiddleware({ threshold: 0 })); + const response = mockHttpResponse({ body: 'hello world', contentType: null }); + const ctx = mockContext({ headers: makeRequestHeaders('gzip') }, response); + m.handler(ctx); + expect(response.getHeader('content-encoding')).toBe('gzip'); + }); + + it('should skip when Accept-Encoding: gzip;q=0 → no changes', () => { + const m = unwrap(compressionMiddleware({ encodings: [Encoding.Gzip], threshold: 0 })); + const response = mockHttpResponse({ body: LARGE_BODY_OBJ, contentType: 'application/json' }); + const ctx = mockContext({ headers: makeRequestHeaders('gzip;q=0') }, response); + m.handler(ctx); + expect(response.getHeader('content-encoding')).toBeNull(); + }); + + it('should skip when no Accept-Encoding header → no changes', () => { + const response = mockHttpResponse({ body: LARGE_BODY_OBJ, contentType: 'application/json' }); + const ctx = mockContext({ headers: makeRequestHeaders() }, response); + middleware.handler(ctx); + expect(response.getHeader('content-encoding')).toBeNull(); + }); + + it('should skip when already Content-Encoding → no changes', () => { + const response = mockHttpResponse({ + body: 'compressed', + contentType: 'text/plain', + headers: { 'content-encoding': 'gzip' }, + }); + const ctx = mockContext({ headers: makeRequestHeaders('gzip') }, response); + middleware.handler(ctx); + // body should remain unchanged (string, not Uint8Array) + expect(response.getBody()).toBe('compressed'); + }); + + it('should skip when Content-Type is image/png → no changes', () => { + const m = unwrap(compressionMiddleware({ threshold: 0 })); + const response = mockHttpResponse({ body: 'fake-png-data', contentType: 'image/png' }); + const ctx = mockContext({ headers: makeRequestHeaders('gzip') }, response); + m.handler(ctx); + expect(response.getHeader('content-encoding')).toBeNull(); + }); + }); + + // --- CO: Corner --- + + describe('corner cases', () => { + it('should return AlreadyEncoded before checking threshold (priority)', () => { + const m = unwrap(compressionMiddleware({ threshold: 999999 })); + const response = mockHttpResponse({ + body: 'x', + contentType: 'text/plain', + headers: { 'content-encoding': 'gzip' }, + }); + const ctx = mockContext({ headers: makeRequestHeaders('gzip') }, response); + m.handler(ctx); + expect(response.getBody()).toBe('x'); + }); + + it('should exclude gzip;q=0 but use wildcard for br', () => { + const m = unwrap(compressionMiddleware({ encodings: [Encoding.Brotli, Encoding.Gzip] })); + const response = mockHttpResponse({ body: LARGE_BODY_OBJ, contentType: 'application/json' }); + const ctx = mockContext({ headers: makeRequestHeaders('*, gzip;q=0') }, response); + m.handler(ctx); + expect(response.getHeader('content-encoding')).toBe('br'); + }); + }); + + // --- ST: State Transition --- + + describe('instance reuse', () => { + it('should handle multiple independent requests with same middleware', () => { + const r1 = mockHttpResponse({ body: LARGE_BODY_OBJ, contentType: 'application/json' }); + middleware.handler(mockContext({ headers: makeRequestHeaders('gzip') }, r1)); + + const r2 = mockHttpResponse({ body: LARGE_BODY_OBJ, contentType: 'application/json' }); + middleware.handler(mockContext({ headers: makeRequestHeaders('br') }, r2)); + + const r3 = mockHttpResponse({ body: LARGE_BODY_OBJ, contentType: 'application/json' }); + middleware.handler(mockContext({ headers: makeRequestHeaders() }, r3)); + + expect(r1.getHeader('content-encoding')).toBe('gzip'); + expect(r2.getHeader('content-encoding')).toBe('br'); + expect(r3.getHeader('content-encoding')).toBeNull(); + }); + }); + + // --- ID: Idempotency --- + + describe('idempotency', () => { + it('should produce equivalent results with same options via different instances', () => { + const m1 = unwrap(compressionMiddleware({ encodings: [Encoding.Gzip] })); + const m2 = unwrap(compressionMiddleware({ encodings: [Encoding.Gzip] })); + + const r1 = mockHttpResponse({ body: LARGE_BODY_OBJ, contentType: 'application/json' }); + m1.handler(mockContext({ headers: makeRequestHeaders('gzip') }, r1)); + + const r2 = mockHttpResponse({ body: LARGE_BODY_OBJ, contentType: 'application/json' }); + m2.handler(mockContext({ headers: makeRequestHeaders('gzip') }, r2)); + + expect(r1.getHeader('content-encoding')).toBe(r2.getHeader('content-encoding')); + }); + }); + + // --- OR: Ordering --- + + describe('ordering', () => { + it('should select different encoding based on encodings order', () => { + const m1 = unwrap(compressionMiddleware({ encodings: [Encoding.Gzip, Encoding.Brotli] })); + const m2 = unwrap(compressionMiddleware({ encodings: [Encoding.Brotli, Encoding.Gzip] })); + + const r1 = mockHttpResponse({ body: LARGE_BODY_OBJ, contentType: 'application/json' }); + m1.handler(mockContext({ headers: makeRequestHeaders('gzip, br') }, r1)); + + const r2 = mockHttpResponse({ body: LARGE_BODY_OBJ, contentType: 'application/json' }); + m2.handler(mockContext({ headers: makeRequestHeaders('gzip, br') }, r2)); + + expect(r1.getHeader('content-encoding')).toBe('gzip'); + expect(r2.getHeader('content-encoding')).toBe('br'); + }); + + it('should check skip conditions in order: NoBody > AlreadyEncoded > FilteredOut > BelowThreshold', () => { + const m = unwrap(compressionMiddleware({ threshold: 999999 })); + + // NoBody (null) + const r1 = mockHttpResponse({ body: null }); + m.handler(mockContext({ headers: makeRequestHeaders('gzip') }, r1)); + expect(r1.getHeader('content-encoding')).toBeNull(); + + // AlreadyEncoded + const r2 = mockHttpResponse({ + body: 'x', + contentType: 'image/png', + headers: { 'content-encoding': 'gzip' }, + }); + m.handler(mockContext({ headers: makeRequestHeaders('gzip') }, r2)); + expect(r2.getBody()).toBe('x'); + + // FilteredOut + const r3 = mockHttpResponse({ body: 'x', contentType: 'image/png' }); + m.handler(mockContext({ headers: makeRequestHeaders('gzip') }, r3)); + expect(r3.getHeader('content-encoding')).toBeNull(); + + // BelowThreshold + const r4 = mockHttpResponse({ body: 'x', contentType: 'text/plain' }); + m.handler(mockContext({ headers: makeRequestHeaders('gzip') }, r4)); + expect(r4.getHeader('content-encoding')).toBeNull(); + }); + }); + + // --- Vary header --- + + describe('Vary header', () => { + it('should not duplicate Vary: Accept-Encoding if already present', () => { + const response = mockHttpResponse({ + body: LARGE_BODY_OBJ, + contentType: 'application/json', + headers: { 'vary': 'accept-encoding' }, + }); + const ctx = mockContext({ headers: makeRequestHeaders('gzip') }, response); + middleware.handler(ctx); + expect(response.getHeader('vary')).toBe('accept-encoding'); + }); + + it('should append to existing Vary header without duplicating', () => { + const response = mockHttpResponse({ + body: LARGE_BODY_OBJ, + contentType: 'application/json', + headers: { 'vary': 'Origin' }, + }); + const ctx = mockContext({ headers: makeRequestHeaders('gzip') }, response); + middleware.handler(ctx); + expect(response.getHeader('vary')).toBe('Origin, accept-encoding'); + }); + + it('should set Vary even when compression is skipped due to threshold', () => { + const response = mockHttpResponse({ body: 'hi', contentType: 'text/plain' }); + const ctx = mockContext({ headers: makeRequestHeaders('gzip') }, response); + middleware.handler(ctx); + expect(response.getHeader('content-encoding')).toBeNull(); + expect(response.getHeader('vary')).toContain('accept-encoding'); + }); + + it('should set Vary even when content-type is filtered out', () => { + const response = mockHttpResponse({ body: LARGE_BODY_OBJ, contentType: 'image/png' }); + const ctx = mockContext({ headers: makeRequestHeaders('gzip') }, response); + middleware.handler(ctx); + expect(response.getHeader('content-encoding')).toBeNull(); + expect(response.getHeader('vary')).toContain('accept-encoding'); + }); + + it('should set Vary even when no matching encoding is negotiated', () => { + const m = unwrap(compressionMiddleware({ encodings: [Encoding.Gzip] })); + const response = mockHttpResponse({ body: LARGE_BODY_OBJ, contentType: 'application/json' }); + const ctx = mockContext({ headers: makeRequestHeaders('br') }, response); + m.handler(ctx); + expect(response.getHeader('content-encoding')).toBeNull(); + expect(response.getHeader('vary')).toContain('accept-encoding'); + }); + + it('should set Vary even when Accept-Encoding header is absent', () => { + const response = mockHttpResponse({ body: LARGE_BODY_OBJ, contentType: 'application/json' }); + const ctx = mockContext({ headers: makeRequestHeaders() }, response); + middleware.handler(ctx); + expect(response.getHeader('content-encoding')).toBeNull(); + expect(response.getHeader('vary')).toContain('accept-encoding'); + }); + + it('should not set Vary when body is null (no content negotiation)', () => { + const response = mockHttpResponse({ body: null }); + const ctx = mockContext({ headers: makeRequestHeaders('gzip') }, response); + middleware.handler(ctx); + expect(response.getHeader('vary')).toBeNull(); + }); + + it('should not set Vary for 204 No Content', () => { + const response = mockHttpResponse({ body: LARGE_BODY_OBJ, contentType: 'application/json', status: 204 }); + const ctx = mockContext({ headers: makeRequestHeaders('gzip') }, response); + middleware.handler(ctx); + expect(response.getHeader('vary')).toBeNull(); + }); + }); + + // --- Robustness --- + + describe('compression failure', () => { + it('should not modify response if compression throws', () => { + const m = unwrap(compressionMiddleware({ threshold: 0 })); + const response = mockHttpResponse({ body: LARGE_BODY_OBJ, contentType: 'application/json' }); + const ctx = mockContext({ headers: makeRequestHeaders('gzip') }, response); + m.handler(ctx); + expect(response.getHeader('content-encoding')).toBe('gzip'); + }); + + it('should not throw when double-invoked on same response (idempotent guard)', () => { + const m = unwrap(compressionMiddleware()); + const response = mockHttpResponse({ body: LARGE_BODY_OBJ, contentType: 'application/json' }); + const ctx = mockContext({ headers: makeRequestHeaders('gzip') }, response); + m.handler(ctx); + expect(response.getHeader('content-encoding')).toBe('gzip'); + + const ctx2 = mockContext({ headers: makeRequestHeaders('gzip') }, response); + m.handler(ctx2); + expect(response.getHeader('content-encoding')).toBe('gzip'); + }); + }); + + // --- BREACH mitigation (HTB) --- + + describe('breach mitigation', () => { + it('should apply gzip padding when breach option is enabled', () => { + const m = unwrap(compressionMiddleware({ breach: { maxPadding: 32 } })); + const response = mockHttpResponse({ body: LARGE_BODY_OBJ, contentType: 'application/json' }); + const ctx = mockContext({ headers: makeRequestHeaders('gzip') }, response); + m.handler(ctx); + + const body = response.getBody() as Uint8Array; + expect(body).toBeInstanceOf(Uint8Array); + expect(body[3] & 0x04).toBe(0x04); + const decompressed = Bun.gunzipSync(body); + expect(JSON.parse(Buffer.from(decompressed).toString())).toEqual(LARGE_BODY_OBJ); + }); + + it('should produce varying gzip output sizes across requests when breach is enabled', () => { + const m = unwrap(compressionMiddleware({ breach: { maxPadding: 64 } })); + const sizes = new Set(); + for (let i = 0; i < 30; i++) { + const response = mockHttpResponse({ body: LARGE_BODY_OBJ, contentType: 'application/json' }); + const ctx = mockContext({ headers: makeRequestHeaders('gzip') }, response); + m.handler(ctx); + sizes.add((response.getBody() as Uint8Array).byteLength); + } + expect(sizes.size).toBeGreaterThan(1); + }); + + it('should return Err when breach enabled with only non-BREACH-safe encodings', () => { + const result = compressionMiddleware({ encodings: [Encoding.Brotli], breach: { maxPadding: 32 } }); + expect(isErr(result)).toBe(true); + if (isErr(result)) { + expect(result.data.reason).toBe(CompressionErrorReason.InvalidBreach); + } + }); + + it('should not apply padding when breach option is not set', () => { + const m = unwrap(compressionMiddleware()); + const response = mockHttpResponse({ body: LARGE_BODY_OBJ, contentType: 'application/json' }); + const ctx = mockContext({ headers: makeRequestHeaders('gzip') }, response); + m.handler(ctx); + + const body = response.getBody() as Uint8Array; + expect(body[3] & 0x04).toBe(0); + }); + + it('should apply zstd skippable frame padding when breach enabled', () => { + const m = unwrap(compressionMiddleware({ encodings: [Encoding.Zstd], breach: { maxPadding: 32 } })); + const response = mockHttpResponse({ body: LARGE_BODY_OBJ, contentType: 'application/json' }); + const ctx = mockContext({ headers: makeRequestHeaders('zstd') }, response); + m.handler(ctx); + + const body = response.getBody() as Uint8Array; + expect(body[0]).toBe(0x50); + expect(body[1]).toBe(0x2a); + expect(body[2]).toBe(0x4d); + expect(body[3]).toBe(0x18); + const decompressed = Bun.zstdDecompressSync(body); + expect(JSON.parse(Buffer.from(decompressed).toString())).toEqual(LARGE_BODY_OBJ); + }); + + it('should filter out brotli/deflate when breach enabled with mixed encodings', () => { + const m = unwrap(compressionMiddleware({ + encodings: [Encoding.Brotli, Encoding.Gzip, Encoding.Deflate], + breach: { maxPadding: 16 }, + })); + const response = mockHttpResponse({ body: LARGE_BODY_OBJ, contentType: 'application/json' }); + const ctx = mockContext({ headers: makeRequestHeaders('br, gzip, deflate') }, response); + m.handler(ctx); + expect(response.getHeader('content-encoding')).toBe('gzip'); + }); + + it('should return Err for invalid breach.maxPadding values', () => { + for (const maxPadding of [0, -1, 1.5, 5000, NaN]) { + const result = compressionMiddleware({ breach: { maxPadding } }); + expect(isErr(result)).toBe(true); + if (isErr(result)) { + expect(result.data.reason).toBe(CompressionErrorReason.InvalidBreach); + } + } + }); + }); + + // --- RFC 9110 compliance --- + + describe('RFC 9110 status code handling', () => { + it('should skip compression for 204 No Content', () => { + const response = mockHttpResponse({ body: LARGE_BODY_OBJ, contentType: 'application/json', status: 204 }); + const ctx = mockContext({ headers: makeRequestHeaders('gzip') }, response); + middleware.handler(ctx); + expect(response.getHeader('content-encoding')).toBeNull(); + }); + + it('should skip compression for 205 Reset Content', () => { + const response = mockHttpResponse({ body: LARGE_BODY_OBJ, contentType: 'application/json', status: 205 }); + const ctx = mockContext({ headers: makeRequestHeaders('gzip') }, response); + middleware.handler(ctx); + expect(response.getHeader('content-encoding')).toBeNull(); + }); + + it('should skip compression for 304 Not Modified', () => { + const response = mockHttpResponse({ body: LARGE_BODY_OBJ, contentType: 'application/json', status: 304 }); + const ctx = mockContext({ headers: makeRequestHeaders('gzip') }, response); + middleware.handler(ctx); + expect(response.getHeader('content-encoding')).toBeNull(); + }); + + it('should skip compression for 1xx informational status', () => { + const response = mockHttpResponse({ body: LARGE_BODY_OBJ, contentType: 'application/json', status: 100 }); + const ctx = mockContext({ headers: makeRequestHeaders('gzip') }, response); + middleware.handler(ctx); + expect(response.getHeader('content-encoding')).toBeNull(); + }); + + it('should skip compression when status is 0 (unset)', () => { + const response = mockHttpResponse({ body: LARGE_BODY_OBJ, contentType: 'application/json', status: 0 }); + const ctx = mockContext({ headers: makeRequestHeaders('gzip') }, response); + middleware.handler(ctx); + expect(response.getHeader('content-encoding')).toBeNull(); + }); + + it('should compress normally for 200 OK', () => { + const response = mockHttpResponse({ body: LARGE_BODY_OBJ, contentType: 'application/json', status: 200 }); + const ctx = mockContext({ headers: makeRequestHeaders('gzip') }, response); + middleware.handler(ctx); + expect(response.getHeader('content-encoding')).toBe('gzip'); + }); + + it('should compress normally for 201 Created', () => { + const response = mockHttpResponse({ body: LARGE_BODY_OBJ, contentType: 'application/json', status: 201 }); + const ctx = mockContext({ headers: makeRequestHeaders('gzip') }, response); + middleware.handler(ctx); + expect(response.getHeader('content-encoding')).toBe('gzip'); + }); + }); + + describe('RFC 9110 HEAD request handling', () => { + it('should skip compression for HEAD requests', () => { + const response = mockHttpResponse({ body: LARGE_BODY_OBJ, contentType: 'application/json' }); + const ctx = mockContext({ headers: makeRequestHeaders('gzip'), httpMethod: 'HEAD' }, response); + middleware.handler(ctx); + expect(response.getHeader('content-encoding')).toBeNull(); + }); + + it('should compress for GET requests', () => { + const response = mockHttpResponse({ body: LARGE_BODY_OBJ, contentType: 'application/json' }); + const ctx = mockContext({ headers: makeRequestHeaders('gzip'), httpMethod: 'GET' }, response); + middleware.handler(ctx); + expect(response.getHeader('content-encoding')).toBe('gzip'); + }); + + it('should compress for POST requests', () => { + const response = mockHttpResponse({ body: LARGE_BODY_OBJ, contentType: 'application/json' }); + const ctx = mockContext({ headers: makeRequestHeaders('gzip'), httpMethod: 'POST' }, response); + middleware.handler(ctx); + expect(response.getHeader('content-encoding')).toBe('gzip'); + }); + }); + + describe('RFC 9110 Cache-Control: no-transform', () => { + it('should skip compression when Cache-Control: no-transform', () => { + const response = mockHttpResponse({ + body: LARGE_BODY_OBJ, + contentType: 'application/json', + headers: { 'cache-control': 'no-transform' }, + }); + const ctx = mockContext({ headers: makeRequestHeaders('gzip') }, response); + middleware.handler(ctx); + expect(response.getHeader('content-encoding')).toBeNull(); + }); + + it('should skip when no-transform is among multiple directives', () => { + const response = mockHttpResponse({ + body: LARGE_BODY_OBJ, + contentType: 'application/json', + headers: { 'cache-control': 'public, no-transform, max-age=3600' }, + }); + const ctx = mockContext({ headers: makeRequestHeaders('gzip') }, response); + middleware.handler(ctx); + expect(response.getHeader('content-encoding')).toBeNull(); + }); + + it('should compress when Cache-Control does not include no-transform', () => { + const response = mockHttpResponse({ + body: LARGE_BODY_OBJ, + contentType: 'application/json', + headers: { 'cache-control': 'public, max-age=3600' }, + }); + const ctx = mockContext({ headers: makeRequestHeaders('gzip') }, response); + middleware.handler(ctx); + expect(response.getHeader('content-encoding')).toBe('gzip'); + }); + }); + + describe('RFC 9110 ETag handling', () => { + it('should weaken a strong ETag after compression', () => { + const response = mockHttpResponse({ + body: LARGE_BODY_OBJ, + contentType: 'application/json', + headers: { 'etag': '"abc123"' }, + }); + const ctx = mockContext({ headers: makeRequestHeaders('gzip') }, response); + middleware.handler(ctx); + expect(response.getHeader('etag')).toBe('W/"abc123"'); + }); + + it('should preserve an already-weak ETag', () => { + const response = mockHttpResponse({ + body: LARGE_BODY_OBJ, + contentType: 'application/json', + headers: { 'etag': 'W/"abc123"' }, + }); + const ctx = mockContext({ headers: makeRequestHeaders('gzip') }, response); + middleware.handler(ctx); + expect(response.getHeader('etag')).toBe('W/"abc123"'); + }); + + it('should not set ETag when none exists', () => { + const response = mockHttpResponse({ body: LARGE_BODY_OBJ, contentType: 'application/json' }); + const ctx = mockContext({ headers: makeRequestHeaders('gzip') }, response); + middleware.handler(ctx); + expect(response.getHeader('etag')).toBeNull(); + }); + }); + + describe('body serialization types', () => { + it('should compress string body', () => { + const m = unwrap(compressionMiddleware({ threshold: 0 })); + const response = mockHttpResponse({ body: 'hello world', contentType: 'text/plain' }); + const ctx = mockContext({ headers: makeRequestHeaders('gzip') }, response); + m.handler(ctx); + expect(response.getHeader('content-encoding')).toBe('gzip'); + const decompressed = gunzipSync(response.getBody() as Uint8Array).toString('utf-8'); + expect(decompressed).toBe('hello world'); + }); + + it('should compress number body (serialized as JSON)', () => { + const m = unwrap(compressionMiddleware({ threshold: 0 })); + const response = mockHttpResponse({ body: 42, contentType: 'application/json' }); + const ctx = mockContext({ headers: makeRequestHeaders('gzip') }, response); + m.handler(ctx); + expect(response.getHeader('content-encoding')).toBe('gzip'); + const decompressed = gunzipSync(response.getBody() as Uint8Array).toString('utf-8'); + expect(decompressed).toBe('42'); + }); + + it('should compress boolean body (serialized as JSON)', () => { + const m = unwrap(compressionMiddleware({ threshold: 0 })); + const response = mockHttpResponse({ body: true, contentType: 'application/json' }); + const ctx = mockContext({ headers: makeRequestHeaders('gzip') }, response); + m.handler(ctx); + expect(response.getHeader('content-encoding')).toBe('gzip'); + const decompressed = gunzipSync(response.getBody() as Uint8Array).toString('utf-8'); + expect(decompressed).toBe('true'); + }); + + it('should compress ArrayBuffer body', () => { + const m = unwrap(compressionMiddleware({ threshold: 0 })); + const buf = new TextEncoder().encode('arraybuffer content').buffer; + const response = mockHttpResponse({ body: buf, contentType: null }); + const ctx = mockContext({ headers: makeRequestHeaders('gzip') }, response); + m.handler(ctx); + expect(response.getHeader('content-encoding')).toBe('gzip'); + const decompressed = gunzipSync(response.getBody() as Uint8Array).toString('utf-8'); + expect(decompressed).toBe('arraybuffer content'); + }); + + it('should compress Uint8Array body', () => { + const m = unwrap(compressionMiddleware({ threshold: 0 })); + const body = new TextEncoder().encode('binary content'); + const response = mockHttpResponse({ body, contentType: null }); + const ctx = mockContext({ headers: makeRequestHeaders('gzip') }, response); + m.handler(ctx); + expect(response.getHeader('content-encoding')).toBe('gzip'); + const decompressed = gunzipSync(response.getBody() as Uint8Array).toString('utf-8'); + expect(decompressed).toBe('binary content'); + }); + }); + + describe('identity encoding handling', () => { + it('should skip when Accept-Encoding is identity only', () => { + const m = unwrap(compressionMiddleware({ threshold: 0 })); + const response = mockHttpResponse({ body: LARGE_BODY_OBJ, contentType: 'application/json' }); + const ctx = mockContext({ headers: makeRequestHeaders('identity') }, response); + m.handler(ctx); + expect(response.getHeader('content-encoding')).toBeNull(); + }); + + it('should skip when Accept-Encoding: *;q=0 (all rejected)', () => { + const m = unwrap(compressionMiddleware({ threshold: 0 })); + const response = mockHttpResponse({ body: LARGE_BODY_OBJ, contentType: 'application/json' }); + const ctx = mockContext({ headers: makeRequestHeaders('*;q=0') }, response); + m.handler(ctx); + expect(response.getHeader('content-encoding')).toBeNull(); + }); + + it('should skip when identity;q=0 but no other matching encoding', () => { + const m = unwrap(compressionMiddleware({ encodings: [Encoding.Gzip], threshold: 0 })); + const response = mockHttpResponse({ body: LARGE_BODY_OBJ, contentType: 'application/json' }); + const ctx = mockContext({ headers: makeRequestHeaders('identity;q=0, br') }, response); + m.handler(ctx); + expect(response.getHeader('content-encoding')).toBeNull(); + }); + }); + + describe('BREACH + zstd varying sizes', () => { + it('should produce varying zstd output sizes across requests when breach is enabled', () => { + const m = unwrap(compressionMiddleware({ + encodings: [Encoding.Zstd], + breach: { maxPadding: 64 }, + })); + const sizes = new Set(); + for (let i = 0; i < 30; i++) { + const response = mockHttpResponse({ body: LARGE_BODY_OBJ, contentType: 'application/json' }); + const ctx = mockContext({ headers: makeRequestHeaders('zstd') }, response); + m.handler(ctx); + sizes.add((response.getBody() as Uint8Array).byteLength); + } + expect(sizes.size).toBeGreaterThan(1); + }); + }); + + describe('RFC 9659 zstd level cap', () => { + it('should return Err for zstd level 20 (exceeds RFC 9659 8MB window)', () => { + const result = compressionMiddleware({ encodings: [Encoding.Zstd], level: { [Encoding.Zstd]: 20 } }); + expect(isErr(result)).toBe(true); + if (isErr(result)) { + expect(result.data.reason).toBe(CompressionErrorReason.InvalidLevel); + } + }); + + it('should accept zstd level 19', () => { + const result = compressionMiddleware({ encodings: [Encoding.Zstd], level: { [Encoding.Zstd]: 19 } }); + expect(isErr(result)).toBe(false); + }); + }); +}); diff --git a/packages/compression/tsconfig.build.json b/packages/compression/tsconfig.build.json new file mode 100644 index 0000000..dc47b07 --- /dev/null +++ b/packages/compression/tsconfig.build.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": true, + "verbatimModuleSyntax": false, + "outDir": "dist" + }, + "include": ["index.ts", "src/**/*.ts"], + "exclude": ["**/*.spec.ts", "**/*.test.ts", "test"] +} diff --git a/packages/compression/tsconfig.json b/packages/compression/tsconfig.json new file mode 100644 index 0000000..4082f16 --- /dev/null +++ b/packages/compression/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig.json" +} diff --git a/packages/shared/src/enums/http-header.ts b/packages/shared/src/enums/http-header.ts index 7aebf2a..2c4555c 100644 --- a/packages/shared/src/enums/http-header.ts +++ b/packages/shared/src/enums/http-header.ts @@ -16,4 +16,10 @@ export enum HttpHeader { AccessControlRequestHeaders = 'access-control-request-headers', ContentType = 'content-type', ContentDisposition = 'content-disposition', + ContentEncoding = 'content-encoding', + ContentLength = 'content-length', + AcceptEncoding = 'accept-encoding', + TransferEncoding = 'transfer-encoding', + CacheControl = 'cache-control', + ETag = 'etag', } diff --git a/packages/shared/src/enums/http-status.ts b/packages/shared/src/enums/http-status.ts index 009e254..9c26d0b 100644 --- a/packages/shared/src/enums/http-status.ts +++ b/packages/shared/src/enums/http-status.ts @@ -4,4 +4,6 @@ export enum HttpStatus { Ok = 200, NoContent = 204, + ResetContent = 205, + NotModified = 304, }