1import * as Effect from "../../Effect.ts"
2import { identity } from "../../Function.ts"
3import * as Stream from "../../Stream.ts"
4import type * as Headers from "../Headers.ts"
5import * as HttpBody from "../HttpBody.ts"
6import type { Compression, CompressionAlgorithm, CompressionOptions } from "../HttpPlatform.ts"
7import * as Response from "../HttpServerResponse.ts"
8
9/**
10 * @internal
11 */
12export const varyWith = (headers: Headers.Headers, dimension: string): string => {
13 const vary = headers["vary"]
14 if (vary === undefined) {
15 return dimension
16 }
17 const members = vary.split(",").map((member) => member.trim().toLowerCase())
18 return members.includes("*") || members.includes(dimension.toLowerCase()) ? vary : `${vary}, ${dimension}`
19}
20
21/**
22 * @internal
23 */
24export const wrapCompression = (impl: Compression): Compression => ({
25 algorithms: impl.algorithms,
26 compressResponse(response, algorithm, options) {
27 return Effect.map(impl.compressResponse(response, algorithm, options), (compressed) => {
28 if (compressed === response) {
29 return response
30 }
31 const headers: Record<string, string> = {
32 "content-encoding": algorithm,
33 vary: varyWith(compressed.headers, "Accept-Encoding")
34 }
35 const etag = compressed.headers["etag"]
36 if (etag !== undefined && !etag.startsWith("W/")) {
37 headers["etag"] = `W/${etag}`
38 }
39 return Response.setHeaders(compressed, headers)
40 })
41 }
42})
43
44/**
45 * @internal
46 */
47export const compressionTransformWeb =
48 (format: string) => (stream: ReadableStream<Uint8Array>): ReadableStream<Uint8Array> =>
49 stream.pipeThrough(
50 new CompressionStream(format as CompressionFormat) as unknown as ReadableWritablePair<Uint8Array, Uint8Array>
51 )
52
53/**
54 * @internal
55 */
56export const setBodyWithoutLength = (
57 response: Response.HttpServerResponse,
58 body: HttpBody.HttpBody
59): Response.HttpServerResponse => Response.removeHeader(Response.setBody(response, body), "content-length")
60
61/**
62 * @internal
63 */
64export const makeCompressionWeb = (options: {
65 readonly algorithms: Iterable<CompressionAlgorithm>
66 readonly transform: (
67 algorithm: CompressionAlgorithm,
68 options?: CompressionOptions | undefined
69 ) => (stream: ReadableStream<Uint8Array>) => ReadableStream<Uint8Array>
70}): Compression => ({
71 algorithms: new Set(options.algorithms),
72 compressResponse(response, algorithm, opts) {
73 const body = response.body
74 switch (body._tag) {
75 case "Uint8Array": {
76 const data = body.body
77 return Effect.succeed(streamBody(
78 response,
79 () => options.transform(algorithm, opts)(singleChunkStream(data)),
80 response.headers["content-type"] ?? body.contentType
81 ))
82 }
83 case "Stream": {
84 const stream = body.stream
85 return Effect.succeed(streamBody(
86 response,
87 () => options.transform(algorithm, opts)(Stream.toReadableStream(stream)),
88 response.headers["content-type"] ?? body.contentType
89 ))
90 }
91 case "Raw": {
92 const readable = rawReadableStream(body.body)
93 if (readable === undefined) {
94 return Effect.succeed(response)
95 }
96 return Effect.succeed(setBodyWithoutLength(
97 response,
98 HttpBody.raw(options.transform(algorithm, opts)(readable), {
99 contentType: response.headers["content-type"] ?? body.contentType
100 })
101 ))
102 }
103 default: {
104 return Effect.succeed(response)
105 }
106 }
107 }
108})
109
110const streamBody = (
111 response: Response.HttpServerResponse,
112 evaluate: () => ReadableStream<Uint8Array>,
113 contentType: string | undefined
114): Response.HttpServerResponse =>
115 setBodyWithoutLength(
116 response,
117 HttpBody.stream(Stream.fromReadableStream({ evaluate, onError: identity }), contentType)
118 )
119
120const singleChunkStream = (data: Uint8Array): ReadableStream<Uint8Array> =>
121 new ReadableStream({
122 start(controller) {
123 controller.enqueue(data)
124 controller.close()
125 }
126 })
127
128const rawReadableStream = (raw: unknown): ReadableStream<Uint8Array> | undefined => {
129 if (typeof ReadableStream !== "undefined" && raw instanceof ReadableStream) {
130 return raw
131 } else if (raw instanceof globalThis.Response) {
132 return raw.body ?? undefined
133 }
134 return new globalThis.Response(raw as BodyInit).body ?? undefined
135}
136
137/**
138 * @internal
139 */
140export const compressionWeb: Compression = makeCompressionWeb({
141 algorithms: ["gzip", "deflate"],
142 transform: (algorithm) => compressionTransformWeb(algorithm)
143})
144
145/**
146 * @internal
147 */
148export const defaultCompressible = (contentType: string): boolean => {
149 const semi = contentType.indexOf(";")
150 const type = (semi === -1 ? contentType : contentType.slice(0, semi)).trim().toLowerCase()
151 if (type.startsWith("text/")) {
152 return true
153 }
154 switch (type) {
155 case "application/json":
156 case "application/javascript":
157 case "application/xml":
158 case "image/svg+xml":
159 case "application/wasm": {
160 return true
161 }
162 }
163 return type.endsWith("+json") || type.endsWith("+xml")
164}
165
166const acceptMember = /^([a-z0-9!#$%&'*+.^_`|~-]+)(?:;q=(0(?:\.[0-9]{0,3})?|1(?:\.0{0,3})?))?$/
167
168/**
169 * @internal
170 */
171export const parseAcceptEncoding = (header: string): ReadonlyMap<string, number> | undefined => {
172 const trimmed = header.trim()
173 if (trimmed === "") {
174 return undefined
175 }
176 const accepted = new Map<string, number>()
177 for (const part of trimmed.split(",")) {
178 const member = part.trim().toLowerCase().replace(/[ \t]*;[ \t]*/g, ";")
179 const match = acceptMember.exec(member)
180 if (match === null) {
181 return undefined
182 }
183 accepted.set(match[1], match[2] === undefined ? 1 : Number(match[2]))
184 }
185 return accepted
186}
187
188/**
189 * @internal
190 */
191export const negotiate = (
192 header: string | undefined,
193 preferred: ReadonlyArray<CompressionAlgorithm>,
194 supported: ReadonlySet<CompressionAlgorithm>
195): CompressionAlgorithm | undefined => {
196 if (header === undefined) {
197 return undefined
198 }
199 const accepted = parseAcceptEncoding(header)
200 if (accepted === undefined) {
201 return undefined
202 }
203 for (const algorithm of preferred) {
204 if (!supported.has(algorithm)) {
205 continue
206 }
207 const quality = accepted.get(algorithm) ?? accepted.get("*")
208 if (quality !== undefined && quality > 0) {
209 return algorithm
210 }
211 }
212 return undefined
213}