Skip to content

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

Open
Mkassabov wants to merge 2 commits into
mainfrom
feat/email-dev-mode
Open

fix(cloudflare/workers): simulate inbound email in alchemy dev#1375
Mkassabov wants to merge 2 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 and others added 2 commits August 26, 2026 04:43
…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 marked this pull request as ready for review August 26, 2026 17:04
// running inside the deployed Worker (the global guard), when `zone`
// is omitted (bring-your-own routing), and in dev (above). Namespaced
// under the host so logical identity is stable per Worker.
if (!globalThis.__ALCHEMY_RUNTIME__ && props.zone !== undefined && !dev) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not a regression but this won't be tree shaken. Might want to wrap if (!globalThis.__ALCHEMY_RUNTIME__ ) { .. } otuer and move the if (props.zone != .. ) stiuff inside

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