1import { assert, describe, it } from "@effect/vitest"
2import * as Cause from "effect/Cause"
3import * as Context from "effect/Context"
4import * as Effect from "effect/Effect"
5import * as Exit from "effect/Exit"
6import * as Fiber from "effect/Fiber"
7import * as Headers from "effect/http/Headers"
8import * as HttpEffect from "effect/http/HttpEffect"
9import * as HttpMiddleware from "effect/http/HttpMiddleware"
10import * as HttpServerError from "effect/http/HttpServerError"
11import * as HttpServerRequest from "effect/http/HttpServerRequest"
12import * as HttpServerResponse from "effect/http/HttpServerResponse"
13import * as Logger from "effect/Logger"
14import * as References from "effect/References"
15import * as Scheduler from "effect/Scheduler"
16import * as Tracer from "effect/Tracer"
17
18describe("HttpMiddleware", () => {
19 describe("cors", () => {
20 it.effect.each([
21 {
22 name: "adds Origin when Vary is absent",
23 vary: undefined,
24 expected: "Origin"
25 },
26 {
27 name: "preserves existing Vary dimensions when adding Origin",
28 vary: "Accept-Language",
29 expected: "Accept-Language, Origin"
30 },
31 {
32 name: "preserves other dimensions without duplicating a mixed-case Origin",
33 vary: "Accept-Language, oRiGiN",
34 expected: "Accept-Language, oRiGiN"
35 },
36 {
37 name: "preserves wildcard Vary",
38 vary: "*",
39 expected: "*"
40 }
41 ])("$name", ({ expected, vary }) =>
42 Effect.gen(function*() {
43 const handler = HttpEffect.toWebHandler(
44 Effect.succeed(HttpServerResponse.text("hello", {
45 headers: vary === undefined ? {} : { Vary: vary }
46 })).pipe(HttpMiddleware.cors({
47 allowedOrigins: ["https://client.example", "https://other.example"]
48 }))
49 )
50 const response = yield* Effect.promise(() =>
51 handler(
52 new Request("http://localhost/", {
53 headers: { Origin: "https://client.example" }
54 })
55 )
56 )
57 assert.strictEqual(response.headers.get("access-control-allow-origin"), "https://client.example")
58 assert.strictEqual(response.headers.get("vary"), expected)
59 }))
60
61 it.effect("preserves both Vary dimensions for preflight requests", () =>
62 Effect.gen(function*() {
63 const handler = HttpEffect.toWebHandler(
64 Effect.succeed(HttpServerResponse.empty()).pipe(HttpMiddleware.cors({
65 allowedOrigins: ["https://client.example", "https://other.example"]
66 }))
67 )
68 const response = yield* Effect.promise(() =>
69 handler(
70 new Request("http://localhost/", {
71 method: "OPTIONS",
72 headers: {
73 Origin: "https://client.example",
74 "Access-Control-Request-Headers": "X-Test"
75 }
76 })
77 )
78 )
79 const vary = response.headers.get("vary")?.split(",").map((member) => member.trim().toLowerCase()).sort()
80 assert.deepStrictEqual(vary, ["access-control-request-headers", "origin"])
81 }))
82
83 it.effect("varies by Origin when the request origin is rejected", () =>
84 Effect.gen(function*() {
85 const handler = HttpEffect.toWebHandler(
86 Effect.succeed(HttpServerResponse.empty()).pipe(HttpMiddleware.cors({
87 allowedOrigins: ["https://client.example", "https://other.example"]
88 }))
89 )
90 const response = yield* Effect.promise(() =>
91 handler(
92 new Request("http://localhost/", {
93 headers: { Origin: "https://rejected.example" }
94 })
95 )
96 )
97 assert.strictEqual(response.headers.get("vary"), "Origin")
98 }))
99
100 it.effect("preserves Vary when allowing all origins", () =>
101 Effect.gen(function*() {
102 const handler = HttpEffect.toWebHandler(
103 Effect.succeed(HttpServerResponse.text("hello", {
104 headers: { Vary: "Accept-Language" }
105 })).pipe(HttpMiddleware.cors())
106 )
107 const response = yield* Effect.promise(() =>
108 handler(
109 new Request("http://localhost/", {
110 headers: { Origin: "https://client.example" }
111 })
112 )
113 )
114 assert.strictEqual(response.headers.get("access-control-allow-origin"), "*")
115 assert.strictEqual(response.headers.get("vary"), "Accept-Language")
116 }))
117 })
118
119 describe("logger", () => {
120 it.effect("preserves failures mixed with client aborts even with status 499", () =>
121 Effect.gen(function*() {
122 const cause = Cause.combine(
123 Cause.annotate(Cause.interrupt(2), Context.make(HttpServerError.ClientAbort, true)),
124 Cause.fail("handler failed")
125 )
126 const logs: Array<{ message: unknown; cause: Cause.Cause<unknown>; status: unknown }> = []
127 const logger = Logger.make<unknown, void>((options) => {
128 logs.push({
129 message: options.message,
130 cause: options.cause,
131 status: options.fiber.getRef(References.CurrentLogAnnotations)["http.status"]
132 })
133 })
134 // The server attaches the response to the failed handler cause.
135 const handlerCause = Cause.combine(cause, Cause.die(HttpServerResponse.empty({ status: 499 })))
136 const exit = yield* HttpMiddleware.logger(Effect.failCause(handlerCause)).pipe(
137 Effect.provideService(
138 HttpServerRequest.HttpServerRequest,
139 HttpServerRequest.fromWeb(new Request("http://localhost/slow"))
140 ),
141 Effect.provide(Logger.layer([logger])),
142 Effect.exit
143 )
144
145 assert.deepStrictEqual(exit, Exit.failCause(handlerCause))
146 assert.deepStrictEqual(logs, [{ message: [], cause, status: 499 }])
147 }))
148
149 it.effect("logs client aborts as sent responses with status 499", () =>
150 Effect.gen(function*() {
151 const logs: Array<{
152 message: unknown
153 cause: Cause.Cause<unknown>
154 level: string
155 annotations: Record<string, unknown>
156 }> = []
157 const logger = Logger.make<unknown, void>((options) => {
158 logs.push({
159 message: options.message,
160 cause: options.cause,
161 level: options.logLevel,
162 annotations: { ...options.fiber.getRef(References.CurrentLogAnnotations) }
163 })
164 })
165 const started = Promise.withResolvers<void>()
166 const handler = HttpEffect.toWebHandler(
167 Effect.interruptible(Effect.andThen(Effect.sync(() => started.resolve()), Effect.never)),
168 (app) => HttpMiddleware.logger(app).pipe(Effect.provide(Logger.layer([logger])))
169 )
170 const controller = new AbortController()
171 const pending = handler(new Request("http://localhost/slow", { signal: controller.signal }))
172 yield* Effect.promise(() => started.promise)
173 controller.abort()
174 const response = yield* Effect.promise(() => pending)
175
176 assert.strictEqual(response.status, 499)
177 assert.strictEqual(logs.length, 1)
178 assert.deepStrictEqual(logs[0].annotations, {
179 "http.method": "GET",
180 "http.url": "/slow",
181 "http.status": 499
182 })
183 assert.strictEqual(logs[0].level, "Info")
184 assert.deepStrictEqual(logs[0].message, ["Sent HTTP response"])
185 assert.deepStrictEqual(logs[0].cause, Cause.empty)
186 }))
187
188 it.effect("annotates method, path, and status without query or hash", () =>
189 Effect.gen(function*() {
190 const annotations: Array<Record<string, unknown>> = []
191 const logger = Logger.make<unknown, void>((options) => {
192 annotations.push({ ...options.fiber.getRef(References.CurrentLogAnnotations) })
193 })
194
195 const request = HttpServerRequest.fromWeb(
196 new Request("http://localhost:3000/todos/1?foo=bar#top", {
197 method: "GET"
198 })
199 )
200
201 yield* HttpMiddleware.logger(
202 Effect.succeed(HttpServerResponse.empty({ status: 204 }))
203 ).pipe(
204 Effect.provideService(HttpServerRequest.HttpServerRequest, request),
205 Effect.provide(Logger.layer([logger]))
206 )
207
208 assert.strictEqual(annotations.length, 1)
209 assert.strictEqual(annotations[0]?.["http.method"], "GET")
210 assert.strictEqual(annotations[0]?.["http.url"], "/todos/1")
211 assert.strictEqual(annotations[0]?.["http.status"], 204)
212 }))
213
214 it.effect("uses a stable http.span log span name", () =>
215 Effect.gen(function*() {
216 const spans: Array<Array<string>> = []
217 const logger = Logger.make<unknown, void>((options) => {
218 spans.push(options.fiber.getRef(References.CurrentLogSpans).map(([label]) => label))
219 })
220
221 const loggedApp = HttpMiddleware.logger(
222 Effect.succeed(HttpServerResponse.empty({ status: 204 }))
223 ).pipe(Effect.provide(Logger.layer([logger])))
224
225 const request1 = HttpServerRequest.fromWeb(new Request("http://localhost:3000/one"))
226 const request2 = HttpServerRequest.fromWeb(new Request("http://localhost:3000/two"))
227
228 yield* loggedApp.pipe(Effect.provideService(HttpServerRequest.HttpServerRequest, request1))
229 yield* loggedApp.pipe(Effect.provideService(HttpServerRequest.HttpServerRequest, request2))
230
231 assert.deepStrictEqual(spans, [["http.span"], ["http.span"]])
232 }))
233 })
234
235 describe("tracer", () => {
236 it.effect("restores ParentSpan and ends the span if interrupted before handler registration", () =>
237 Effect.gen(function*() {
238 const request = HttpServerRequest.fromWeb(new Request("http://localhost:3000/"))
239 let span: Tracer.NativeSpan | undefined
240 const tracer = Tracer.make({
241 span(options) {
242 span = new Tracer.NativeSpan(options)
243 return span
244 }
245 })
246 const tasks: Array<() => void> = []
247 let yielded = false
248 let original: Context.Context<never> | undefined
249 let observed: Context.Context<never> | undefined
250 const scheduler: Scheduler.Scheduler = {
251 executionMode: "async",
252 makeDispatcher: () => ({
253 scheduleTask: (task) => {
254 tasks.push(task)
255 },
256 flush() {}
257 }),
258 shouldYield(fiber) {
259 if (
260 !yielded && original !== undefined && span !== undefined &&
261 Context.getOrUndefined(fiber.context, Tracer.ParentSpan) === span
262 ) {
263 yielded = true
264 return true
265 }
266 return false
267 }
268 }
269 const child = yield* Effect.gen(function*() {
270 original = yield* Effect.withFiber((fiber) => Effect.succeed(fiber.context))
271 yield* HttpMiddleware.tracer(Effect.succeed(HttpServerResponse.empty()))
272 }).pipe(
273 Effect.onExit(() =>
274 Effect.withFiber((fiber) =>
275 Effect.sync(() => {
276 observed = fiber.context
277 })
278 )
279 ),
280 Effect.provideService(HttpServerRequest.HttpServerRequest, request),
281 Effect.provideService(Tracer.Tracer, tracer),
282 Effect.provideService(Scheduler.Scheduler, scheduler),
283 Effect.forkChild({ startImmediately: true })
284 )
285 assert.isTrue(yielded)
286 child.interruptUnsafe()
287 while (tasks.length > 0) tasks.shift()!()
288 yield* Fiber.await(child)
289 assert.strictEqual(observed, original)
290 assert.strictEqual(span?.status._tag, "Ended")
291 }))
292
293 it.effect("restores the ParentSpan context identity", () => {
294 const request = HttpServerRequest.fromWeb(new Request("http://localhost:3000/"))
295 return Effect.gen(function*() {
296 const before = yield* Effect.withFiber((fiber) => Effect.succeed(fiber.context))
297 let during: typeof before | undefined
298
299 yield* HttpMiddleware.tracer(
300 Effect.withFiber((fiber) => {
301 during = fiber.context
302 assert.strictEqual(Context.getOrUndefined(fiber.context, Tracer.ParentSpan) !== undefined, true)
303 return Effect.succeed(HttpServerResponse.empty())
304 })
305 )
306
307 const after = yield* Effect.withFiber((fiber) => Effect.succeed(fiber.context))
308 assert.notStrictEqual(during, before)
309 assert.strictEqual(after, before)
310 }).pipe(Effect.provideService(HttpServerRequest.HttpServerRequest, request))
311 })
312
313 it.effect("records attributes for sampled spans", () =>
314 Effect.gen(function*() {
315 let serverSpan: Tracer.NativeSpan | undefined
316 const tracer = Tracer.make({
317 span(options) {
318 serverSpan = new Tracer.NativeSpan(options)
319 return serverSpan
320 }
321 })
322 const request = HttpServerRequest.fromWeb(
323 new Request("https://localhost:3000/todos/1?foo=bar", {
324 method: "POST",
325 headers: {
326 "user-agent": "test-agent",
327 "x-request": "request",
328 "x-request-secret": "request-secret"
329 }
330 })
331 )
332 const response = HttpServerResponse.empty({
333 status: 201,
334 headers: {
335 "x-response": "response",
336 "x-response-secret": "response-secret"
337 }
338 })
339
340 yield* HttpMiddleware.tracer(Effect.succeed(response)).pipe(
341 Effect.provideService(HttpServerRequest.HttpServerRequest, request),
342 Effect.provideService(Headers.CurrentRedactedNames, ["x-request-secret", "x-response-secret"]),
343 Effect.provideService(Tracer.Tracer, tracer)
344 )
345 yield* Effect.yieldNow
346
347 assert(serverSpan !== undefined)
348 assert.strictEqual(serverSpan.sampled, true)
349 assert.strictEqual(serverSpan.attributes.get("http.request.method"), "POST")
350 assert.strictEqual(serverSpan.attributes.get("url.path"), "/todos/1")
351 assert.strictEqual(serverSpan.attributes.get("url.query"), "foo=bar")
352 assert.strictEqual(serverSpan.attributes.get("user_agent.original"), "test-agent")
353 assert.strictEqual(serverSpan.attributes.get("http.request.header.x-request"), "request")
354 assert.strictEqual(serverSpan.attributes.get("http.request.header.x-request-secret"), "<redacted>")
355 assert.strictEqual(serverSpan.attributes.get("http.response.status_code"), 201)
356 assert.strictEqual(serverSpan.attributes.get("http.response.header.x-response"), "response")
357 assert.strictEqual(serverSpan.attributes.get("http.response.header.x-response-secret"), "<redacted>")
358 }))
359
360 it.effect("skips attributes for unsampled spans", () =>
361 Effect.gen(function*() {
362 let serverSpan: Tracer.NativeSpan | undefined
363 const tracer = Tracer.make({
364 span(options) {
365 serverSpan = new Tracer.NativeSpan(options)
366 return serverSpan
367 }
368 })
369 const request = HttpServerRequest.fromWeb(new Request("http://localhost:3000/unsampled"))
370
371 yield* HttpMiddleware.tracer(Effect.succeed(HttpServerResponse.empty({ status: 204 }))).pipe(
372 Effect.provideService(HttpServerRequest.HttpServerRequest, request),
373 Effect.provideService(Tracer.MinimumTraceLevel, "Fatal"),
374 Effect.provideService(Tracer.Tracer, tracer)
375 )
376 yield* Effect.yieldNow
377
378 assert(serverSpan !== undefined)
379 assert.strictEqual(serverSpan.sampled, false)
380 assert.strictEqual(serverSpan.attributes.size, 0)
381 assert.strictEqual(serverSpan.status._tag, "Ended")
382 }))
383
384 it.effect("ends the span and restores the context when the app is interrupted", () =>
385 Effect.gen(function*() {
386 const spans: Array<Tracer.NativeSpan> = []
387 const tracer = Tracer.make({
388 span(options) {
389 const span = new Tracer.NativeSpan(options)
390 spans.push(span)
391 return span
392 }
393 })
394 const request = HttpServerRequest.fromWeb(new Request("http://localhost:3000/slow"))
395 let parentInFinalizer: Tracer.AnySpan | undefined
396 const fiber = yield* Effect.forkChild(
397 Effect.ensuring(
398 HttpMiddleware.tracer(Effect.never),
399 Effect.withFiber((fiber) => {
400 parentInFinalizer = Context.getOrUndefined(fiber.context, Tracer.ParentSpan)
401 return Effect.void
402 })
403 ).pipe(
404 Effect.provideService(HttpServerRequest.HttpServerRequest, request),
405 Effect.provideService(Tracer.Tracer, tracer)
406 )
407 )
408 yield* Effect.yieldNow
409 yield* Fiber.interrupt(fiber)
410 yield* Effect.yieldNow
411
412 assert.strictEqual(spans.length, 1)
413 assert.strictEqual(spans[0].status._tag, "Ended")
414 assert.strictEqual(parentInFinalizer, undefined)
415 }))
416
417 it.effect("excludes the sent response from a failed stream span", () =>
418 Effect.gen(function*() {
419 let serverSpan: Tracer.NativeSpan | undefined
420 const tracer = Tracer.make({
421 span(options) {
422 serverSpan = new Tracer.NativeSpan(options)
423 return serverSpan
424 }
425 })
426 const request = HttpServerRequest.fromWeb(new Request("http://localhost:3000/stream"))
427 const response = HttpServerResponse.empty({
428 status: 200,
429 headers: { "content-type": "text/event-stream" }
430 })
431 const streamError = new Error("stream failed")
432
433 // Once headers have been sent, the platform attaches the response to
434 // the stream failure so the server can finish the request correctly.
435 const app = Effect.failCause(Cause.combine(Cause.fail(streamError), Cause.die(response)))
436
437 yield* Effect.exit(
438 HttpMiddleware.tracer(app).pipe(
439 Effect.provideService(HttpServerRequest.HttpServerRequest, request),
440 Effect.provideService(Tracer.Tracer, tracer)
441 )
442 )
443 yield* Effect.yieldNow
444
445 assert(serverSpan !== undefined)
446 assert.strictEqual(serverSpan.attributes.get("http.response.status_code"), 200)
447 assert.strictEqual(serverSpan.status._tag, "Ended")
448 if (serverSpan.status._tag === "Ended") {
449 assert.deepStrictEqual(serverSpan.status.exit, Exit.fail(streamError))
450 }
451 }))
452 })
453})