diff --git a/.changeset/friendly-wasps-hunt.md b/.changeset/friendly-wasps-hunt.md new file mode 100644 index 00000000000..4c90b4e57a0 --- /dev/null +++ b/.changeset/friendly-wasps-hunt.md @@ -0,0 +1,5 @@ +--- +"@effect/platform": patch +--- + +add HttpApp.fromWebHandler for wrapping web handlers diff --git a/packages/platform/src/HttpApp.ts b/packages/platform/src/HttpApp.ts index eec4158f59d..a51c452cded 100644 --- a/packages/platform/src/HttpApp.ts +++ b/packages/platform/src/HttpApp.ts @@ -318,3 +318,42 @@ export const toWebHandlerLayer = ( ...options, toHandler: () => Effect.succeed(self) }) + +/** + * Converts a web handler function into an Effect `HttpApp`. + * + * This is the inverse of `toWebHandler` - it wraps a standard web handler + * `(Request) => Promise` so it can be used within an Effect HTTP application. + * + * @example + * ```ts + * import { HttpApp } from "@effect/platform" + * + * // Wrap any web handler (e.g., BetterAuth, Hono) + * const WebApp = HttpApp.fromWebHandler(async (request) => { + * return new Response("Hello from web handler!") + * }) + * ``` + * + * @since 1.0.0 + * @category conversions + */ +export const fromWebHandler = ( + handler: (request: Request) => Promise +): Default => + Effect.flatMap(ServerRequest.HttpServerRequest, (request) => { + const webRequest = ServerRequest.toWeb(request) + if (webRequest === undefined) { + return Effect.die( + new Error( + "HttpApp.fromWebHandler: Unable to convert request to web Request. " + + "This typically means the request came from a Node.js server with a malformed URL. " + + "fromWebHandler is designed for web-to-web request conversion." + ) + ) + } + return Effect.map( + Effect.promise(() => handler(webRequest)), + ServerResponse.fromWeb + ) + }) diff --git a/packages/platform/test/HttpApp.test.ts b/packages/platform/test/HttpApp.test.ts index a70e334a125..7c6602e7c4c 100644 --- a/packages/platform/test/HttpApp.test.ts +++ b/packages/platform/test/HttpApp.test.ts @@ -99,4 +99,92 @@ describe("Http/App", () => { foo: "baz" }) }) + + describe("fromWebHandler", () => { + test("basic GET request", async () => { + const webHandler = async (request: Request) => { + return new Response(`Hello from ${request.url}`, { + status: 200, + headers: { "Content-Type": "text/plain" } + }) + } + const app = HttpApp.fromWebHandler(webHandler) + const handler = HttpApp.toWebHandler(app) + const response = await handler(new Request("http://localhost:3000/hello")) + strictEqual(response.status, 200) + strictEqual(await response.text(), "Hello from http://localhost:3000/hello") + }) + + test("POST with JSON body", async () => { + const webHandler = async (request: Request) => { + const body = await request.json() + return Response.json({ received: body }) + } + const app = HttpApp.fromWebHandler(webHandler) + const handler = HttpApp.toWebHandler(app) + const response = await handler( + new Request("http://localhost:3000/", { + method: "POST", + body: JSON.stringify({ message: "hello" }), + headers: { "Content-Type": "application/json" } + }) + ) + deepStrictEqual(await response.json(), { + received: { message: "hello" } + }) + }) + + test("preserves request headers", async () => { + const webHandler = async (request: Request) => { + return Response.json({ + authorization: request.headers.get("Authorization"), + custom: request.headers.get("X-Custom-Header") + }) + } + const app = HttpApp.fromWebHandler(webHandler) + const handler = HttpApp.toWebHandler(app) + const response = await handler( + new Request("http://localhost:3000/", { + headers: { + "Authorization": "Bearer token123", + "X-Custom-Header": "custom-value" + } + }) + ) + deepStrictEqual(await response.json(), { + authorization: "Bearer token123", + custom: "custom-value" + }) + }) + + test("preserves response status and headers", async () => { + const webHandler = async (_request: Request) => { + return new Response("Not Found", { + status: 404, + statusText: "Not Found", + headers: { + "X-Error-Code": "RESOURCE_NOT_FOUND", + "Content-Type": "text/plain" + } + }) + } + const app = HttpApp.fromWebHandler(webHandler) + const handler = HttpApp.toWebHandler(app) + const response = await handler(new Request("http://localhost:3000/missing")) + strictEqual(response.status, 404) + strictEqual(response.headers.get("X-Error-Code"), "RESOURCE_NOT_FOUND") + strictEqual(await response.text(), "Not Found") + }) + + test("round-trip with toWebHandler", async () => { + // Create an Effect app, convert to web handler, then back to Effect app + const originalApp = HttpServerResponse.json({ source: "effect" }) + const webHandler = HttpApp.toWebHandler(originalApp) + const wrappedApp = HttpApp.fromWebHandler(webHandler) + const finalHandler = HttpApp.toWebHandler(wrappedApp) + + const response = await finalHandler(new Request("http://localhost:3000/")) + deepStrictEqual(await response.json(), { source: "effect" }) + }) + }) })