| 1 | import * as BunHttpPlatform from "@effect/platform-bun/BunHttpPlatform" |
| 2 | import { assert, describe, it } from "@effect/vitest" |
| 3 | import * as Effect from "effect/Effect" |
| 4 | import * as HttpEffect from "effect/http/HttpEffect" |
| 5 | import * as HttpMiddleware from "effect/http/HttpMiddleware" |
| 6 | import * as HttpPlatform from "effect/http/HttpPlatform" |
| 7 | import type { HttpServerRequest } from "effect/http/HttpServerRequest" |
| 8 | import * as HttpServerResponse from "effect/http/HttpServerResponse" |
| 9 | import * as Stream from "effect/Stream" |
| 10 | import * as Fs from "node:fs" |
| 11 | import { fileURLToPath } from "node:url" |
| 12 | import * as Zlib from "node:zlib" |
| 13 | |
| 14 | const bigJson = JSON.stringify({ text: "All work and no play makes Jack a dull boy. ".repeat(100) }) |
| 15 | const bigJsonApp = Effect.succeed(HttpServerResponse.text(bigJson, { contentType: "application/json" })) |
| 16 | |
| 17 | type App = Effect.Effect<HttpServerResponse.HttpServerResponse, any, any> |
| 18 | type CompressionOptions = Parameters<typeof HttpMiddleware.compression>[0] |
| 19 | type Algorithm = HttpPlatform.CompressionAlgorithm |
| 20 | |
| 21 | const algorithms: ReadonlyArray<Algorithm> = ["gzip", "deflate", "br", "zstd"] |
| 22 | |
| 23 | const decompress = (algorithm: Algorithm, data: Uint8Array): string => { |
| 24 | switch (algorithm) { |
| 25 | case "gzip": |
| 26 | return Zlib.gunzipSync(data).toString() |
| 27 | case "deflate": |
| 28 | return Zlib.inflateSync(data).toString() |
| 29 | case "br": |
| 30 | return Zlib.brotliDecompressSync(data).toString() |
| 31 | case "zstd": |
| 32 | return Zlib.zstdDecompressSync(data).toString() |
| 33 | } |
| 34 | } |
| 35 | |
| 36 | const withHandler = async ( |
| 37 | app: App, |
| 38 | options: CompressionOptions, |
| 39 | run: (handler: (request: Request) => Promise<Response>) => Promise<void> |
| 40 | ) => { |
| 41 | const { dispose, handler } = HttpEffect.toWebHandlerLayer( |
| 42 | app as Effect.Effect<HttpServerResponse.HttpServerResponse, never, HttpServerRequest>, |
| 43 | BunHttpPlatform.layer, |
| 44 | { middleware: HttpMiddleware.compression(options) } |
| 45 | ) |
| 46 | try { |
| 47 | await run(handler) |
| 48 | } finally { |
| 49 | await dispose() |
| 50 | } |
| 51 | } |
| 52 | |
| 53 | const get = ( |
| 54 | handler: (request: Request) => Promise<Response>, |
| 55 | algorithm: Algorithm |
| 56 | ) => handler(new Request("http://localhost/", { headers: { "accept-encoding": algorithm } })) |
| 57 | |
| 58 | // Bun's CompressionStream does not expose an explicit flush operation, so |
| 59 | // incremental SSE-style delivery is runtime-defined and intentionally is not |
| 60 | // guaranteed by this suite. |
| 61 | describe("BunHttpCompression", () => { |
| 62 | it.effect("advertises Bun's supported algorithms", () => |
| 63 | Effect.gen(function*() { |
| 64 | const platform = yield* HttpPlatform.HttpPlatform |
| 65 | assert.deepStrictEqual([...platform.compression.algorithms], algorithms) |
| 66 | }).pipe(Effect.provide(BunHttpPlatform.layer))) |
| 67 | |
| 68 | for (const algorithm of algorithms) { |
| 69 | it(`compresses one-shot bodies with ${algorithm}`, () => |
| 70 | withHandler(bigJsonApp, { algorithms }, async (handler) => { |
| 71 | const response = await get(handler, algorithm) |
| 72 | assert.strictEqual(response.headers.get("content-encoding"), algorithm) |
| 73 | assert.strictEqual(response.headers.get("vary"), "Accept-Encoding") |
| 74 | const compressed = new Uint8Array(await response.arrayBuffer()) |
| 75 | assert.strictEqual(response.headers.get("content-length"), compressed.byteLength.toString()) |
| 76 | assert.strictEqual(decompress(algorithm, compressed), bigJson) |
| 77 | })) |
| 78 | |
| 79 | it(`compresses stream bodies with ${algorithm}`, () => |
| 80 | withHandler( |
| 81 | Effect.succeed(HttpServerResponse.stream( |
| 82 | Stream.fromArray([new TextEncoder().encode(bigJson)]), |
| 83 | { contentType: "application/json" } |
| 84 | )), |
| 85 | { algorithms }, |
| 86 | async (handler) => { |
| 87 | const response = await get(handler, algorithm) |
| 88 | assert.strictEqual(response.headers.get("content-encoding"), algorithm) |
| 89 | assert.strictEqual(response.headers.get("content-length"), null) |
| 90 | assert.strictEqual(decompress(algorithm, new Uint8Array(await response.arrayBuffer())), bigJson) |
| 91 | } |
| 92 | )) |
| 93 | } |
| 94 | |
| 95 | it("compresses Bun.file responses", async () => { |
| 96 | const path = fileURLToPath(new URL("./BunHttpCompression.test.ts", import.meta.url)) |
| 97 | const contents = Fs.readFileSync(path, "utf8") |
| 98 | await withHandler( |
| 99 | HttpServerResponse.file(path, { headers: { "content-type": "text/plain" } }), |
| 100 | { algorithms, minSize: 0 }, |
| 101 | async (handler) => { |
| 102 | const response = await get(handler, "gzip") |
| 103 | assert.strictEqual(response.headers.get("content-encoding"), "gzip") |
| 104 | assert.strictEqual(response.headers.get("content-length"), null) |
| 105 | assert.isTrue(response.headers.get("etag")!.startsWith("W/")) |
| 106 | assert.strictEqual(decompress("gzip", new Uint8Array(await response.arrayBuffer())), contents) |
| 107 | } |
| 108 | ) |
| 109 | }) |
| 110 | }) |