From fa8df24b041ea32f85d2bf62f5633859b104ba08 Mon Sep 17 00:00:00 2001 From: "Michael (Pear)" Date: Mon, 4 May 2026 12:22:00 -0400 Subject: [PATCH 1/3] fix(cloudflare/workers): drain pre-response handlers in serveWebRequest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `HttpMiddleware.cors()` (and any other middleware that registers a `preResponseHandler` for non-OPTIONS requests) was a no-op on real responses inside a `Cloudflare.Worker` — only OPTIONS preflights short-circuited with CORS headers. Run the handler through `HttpEffect.toHandled` so the registered pre-response handler chain fires before the response is converted to a web `Response`. Fixes #175 --- .../src/Cloudflare/Workers/HttpServer.ts | 81 ++++++++++--------- 1 file changed, 44 insertions(+), 37 deletions(-) diff --git a/packages/alchemy/src/Cloudflare/Workers/HttpServer.ts b/packages/alchemy/src/Cloudflare/Workers/HttpServer.ts index 69bb7bf3..f1664be1 100644 --- a/packages/alchemy/src/Cloudflare/Workers/HttpServer.ts +++ b/packages/alchemy/src/Cloudflare/Workers/HttpServer.ts @@ -1,10 +1,10 @@ import type * as cf from "@cloudflare/workers-types"; -import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import type { Scope } from "effect/Scope"; import type { HttpBodyError } from "effect/unstable/http/HttpBody"; -import * as HttpServerError from "effect/unstable/http/HttpServerError"; +import * as HttpEffectModule from "effect/unstable/http/HttpEffect"; +import type * as HttpServerError from "effect/unstable/http/HttpServerError"; import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; import * as Http from "../../Http.ts"; @@ -47,41 +47,48 @@ export const serveWebRequest = ( never, Exclude > => - Effect.gen(function* () { - const request = HttpServerRequest.fromWeb( - webRequest as any as globalThis.Request, - ).modify({ - remoteAddress: Option.fromUndefinedOr(options.remoteAddress), - }); - - Object.defineProperty(request, "raw", { - get: () => - Object.assign(request.stream, { - raw: webRequest.body, - }), - }); + Effect.flatMap(Effect.context(), (parentContext) => + Effect.callback((resume) => { + const request = HttpServerRequest.fromWeb( + webRequest as any as globalThis.Request, + ).modify({ + remoteAddress: Option.fromUndefinedOr(options.remoteAddress), + }); - const response = yield* handler.pipe( - Effect.provideService(HttpServerRequest.HttpServerRequest, request), - Effect.provideService(Request, webRequest as any), - Effect.catchCause((cause) => { - const message = Option.match(Cause.findErrorOption(cause), { - onNone: () => "Internal Server Error", - onSome: (error) => - error instanceof Error && error.message - ? error.message - : "Internal Server Error", - }); - return Effect.succeed( - HttpServerResponse.text(message, { - status: 500, - statusText: message, + Object.defineProperty(request, "raw", { + get: () => + Object.assign(request.stream, { + raw: webRequest.body, }), - ); - }), - ); + }); - return HttpServerResponse.toWeb(response, { - context: yield* Effect.context(), - }); - }) as any; + // Run the handler through `toHandled` so any `preResponseHandler`s that + // middleware (e.g. `HttpMiddleware.cors()`) registered on the request + // are applied to the final response. Without this drain, only OPTIONS + // preflights — which `cors()` short-circuits — get CORS headers; real + // GET/POST/etc. responses don't. + const httpApp = HttpEffectModule.toHandled( + handler, + (_req, response) => { + const transferred = HttpEffectModule.scopeTransferToStream(response); + resume( + Effect.succeed( + HttpServerResponse.toWeb(transferred, { context: parentContext }), + ), + ); + return Effect.void; + }, + ); + + const fiber = httpApp.pipe( + Effect.provideService(HttpServerRequest.HttpServerRequest, request), + Effect.provideService(Request, webRequest as any), + Effect.provide(parentContext), + Effect.runFork, + ); + + return Effect.sync(() => { + fiber.interruptUnsafe(); + }); + }), + ) as any; From ad05df16cf7b3bdcb53a0059051d9bb0c8e2775e Mon Sep 17 00:00:00 2001 From: "Michael (Pear)" Date: Mon, 4 May 2026 12:39:19 -0400 Subject: [PATCH 2/3] remove comment --- .../src/Cloudflare/Workers/HttpServer.ts | 26 +++++++------------ 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/packages/alchemy/src/Cloudflare/Workers/HttpServer.ts b/packages/alchemy/src/Cloudflare/Workers/HttpServer.ts index f1664be1..876be7ea 100644 --- a/packages/alchemy/src/Cloudflare/Workers/HttpServer.ts +++ b/packages/alchemy/src/Cloudflare/Workers/HttpServer.ts @@ -62,23 +62,15 @@ export const serveWebRequest = ( }), }); - // Run the handler through `toHandled` so any `preResponseHandler`s that - // middleware (e.g. `HttpMiddleware.cors()`) registered on the request - // are applied to the final response. Without this drain, only OPTIONS - // preflights — which `cors()` short-circuits — get CORS headers; real - // GET/POST/etc. responses don't. - const httpApp = HttpEffectModule.toHandled( - handler, - (_req, response) => { - const transferred = HttpEffectModule.scopeTransferToStream(response); - resume( - Effect.succeed( - HttpServerResponse.toWeb(transferred, { context: parentContext }), - ), - ); - return Effect.void; - }, - ); + const httpApp = HttpEffectModule.toHandled(handler, (_req, response) => { + const transferred = HttpEffectModule.scopeTransferToStream(response); + resume( + Effect.succeed( + HttpServerResponse.toWeb(transferred, { context: parentContext }), + ), + ); + return Effect.void; + }); const fiber = httpApp.pipe( Effect.provideService(HttpServerRequest.HttpServerRequest, request), From 245e617f44242bb4fd09947cd79557237f71dc33 Mon Sep 17 00:00:00 2001 From: "Michael (Pear)" Date: Mon, 4 May 2026 12:43:54 -0400 Subject: [PATCH 3/3] test(cloudflare/workers): cover serveWebRequest preResponseHandler drain --- .../Cloudflare/Workers/HttpServer.test.ts | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 packages/alchemy/test/Cloudflare/Workers/HttpServer.test.ts diff --git a/packages/alchemy/test/Cloudflare/Workers/HttpServer.test.ts b/packages/alchemy/test/Cloudflare/Workers/HttpServer.test.ts new file mode 100644 index 00000000..6802382a --- /dev/null +++ b/packages/alchemy/test/Cloudflare/Workers/HttpServer.test.ts @@ -0,0 +1,71 @@ +import { serveWebRequest } from "@/Cloudflare/Workers/HttpServer"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as HttpEffect from "effect/unstable/http/HttpEffect"; +import * as HttpMiddleware from "effect/unstable/http/HttpMiddleware"; +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; + +const helloHandler = Effect.succeed( + HttpServerResponse.text("hello", { status: 200 }), +); + +describe("serveWebRequest", () => { + it.effect( + "drains preResponseHandlers so HttpMiddleware.cors() tags GET responses", + () => + Effect.gen(function* () { + const request = new Request("https://worker.test/hello", { + method: "GET", + headers: { Origin: "https://example.test" }, + }); + + const response = yield* serveWebRequest( + request as any, + HttpMiddleware.cors()(helloHandler), + ); + + expect(response.status).toBe(200); + expect(response.headers.get("access-control-allow-origin")).toBe("*"); + expect(yield* Effect.promise(() => response.text())).toBe("hello"); + }), + ); + + it.effect( + "lets HttpMiddleware.cors() answer OPTIONS preflight with CORS headers", + () => + Effect.gen(function* () { + const request = new Request("https://worker.test/hello", { + method: "OPTIONS", + headers: { + Origin: "https://example.test", + "Access-Control-Request-Method": "GET", + }, + }); + + const response = yield* serveWebRequest( + request as any, + HttpMiddleware.cors()(helloHandler), + ); + + expect(response.status).toBe(204); + expect(response.headers.get("access-control-allow-origin")).toBe("*"); + }), + ); + + it.effect( + "applies a manually-registered preResponseHandler to the response", + () => + Effect.gen(function* () { + const handler = helloHandler.pipe( + HttpEffect.withPreResponseHandler((_req, res) => + Effect.succeed(HttpServerResponse.setHeader(res, "x-tagged", "yes")), + ), + ); + + const request = new Request("https://worker.test/", { method: "GET" }); + const response = yield* serveWebRequest(request as any, handler); + + expect(response.headers.get("x-tagged")).toBe("yes"); + }), + ); +});