fix(cloudflare/workers): simulate inbound email in alchemy dev - #1375
Draft
Mkassabov wants to merge 3 commits into
Draft
fix(cloudflare/workers): simulate inbound email in alchemy dev#1375Mkassabov wants to merge 3 commits into
Mkassabov wants to merge 3 commits into
Conversation
Mkassabov
marked this pull request as ready for review
August 26, 2026 17:04
sam-goodwin
reviewed
Aug 26, 2026
…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
force-pushed
the
feat/email-dev-mode
branch
from
August 30, 2026 04:36
4d10667 to
2c4f3d5
Compare
Contributor
|
Install the packages built from this commit: Alchemyalchemy 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/bbcb783Distilled@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
marked this pull request as draft
August 31, 2026 17:13
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.