Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/whole-bags-know.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@effect/platform": patch
---

add HttpApp.fromWebHandler
26 changes: 26 additions & 0 deletions packages/platform-node/test/HttpServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
})
})
35 changes: 35 additions & 0 deletions packages/platform/src/HttpApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -318,3 +318,38 @@ export const toWebHandlerLayer = <E, R, RE>(
...options,
toHandler: () => Effect.succeed(self)
})

/**
* @since 1.0.0
* @category conversions
*/
export const fromWebHandler = (
handler: (request: Request) => Promise<Response>
): Default<ServerError.HttpServerError> =>
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"
})
))
)
})
49 changes: 40 additions & 9 deletions packages/platform/src/HttpServerRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -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<never> | undefined
}): Either.Either<Request, Error.RequestError> => {
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<Request, Error.RequestError> =>
Effect.flatMap(Effect.runtime<never>(), (runtime) =>
toWebEither(self, {
signal: options?.signal,
runtime
}))

/**
* @since 1.0.0
* @category conversions
Expand Down
2 changes: 1 addition & 1 deletion packages/platform/src/HttpServerResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
88 changes: 88 additions & 0 deletions packages/platform/test/HttpApp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" })
})
})
})