| 1 | import * as DenoHttpPlatform from "@effect/platform-deno/DenoHttpPlatform" |
| 2 | import * as DenoHttpServer from "@effect/platform-deno/DenoHttpServer" |
| 3 | import { assert, describe, it } from "@effect/vitest" |
| 4 | import * as Effect from "effect/Effect" |
| 5 | import * as HttpClient from "effect/http/HttpClient" |
| 6 | import * as HttpEffect from "effect/http/HttpEffect" |
| 7 | import * as HttpMiddleware from "effect/http/HttpMiddleware" |
| 8 | import * as HttpPlatform from "effect/http/HttpPlatform" |
| 9 | import * as HttpRouter from "effect/http/HttpRouter" |
| 10 | import type { HttpServerRequest } from "effect/http/HttpServerRequest" |
| 11 | import * as HttpServerResponse from "effect/http/HttpServerResponse" |
| 12 | import * as Layer from "effect/Layer" |
| 13 | import * as Stream from "effect/Stream" |
| 14 | import { fileURLToPath } from "node:url" |
| 15 | import * as Zlib from "node:zlib" |
| 16 | |
| 17 | const bigJson = JSON.stringify({ text: "All work and no play makes Jack a dull boy. ".repeat(100) }) |
| 18 | const bigJsonApp = Effect.succeed(HttpServerResponse.text(bigJson, { contentType: "application/json" })) |
| 19 | |
| 20 | const zstdSupported = typeof Zlib.zstdCompress === "function" |
| 21 | |
| 22 | type App = Effect.Effect<HttpServerResponse.HttpServerResponse, any, any> |
| 23 | type CompressionOptions = Parameters<typeof HttpMiddleware.compression>[0] |
| 24 | |
| 25 | const 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 | |
| 42 | const get = ( |
| 43 | handler: (request: Request) => Promise<Response>, |
| 44 | headers?: Record<string, string> |
| 45 | ) => handler(new Request("http://localhost/", headers === undefined ? {} : { headers })) |
| 46 | |
| 47 | describe("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 | }) |