Skip to content

fix(cloudflare/workers): simulate inbound email in alchemy dev - #1375

Draft
Mkassabov wants to merge 3 commits into
mainfrom
feat/email-dev-mode
Draft

fix(cloudflare/workers): simulate inbound email in alchemy dev#1375
Mkassabov wants to merge 3 commits into
mainfrom
feat/email-dev-mode

Conversation

@Mkassabov

Copy link
Copy Markdown
Contributor

Cloudflare.email().subscribe(...) did not work under alchemy dev, in two independent ways.

Handlers have to live on the bridge prototype

workerd resolves JSRPC methods only on the target entrypoint's prototype chain. An own instance property of the same name shadows the prototype entry and the lookup fails outright — it does not fall back:

TypeError: The RPC receiver does not implement the method "email".

WorkerBridge assigned the whole handler set in the constructor, leaving throwing stubs on the prototype. Move the real dispatch onto the prototype and drop the stubs:

-      for (const methodName of ExportedHandlerMethods) {
-        (this as any)[methodName] = async (input: any) => processEvent(...);
-      }
-
       return new Proxy(this, { /* user-shape RPC fallback */ });
 for (const method of ExportedHandlerMethods) {
   Object.defineProperty(WorkerBridge.prototype, method, {
-    value: function () {
-      throw new Error(`Bridge method '${method}' was called before instance setup`);
-    },
+    value: function (this: any, input: any) {
+      return processEvent(
+        (built) => built.export[method](input, this.env, this.ctx),
+        this.ctx,
+        this.env,
+        (exit) => exit._tag === "Success"
+          ? Promise.resolve(exit.value)
+          : Promise.reject(Cause.squash(exit.cause)),
+      );
+    },

fetch/scheduled/queue never hit this path — workerd dispatches those as built-in events. The local runtime is the exception: its entry worker forwards the /cdn-cgi/handler/email trigger route to the user worker as env[USER_WORKER].email(message), a plain JSRPC call. So inbound email was the one handler that worked deployed and 500'd in dev.

Isolated with four workerd probes, calling .email() over a service binding:

entrypoint shape result
prototype method resolves
own property assigned in constructor does not implement the method
own property + prototype stub does not implement the method
prototype method + constructor Proxy resolves

Dev must not touch real Email Routing

Email resources have no local providers, so email({ zone }).subscribe(...) provisioned a real Email.Routing toggle and Email.CatchAll during alchemy dev — pointing a live zone's catch-all at a script that was never uploaded. If that landed it would silently take over inbound mail for the whole zone and drop it.

+const dev = yield* Effect.serviceOption(AlchemyContext).pipe(
+  Effect.map((ctx) => (ctx._tag === "Some" ? ctx.value.dev : false)),
+);
+
-if (!globalThis.__ALCHEMY_RUNTIME__ && props.zone !== undefined) {
+if (!globalThis.__ALCHEMY_RUNTIME__ && props.zone !== undefined && !dev) {

Local inbound is driven by the trigger route instead, which reaches the same registered listener.

EmailEventSource.local.test.ts covers the accept path and setReject end-to-end against the local simulator, and guards the prototype placement — CronEventSource.local.test.ts cannot, since scheduled never goes over RPC.

@Mkassabov
Mkassabov marked this pull request as ready for review August 26, 2026 17:04
Comment thread packages/alchemy/src/Cloudflare/Workers/EmailEventSource.ts Outdated
Mkassabov and others added 2 commits August 29, 2026 23:59
…my dev

`email({ zone }).subscribe(...)` provisioned `Email.Routing` plus the
zone's `Email.CatchAll`/`Email.Rule` unconditionally. Those resources have
no local providers, so under `alchemy dev` they acted on the real
Cloudflare account — pointing a real zone's catch-all at a script that was
never uploaded. That either fails, or worse, lands and silently takes over
inbound mail for the whole zone and drops it.

Skip the deploy-time half when AlchemyContext.dev is set. The runtime
listener is still registered, and local inbound is driven by the runtime's
`POST /cdn-cgi/handler/email` trigger route.

Adds EmailEventSource.local.test.ts, currently skipped: that trigger route
does not reach an Effect-native subscribe() handler yet — see the test's
comment for the diagnosis.
workerd's JSRPC method lookup only resolves methods on the target
entrypoint's prototype chain. An own instance property of the same name
shadows the prototype entry and makes the lookup fail outright with
`The RPC receiver does not implement the method "..."`.

`WorkerBridge` assigned the whole handler set in the constructor, leaving
only throwing stubs on the prototype. `fetch`/`scheduled`/`queue` never
noticed — workerd dispatches those as built-in events — but the local
runtime forwards its `/cdn-cgi/handler/email` trigger route to the user
worker as `env[USER_WORKER].email(message)`, a plain JSRPC call, so
`Cloudflare.email().subscribe(...)` worked deployed and 500'd in
`alchemy dev`.

Move the real dispatch onto the prototype and drop the stubs.

Un-skips `EmailEventSource.local.test.ts`, which now covers the accept and
`setReject` paths end-to-end against the local simulator.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Mkassabov
Mkassabov force-pushed the feat/email-dev-mode branch from 4d10667 to 2c4f3d5 Compare August 30, 2026 04:36
@alchemy-version-bot

alchemy-version-bot Bot commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Install the packages built from this commit:

Alchemy

alchemy

bun add https://pkg.ing/alchemy/bbcb783

@alchemy.run/better-auth

bun add https://pkg.ing/@alchemy.run/better-auth/bbcb783

@alchemy.run/cloudflare-runtime

bun add https://pkg.ing/@alchemy.run/cloudflare-runtime/bbcb783

@alchemy.run/frontend-frameworks

bun add https://pkg.ing/@alchemy.run/frontend-frameworks/bbcb783

@alchemy.run/node-utils

bun add https://pkg.ing/@alchemy.run/node-utils/bbcb783

@alchemy.run/pr-package

bun add https://pkg.ing/@alchemy.run/pr-package/bbcb783

@alchemy.run/floci

bun add https://pkg.ing/@alchemy.run/floci/bbcb783

Distilled

@distilled.cloud/core

bun add https://pkg.ing/@distilled.cloud/core/809f3d8

@distilled.cloud/aws

bun add https://pkg.ing/@distilled.cloud/aws/809f3d8

@distilled.cloud/axiom

bun add https://pkg.ing/@distilled.cloud/axiom/809f3d8

@distilled.cloud/cloudflare

bun add https://pkg.ing/@distilled.cloud/cloudflare/809f3d8

@distilled.cloud/hetzner

bun add https://pkg.ing/@distilled.cloud/hetzner/809f3d8

@distilled.cloud/neon

bun add https://pkg.ing/@distilled.cloud/neon/809f3d8

@distilled.cloud/planetscale

bun add https://pkg.ing/@distilled.cloud/planetscale/809f3d8

…e bundle

Addresses review feedback on #1375.

The deploy-time provisioning was guarded by a compound condition, with the
`dev` lookup hoisted above it:

    const dev = yield* Effect.serviceOption(AlchemyContext)...
    if (!globalThis.__ALCHEMY_RUNTIME__ && props.zone !== undefined && !dev) {

The bundler folds `__ALCHEMY_RUNTIME__` to `true` (`ALCHEMY_DEFINE`) so that
plan-only branches are dead-code-eliminated from deployed Workers, but only
the `if` body is reachable that way — the `AlchemyContext` lookup sat outside
it and stayed live in the runtime bundle.

Guard on `__ALCHEMY_RUNTIME__` alone and nest everything else inside, matching
the idiom used by the AWS `*BindingHttp` layers, so the whole block — and with
it `AlchemyContext`, `Namespace`, `Routing`, `CatchAll` and `Rule` — drops out
of the Worker.

No behavior change: the conditions are the same, only their nesting differs.

Claude-Session: https://claude.ai/code/session_01QShcJ78QmS5g3rTfj6qdA5
@Mkassabov
Mkassabov marked this pull request as draft August 31, 2026 17:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants