1import { describe, it, test } from "@effect/vitest"
2import { deepStrictEqual, strictEqual } from "@effect/vitest/utils"
3import { Cause, Context, Effect, Exit, Option, References, Scope, Stream, Tracer } from "effect"
4import { identity } from "effect/Function"
5import { HttpEffect, HttpServerRequest, HttpServerResponse } from "effect/http"
6import { appendPreResponseHandlerUnsafe, requestPreResponseHandlers } from "effect/http/internal/preResponseHandler"
7import * as Layer from "effect/Layer"
8
9const TestValue = Context.Reference<number>("test/TestValue", { defaultValue: () => 0 })
10
11describe("HttpEffect", () => {
12 it.effect("restores the request Scope context identity", () => {
13 const request = HttpServerRequest.fromWeb(new Request("http://localhost:3000/"))
14 return Effect.gen(function*() {
15 const before = yield* Effect.withFiber((fiber) => Effect.succeed(fiber.context))
16 let during: Context.Context<never> | undefined
17
18 yield* HttpEffect.toHandled(
19 Effect.withFiber((fiber) => {
20 during = fiber.context
21 strictEqual(Context.getOrUndefined(fiber.context, Scope.Scope) !== undefined, true)
22 return Effect.succeed(HttpServerResponse.empty())
23 }),
24 () => Effect.void
25 )
26
27 const after = yield* Effect.withFiber((fiber) => Effect.succeed(fiber.context))
28 strictEqual(during === before, false)
29 strictEqual(after, before)
30 }).pipe(
31 Effect.provideService(HttpServerRequest.HttpServerRequest, request),
32 Effect.provideService(References.TracerEnabled, false)
33 )
34 })
35
36 it.effect("propagates a handler failure through middleware to request and forked scopes", () => {
37 const request = HttpServerRequest.fromWeb(new Request("http://localhost:3000/"))
38 const error = new Error("handler failed")
39 let requestExit: Exit.Exit<unknown, unknown> | undefined
40 let childExit: Exit.Exit<unknown, unknown> | undefined
41 return Effect.gen(function*() {
42 yield* HttpEffect.toHandled(
43 Effect.gen(function*() {
44 const scope = yield* Effect.scope
45 const child = yield* Scope.fork(scope)
46 yield* Scope.addFinalizerExit(child, (exit) =>
47 Effect.sync(() => {
48 childExit = exit
49 }))
50 yield* Scope.addFinalizerExit(scope, (exit) =>
51 Effect.sync(() => {
52 requestExit = exit
53 }))
54 return yield* Effect.fail(error)
55 }),
56 () => Effect.void,
57 identity
58 )
59 for (const exit of [requestExit, childExit]) {
60 strictEqual(exit !== undefined && Exit.isFailure(exit), true)
61 if (exit && Exit.isFailure(exit)) {
62 strictEqual(exit.cause.reasons.length, 1)
63 strictEqual(Cause.squash(exit.cause), error)
64 }
65 }
66 }).pipe(
67 Effect.provideService(HttpServerRequest.HttpServerRequest, request),
68 Effect.provideService(References.TracerEnabled, false)
69 )
70 })
71
72 describe("toWebHandler", () => {
73 test("skips the server span with the default tracer", async () => {
74 let hasParentSpan = false
75 const handler = HttpEffect.toWebHandler(Effect.gen(function*() {
76 hasParentSpan = Option.isSome(yield* Effect.serviceOption(Tracer.ParentSpan))
77 return HttpServerResponse.empty()
78 }))
79
80 await handler(new Request("http://localhost:3000/"))
81
82 strictEqual(hasParentSpan, false)
83 })
84
85 test("provides a parent span with a configured tracer", async () => {
86 let hasParentSpan = false
87 const tracer = Tracer.make({
88 span: (options) => new Tracer.NativeSpan(options)
89 })
90 const handler = HttpEffect.toWebHandler(Effect.gen(function*() {
91 hasParentSpan = Option.isSome(yield* Effect.serviceOption(Tracer.ParentSpan))
92 return HttpServerResponse.empty()
93 }))
94
95 await handler(new Request("http://localhost:3000/"), Context.make(Tracer.Tracer, tracer))
96
97 strictEqual(hasParentSpan, true)
98 })
99
100 test("json", async () => {
101 const handler = HttpEffect.toWebHandler(HttpServerResponse.json({ foo: "bar" }))
102 const response = await handler(new Request("http://localhost:3000/"))
103 deepStrictEqual(await response.json(), {
104 foo: "bar"
105 })
106 })
107
108 test("cookies", async () => {
109 const handler = HttpEffect.toWebHandler(Effect.succeed(
110 HttpServerResponse.jsonUnsafe({ foo: "bar" }).pipe(
111 HttpServerResponse.setCookieUnsafe("foo", "bar"),
112 HttpServerResponse.setCookieUnsafe("test", "123", { secure: true, httpOnly: true, sameSite: "strict" })
113 )
114 ))
115 const response = await handler(new Request("http://localhost:3000/"))
116 deepStrictEqual(response.headers.getSetCookie(), [
117 "foo=bar",
118 "test=123; HttpOnly; Secure; SameSite=Strict"
119 ])
120 deepStrictEqual(await response.json(), {
121 foo: "bar"
122 })
123 })
124
125 test("expireCookie", async () => {
126 const handler = HttpEffect.toWebHandler(
127 HttpServerResponse.empty().pipe(
128 HttpServerResponse.expireCookie("foo", { path: "/" }),
129 Effect.map(HttpServerResponse.expireCookieUnsafe("bar"))
130 )
131 )
132 const response = await handler(new Request("http://localhost:3000/"))
133 deepStrictEqual(response.headers.getSetCookie(), [
134 "foo=; Max-Age=0; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT",
135 "bar=; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT"
136 ])
137 })
138
139 test("stream", async () => {
140 const handler = HttpEffect.toWebHandler(
141 Effect.succeed(HttpServerResponse.stream(Stream.make("foo", "bar").pipe(Stream.encodeText)))
142 )
143 const response = await handler(new Request("http://localhost:3000/"))
144 strictEqual(await response.text(), "foobar")
145 })
146
147 test("preserves explicit stream headers", async () => {
148 const handler = HttpEffect.toWebHandler(Effect.succeed(
149 HttpServerResponse.stream(Stream.empty).pipe(
150 HttpServerResponse.setHeaders({
151 "Content-Type": "text/javascript",
152 "Content-Length": "9007199254740993"
153 })
154 )
155 ))
156 const response = await handler(new Request("http://localhost/script.js"))
157 await response.arrayBuffer()
158
159 strictEqual(response.headers.get("content-type"), "text/javascript")
160 strictEqual(response.headers.get("content-length"), "9007199254740993")
161 })
162
163 test("stream scope", async () => {
164 let order = 0
165 let streamFinalized = 0
166 let handlerFinalized = 0
167 const handler = HttpEffect.toWebHandler(Effect.gen(function*() {
168 yield* Effect.addFinalizer(() =>
169 Effect.sync(() => {
170 handlerFinalized = order
171 order += 1
172 })
173 )
174 const stream = Stream.make("foo", "bar").pipe(
175 Stream.encodeText,
176 Stream.ensuring(Effect.sync(() => {
177 streamFinalized = order
178 order += 1
179 }))
180 )
181 return HttpServerResponse.stream(stream)
182 }))
183 const response = await handler(new Request("http://localhost:3000/"))
184 strictEqual(await response.text(), "foobar")
185 strictEqual(streamFinalized < handlerFinalized, true)
186 })
187
188 test("streaming HEAD closes the request scope", async () => {
189 let finalized = false
190 const handler = HttpEffect.toWebHandler(Effect.gen(function*() {
191 yield* Effect.addFinalizer(() =>
192 Effect.sync(() => {
193 finalized = true
194 })
195 )
196 return HttpServerResponse.stream(Stream.make("body").pipe(Stream.encodeText))
197 }))
198
199 await handler(new Request("http://localhost:3000/", { method: "HEAD" }))
200
201 strictEqual(finalized, true)
202 })
203
204 test.each([204, 205, 304])("status %i closes the request scope without starting the stream", async (status) => {
205 let finalized = false
206 let streamStarted = false
207 const handler = HttpEffect.toWebHandler(Effect.gen(function*() {
208 yield* Effect.addFinalizer(() =>
209 Effect.sync(() => {
210 finalized = true
211 })
212 )
213 return HttpServerResponse.stream(
214 Stream.fromEffect(Effect.sync(() => {
215 streamStarted = true
216 return new Uint8Array([1])
217 })),
218 { status }
219 )
220 }))
221
222 const response = await handler(new Request("http://localhost:3000/"))
223
224 strictEqual(response.status, status)
225 strictEqual(response.body, null)
226 strictEqual(streamStarted, false)
227 strictEqual(finalized, true)
228 })
229
230 test("stream runtime", async () => {
231 const handler = Effect.succeed(HttpServerResponse.stream(
232 Stream.fromEffect(TestValue).pipe(Stream.map(String), Stream.encodeText)
233 )).pipe(
234 HttpEffect.toWebHandlerWith(TestValue.context(420))
235 )
236 const response = await handler(new Request("http://localhost:3000/"))
237 strictEqual(await response.text(), "420")
238 })
239
240 test("stream layer", async () => {
241 const { handler } = HttpEffect.toWebHandlerLayer(
242 Effect.succeed(HttpServerResponse.stream(
243 TestValue.pipe(
244 Stream.fromEffect,
245 Stream.map(String),
246 Stream.encodeText
247 )
248 )),
249 Layer.succeed(TestValue, 420)
250 )
251 const response = await handler(new Request("http://localhost:3000/"))
252 strictEqual(await response.text(), "420")
253 })
254
255 test("pre-response handlers are keyed by request source", () => {
256 const request = HttpServerRequest.fromWeb(new Request("http://localhost:3000/"))
257 const modified = request.modify({ url: "/updated" })
258 const handler = (
259 _request: HttpServerRequest.HttpServerRequest,
260 response: HttpServerResponse.HttpServerResponse
261 ) => Effect.succeed(response)
262
263 appendPreResponseHandlerUnsafe(request, handler)
264
265 strictEqual(requestPreResponseHandlers.get(modified.source), handler)
266 })
267 })
268
269 test("custom context", async () => {
270 const Env = Context.Reference<{ foo: string }>("Env", {
271 defaultValue: () => ({ foo: "bar" })
272 })
273 const handler = HttpEffect.toWebHandler(Effect.gen(function*() {
274 const env = yield* Env
275 return yield* HttpServerResponse.json(env)
276 }))
277 const response = await handler(new Request("http://localhost:3000/"), Env.context({ foo: "baz" }))
278 deepStrictEqual(await response.json(), {
279 foo: "baz"
280 })
281 })
282
283 describe("toWebHandlerLayer", () => {
284 test("builds the layer when the handler is created", async () => {
285 let builds = 0
286 const { dispose, handler } = HttpEffect.toWebHandlerLayer(
287 Effect.map(TestValue, (value) => HttpServerResponse.text(String(value))),
288 Layer.effect(
289 TestValue,
290 Effect.sync(() => {
291 builds++
292 return 420
293 })
294 )
295 )
296 strictEqual(builds, 1)
297 const response = await handler(new Request("http://localhost:3000/"))
298 strictEqual(await response.text(), "420")
299 strictEqual(builds, 1)
300 await dispose()
301 })
302
303 test("a failing layer rejects every request without an unhandled rejection", async () => {
304 const unhandled: Array<unknown> = []
305 const onUnhandled = (reason: unknown) => {
306 unhandled.push(reason)
307 }
308 process.on("unhandledRejection", onUnhandled)
309 try {
310 const error = new Error("boom")
311 const { handler } = HttpEffect.toWebHandlerLayer(
312 Effect.succeed(HttpServerResponse.empty()),
313 Layer.effectDiscard(Effect.fail(error))
314 )
315 await new Promise((resolve) => setTimeout(resolve, 0))
316 for (let i = 0; i < 2; i++) {
317 const rejected = await handler(new Request("http://localhost:3000/")).then(
318 () => undefined,
319 (cause) => cause
320 )
321 strictEqual(rejected, error)
322 }
323 await new Promise((resolve) => setTimeout(resolve, 0))
324 deepStrictEqual(unhandled, [])
325 } finally {
326 process.off("unhandledRejection", onUnhandled)
327 }
328 })
329 })
330
331 describe("fromWebHandler", () => {
332 test("basic GET request", async () => {
333 const webHandler = async (request: Request) => {
334 return new Response(`Hello from ${request.url}`, {
335 status: 200,
336 headers: { "Content-Type": "text/plain" }
337 })
338 }
339 const app = HttpEffect.fromWebHandler(webHandler)
340 const handler = HttpEffect.toWebHandler(app)
341 const response = await handler(new Request("http://localhost:3000/hello"))
342 strictEqual(response.status, 200)
343 strictEqual(await response.text(), "Hello from http://localhost:3000/hello")
344 })
345
346 test("POST with JSON body", async () => {
347 const webHandler = async (request: Request) => {
348 const body = await request.json()
349 return Response.json({ received: body })
350 }
351 const app = HttpEffect.fromWebHandler(webHandler)
352 const handler = HttpEffect.toWebHandler(app)
353 const response = await handler(
354 new Request("http://localhost:3000/", {
355 method: "POST",
356 body: JSON.stringify({ message: "hello" }),
357 headers: { "Content-Type": "application/json" }
358 })
359 )
360 deepStrictEqual(await response.json(), {
361 received: { message: "hello" }
362 })
363 })
364
365 test("preserves request headers", async () => {
366 const webHandler = async (request: Request) => {
367 return Response.json({
368 authorization: request.headers.get("Authorization"),
369 custom: request.headers.get("X-Custom-Header")
370 })
371 }
372 const app = HttpEffect.fromWebHandler(webHandler)
373 const handler = HttpEffect.toWebHandler(app)
374 const response = await handler(
375 new Request("http://localhost:3000/", {
376 headers: {
377 "Authorization": "Bearer token123",
378 "X-Custom-Header": "custom-value"
379 }
380 })
381 )
382 deepStrictEqual(await response.json(), {
383 authorization: "Bearer token123",
384 custom: "custom-value"
385 })
386 })
387
388 test("preserves response status and headers", async () => {
389 const webHandler = async (_request: Request) => {
390 return new Response("Not Found", {
391 status: 404,
392 statusText: "Not Found",
393 headers: {
394 "X-Error-Code": "RESOURCE_NOT_FOUND",
395 "Content-Type": "text/plain"
396 }
397 })
398 }
399 const app = HttpEffect.fromWebHandler(webHandler)
400 const handler = HttpEffect.toWebHandler(app)
401 const response = await handler(new Request("http://localhost:3000/missing"))
402 strictEqual(response.status, 404)
403 strictEqual(response.headers.get("X-Error-Code"), "RESOURCE_NOT_FOUND")
404 strictEqual(await response.text(), "Not Found")
405 })
406
407 test("round-trip with toWebHandler", async () => {
408 // Create an Effect app, convert to web handler, then back to Effect app
409 const originalApp = HttpServerResponse.json({ source: "effect" })
410 const webHandler = HttpEffect.toWebHandler(originalApp)
411 const wrappedApp = HttpEffect.fromWebHandler(webHandler)
412 const finalHandler = HttpEffect.toWebHandler(wrappedApp)
413
414 const response = await finalHandler(new Request("http://localhost:3000/"))
415 deepStrictEqual(await response.json(), { source: "effect" })
416 })
417
418 test("json preserves content-type", async () => {
419 const handler = HttpEffect.toWebHandler(HttpServerResponse.json({ foo: "bar" }))
420 const response = await handler(new Request("http://localhost:3000/"))
421 strictEqual(response.headers.get("Content-Type"), "application/json")
422 })
423
424 test("preserves response content-type header", async () => {
425 const webHandler = async (_request: Request) => {
426 return Response.json({ message: "hello" })
427 }
428 const app = HttpEffect.fromWebHandler(webHandler)
429 const handler = HttpEffect.toWebHandler(app)
430 const response = await handler(new Request("http://localhost:3000/"))
431 strictEqual(response.headers.get("Content-Type"), "application/json")
432 deepStrictEqual(await response.json(), { message: "hello" })
433 })
434
435 test("preserves custom content-type header", async () => {
436 const webHandler = async (_request: Request) => {
437 return new Response("<html></html>", {
438 headers: { "Content-Type": "text/html; charset=utf-8" }
439 })
440 }
441 const app = HttpEffect.fromWebHandler(webHandler)
442 const handler = HttpEffect.toWebHandler(app)
443 const response = await handler(new Request("http://localhost:3000/"))
444 strictEqual(response.headers.get("Content-Type"), "text/html; charset=utf-8")
445 })
446 })
447})