Skip to content

HttpMiddleware.cors() doesn't tag non-preflight responses when used inside a Cloudflare.Worker #175

@Mkassabov

Description

@Mkassabov

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.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions