1import * as BunHttpPlatform from "@effect/platform-bun/BunHttpPlatform"
2import { assert, describe, it } from "@effect/vitest"
3import * as Effect from "effect/Effect"
4import * as HttpEffect from "effect/http/HttpEffect"
5import * as HttpMiddleware from "effect/http/HttpMiddleware"
6import * as HttpPlatform from "effect/http/HttpPlatform"
7import type { HttpServerRequest } from "effect/http/HttpServerRequest"
8import * as HttpServerResponse from "effect/http/HttpServerResponse"
9import * as Stream from "effect/Stream"
10import * as Fs from "node:fs"
11import { fileURLToPath } from "node:url"
12import * as Zlib from "node:zlib"
13
14const bigJson = JSON.stringify({ text: "All work and no play makes Jack a dull boy. ".repeat(100) })
15const bigJsonApp = Effect.succeed(HttpServerResponse.text(bigJson, { contentType: "application/json" }))
16
17type App = Effect.Effect<HttpServerResponse.HttpServerResponse, any, any>
18type CompressionOptions = Parameters<typeof HttpMiddleware.compression>[0]
19type Algorithm = HttpPlatform.CompressionAlgorithm
20
21const algorithms: ReadonlyArray<Algorithm> = ["gzip", "deflate", "br", "zstd"]
22
23const 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
36const 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
53const 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.
61describe("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})