diff --git a/.changeset/x-forwarded-headers.md b/.changeset/x-forwarded-headers.md new file mode 100644 index 000000000..6914aa9b7 --- /dev/null +++ b/.changeset/x-forwarded-headers.md @@ -0,0 +1,7 @@ +--- +"effect-app": patch +--- + +Re-export `HttpMiddleware` from `effect-app/http` with `xForwardedHeaders` keeping the app's error and `HttpServerRequest` requirement. + +Effect 4.0.6 types that factory as `HttpMiddleware`, so the result is `Effect`. Import `HttpMiddleware` from `effect-app/http`. The copy from `effect/http` is unchanged. diff --git a/packages/effect-app/src/http/HttpMiddleware.ts b/packages/effect-app/src/http/HttpMiddleware.ts new file mode 100644 index 000000000..897349143 --- /dev/null +++ b/packages/effect-app/src/http/HttpMiddleware.ts @@ -0,0 +1,21 @@ +import type { Effect } from "effect/Effect" +import { type TrustedProxyOptions, xForwardedHeaders as xForwardedHeadersUpstream } from "effect/http/HttpMiddleware" +import type { HttpServerRequest } from "effect/http/HttpServerRequest" +import type { HttpServerResponse } from "effect/http/HttpServerResponse" + +export type { HttpMiddleware, TrustedProxyOptions } from "effect/http/HttpMiddleware" + +export { compression, cors, decompression, layerTracerDisabledForUrls, logger, make, searchParamsParser, SpanNameGenerator, tracer, TracerDisabledWhen, TracerHeaderFilter, withLoggerDisabled } from "effect/http/HttpMiddleware" + +type ForwardedApp = Effect + +// Effect 4.0.6 types this factory as `HttpMiddleware`, whose call signature +// returns `Effect`. The middleware only updates +// the current `HttpServerRequest`, so the app's error and requirements stay. +export const xForwardedHeaders: ( + options: TrustedProxyOptions +) => ( + httpApp: Effect +) => Effect = + (options) => (httpApp: ForwardedApp): ForwardedApp => + xForwardedHeadersUpstream(options)(httpApp) as ForwardedApp diff --git a/packages/effect-app/src/http/internal/lib.ts b/packages/effect-app/src/http/internal/lib.ts index 6c035c281..c6e1311d7 100644 --- a/packages/effect-app/src/http/internal/lib.ts +++ b/packages/effect-app/src/http/internal/lib.ts @@ -5,9 +5,9 @@ export * as HttpClientError from "effect/http/HttpClientError" export * as HttpClientRequest from "effect/http/HttpClientRequest" export * as HttpClientResponse from "effect/http/HttpClientResponse" // HttpLayerRouter removed in v4 — use HttpRouter instead -export * as HttpMiddleware from "effect/http/HttpMiddleware" export * as HttpRouter from "effect/http/HttpRouter" export * as HttpServer from "effect/http/HttpServer" export * as HttpServerError from "effect/http/HttpServerError" export * as HttpServerRequest from "effect/http/HttpServerRequest" export * as HttpServerResponse from "effect/http/HttpServerResponse" +export * as HttpMiddleware from "../HttpMiddleware.ts" diff --git a/packages/effect-app/test/x-forwarded-headers.test.ts b/packages/effect-app/test/x-forwarded-headers.test.ts new file mode 100644 index 000000000..58d620ab1 --- /dev/null +++ b/packages/effect-app/test/x-forwarded-headers.test.ts @@ -0,0 +1,64 @@ +import * as Context from "effect/Context" +import * as Effect from "effect/Effect" +import type * as Upstream from "effect/http/HttpMiddleware" +import { IpNetwork } from "effect/net" +import * as Option from "effect/Option" +import { expect, expectTypeOf, test } from "vitest" +import { HttpMiddleware, HttpRouter, HttpServerRequest, HttpServerResponse } from "../src/http.js" + +class Svc extends Context.Service()("Svc") {} + +const trustedProxies = [IpNetwork.fromStringUnsafe("10.0.0.0/8")] + +const addressText = (request: HttpServerRequest.HttpServerRequest) => + Option.getOrElse(request.remoteAddress, () => "unknown") + +test("xForwardedHeaders keeps the app error and HttpServerRequest", () => { + const app = HttpServerRequest + .HttpServerRequest + .use((request) => Effect.succeed(HttpServerResponse.text(addressText(request)))) + .pipe( + HttpMiddleware.xForwardedHeaders({ trustedProxies }) + ) + + expectTypeOf(app).toEqualTypeOf< + Effect.Effect + >() + + const rich: Effect.Effect< + HttpServerResponse.HttpServerResponse, + "Boom", + HttpServerRequest.HttpServerRequest | Svc + > = Effect.fail("Boom") + const wrapped = rich.pipe(HttpMiddleware.xForwardedHeaders({ trustedProxies })) + + expectTypeOf(wrapped).toEqualTypeOf< + Effect.Effect< + HttpServerResponse.HttpServerResponse, + "Boom", + HttpServerRequest.HttpServerRequest | Svc + > + >() + + const layer = HttpRouter.middleware(HttpMiddleware.xForwardedHeaders({ trustedProxies })) + const corsLayer = HttpRouter.middleware(HttpMiddleware.cors()) + expectTypeOf(layer).toEqualTypeOf(corsLayer) + + expectTypeOf().toEqualTypeOf() +}) + +test("xForwardedHeaders still applies a trusted proxy hop", async () => { + const app = HttpServerRequest + .HttpServerRequest + .use((request) => Effect.succeed(HttpServerResponse.text(addressText(request)))) + .pipe( + HttpMiddleware.xForwardedHeaders({ trustedProxies }) + ) + const request = HttpServerRequest + .fromWeb( + new Request("http://app.test/", { headers: { "x-forwarded-for": "6.6.6.6, 198.51.100.7" } }) + ) + .modify({ remoteAddress: Option.some("10.0.0.1") }) + const response = await Effect.runPromise(Effect.provideService(app, HttpServerRequest.HttpServerRequest, request)) + expect(await HttpServerResponse.toWeb(response).text()).toBe("198.51.100.7") +})