Wrapping HttpMiddleware.cors() around the api handler returned by a Cloudflare.Worker resource only attaches CORS headers to preflight (OPTIONS) responses. Real responses (GET / POST / etc.) come back without any CORS headers, so browsers block the response.
Reproduction
https://github.com/Mkassabov/alchemy-effect-cors-repro
bun install
bun alchemy deploy --profile <yours> --yes
# echo "WORKER_URL=<deployed url>" > .env
bun test
# → 1 pass, 1 fail
The two tests in test/cors.test.ts are the executable spec:
- ✅
OPTIONS preflight carries Access-Control-Allow-Origin
- ❌
GET response carries Access-Control-Allow-Origin (BUG: missing)
Root cause
HttpMiddleware.cors() handles the two paths very differently (effect source):
return httpApp => Effect.withFiber(fiber => {
const request = ...;
if (request.method === "OPTIONS") {
// builds and returns a 204 with all the CORS headers itself
return Effect.succeed(Response.empty({ status: 204, headers: ... }));
}
// for GET/POST/etc — just registers a callback on the request that
// a downstream pre-response stage is supposed to invoke later
appendPreResponseHandlerUnsafe(request, preResponseHandler);
return httpApp;
});
- OPTIONS preflight — middleware constructs the response itself, headers ride along. ✅
- GET / POST / etc. — middleware does NOT touch the response. It pushes a
preResponseHandler onto a queue on the request. That queue is meant to be drained right before the response is shipped — that drain step is what stamps Access-Control-Allow-Origin onto real responses.
When running an effect HTTP server (e.g. via BunHttpServer.layer), the server loop walks the pre-response queue. The Cloudflare.Worker pipeline takes the Effect<HttpServerResponse> returned from `fetch` and converts it directly to a Cloudflare `Response` — the pre-response queue is never drained, so CORS headers never make it onto real responses. Only preflight works because it short-circuits before that step.
Wrapping
HttpMiddleware.cors()around the api handler returned by aCloudflare.Workerresource only attaches CORS headers to preflight (OPTIONS) responses. Real responses (GET/POST/ etc.) come back without any CORS headers, so browsers block the response.Reproduction
https://github.com/Mkassabov/alchemy-effect-cors-repro
The two tests in
test/cors.test.tsare the executable spec:OPTIONS preflight carries Access-Control-Allow-OriginGET response carries Access-Control-Allow-Origin (BUG: missing)Root cause
HttpMiddleware.cors()handles the two paths very differently (effect source):preResponseHandleronto a queue on the request. That queue is meant to be drained right before the response is shipped — that drain step is what stampsAccess-Control-Allow-Originonto real responses.When running an effect HTTP server (e.g. via
BunHttpServer.layer), the server loop walks the pre-response queue. The Cloudflare.Worker pipeline takes theEffect<HttpServerResponse>returned from `fetch` and converts it directly to a Cloudflare `Response` — the pre-response queue is never drained, so CORS headers never make it onto real responses. Only preflight works because it short-circuits before that step.