diff --git a/.changeset/whole-bags-know.md b/.changeset/whole-bags-know.md new file mode 100644 index 00000000000..20ce42c54a5 --- /dev/null +++ b/.changeset/whole-bags-know.md @@ -0,0 +1,5 @@ +--- +"@effect/platform": patch +--- + +add HttpApp.fromWebHandler diff --git a/packages/platform-node/test/HttpServer.test.ts b/packages/platform-node/test/HttpServer.test.ts index 72a336bd584..fa6724f90ad 100644 --- a/packages/platform-node/test/HttpServer.test.ts +++ b/packages/platform-node/test/HttpServer.test.ts @@ -755,4 +755,30 @@ describe("HttpServer", () => { ) expect(root).toEqual("root") }).pipe(Effect.provide(NodeHttpServer.layerTest))) + + describe("HttpServerRequest.toWeb", () => { + it.scoped("converts POST request with body", () => + Effect.gen(function*() { + yield* HttpRouter.empty.pipe( + HttpRouter.post( + "/echo", + Effect.gen(function*() { + const request = yield* HttpServerRequest.HttpServerRequest + const webRequest = yield* HttpServerRequest.toWeb(request) + assert(webRequest !== undefined, "toWeb returned undefined") + const body = yield* Effect.promise(() => webRequest.json()) + return HttpServerResponse.unsafeJson({ received: body }) + }) + ), + HttpServer.serveEffect() + ) + const client = yield* HttpClient.HttpClient + const res = yield* client.post("/echo", { + body: HttpBody.unsafeJson({ message: "hello" }) + }) + assert.strictEqual(res.status, 200) + const json = yield* res.json + assert.deepStrictEqual(json, { received: { message: "hello" } }) + }).pipe(Effect.provide(NodeHttpServer.layerTest))) + }) }) diff --git a/packages/platform/src/HttpApp.ts b/packages/platform/src/HttpApp.ts index eec4158f59d..31d44c88c9e 100644 --- a/packages/platform/src/HttpApp.ts +++ b/packages/platform/src/HttpApp.ts @@ -318,3 +318,38 @@ export const toWebHandlerLayer = ( ...options, toHandler: () => Effect.succeed(self) }) + +/** + * @since 1.0.0 + * @category conversions + */ +export const fromWebHandler = ( + handler: (request: Request) => Promise +): Default => + Effect.async((resume, signal) => { + const fiber = Option.getOrThrow(Fiber.getCurrentFiber()) + const request = Context.unsafeGet(fiber.currentContext, ServerRequest.HttpServerRequest) + const requestResult = ServerRequest.toWebEither(request, { + signal, + runtime: Runtime.make({ + context: fiber.currentContext, + fiberRefs: fiber.getFiberRefs(), + runtimeFlags: Runtime.defaultRuntimeFlags + }) + }) + if (requestResult._tag === "Left") { + return resume(Effect.fail(requestResult.left)) + } + handler(requestResult.right).then( + (response) => resume(Effect.succeed(ServerResponse.fromWeb(response))), + (cause) => + resume(Effect.fail( + new ServerError.RequestError({ + cause, + request, + reason: "Transport", + description: "HttpApp.fromWebHandler: Error in handler" + }) + )) + ) + }) diff --git a/packages/platform/src/HttpServerRequest.ts b/packages/platform/src/HttpServerRequest.ts index 29e2d4a5695..ca39ccc6a6b 100644 --- a/packages/platform/src/HttpServerRequest.ts +++ b/packages/platform/src/HttpServerRequest.ts @@ -4,10 +4,12 @@ import type { Channel } from "effect/Channel" import type { Chunk } from "effect/Chunk" import type * as Context from "effect/Context" -import type * as Effect from "effect/Effect" +import * as Effect from "effect/Effect" +import * as Either from "effect/Either" import * as Option from "effect/Option" import type * as ParseResult from "effect/ParseResult" import type { ReadonlyRecord } from "effect/Record" +import * as Runtime from "effect/Runtime" import type * as Schema from "effect/Schema" import type { ParseOptions } from "effect/SchemaAST" import type * as Scope from "effect/Scope" @@ -16,7 +18,7 @@ import type * as FileSystem from "./FileSystem.js" import type * as Headers from "./Headers.js" import type * as IncomingMessage from "./HttpIncomingMessage.js" import { hasBody, type HttpMethod } from "./HttpMethod.js" -import type * as Error from "./HttpServerError.js" +import * as Error from "./HttpServerError.js" import * as internal from "./internal/httpServerRequest.js" import type * as Multipart from "./Multipart.js" import type * as Path from "./Path.js" @@ -235,19 +237,48 @@ export const fromWeb: (request: Request) => HttpServerRequest = internal.fromWeb * @since 1.0.0 * @category conversions */ -export const toWeb = (self: HttpServerRequest): Request | undefined => { +export const toWebEither = (self: HttpServerRequest, options?: { + readonly signal?: AbortSignal | undefined + readonly runtime?: Runtime.Runtime | undefined +}): Either.Either => { if (self.source instanceof Request) { - return self.source + return Either.right(self.source) } const ourl = toURL(self) - if (Option.isNone(ourl)) return undefined - return new Request(ourl.value, { + if (Option.isNone(ourl)) { + return Either.left( + new Error.RequestError({ + request: self, + reason: "Decode", + description: "Invalid URL" + }) + ) + } + const requestInit: RequestInit = { method: self.method, - body: hasBody(self.method) ? Stream.toReadableStream(self.stream) : undefined, - headers: self.headers - }) + headers: self.headers, + signal: options?.signal + } + if (hasBody(self.method)) { + requestInit.body = Stream.toReadableStreamRuntime(self.stream, options?.runtime ?? Runtime.defaultRuntime) + ;(requestInit as any).duplex = "half" + } + return Either.right(new Request(ourl.value, requestInit)) } +/** + * @since 1.0.0 + * @category conversions + */ +export const toWeb = (self: HttpServerRequest, options?: { + readonly signal?: AbortSignal | undefined +}): Effect.Effect => + Effect.flatMap(Effect.runtime(), (runtime) => + toWebEither(self, { + signal: options?.signal, + runtime + })) + /** * @since 1.0.0 * @category conversions diff --git a/packages/platform/src/HttpServerResponse.ts b/packages/platform/src/HttpServerResponse.ts index 42164522003..cd3a5d8e7d9 100644 --- a/packages/platform/src/HttpServerResponse.ts +++ b/packages/platform/src/HttpServerResponse.ts @@ -400,7 +400,7 @@ export const toWeb: ( * @category conversions */ export const fromWeb = (response: Response): HttpServerResponse => { - const headers = response.headers + const headers = new globalThis.Headers(response.headers) const setCookieHeaders = headers.getSetCookie() headers.delete("set-cookie") let self = empty({ 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" }) + }) + }) })