1/**
2 * Wraps HTTP server apps with request and response behavior.
3 *
4 * A middleware is a function from one HTTP server app effect to another. The app
5 * runs with the current `HttpServerRequest` in its context, so middleware can
6 * inspect or rewrite the request, provide request-scoped services, attach hooks
7 * before the response is sent, or observe the app exit. This module includes
8 * middleware for response logging, server tracing, forwarded proxy headers,
9 * parsed search parameters, and CORS response headers.
10 *
11 * @stability unstable
12 * @since 4.0.0
13 */
14import { Clock } from "../Clock.ts"
15import * as Context from "../Context.ts"
16import * as Effect from "../Effect.ts"
17import * as Exit from "../Exit.ts"
18import type * as Fiber from "../Fiber.ts"
19import { constant, constFalse } from "../Function.ts"
20import * as internalEffect from "../internal/effect.ts"
21import * as Layer from "../Layer.ts"
22import * as Option from "../Option.ts"
23import type { Predicate } from "../Predicate.ts"
24import type { ReadonlyRecord } from "../Record.ts"
25import { nativeTracer, ParentSpan, Tracer } from "../Tracer.ts"
26import * as Headers from "./Headers.ts"
27import type { CompressionAlgorithm } from "./HttpPlatform.ts"
28import { HttpPlatform } from "./HttpPlatform.ts"
29import { causeResponseStripped, ClientAbort } from "./HttpServerError.ts"
30import { HttpServerRequest } from "./HttpServerRequest.ts"
31import * as Request from "./HttpServerRequest.ts"
32import * as Response from "./HttpServerResponse.ts"
33import type { HttpServerResponse } from "./HttpServerResponse.ts"
34import * as TraceContext from "./HttpTraceContext.ts"
35import * as compressionInternal from "./internal/compression.ts"
36import * as bodyInternal from "./internal/httpBody.ts"
37import { appendPreResponseHandlerUnsafe } from "./internal/preResponseHandler.ts"
38
39/**
40 * Middleware that transforms an HTTP server app effect into another HTTP server app effect.
41 *
42 * @stability unstable
43 * @category models
44 * @since 4.0.0
45 */
46export interface HttpMiddleware {
47 <E, R>(self: Effect.Effect<HttpServerResponse, E, R | HttpServerRequest>): Effect.Effect<HttpServerResponse, any, any>
48}
49
50/**
51 * Namespace containing types associated with `HttpMiddleware`.
52 *
53 * @stability unstable
54 * @since 4.0.0
55 */
56export declare namespace HttpMiddleware {
57 /**
58 * Callable type representing middleware already specialized to a particular transformed app type.
59 *
60 * @stability unstable
61 * @category models
62 * @since 4.0.0
63 */
64 export interface Applied<A extends Effect.Effect<HttpServerResponse, any, any>, E, R> {
65 (self: Effect.Effect<HttpServerResponse, E, R>): A
66 }
67}
68
69/**
70 * Defines an `HttpMiddleware` while preserving its precise type.
71 *
72 * @stability unstable
73 * @category constructors
74 * @since 4.0.0
75 */
76export const make = <M extends HttpMiddleware>(middleware: M): M => middleware
77
78const loggerDisabledRequests = new WeakSet<object>()
79
80const stripSearchAndHash = (url: string): string => {
81 const queryIndex = url.indexOf("?")
82 const hashIndex = url.indexOf("#")
83
84 if (queryIndex === -1) {
85 return hashIndex === -1 ? url : url.slice(0, hashIndex)
86 }
87 if (hashIndex === -1) {
88 return url.slice(0, queryIndex)
89 }
90 return url.slice(0, Math.min(queryIndex, hashIndex))
91}
92
93/**
94 * Runs an effect with HTTP response logging disabled for the current server request.
95 *
96 * @stability unstable
97 * @category logging
98 * @since 4.0.0
99 */
100export const withLoggerDisabled = <A, E, R>(self: Effect.Effect<A, E, R>): Effect.Effect<A, E, R | HttpServerRequest> =>
101 Effect.withFiber((fiber) => {
102 const request = Context.getUnsafe(fiber.context, HttpServerRequest)
103 loggerDisabledRequests.add(request.source)
104 return self
105 })
106
107/**
108 * Context reference for a predicate that disables server-side tracing for matching requests.
109 *
110 * @stability unstable
111 * @category services
112 * @since 4.0.0
113 */
114export const TracerDisabledWhen = Context.Reference<Predicate<HttpServerRequest>>(
115 "effect/http/HttpMiddleware/TracerDisabledWhen",
116 { defaultValue: () => constFalse }
117)
118
119/**
120 * Creates a layer that disables server-side tracing for requests whose URL exactly matches one of the supplied URLs.
121 *
122 * @stability unstable
123 * @category layers
124 * @since 4.0.0
125 */
126export const layerTracerDisabledForUrls = (
127 urls: ReadonlyArray<string>
128): Layer.Layer<never> => Layer.succeed(TracerDisabledWhen)((req) => urls.includes(req.url))
129
130/**
131 * Context reference for generating server span names from HTTP server requests.
132 *
133 * @stability unstable
134 * @category services
135 * @since 4.0.0
136 */
137export const SpanNameGenerator = Context.Reference<(request: HttpServerRequest) => string>(
138 "@effect/platform/HttpMiddleware/SpanNameGenerator",
139 { defaultValue: () => (request) => `http.server ${request.method}` }
140)
141
142/**
143 * Middleware that logs sent HTTP responses with request method, request URL, and response status annotations.
144 *
145 * @stability unstable
146 * @category logging
147 * @since 4.0.0
148 */
149export const logger: <E, R>(
150 httpApp: Effect.Effect<HttpServerResponse, E, HttpServerRequest | R>
151) => Effect.Effect<HttpServerResponse, E, HttpServerRequest | R> = make((httpApp) =>
152 Effect.withFiber((fiber) => {
153 const request = Context.getUnsafe(fiber.context, HttpServerRequest)
154 const path = stripSearchAndHash(request.url)
155 return Effect.withLogSpan(
156 Effect.flatMap(Effect.exit(httpApp), (exit) => {
157 if (loggerDisabledRequests.has(request.source)) {
158 return exit
159 } else if (exit._tag === "Failure") {
160 const [response, cause] = causeResponseStripped(exit.cause)
161 const message = Option.isSome(cause) &&
162 !cause.value.reasons.every((reason) =>
163 reason._tag === "Interrupt" && reason.annotations.has(ClientAbort.key)
164 )
165 ? cause.value
166 : "Sent HTTP response"
167 return Effect.andThen(
168 Effect.annotateLogs(Effect.log(message), {
169 "http.method": request.method,
170 "http.url": path,
171 "http.status": response.status
172 }),
173 exit
174 )
175 }
176 return Effect.andThen(
177 Effect.annotateLogs(Effect.log("Sent HTTP response"), {
178 "http.method": request.method,
179 "http.url": path,
180 "http.status": exit.value.status
181 }),
182 exit
183 )
184 }),
185 "http.span"
186 )
187 })
188)
189
190/**
191 * @internal
192 */
193export const isTracerDisabledUnsafe = (
194 fiber: Fiber.Fiber<unknown, unknown>,
195 request: HttpServerRequest
196): boolean => !fiber.cache.tracerEnabled || fiber.getRef(TracerDisabledWhen)(request)
197
198/**
199 * Middleware that creates a server trace span for each request and records request and response HTTP attributes.
200 *
201 * @stability unstable
202 * @category tracing
203 * @since 4.0.0
204 */
205export const tracer: <E, R>(
206 httpApp: Effect.Effect<HttpServerResponse, E, HttpServerRequest | R>
207) => Effect.Effect<HttpServerResponse, E, HttpServerRequest | R> = make((httpApp) =>
208 Effect.withFiber((fiber) => {
209 const request = Context.getUnsafe(fiber.context, HttpServerRequest)
210 if (isTracerDisabledUnsafe(fiber, request)) {
211 return httpApp
212 }
213 const nameGenerator = fiber.getRef(SpanNameGenerator)
214 const span = internalEffect.makeSpanUnsafe(fiber, nameGenerator(request), {
215 parent: Option.getOrUndefined(TraceContext.fromHeaders(request.headers)),
216 kind: "server"
217 })
218 const prevServices = fiber.context
219 fiber.setContext(Context.add(fiber.context, ParentSpan, span))
220 internalEffect.onExitUnsafe<HttpServerResponse, unknown>(fiber, (exit) => {
221 fiber.setContext(prevServices)
222 const endTime = fiber.getRef(Clock).currentTimeNanosUnsafe()
223 if (Exit.isSuccess(exit) && (!span.sampled || fiber.getRef(Tracer) === nativeTracer)) {
224 span.end(endTime, exit)
225 return undefined
226 }
227 const redactedHeaderNames = fiber.getRef(Headers.CurrentRedactedNames)
228 fiber.currentDispatcher.scheduleTask(() => {
229 let response: HttpServerResponse
230 let spanExit = exit
231 if (Exit.isFailure(exit)) {
232 const [failureResponse, cause] = causeResponseStripped(exit.cause)
233 response = failureResponse
234 spanExit = Option.isSome(cause) ? Exit.failCause(cause.value) : Exit.succeed(response)
235 } else {
236 response = exit.value
237 }
238 if (span.sampled) {
239 span.attribute("http.request.method", request.method)
240 if (request.url.startsWith("/")) {
241 const host = request.headers.host ?? "localhost"
242 const protocol = request.headers["x-forwarded-proto"] === "https" ? "https" : "http"
243 span.attribute("url.full", `${protocol}://${host}${request.url}`)
244 const queryIndex = request.url.indexOf("?")
245 if (queryIndex === -1) {
246 span.attribute("url.path", request.url)
247 } else {
248 span.attribute("url.path", request.url.slice(0, queryIndex))
249 if (queryIndex < request.url.length - 1) {
250 span.attribute("url.query", request.url.slice(queryIndex + 1))
251 }
252 }
253 span.attribute("url.scheme", protocol)
254 } else {
255 const url = Request.toURL(request)
256 if (Option.isSome(url)) {
257 if (url.value.username !== "" || url.value.password !== "") {
258 url.value.username = "REDACTED"
259 url.value.password = "REDACTED"
260 }
261 span.attribute("url.full", url.value.toString())
262 span.attribute("url.path", url.value.pathname)
263 const query = url.value.search.slice(1)
264 if (query !== "") {
265 span.attribute("url.query", query)
266 }
267 span.attribute("url.scheme", url.value.protocol.slice(0, -1))
268 }
269 }
270 if (request.headers["user-agent"] !== undefined) {
271 span.attribute("user_agent.original", request.headers["user-agent"])
272 }
273 for (const name in request.headers) {
274 span.attribute(
275 `http.request.header.${name}`,
276 Headers.isRedactedName(name, redactedHeaderNames) ? "<redacted>" : request.headers[name]
277 )
278 }
279 if (Option.isSome(request.remoteAddress)) {
280 span.attribute("client.address", request.remoteAddress.value)
281 }
282 span.attribute("http.response.status_code", response.status)
283 for (const name in response.headers) {
284 span.attribute(
285 `http.response.header.${name}`,
286 Headers.isRedactedName(name, redactedHeaderNames) ? "<redacted>" : response.headers[name]
287 )
288 }
289 }
290 span.end(endTime, spanExit)
291 }, 0)
292 return undefined
293 })
294 return httpApp
295 })
296)
297
298/**
299 * Middleware that trusts `X-Forwarded-Host` and `X-Forwarded-For`, updating the request host header and remote address.
300 *
301 * @stability unstable
302 * @category proxying
303 * @since 4.0.0
304 */
305export const xForwardedHeaders = make((httpApp) =>
306 Effect.updateService(httpApp, HttpServerRequest, (request) =>
307 request.headers["x-forwarded-host"]
308 ? request.modify({
309 headers: Headers.set(
310 request.headers,
311 "host",
312 request.headers["x-forwarded-host"]
313 ),
314 remoteAddress: Option.fromNullishOr(request.headers["x-forwarded-for"]?.split(",")[0].trim())
315 })
316 : request)
317)
318
319/**
320 * Middleware that parses the current request URL's search parameters and provides them as `ParsedSearchParams`.
321 *
322 * @stability unstable
323 * @category parsing
324 * @since 4.0.0
325 */
326export const searchParamsParser = <E, R>(
327 httpApp: Effect.Effect<HttpServerResponse, E, R>
328): Effect.Effect<Response.HttpServerResponse, E, HttpServerRequest | Exclude<R, Request.ParsedSearchParams>> =>
329 Effect.withFiber((fiber) => {
330 const services = fiber.context
331 const request = Context.getUnsafe(services, HttpServerRequest)
332 const params = Request.searchParamsFromURL(new URL(request.originalUrl))
333 return Effect.provideService(
334 httpApp,
335 Request.ParsedSearchParams,
336 params
337 ) as any
338 })
339
340/**
341 * Middleware that handles CORS preflight requests and adds configured CORS headers to HTTP responses.
342 *
343 * @stability unstable
344 * @category middleware
345 * @since 4.0.0
346 */
347export const cors = (options?: {
348 readonly allowedOrigins?: ReadonlyArray<string> | Predicate<string> | undefined
349 readonly allowedMethods?: ReadonlyArray<string> | undefined
350 readonly allowedHeaders?: ReadonlyArray<string> | undefined
351 readonly exposedHeaders?: ReadonlyArray<string> | undefined
352 readonly maxAge?: number | undefined
353 readonly credentials?: boolean | undefined
354}): <E, R>(
355 httpApp: Effect.Effect<HttpServerResponse, E, R>
356) => Effect.Effect<HttpServerResponse, E, R | HttpServerRequest> => {
357 const opts = {
358 allowedOrigins: options?.allowedOrigins ?? [],
359 allowedMethods: options?.allowedMethods ?? ["GET", "HEAD", "PUT", "PATCH", "POST", "DELETE"],
360 allowedHeaders: options?.allowedHeaders ?? [],
361 exposedHeaders: options?.exposedHeaders ?? [],
362 credentials: options?.credentials ?? false,
363 maxAge: options?.maxAge
364 }
365
366 const isAllowedOrigin = typeof opts.allowedOrigins === "function"
367 ? opts.allowedOrigins
368 : (origin: string) => (opts.allowedOrigins as ReadonlyArray<string>).includes(origin)
369
370 const allowOrigin = typeof opts.allowedOrigins === "function" || opts.allowedOrigins.length > 1
371 ? ((originHeader: string): ReadonlyRecord<string, string> => {
372 if (!isAllowedOrigin(originHeader)) return { vary: "Origin" }
373 return {
374 "access-control-allow-origin": originHeader,
375 vary: "Origin"
376 }
377 })
378 : opts.allowedOrigins.length === 0
379 ? constant({
380 "access-control-allow-origin": "*"
381 })
382 : constant({
383 "access-control-allow-origin": opts.allowedOrigins[0],
384 vary: "Origin"
385 })
386
387 const allowMethods = opts.allowedMethods.length > 0
388 ? { "access-control-allow-methods": opts.allowedMethods.join(", ") }
389 : undefined
390
391 const allowCredentials = opts.credentials
392 ? { "access-control-allow-credentials": "true" }
393 : undefined
394
395 const allowHeaders = (
396 accessControlRequestHeaders: string | undefined
397 ): ReadonlyRecord<string, string> | undefined => {
398 if (opts.allowedHeaders.length === 0 && accessControlRequestHeaders) {
399 return {
400 vary: "Access-Control-Request-Headers",
401 "access-control-allow-headers": accessControlRequestHeaders
402 }
403 }
404
405 if (opts.allowedHeaders) {
406 return {
407 "access-control-allow-headers": opts.allowedHeaders.join(",")
408 }
409 }
410
411 return undefined
412 }
413
414 const exposeHeaders = opts.exposedHeaders.length > 0
415 ? { "access-control-expose-headers": opts.exposedHeaders.join(",") }
416 : undefined
417
418 const maxAge = opts.maxAge
419 ? { "access-control-max-age": opts.maxAge.toString() }
420 : undefined
421
422 const headersFromRequest = (request: HttpServerRequest) => {
423 const origin = request.headers["origin"]
424 return Headers.fromRecordUnsafe({
425 ...allowOrigin(origin),
426 ...allowCredentials,
427 ...exposeHeaders
428 })
429 }
430
431 const headersFromRequestOptions = (request: HttpServerRequest) => {
432 const origin = request.headers["origin"]
433 const accessControlRequestHeaders = request.headers["access-control-request-headers"]
434 const headers = Headers.fromRecordUnsafe({
435 ...allowOrigin(origin),
436 ...allowCredentials,
437 ...exposeHeaders,
438 ...allowMethods,
439 ...maxAge
440 })
441 const accessControlHeaders = allowHeaders(accessControlRequestHeaders)
442 if (accessControlHeaders === undefined) return headers
443 const vary = accessControlHeaders["vary"]
444 return Headers.setAll(
445 headers,
446 vary === undefined
447 ? accessControlHeaders
448 : {
449 ...accessControlHeaders,
450 vary: compressionInternal.varyWith(headers, vary)
451 }
452 )
453 }
454
455 const preResponseHandler = (request: HttpServerRequest, response: HttpServerResponse) => {
456 const headers = headersFromRequest(request)
457 return Effect.succeed(Response.setHeaders(
458 response,
459 headers["vary"] === undefined
460 ? headers
461 : Headers.set(headers, "vary", compressionInternal.varyWith(response.headers, "Origin"))
462 ))
463 }
464
465 return <E, R>(
466 httpApp: Effect.Effect<HttpServerResponse, E, R>
467 ): Effect.Effect<HttpServerResponse, E, R | HttpServerRequest> =>
468 Effect.withFiber((fiber) => {
469 const request = Context.getUnsafe(fiber.context, HttpServerRequest)
470 if (request.method === "OPTIONS") {
471 return Effect.succeed(Response.empty({
472 status: 204,
473 headers: headersFromRequestOptions(request)
474 }))
475 }
476 appendPreResponseHandlerUnsafe(request, preResponseHandler)
477 return httpApp
478 })
479}
480
481/**
482 * Middleware that compresses HTTP response bodies based on the request's
483 * `Accept-Encoding` header.
484 *
485 * **Details**
486 *
487 * Content negotiation follows RFC 9110: the first algorithm in server
488 * preference order that the client accepts with a positive q-value and the
489 * platform supports is used. When no algorithm is acceptable the response is
490 * sent uncompressed.
491 *
492 * The body transform is performed by the `HttpPlatform` service.
493 *
494 * Responses are skipped when the status is 1xx, 204, 206, or 304, when a
495 * `Content-Encoding` is already present, when `Cache-Control: no-transform`
496 * is set, when the content type is absent or not compressible, when a known
497 * body length is below `minSize`, or when the body is empty or `FormData`. A
498 * response carrying `Content-Encoding: identity` opts out of compression and
499 * has the header stripped before sending.
500 *
501 * `Vary: Accept-Encoding` is set on every response that was eligible by
502 * status and content type, including ones skipped by negotiation or
503 * `minSize`.
504 *
505 * Do not combine this middleware with Deno's automatic response compression.
506 * On edge runtimes that already apply automatic compression, the middleware
507 * is unnecessary. Platforms backed by Web `CompressionStream` also cannot
508 * explicitly flush each input chunk, so incremental delivery depends on the
509 * runtime's implementation.
510 *
511 * **Gotchas**
512 *
513 * Compression can expose secrets through BREACH-style attacks when one
514 * response contains both secret data and attacker-controlled input and an
515 * attacker can observe the compressed response length. For affected routes,
516 * disable compression with `Content-Encoding: identity` or
517 * `Cache-Control: no-transform`, or use `compressible` to restrict which
518 * response content types can be compressed.
519 *
520 * @stability unstable
521 * @category compression
522 * @since 4.0.0
523 */
524export const compression = (
525 options?: {
526 /**
527 * Server preference order. Negotiation picks the first accepted algorithm
528 * supported by the platform. Defaults to `["br", "gzip", "deflate"]`;
529 * `zstd` must be explicitly opted into.
530 */
531 readonly algorithms?: ReadonlyArray<CompressionAlgorithm> | undefined
532 /**
533 * Minimum body size in bytes when the length is known. Unknown-length bodies
534 * are always compressed. Defaults to `1024`.
535 */
536 readonly minSize?: number | undefined
537 /** Replaces the default content-type predicate. */
538 readonly compressible?: ((contentType: string) => boolean) | undefined
539 /** Per-algorithm levels. Platforms without a level knob ignore them. */
540 readonly levels?: {
541 readonly gzip?: number | undefined
542 readonly deflate?: number | undefined
543 readonly br?: number | undefined
544 readonly zstd?: number | undefined
545 } | undefined
546 } | undefined
547): <E, R>(
548 httpApp: Effect.Effect<HttpServerResponse, E, R>
549) => Effect.Effect<HttpServerResponse, E, R | HttpServerRequest | HttpPlatform> => {
550 const preferred = options?.algorithms ?? defaultAlgorithms
551 const minSize = options?.minSize ?? 1024
552 const compressible = options?.compressible ?? compressionInternal.defaultCompressible
553 const levels = { ...defaultLevels, ...options?.levels }
554 const levelOptions: Record<CompressionAlgorithm, { readonly level: number | undefined }> = {
555 gzip: { level: levels.gzip },
556 deflate: { level: levels.deflate },
557 br: { level: levels.br },
558 zstd: { level: levels.zstd }
559 }
560 const transform = (
561 compression: HttpPlatform["Service"]["compression"],
562 acceptEncoding: string | undefined,
563 response: HttpServerResponse
564 ): Effect.Effect<HttpServerResponse> => {
565 if (
566 response.status < 200 ||
567 response.status === 204 ||
568 response.status === 206 ||
569 response.status === 304
570 ) {
571 return Effect.succeed(response)
572 }
573 const currentEncoding = response.headers["content-encoding"]
574 if (currentEncoding !== undefined) {
575 return Effect.succeed(
576 currentEncoding.trim().toLowerCase() === "identity"
577 ? Response.removeHeader(response, "content-encoding")
578 : response
579 )
580 }
581 const body = response.body
582 if (body._tag === "Empty" || body._tag === "FormData") {
583 return Effect.succeed(response)
584 }
585 const cacheControl = response.headers["cache-control"]
586 if (cacheControl !== undefined && noTransformRegex.test(cacheControl)) {
587 return Effect.succeed(response)
588 }
589 const contentType = response.headers["content-type"] ?? body.contentType
590 if (contentType === undefined || !compressible(contentType)) {
591 return Effect.succeed(response)
592 }
593 const algorithm = compressionInternal.negotiate(acceptEncoding, preferred, compression.algorithms)
594 if (algorithm === undefined) {
595 return Effect.succeed(withVary(response))
596 }
597 const contentLength = body.contentLength ?? bodyInternal.parseContentLength(response.headers["content-length"])
598 if (contentLength !== undefined && contentLength < minSize) {
599 return Effect.succeed(withVary(response))
600 }
601 return compression.compressResponse(response, algorithm, levelOptions[algorithm])
602 }
603 return <E, R>(
604 httpApp: Effect.Effect<HttpServerResponse, E, R>
605 ): Effect.Effect<HttpServerResponse, E, R | HttpServerRequest | HttpPlatform> =>
606 Effect.withFiber((fiber) => {
607 const request = Context.getUnsafe(fiber.context, HttpServerRequest)
608 const compression = Context.getUnsafe(fiber.context, HttpPlatform).compression
609 appendPreResponseHandlerUnsafe(request, (request, response) =>
610 transform(compression, request.headers["accept-encoding"], response))
611 return httpApp
612 })
613}
614
615const withVary = (response: HttpServerResponse): HttpServerResponse => {
616 const vary = compressionInternal.varyWith(response.headers, "Accept-Encoding")
617 return Response.setHeader(response, "vary", vary)
618}
619
620const defaultAlgorithms: ReadonlyArray<CompressionAlgorithm> = ["br", "gzip", "deflate"]
621
622const defaultLevels = {
623 gzip: 6,
624 deflate: 6,
625 br: 4,
626 zstd: 3
627} as const
628
629const noTransformRegex = /(?:^|[\s,])no-transform(?:$|[\s,;])/i