| 1 | /** |
| 2 | * HTTP response compression backed by `node:zlib`, shared by the Node.js, Bun, |
| 3 | * and Deno platforms. |
| 4 | * |
| 5 | * Byte-array bodies are compressed in one shot with the asynchronous |
| 6 | * `node:zlib` APIs, preserving an exact `Content-Length`. Streaming bodies go |
| 7 | * through `node:zlib` transform streams that flush each input chunk. |
| 8 | * |
| 9 | * @since 4.0.0 |
| 10 | */ |
| 11 | import * as Effect from "effect/Effect" |
| 12 | import * as HttpBody from "effect/http/HttpBody" |
| 13 | import type * as Platform from "effect/http/HttpPlatform" |
| 14 | import * as Response from "effect/http/HttpServerResponse" |
| 15 | import type { Duplex } from "node:stream" |
| 16 | import { Readable } from "node:stream" |
| 17 | import * as Zlib from "node:zlib" |
| 18 | |
| 19 | /** |
| 20 | * The compression algorithms supported by the runtime's `node:zlib`. `zstd` |
| 21 | * requires Node.js 22.15 or newer. |
| 22 | * |
| 23 | * @category constants |
| 24 | * @since 4.0.0 |
| 25 | */ |
| 26 | export const algorithms: ReadonlySet<Platform.CompressionAlgorithm> = new Set( |
| 27 | typeof Zlib.zstdCompress === "function" |
| 28 | ? ["gzip", "deflate", "br", "zstd"] |
| 29 | : ["gzip", "deflate", "br"] |
| 30 | ) |
| 31 | |
| 32 | const brotliParams = (level: number | undefined, sizeHint?: number): Zlib.BrotliOptions => { |
| 33 | const params: Record<number, number> = {} |
| 34 | if (level !== undefined) { |
| 35 | params[Zlib.constants.BROTLI_PARAM_QUALITY] = level |
| 36 | } |
| 37 | if (sizeHint !== undefined) { |
| 38 | params[Zlib.constants.BROTLI_PARAM_SIZE_HINT] = sizeHint |
| 39 | } |
| 40 | return { params } |
| 41 | } |
| 42 | |
| 43 | const zstdParams = (level: number | undefined): Zlib.ZstdOptions | undefined => |
| 44 | level === undefined || level === 3 ? undefined : { params: { [Zlib.constants.ZSTD_c_compressionLevel]: level } } |
| 45 | |
| 46 | const compress = ( |
| 47 | data: Uint8Array, |
| 48 | algorithm: Platform.CompressionAlgorithm, |
| 49 | options?: Platform.CompressionOptions | undefined |
| 50 | ): Effect.Effect<Uint8Array> => |
| 51 | Effect.callback((resume) => { |
| 52 | const complete = (error: Error | null, result: Uint8Array) => |
| 53 | resume(error === null ? Effect.succeed(result) : Effect.die(error)) |
| 54 | switch (algorithm) { |
| 55 | case "gzip": { |
| 56 | Zlib.gzip(data, { level: options?.level }, complete) |
| 57 | break |
| 58 | } |
| 59 | case "deflate": { |
| 60 | Zlib.deflate(data, { level: options?.level }, complete) |
| 61 | break |
| 62 | } |
| 63 | case "br": { |
| 64 | Zlib.brotliCompress(data, brotliParams(options?.level, data.byteLength), complete) |
| 65 | break |
| 66 | } |
| 67 | case "zstd": { |
| 68 | const params = zstdParams(options?.level) |
| 69 | if (params === undefined) { |
| 70 | Zlib.zstdCompress(data, complete) |
| 71 | } else { |
| 72 | Zlib.zstdCompress(data, params, complete) |
| 73 | } |
| 74 | break |
| 75 | } |
| 76 | } |
| 77 | }) |
| 78 | |
| 79 | /** |
| 80 | * Creates a `Compression` that compresses byte-array bodies in one shot with |
| 81 | * the asynchronous `node:zlib` APIs, setting the exact `Content-Length` of the |
| 82 | * compressed body. All other bodies are delegated to `fallback`. |
| 83 | * |
| 84 | * @category constructors |
| 85 | * @since 4.0.0 |
| 86 | */ |
| 87 | export const make = (fallback: Platform.Compression): Platform.Compression => ({ |
| 88 | algorithms: fallback.algorithms, |
| 89 | compressResponse(response, algorithm, options) { |
| 90 | const body = response.body |
| 91 | if (body._tag !== "Uint8Array") { |
| 92 | return fallback.compressResponse(response, algorithm, options) |
| 93 | } |
| 94 | return Effect.map(compress(body.body, algorithm, options), (result) => |
| 95 | Response.setHeader( |
| 96 | Response.setBody(response, HttpBody.uint8Array(result, response.headers["content-type"] ?? body.contentType)), |
| 97 | "content-length", |
| 98 | result.byteLength.toString() |
| 99 | )) |
| 100 | } |
| 101 | }) |
| 102 | |
| 103 | /** |
| 104 | * Creates a `node:zlib` compression transform stream that flushes each input |
| 105 | * chunk, for streaming response bodies. |
| 106 | * |
| 107 | * @category constructors |
| 108 | * @since 4.0.0 |
| 109 | */ |
| 110 | export const compressTransform = ( |
| 111 | algorithm: Platform.CompressionAlgorithm, |
| 112 | options?: Platform.CompressionOptions | undefined |
| 113 | ): Duplex => { |
| 114 | switch (algorithm) { |
| 115 | case "gzip": { |
| 116 | return Zlib.createGzip({ level: options?.level, flush: Zlib.constants.Z_SYNC_FLUSH }) |
| 117 | } |
| 118 | case "deflate": { |
| 119 | return Zlib.createDeflate({ level: options?.level, flush: Zlib.constants.Z_SYNC_FLUSH }) |
| 120 | } |
| 121 | case "br": { |
| 122 | return Zlib.createBrotliCompress({ |
| 123 | ...brotliParams(options?.level), |
| 124 | flush: Zlib.constants.BROTLI_OPERATION_FLUSH |
| 125 | }) |
| 126 | } |
| 127 | case "zstd": { |
| 128 | return Zlib.createZstdCompress({ |
| 129 | ...zstdParams(options?.level), |
| 130 | flush: Zlib.constants.ZSTD_e_flush |
| 131 | }) |
| 132 | } |
| 133 | } |
| 134 | } |
| 135 | |
| 136 | /** |
| 137 | * A Web `ReadableStream` version of `compressTransform`, for platforms that |
| 138 | * stream response bodies as Web streams. |
| 139 | * |
| 140 | * @category constructors |
| 141 | * @since 4.0.0 |
| 142 | */ |
| 143 | export const compressTransformWeb = ( |
| 144 | algorithm: Platform.CompressionAlgorithm, |
| 145 | options?: Platform.CompressionOptions | undefined |
| 146 | ) => |
| 147 | (stream: ReadableStream<Uint8Array>): ReadableStream<Uint8Array> => { |
| 148 | const transform = compressTransform(algorithm, options) |
| 149 | const source = Readable.fromWeb(stream as any) |
| 150 | source.on("error", (cause) => transform.destroy(cause)) |
| 151 | transform.on("close", () => source.destroy()) |
| 152 | return Readable.toWeb(source.pipe(transform)) as unknown as ReadableStream<Uint8Array> |
| 153 | } |