Skip to content

Commit a0a84d8

Browse files
authored
add HttpApp.fromWebHandler (#5902)
1 parent a6dfca9 commit a0a84d8

File tree

6 files changed

+195
-10
lines changed

6 files changed

+195
-10
lines changed

.changeset/whole-bags-know.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@effect/platform": patch
3+
---
4+
5+
add HttpApp.fromWebHandler

packages/platform-node/test/HttpServer.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -755,4 +755,30 @@ describe("HttpServer", () => {
755755
)
756756
expect(root).toEqual("root")
757757
}).pipe(Effect.provide(NodeHttpServer.layerTest)))
758+
759+
describe("HttpServerRequest.toWeb", () => {
760+
it.scoped("converts POST request with body", () =>
761+
Effect.gen(function*() {
762+
yield* HttpRouter.empty.pipe(
763+
HttpRouter.post(
764+
"/echo",
765+
Effect.gen(function*() {
766+
const request = yield* HttpServerRequest.HttpServerRequest
767+
const webRequest = yield* HttpServerRequest.toWeb(request)
768+
assert(webRequest !== undefined, "toWeb returned undefined")
769+
const body = yield* Effect.promise(() => webRequest.json())
770+
return HttpServerResponse.unsafeJson({ received: body })
771+
})
772+
),
773+
HttpServer.serveEffect()
774+
)
775+
const client = yield* HttpClient.HttpClient
776+
const res = yield* client.post("/echo", {
777+
body: HttpBody.unsafeJson({ message: "hello" })
778+
})
779+
assert.strictEqual(res.status, 200)
780+
const json = yield* res.json
781+
assert.deepStrictEqual(json, { received: { message: "hello" } })
782+
}).pipe(Effect.provide(NodeHttpServer.layerTest)))
783+
})
758784
})

packages/platform/src/HttpApp.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,3 +318,38 @@ export const toWebHandlerLayer = <E, R, RE>(
318318
...options,
319319
toHandler: () => Effect.succeed(self)
320320
})
321+
322+
/**
323+
* @since 1.0.0
324+
* @category conversions
325+
*/
326+
export const fromWebHandler = (
327+
handler: (request: Request) => Promise<Response>
328+
): Default<ServerError.HttpServerError> =>
329+
Effect.async((resume, signal) => {
330+
const fiber = Option.getOrThrow(Fiber.getCurrentFiber())
331+
const request = Context.unsafeGet(fiber.currentContext, ServerRequest.HttpServerRequest)
332+
const requestResult = ServerRequest.toWebEither(request, {
333+
signal,
334+
runtime: Runtime.make({
335+
context: fiber.currentContext,
336+
fiberRefs: fiber.getFiberRefs(),
337+
runtimeFlags: Runtime.defaultRuntimeFlags
338+
})
339+
})
340+
if (requestResult._tag === "Left") {
341+
return resume(Effect.fail(requestResult.left))
342+
}
343+
handler(requestResult.right).then(
344+
(response) => resume(Effect.succeed(ServerResponse.fromWeb(response))),
345+
(cause) =>
346+
resume(Effect.fail(
347+
new ServerError.RequestError({
348+
cause,
349+
request,
350+
reason: "Transport",
351+
description: "HttpApp.fromWebHandler: Error in handler"
352+
})
353+
))
354+
)
355+
})

packages/platform/src/HttpServerRequest.ts

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@
44
import type { Channel } from "effect/Channel"
55
import type { Chunk } from "effect/Chunk"
66
import type * as Context from "effect/Context"
7-
import type * as Effect from "effect/Effect"
7+
import * as Effect from "effect/Effect"
8+
import * as Either from "effect/Either"
89
import * as Option from "effect/Option"
910
import type * as ParseResult from "effect/ParseResult"
1011
import type { ReadonlyRecord } from "effect/Record"
12+
import * as Runtime from "effect/Runtime"
1113
import type * as Schema from "effect/Schema"
1214
import type { ParseOptions } from "effect/SchemaAST"
1315
import type * as Scope from "effect/Scope"
@@ -16,7 +18,7 @@ import type * as FileSystem from "./FileSystem.js"
1618
import type * as Headers from "./Headers.js"
1719
import type * as IncomingMessage from "./HttpIncomingMessage.js"
1820
import { hasBody, type HttpMethod } from "./HttpMethod.js"
19-
import type * as Error from "./HttpServerError.js"
21+
import * as Error from "./HttpServerError.js"
2022
import * as internal from "./internal/httpServerRequest.js"
2123
import type * as Multipart from "./Multipart.js"
2224
import type * as Path from "./Path.js"
@@ -235,19 +237,48 @@ export const fromWeb: (request: Request) => HttpServerRequest = internal.fromWeb
235237
* @since 1.0.0
236238
* @category conversions
237239
*/
238-
export const toWeb = (self: HttpServerRequest): Request | undefined => {
240+
export const toWebEither = (self: HttpServerRequest, options?: {
241+
readonly signal?: AbortSignal | undefined
242+
readonly runtime?: Runtime.Runtime<never> | undefined
243+
}): Either.Either<Request, Error.RequestError> => {
239244
if (self.source instanceof Request) {
240-
return self.source
245+
return Either.right(self.source)
241246
}
242247
const ourl = toURL(self)
243-
if (Option.isNone(ourl)) return undefined
244-
return new Request(ourl.value, {
248+
if (Option.isNone(ourl)) {
249+
return Either.left(
250+
new Error.RequestError({
251+
request: self,
252+
reason: "Decode",
253+
description: "Invalid URL"
254+
})
255+
)
256+
}
257+
const requestInit: RequestInit = {
245258
method: self.method,
246-
body: hasBody(self.method) ? Stream.toReadableStream(self.stream) : undefined,
247-
headers: self.headers
248-
})
259+
headers: self.headers,
260+
signal: options?.signal
261+
}
262+
if (hasBody(self.method)) {
263+
requestInit.body = Stream.toReadableStreamRuntime(self.stream, options?.runtime ?? Runtime.defaultRuntime)
264+
;(requestInit as any).duplex = "half"
265+
}
266+
return Either.right(new Request(ourl.value, requestInit))
249267
}
250268

269+
/**
270+
* @since 1.0.0
271+
* @category conversions
272+
*/
273+
export const toWeb = (self: HttpServerRequest, options?: {
274+
readonly signal?: AbortSignal | undefined
275+
}): Effect.Effect<Request, Error.RequestError> =>
276+
Effect.flatMap(Effect.runtime<never>(), (runtime) =>
277+
toWebEither(self, {
278+
signal: options?.signal,
279+
runtime
280+
}))
281+
251282
/**
252283
* @since 1.0.0
253284
* @category conversions

packages/platform/src/HttpServerResponse.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -400,7 +400,7 @@ export const toWeb: (
400400
* @category conversions
401401
*/
402402
export const fromWeb = (response: Response): HttpServerResponse => {
403-
const headers = response.headers
403+
const headers = new globalThis.Headers(response.headers)
404404
const setCookieHeaders = headers.getSetCookie()
405405
headers.delete("set-cookie")
406406
let self = empty({

packages/platform/test/HttpApp.test.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,4 +99,92 @@ describe("Http/App", () => {
9999
foo: "baz"
100100
})
101101
})
102+
103+
describe("fromWebHandler", () => {
104+
test("basic GET request", async () => {
105+
const webHandler = async (request: Request) => {
106+
return new Response(`Hello from ${request.url}`, {
107+
status: 200,
108+
headers: { "Content-Type": "text/plain" }
109+
})
110+
}
111+
const app = HttpApp.fromWebHandler(webHandler)
112+
const handler = HttpApp.toWebHandler(app)
113+
const response = await handler(new Request("http://localhost:3000/hello"))
114+
strictEqual(response.status, 200)
115+
strictEqual(await response.text(), "Hello from http://localhost:3000/hello")
116+
})
117+
118+
test("POST with JSON body", async () => {
119+
const webHandler = async (request: Request) => {
120+
const body = await request.json()
121+
return Response.json({ received: body })
122+
}
123+
const app = HttpApp.fromWebHandler(webHandler)
124+
const handler = HttpApp.toWebHandler(app)
125+
const response = await handler(
126+
new Request("http://localhost:3000/", {
127+
method: "POST",
128+
body: JSON.stringify({ message: "hello" }),
129+
headers: { "Content-Type": "application/json" }
130+
})
131+
)
132+
deepStrictEqual(await response.json(), {
133+
received: { message: "hello" }
134+
})
135+
})
136+
137+
test("preserves request headers", async () => {
138+
const webHandler = async (request: Request) => {
139+
return Response.json({
140+
authorization: request.headers.get("Authorization"),
141+
custom: request.headers.get("X-Custom-Header")
142+
})
143+
}
144+
const app = HttpApp.fromWebHandler(webHandler)
145+
const handler = HttpApp.toWebHandler(app)
146+
const response = await handler(
147+
new Request("http://localhost:3000/", {
148+
headers: {
149+
"Authorization": "Bearer token123",
150+
"X-Custom-Header": "custom-value"
151+
}
152+
})
153+
)
154+
deepStrictEqual(await response.json(), {
155+
authorization: "Bearer token123",
156+
custom: "custom-value"
157+
})
158+
})
159+
160+
test("preserves response status and headers", async () => {
161+
const webHandler = async (_request: Request) => {
162+
return new Response("Not Found", {
163+
status: 404,
164+
statusText: "Not Found",
165+
headers: {
166+
"X-Error-Code": "RESOURCE_NOT_FOUND",
167+
"Content-Type": "text/plain"
168+
}
169+
})
170+
}
171+
const app = HttpApp.fromWebHandler(webHandler)
172+
const handler = HttpApp.toWebHandler(app)
173+
const response = await handler(new Request("http://localhost:3000/missing"))
174+
strictEqual(response.status, 404)
175+
strictEqual(response.headers.get("X-Error-Code"), "RESOURCE_NOT_FOUND")
176+
strictEqual(await response.text(), "Not Found")
177+
})
178+
179+
test("round-trip with toWebHandler", async () => {
180+
// Create an Effect app, convert to web handler, then back to Effect app
181+
const originalApp = HttpServerResponse.json({ source: "effect" })
182+
const webHandler = HttpApp.toWebHandler(originalApp)
183+
const wrappedApp = HttpApp.fromWebHandler(webHandler)
184+
const finalHandler = HttpApp.toWebHandler(wrappedApp)
185+
186+
const response = await finalHandler(new Request("http://localhost:3000/"))
187+
deepStrictEqual(await response.json(), { source: "effect" })
188+
})
189+
})
102190
})

0 commit comments

Comments
 (0)