1import * as DenoHttpPlatform from "@effect/platform-deno/DenoHttpPlatform"
2import * as DenoHttpServer from "@effect/platform-deno/DenoHttpServer"
3import { assert, describe, it } from "@effect/vitest"
4import * as Effect from "effect/Effect"
5import * as HttpClient from "effect/http/HttpClient"
6import * as HttpEffect from "effect/http/HttpEffect"
7import * as HttpMiddleware from "effect/http/HttpMiddleware"
8import * as HttpPlatform from "effect/http/HttpPlatform"
9import * as HttpRouter from "effect/http/HttpRouter"
10import type { HttpServerRequest } from "effect/http/HttpServerRequest"
11import * as HttpServerResponse from "effect/http/HttpServerResponse"
12import * as Layer from "effect/Layer"
13import * as Stream from "effect/Stream"
14import { fileURLToPath } from "node:url"
15import * as Zlib from "node:zlib"
16
17const bigJson = JSON.stringify({ text: "All work and no play makes Jack a dull boy. ".repeat(100) })
18const bigJsonApp = Effect.succeed(HttpServerResponse.text(bigJson, { contentType: "application/json" }))
19
20const zstdSupported = typeof Zlib.zstdCompress === "function"
21
22type App = Effect.Effect<HttpServerResponse.HttpServerResponse, any, any>
23type CompressionOptions = Parameters<typeof HttpMiddleware.compression>[0]
24
25const withHandler = async (
26 app: App,
27 options: CompressionOptions,
28 run: (handler: (request: Request) => Promise<Response>) => Promise<void>
29) => {
30 const { dispose, handler } = HttpEffect.toWebHandlerLayer(
31 app as Effect.Effect<HttpServerResponse.HttpServerResponse, never, HttpServerRequest>,
32 DenoHttpPlatform.layer,
33 { middleware: HttpMiddleware.compression(options) }
34 )
35 try {
36 await run(handler)
37 } finally {
38 await dispose()
39 }
40}
41
42const get = (
43 handler: (request: Request) => Promise<Response>,
44 headers?: Record<string, string>
45) => handler(new Request("http://localhost/", headers === undefined ? {} : { headers }))
46
47describe("DenoHttpCompression", () => {
48 it.effect("advertises supported algorithms", () =>
49 Effect.gen(function*() {
50 const platform = yield* HttpPlatform.HttpPlatform
51 assert.isTrue(platform.compression.algorithms.has("gzip"))
52 assert.isTrue(platform.compression.algorithms.has("deflate"))
53 assert.isTrue(platform.compression.algorithms.has("br"))
54 assert.strictEqual(platform.compression.algorithms.has("zstd"), zstdSupported)
55 }).pipe(Effect.provide(DenoHttpPlatform.layer)))
56
57 it("compresses one-shot bodies asynchronously with gzip and an exact Content-Length", () =>
58 withHandler(bigJsonApp, undefined, async (handler) => {
59 const response = await get(handler, { "accept-encoding": "gzip" })
60 assert.strictEqual(response.headers.get("content-encoding"), "gzip")
61 assert.strictEqual(response.headers.get("vary"), "Accept-Encoding")
62 const compressed = new Uint8Array(await response.arrayBuffer())
63 assert.strictEqual(response.headers.get("content-length"), compressed.byteLength.toString())
64 assert.strictEqual(Zlib.gunzipSync(compressed).toString(), bigJson)
65 }))
66
67 it("prefers br over gzip in server order", () =>
68 withHandler(bigJsonApp, undefined, async (handler) => {
69 const response = await get(handler, { "accept-encoding": "gzip, br" })
70 assert.strictEqual(response.headers.get("content-encoding"), "br")
71 const compressed = new Uint8Array(await response.arrayBuffer())
72 assert.strictEqual(response.headers.get("content-length"), compressed.byteLength.toString())
73 assert.strictEqual(Zlib.brotliDecompressSync(compressed).toString(), bigJson)
74 }))
75
76 it.skipIf(!zstdSupported)(
77 "compresses with zstd when opted in",
78 () =>
79 withHandler(bigJsonApp, { algorithms: ["zstd", "gzip"] }, async (handler) => {
80 const response = await get(handler, { "accept-encoding": "zstd" })
81 assert.strictEqual(response.headers.get("content-encoding"), "zstd")
82 const compressed = new Uint8Array(await response.arrayBuffer())
83 assert.strictEqual(response.headers.get("content-length"), compressed.byteLength.toString())
84 assert.strictEqual(Zlib.zstdDecompressSync(compressed).toString(), bigJson)
85 })
86 )
87
88 it("compresses stream bodies with gzip via CompressionStream", () =>
89 withHandler(
90 Effect.succeed(HttpServerResponse.stream(
91 Stream.fromArray([new TextEncoder().encode(bigJson)]),
92 { contentType: "application/json" }
93 )),
94 undefined,
95 async (handler) => {
96 const response = await get(handler, { "accept-encoding": "gzip" })
97 assert.strictEqual(response.headers.get("content-encoding"), "gzip")
98 assert.strictEqual(response.headers.get("content-length"), null)
99 assert.strictEqual(Zlib.gunzipSync(new Uint8Array(await response.arrayBuffer())).toString(), bigJson)
100 }
101 ))
102
103 it("compresses stream bodies with br via node:zlib streams", () =>
104 withHandler(
105 Effect.succeed(HttpServerResponse.stream(
106 Stream.fromArray([new TextEncoder().encode(bigJson)]),
107 { contentType: "application/json" }
108 )),
109 undefined,
110 async (handler) => {
111 const response = await get(handler, { "accept-encoding": "br" })
112 assert.strictEqual(response.headers.get("content-encoding"), "br")
113 assert.strictEqual(response.headers.get("content-length"), null)
114 assert.strictEqual(Zlib.brotliDecompressSync(new Uint8Array(await response.arrayBuffer())).toString(), bigJson)
115 }
116 ))
117
118 it("compresses file responses through the streaming path and weakens the ETag", async () => {
119 const path = fileURLToPath(new URL("./DenoHttpCompression.test.ts", import.meta.url))
120 const contents = await Deno.readTextFile(path)
121 await withHandler(
122 HttpServerResponse.file(path, { headers: { "content-type": "text/plain" } }),
123 undefined,
124 async (handler) => {
125 const response = await get(handler, { "accept-encoding": "gzip" })
126 assert.strictEqual(response.headers.get("content-encoding"), "gzip")
127 assert.strictEqual(response.headers.get("content-length"), null)
128 assert.isTrue(response.headers.get("etag")!.startsWith("W/"))
129 assert.strictEqual(Zlib.gunzipSync(new Uint8Array(await response.arrayBuffer())).toString(), contents)
130 }
131 )
132 })
133
134 it.effect("compresses end-to-end through the Deno HTTP server", () =>
135 Effect.gen(function*() {
136 yield* HttpRouter.add("GET", "/json", bigJsonApp).pipe(
137 (self) =>
138 HttpRouter.serve(self, {
139 middleware: HttpMiddleware.compression({ algorithms: ["zstd", "br", "gzip", "deflate"] })
140 }),
141 Layer.build
142 )
143 const client = yield* HttpClient.HttpClient
144 // Deno's fetch transparently decompresses gzip and strips the
145 // Content-Encoding header, so assert on the round-tripped body
146 const gzip = yield* client.get("/json", { headers: { "accept-encoding": "gzip" } })
147 assert.strictEqual(gzip.headers["vary"], "Accept-Encoding")
148 assert.strictEqual(yield* gzip.text, bigJson)
149 if (zstdSupported) {
150 // Deno's fetch does not decode zstd, so the raw compressed payload and
151 // its headers are observable
152 const zstd = yield* client.get("/json", { headers: { "accept-encoding": "zstd" } })
153 assert.strictEqual(zstd.headers["content-encoding"], "zstd")
154 const compressed = new Uint8Array(yield* zstd.arrayBuffer)
155 assert.strictEqual(Zlib.zstdDecompressSync(compressed).toString(), bigJson)
156 }
157 }).pipe(Effect.provide(DenoHttpServer.layerTest)))
158})