fix(cloudflare/workers): simulate inbound email in alchemy dev - #1375
Open
Mkassabov wants to merge 2 commits into
Open
fix(cloudflare/workers): simulate inbound email in alchemy dev#1375Mkassabov wants to merge 2 commits into
Mkassabov wants to merge 2 commits into
Conversation
…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
marked this pull request as ready for review
August 26, 2026 17:04
sam-goodwin
reviewed
Aug 26, 2026
| // 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) { |
Contributor
There was a problem hiding this comment.
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Cloudflare.email().subscribe(...)did not work underalchemy 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:
WorkerBridgeassigned 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 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/queuenever hit this path — workerd dispatches those as built-in events. The local runtime is the exception: its entry worker forwards the/cdn-cgi/handler/emailtrigger route to the user worker asenv[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:does not implement the methoddoes not implement the methodProxyDev must not touch real Email Routing
Email resources have no local providers, so
email({ zone }).subscribe(...)provisioned a realEmail.Routingtoggle andEmail.CatchAllduringalchemy 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.Local inbound is driven by the trigger route instead, which reaches the same registered listener.
EmailEventSource.local.test.tscovers the accept path andsetRejectend-to-end against the local simulator, and guards the prototype placement —CronEventSource.local.test.tscannot, sinceschedulednever goes over RPC.