1import { assert, describe, it } from "@effect/vitest"
2import { Context, Effect, Stream } from "effect"
3import { Cookies, HttpBody, HttpClientRequest, HttpClientResponse, HttpServerResponse } from "effect/http"
4
5const TestValue = Context.Reference<number>("test/TestValue", { defaultValue: () => 0 })
6
7describe("HttpServerResponse", () => {
8 describe("toWeb", () => {
9 it.each([
10 { status: 204, withoutBody: false },
11 { status: 205, withoutBody: false },
12 { status: 304, withoutBody: true }
13 ])(
14 "prefers outer status $status and merges raw Response headers with withoutBody=$withoutBody",
15 ({ status, withoutBody }) => {
16 const nativeCookies = ["sid=abc; Path=/; HttpOnly", "theme=dark; Path=/"]
17 const native = new Response("cached body", {
18 status: 200,
19 statusText: "Native status",
20 headers: [
21 ["etag", "\"version-1\""],
22 ["x-shared", "native"],
23 ...nativeCookies.map((cookie): [string, string] => ["set-cookie", cookie])
24 ]
25 })
26 const web = HttpServerResponse.toWeb(
27 HttpServerResponse.raw(native, {
28 status,
29 statusText: "Outer status",
30 headers: { "x-outer": "outer", "x-shared": "outer" },
31 cookies: Cookies.setAllUnsafe(Cookies.empty, [["a", "1", {}], ["b", "2", {}]])
32 }),
33 { withoutBody }
34 )
35
36 assert.strictEqual(web.status, status)
37 assert.strictEqual(web.statusText, "Outer status")
38 assert.strictEqual(web.body, null)
39 assert.strictEqual(web.headers.get("etag"), "\"version-1\"")
40 assert.strictEqual(web.headers.get("x-outer"), "outer")
41 assert.strictEqual(web.headers.get("x-shared"), "outer")
42 assert.deepStrictEqual(web.headers.getSetCookie(), [...nativeCookies, "a=1", "b=2"])
43 }
44 )
45
46 it("preserves raw Response metadata for HEAD", () => {
47 const native = new Response("body", {
48 status: 202,
49 statusText: "Accepted by upstream",
50 headers: { "x-probe": "native", "set-cookie": "sid=abc" }
51 })
52 const web = HttpServerResponse.toWeb(
53 HttpServerResponse.raw(native, { status: 200, statusText: "Outer status" }),
54 { withoutBody: true }
55 )
56
57 assert.strictEqual(web.body, null)
58 assert.strictEqual(web.status, 202)
59 assert.strictEqual(web.statusText, "Accepted by upstream")
60 assert.strictEqual(web.headers.get("x-probe"), "native")
61 assert.deepStrictEqual(web.headers.getSetCookie(), ["sid=abc"])
62 })
63
64 it("returns the raw Response with merged headers for GET", async () => {
65 const native = new Response("body", {
66 status: 202,
67 statusText: "Accepted by upstream",
68 headers: { "x-probe": "native", "x-shared": "native", "set-cookie": "sid=abc; Path=/; HttpOnly" }
69 })
70 const web = HttpServerResponse.toWeb(
71 HttpServerResponse.raw(native, {
72 headers: { "x-outer": "outer", "x-shared": "outer" },
73 cookies: Cookies.setAllUnsafe(Cookies.empty, [["a", "1", {}], ["b", "2", {}]])
74 })
75 )
76
77 assert.strictEqual(web, native)
78 assert.strictEqual(web.status, 202)
79 assert.strictEqual(web.statusText, "Accepted by upstream")
80 assert.strictEqual(web.headers.get("x-probe"), "native")
81 assert.strictEqual(web.headers.get("x-outer"), "outer")
82 assert.strictEqual(web.headers.get("x-shared"), "outer")
83 assert.deepStrictEqual(web.headers.getSetCookie(), ["sid=abc; Path=/; HttpOnly", "a=1", "b=2"])
84 assert.strictEqual(await web.text(), "body")
85 })
86
87 it.each([
88 { status: 200, withoutBody: true },
89 { status: 304, withoutBody: false }
90 ])(
91 "preserves representation headers for status $status with withoutBody=$withoutBody",
92 ({ status, withoutBody }) => {
93 const web = HttpServerResponse.toWeb(HttpServerResponse.text("body", { status }), { withoutBody })
94
95 assert.strictEqual(web.body, null)
96 assert.strictEqual(web.headers.get("content-length"), "4")
97 assert.strictEqual(web.headers.get("content-type"), "text/plain")
98 }
99 )
100
101 it.each([
102 { status: 200, withoutBody: true },
103 { status: 204, withoutBody: false },
104 { status: 205, withoutBody: false },
105 { status: 304, withoutBody: false }
106 ])("cancels raw streams for status $status with withoutBody=$withoutBody", async ({ status, withoutBody }) => {
107 let cancelled = false
108 const body = new ReadableStream({
109 cancel() {
110 cancelled = true
111 }
112 })
113 try {
114 const web = HttpServerResponse.toWeb(HttpServerResponse.raw(body, { status }), { withoutBody })
115
116 assert.strictEqual(web.status, status)
117 assert.strictEqual(web.body, null)
118 assert.strictEqual(await web.text(), "")
119 assert.strictEqual(cancelled, true)
120 } finally {
121 await body.cancel()
122 }
123 })
124
125 for (const status of [204, 205, 304]) {
126 it.each([
127 { name: "text", response: HttpServerResponse.text("body", { status }) },
128 { name: "uint8Array", response: HttpServerResponse.uint8Array(new Uint8Array([1]), { status }) },
129 { name: "raw", response: HttpServerResponse.raw("body", { status }) },
130 { name: "formData", response: HttpServerResponse.formData(new FormData(), { status }) },
131 { name: "stream", response: HttpServerResponse.stream(Stream.succeed(new Uint8Array([1])), { status }) }
132 ])(`omits $name bodies for status ${status}`, ({ response }) => {
133 const web = HttpServerResponse.toWeb(response)
134
135 assert.strictEqual(web.status, status)
136 assert.strictEqual(web.body, null)
137 })
138 }
139 })
140
141 it("setHeader overrides body-derived content headers", () => {
142 const response = HttpServerResponse.text("body").pipe(
143 HttpServerResponse.setHeader("content-type", "text/custom"),
144 HttpServerResponse.setHeader("content-length", "1")
145 )
146
147 assert.strictEqual(response.headers["content-type"], "text/custom")
148 assert.strictEqual(response.headers["content-length"], "1")
149 })
150
151 it.effect("fromWeb preserves content-length through a Web round trip", () =>
152 Effect.gen(function*() {
153 const response = HttpServerResponse.fromWeb(
154 new Response("hello", { headers: { "content-length": "5" } })
155 )
156 const roundTrip = HttpServerResponse.toWeb(response)
157
158 assert.strictEqual(yield* Effect.promise(() => roundTrip.text()), "hello")
159 assert.strictEqual(roundTrip.headers.get("content-length"), "5")
160 }))
161
162 describe("toClientResponse", () => {
163 it.effect("supports repeated reads of raw Web Response bodies", () =>
164 Effect.gen(function*() {
165 const clientResponse = HttpServerResponse.toClientResponse(
166 HttpServerResponse.raw(new Response("hello"))
167 )
168
169 assert.strictEqual(yield* clientResponse.text, "hello")
170 assert.strictEqual(yield* clientResponse.text, "hello")
171 assert.deepStrictEqual(new Uint8Array(yield* clientResponse.arrayBuffer), new TextEncoder().encode("hello"))
172 }))
173
174 it.effect("leaves the raw Web Response servable after a client-body read", () =>
175 Effect.gen(function*() {
176 const response = HttpServerResponse.raw(new Response("hello"))
177
178 assert.strictEqual(yield* HttpServerResponse.toClientResponse(response).text, "hello")
179 assert.strictEqual(yield* Effect.promise(() => HttpServerResponse.toWeb(response).text()), "hello")
180 }))
181 })
182
183 it.effect("fromClientResponse preserves status, headers, cookies, and json", () =>
184 Effect.gen(function*() {
185 const request = HttpClientRequest.get("http://localhost:3000/todos/1?existing=1", {
186 urlParams: { value: "a#b" },
187 hash: "fragment"
188 })
189 const clientResponse = HttpServerResponse.toClientResponse(
190 HttpServerResponse.jsonUnsafe({ foo: "bar" }, { status: 201 }).pipe(
191 HttpServerResponse.setHeader("x-test", "ok"),
192 HttpServerResponse.setCookieUnsafe("session", "123")
193 ),
194 { request }
195 )
196
197 const response = HttpServerResponse.fromClientResponse(clientResponse)
198 const roundTrip = HttpServerResponse.toClientResponse(response, { request })
199
200 assert.strictEqual(clientResponse.url, "http://localhost:3000/todos/1?existing=1&value=a%23b")
201 assert.strictEqual(roundTrip.url, clientResponse.url)
202 assert.strictEqual(response.status, 201)
203 assert.strictEqual(response.headers["content-type"], "application/json")
204 assert.strictEqual(response.headers["x-test"], "ok")
205 assert.strictEqual(response.headers["set-cookie"], undefined)
206 assert.strictEqual(response.cookies.cookies.session?.value, "123")
207 assert.deepStrictEqual(yield* roundTrip.json, { foo: "bar" })
208 }))
209
210 it.effect("fromClientResponse preserves stream requirements", () =>
211 Effect.gen(function*() {
212 const clientResponse = HttpServerResponse.toClientResponse(
213 HttpServerResponse.stream(
214 Stream.fromEffect(TestValue).pipe(
215 Stream.map(String),
216 Stream.encodeText
217 )
218 )
219 )
220
221 const response = HttpServerResponse.fromClientResponse(clientResponse)
222 const roundTrip = HttpServerResponse.toClientResponse(response)
223 assert.strictEqual(roundTrip.url, "")
224 const text = yield* roundTrip.text.pipe(
225 Effect.provideService(TestValue, 420)
226 )
227
228 assert.strictEqual(text, "420")
229 }))
230
231 it.effect("fromClientResponse preserves formData bodies", () =>
232 Effect.gen(function*() {
233 const formData = new FormData()
234 formData.set("foo", "bar")
235
236 const response = HttpServerResponse.fromClientResponse(
237 HttpServerResponse.toClientResponse(HttpServerResponse.formData(formData))
238 )
239 const roundTrip = HttpServerResponse.toClientResponse(response)
240 const parsed = yield* roundTrip.formData
241
242 assert.strictEqual(
243 response.headers["content-type"]?.startsWith("multipart/form-data; boundary="),
244 true
245 )
246 assert.strictEqual(parsed.get("foo"), "bar")
247 }))
248
249 it.effect("fromClientResponse turns empty client bodies into empty server streams", () =>
250 Effect.gen(function*() {
251 const request = HttpClientRequest.get("http://localhost:3000/empty")
252 const clientResponse = HttpClientResponse.fromWeb(request, new Response(null, { status: 200 }))
253 const response = HttpServerResponse.fromClientResponse(clientResponse)
254 const roundTrip = HttpServerResponse.toClientResponse(response, { request })
255
256 assert.strictEqual(response.status, 200)
257 assert.strictEqual(yield* roundTrip.text, "")
258 }))
259
260 it("fromClientResponse ignores malformed or unsafe content lengths", () => {
261 const request = HttpClientRequest.get("http://localhost:3000")
262 for (const contentLength of ["2junk", "1.5", "1e3", "9007199254740992"]) {
263 const clientResponse = HttpClientResponse.fromWeb(
264 request,
265 new Response("hello", { headers: { "content-length": contentLength } })
266 )
267 const response = HttpServerResponse.fromClientResponse(clientResponse)
268
269 assert.strictEqual(response.body._tag, "Stream")
270 if (response.body._tag === "Stream") {
271 assert.strictEqual(response.body.contentLength, undefined)
272 }
273 }
274 })
275
276 it("synchronizes body metadata headers for empty and replaced bodies", () => {
277 const emptyBytes = HttpServerResponse.uint8Array(new Uint8Array())
278 assert.strictEqual(emptyBytes.headers["content-length"], "0")
279
280 const replaced = HttpServerResponse.setBody(HttpServerResponse.text("abc"), HttpBody.empty)
281 assert.notProperty(replaced.headers, "content-type")
282 assert.notProperty(replaced.headers, "content-length")
283
284 const streamed = HttpServerResponse.setBody(
285 HttpServerResponse.text("abc"),
286 HttpBody.stream(Stream.empty, "application/octet-stream")
287 )
288 assert.strictEqual(streamed.headers["content-type"], "application/octet-stream")
289 assert.notProperty(streamed.headers, "content-length")
290 })
291})