Skip to content

Release Version 1 - #1

Open
Refzlund wants to merge 324 commits into
mainfrom
next
Open

Release Version 1#1
Refzlund wants to merge 324 commits into
mainfrom
next

Conversation

@Refzlund

Copy link
Copy Markdown
Owner

No description provided.

@changeset-bot

changeset-bot Bot commented Apr 11, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 1c7f506

The changes in this PR will be included in the next version bump.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actions Bot commented Apr 14, 2026

Copy link
Copy Markdown

✅ Documentation PR preview deployed successfully

This pull request gets a stable PR-scoped preview link that is updated in place on every preview run.

Logs and details
.svelte-kit/output/server/entries/pages/_layout.svelte.js              18.47 kB │ gzip:   4.60 kB
.svelte-kit/output/server/chunks/internal.js                           19.02 kB │ gzip:   5.77 kB
.svelte-kit/output/server/chunks/shared.js                             23.90 kB │ gzip:   6.44 kB
.svelte-kit/output/server/chunks/utils.js                              29.10 kB │ gzip:   8.39 kB
.svelte-kit/output/server/remote-entry.js                              35.60 kB │ gzip:   8.02 kB
.svelte-kit/output/server/chunks/runtime.js                            43.37 kB │ gzip:  10.59 kB
.svelte-kit/output/server/chunks/messages.js                           87.43 kB │ gzip:  16.42 kB
.svelte-kit/output/server/index.js                                    130.59 kB │ gzip:  33.53 kB
.svelte-kit/output/server/entries/pages/docs/_slug_/_page.svelte.js   134.93 kB │ gzip:  27.06 kB
.svelte-kit/output/server/chunks/dev.js                               142.87 kB │ gzip:  36.83 kB
.svelte-kit/output/server/entries/hooks.server.js                     170.11 kB │ gzip:  35.52 kB
.svelte-kit/output/server/chunks/content.js                           525.11 kB │ gzip: 133.91 kB

�[33m�[33m[PLUGIN_TIMINGS] Warning:�[0m Your build spent significant time in plugins. Here is a breakdown:
  - devflare (48%)
  - vite-plugin-sveltekit-guard (35%)
  - vite-plugin-sveltekit-virtual-modules (9%)
  - vite-plugin-sveltekit-compile (4%)
See https://rolldown.rs/options/checks#plugintimings for more details.
�[39m
�[32m✓ built in 9.75s�[39m

Run npm run preview to preview your production build locally.

> Using @sveltejs/adapter-cloudflare
  ✔ done
[log] Generated deploy Wrangler config: .devflare/build/wrangler.jsonc
[log] Generated deploy redirect: .wrangler/deploy/config.json
[log] worker devflare-docs-pr-1
[log] Deploying with Wrangler…

 ⛅️ wrangler 4.81.1 (update available 4.82.2)
─────────────────────────────────────────────
Using redirected Wrangler configuration.
 - Configuration being used: ".devflare/build/wrangler.jsonc"
 - Original user's configuration: "<no user config found>"
 - Deploy configuration file: ".wrangler/deploy/config.json"
[success] Preview URL: https://devflare-docs-pr-1.refz.workers.dev
[warn] Deployment verification note: Wrangler completed the named preview-scope deploy for Worker "devflare-docs-pr-1" and exposed https://devflare-docs-pr-1.refz.workers.dev, but Cloudflare did not return a Worker version id. Devflare is treating this branch-scoped preview deploy as successful because named preview workers can lag in control-plane version metadata.
[success] Deployed successfully!

@github-actions

github-actions Bot commented Apr 14, 2026

Copy link
Copy Markdown

✅ Testing branch preview deployed successfully

This workflow publishes a branch-scoped testing preview on every qualifying push, even when no pull request exists. When the branch belongs to an open pull request, the same run also refreshes the stable PR preview comment.

Logs and details
Using deploy command: bunx --bun devflare deploy
Deploy target: preview scope (next)
Bun version: 1.3.12
Node version: v20.20.2
[log]
[log] deploy Shipping to Cloudflare
[log] Reused preview-scoped resources: KV 2 · D1 3 · R2 2 · Queues 4 · Vectorize 2
[warn] Workers Analytics Engine datasets are created automatically on first write, so Devflare does not provision or delete preview-scoped analytics datasets.
[warn] Browser Rendering bindings do not own account-scoped resources, so Devflare does not provision or delete preview-scoped browser bindings.
[warn] Preview Hyperdrive "devflare-testing-next" is not auto-provisioned because Cloudflare does not expose stored Hyperdrive credentials for cloning. Devflare will reuse the base Hyperdrive "devflare-testing" for binding POSTGRES.
[log] Building: devflare-testing-binding-matrix-next
[log] Branch-scoped preview deploy detected (next); omitting shared queue consumers and cron triggers from the deployed Wrangler config to avoid singleton Cloudflare resource conflicts.
[log] Generated bundled worker entry: .devflare/worker-entrypoints/main.js
[log] Skipping Vite build (no effective Vite config found for this package)
[log] Generated deploy artifact: .devflare/build/worker.js
[log] Generated deploy Wrangler config: .devflare/build/wrangler.jsonc
[log] Generated deploy redirect: .wrangler/deploy/config.json
[log] worker devflare-testing-binding-matrix-next
[log] Deploying with Wrangler…

 ⛅️ wrangler 4.81.1 (update available 4.82.2)
─────────────────────────────────────────────
Using redirected Wrangler configuration.
 - Configuration being used: ".devflare/build/wrangler.jsonc"
 - Original user's configuration: "<no user config found>"
 - Deploy configuration file: ".wrangler/deploy/config.json"
[success] Preview URL: https://devflare-testing-binding-matrix-next.refz.workers.dev
[warn] Deployment verification note: Wrangler completed the named preview-scope deploy for Worker "devflare-testing-binding-matrix-next" and exposed https://devflare-testing-binding-matrix-next.refz.workers.dev, but Cloudflare did not return a Worker version id. Devflare is treating this branch-scoped preview deploy as successful because named preview workers can lag in control-plane version metadata.
[warn] Devflare preview registry sync failed: This Worker does not exist on your account.
[success] Deployed successfully!
  • Auth impact: deploy (workspace dependency "devflare" changed)
  • Auth service version: not available
  • Search impact: deploy (workspace dependency "devflare" changed)
  • Search service version: not available
  • Main preview impact: deploy (workspace dependency "devflare" changed)
  • Deployed binding verification: passed
  • Verification mode: CI inspects deployed preview Worker metadata directly when Cloudflare exposes it, and otherwise falls back to the successful named-preview deploy output plus resolved preview config because the testing preview URL is protected by Cloudflare Access.
  • Preview strategy: named preview scope via --preview <branch> so the testing worker family deploys to the same explicit branch target.
  • Auth deploy status: success
  • Auth failure stage: n/a
  • Search deploy status: success
  • Search failure stage: n/a
  • Main preview deploy status: success
  • Main preview failure stage: n/a

@github-actions
github-actions Bot had a problem deploying to testing branch preview / next April 14, 2026 12:06 Failure
@github-actions
github-actions Bot had a problem deploying to testing branch preview / next April 14, 2026 13:21 Failure
@github-actions
github-actions Bot had a problem deploying to documentation branch preview / next April 14, 2026 17:21 Failure
@github-actions
github-actions Bot had a problem deploying to testing branch preview / next April 14, 2026 17:28 Failure
Refzlund and others added 30 commits August 1, 2026 13:17
…only

1.1.0 moved every URL the shim answers, without a major bump, so a current
client never reached it: launch() died at the first call with "Unable to
create new browser: code: 404: message: Not found" — the shim's own 404,
travelling back out through the binding. Verified against 1.0.7, 1.1.0 and
1.2.0 rather than inferred: acquire went GET /v1/acquire -> POST
/v1/devtools/browser, and devtools went /v1/connectDevtools?browser_session=X
-> /v1/devtools/browser/X, session id in the path rather than a parameter.

Both spellings are served now, from one route table in src/browser-shim/routes.ts,
so which client version an app pins stays the app's decision. /v1/sessions,
/v1/history and /v1/limits never moved.

The paths alone would only have moved the failure. The same release deleted
the transport's protocol.js: up to 1.0.7 every CDP message crossed as binary
frames behind a 4-byte length header, with a ping each second, and from 1.1.0
it is plain unframed JSON both ways. A session would therefore have opened and
then Browser.getVersion would have arrived wrapped in a header the client no
longer unwraps. The binding worker picks its framing from the path it was
connected on, which is exactly as reliable a signal, the two having changed
together in 1.1.0.

Two smaller things fell out of routing through one table. A non-numeric
keep_alive used to reach the idle timer as NaN, where NaN > 0 is false, so the
session got no timeout at all and leaked a Chrome; it now falls back to the
configured default. And acquire no longer swallows a JSON parse error to cope
with the empty body 1.1.0 sends — it reads the body only when there is one.

Tests compile the generated binding worker and drive its fetch handler with
stubbed globals, rather than asserting on the script's text, which would pass
on any string that merely mentions the right path. Three fail against the code
this replaces; the four covering the 1.0.x client pass on both sides, which is
the point of keeping them.
puppeteer.sessions(env.BROWSER) and .history(env.BROWSER) both resolved to
undefined against the local shim. The client reads JSON.parse(text).sessions
and JSON.parse(text).history — it has in every version it has shipped, and its
own SessionsResponse/HistoryResponse types say so — while the shim answered
with a bare array, so the field it looked for was never there.

Found while confirming which routes the 1.1.0 endpoint move had left alone.
The paths had indeed not moved; the payloads were simply never right, for any
client version, which is why this is a separate change from that one rather
than part of it. /v1/limits already matched and is untouched.

Verified against a running shim by reading the response exactly as
PuppeteerWorkers does: sessions() now yields the acquired session rather than
undefined. There is no unit test because the route bodies close over the
server's live session state, which cannot be reached without installing and
launching Chrome.
…paths

A dependency that imports a builtin SUBPATH made rolldown warn on every
`devflare dev` start-up: `[UNRESOLVED_IMPORT] Could not resolve 'fs/promises'`,
pointing at a line devflare itself had written. @cloudflare/puppeteer's util.js
does `fs = await import('fs/promises')` in a Node-only branch, and the
worker-compat transform hoists a literal dynamic import into a static one —
which is what forces rolldown to resolve the specifier at bundle time.

DEFAULT_EXTERNAL_MODULES was hand-maintained and named only bare builtins, so no
subpath ever matched it: fs/promises, stream/web, stream/promises,
timers/promises, dns/promises, util/types, assert/strict, path/posix,
readline/promises, inspector/promises. Whole builtins the list never gained —
timers, process, worker_threads, perf_hooks, diagnostics_channel, http2,
readline, tty, vm, v8, module, cluster, dgram — fell through it too. Two lists
that must agree and are not derived from each other is the bug, not the missing
entry, so adding 'fs/promises' would have fixed this report and left the class
of it intact.

The list now comes from the runtime's own `builtinModules`, so it cannot drift
from Node's builtin set again. Rolldown already externalized these specifiers
after warning about them — "treating it as an external dependency" — so this
only removes the diagnostic. Verified rather than assumed: bundling a fixture
shaped after puppeteer's util.js before and after yields a byte-identical
bundle, same 443 bytes, same hash, with the warning gone.

`builtinModules` is the host's, not Node's, and under Bun it also reports ws,
undici and bun, which Node does not. Those are installable packages, so
externalizing them would leave a worker's own copy unbundled and hand workerd an
import it cannot resolve; they are excluded, and the exclusion is inert under
Node. Confirmed load-bearing — with it removed, a fixture importing ws leaves
`import { WS_MARKER } from "ws"` in the bundle.

One behaviour change is worth stating plainly: a bare builtin name now resolves
to the builtin even when an npm package of that name is installed. That was
already true of fs, path, stream, crypto, events, util, url and assert — every
one a publishable name — and now also holds for the builtins the list happened
to miss, punycode among them.

The subpath test drives the real bundler and asserts on rolldown's log rather
than the output, because the output is the same either way; that is the whole
point. It fails against the code this replaces.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every binding a context hands out is backed by the Miniflare instance that
context booted, and dispose() takes that instance — and its storage — with it.
Nothing asserted it, and it is about to matter: with the Durable Object bundle
cached, a suite can put several test files in one process, and this is the
contract that stops the first file's rows, objects, keys and Durable Object
state from turning up in the second.

It also records why the runtime is not reused between contexts, tempting as
that is at ~700ms a boot. Measured against miniflare 4.20260424.0:
setOptions() with identical options keeps every store alive — KV value, R2
object, D1 row and Durable Object state all survived the reconfigure, where a
fresh instance correctly saw none of them. Changing the KV, R2 and D1 binding
ids does clear those three, but the Durable Object counter kept counting,
because that storage is keyed by class and object id rather than by binding.
And it buys nothing anyway: setOptions() plus ready measured 715-805ms against
682-1175ms for a fresh instance, since it restarts workerd rather than
reconfiguring it in place.

So anything that later reaches for reuse has to keep this test green, and on
the evidence above it cannot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Resolving it — evaluating the config module, then the env placeholders and the
.dev.vars overlay — costs 15-130ms depending on the project, and a suite
creating a context per test file paid it every time.

For nothing, on the part that dominates: the config module was never actually
re-read. The loader evaluates it once per process and hands back that same
instance afterwards, rewritten file or not, which a probe confirms — write v1,
load, write v2, load, and the second load still answers v1. So the repeat work
could only ever reproduce what the first call already had.

The resolution is memoised per config path. The path is normalised first,
because autodiscovery answers in posix separators and an explicit path in the
platform's, so one config file reached both ways used to load twice and
produce two config objects for one file — visible on Windows, where the two
spellings differ.

What the memo additionally holds still is the env / .dev.vars overlay, which
does re-read from disk and no longer follows a change made between two
contexts in one process. __resetTestContextConfigCache() is the way back for a
suite that needs it; the next commit puts it on the devflare/test surface.

Nothing mutates the shared config: every consumer of it in createTestContext()
was traced, and JSON.stringify(config) is byte-identical after two full context
lifecycles with kv/r2/d1/sendEmail/DO/vars bindings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ocess

createTestContext() bundles the Durable Object class graph with Bun.build
before the runtime boots, and it did so on every context, keeping no cache —
unlike bundleAllEntrypoints() beside it. That is not merely slow: once the bun
test runner has loaded a module on that graph, Bun.build will not re-read it
and fails against an ordinary file with a misleading errno. The report against
1.0.0-next.74 is "EISDIR reading file: .../store/d1.ts" with the runtime
transpiler cache on, "Unexpected" with it off; the named file is whichever
graph module the tests also import. It does not reproduce on every project or
bun build — it would not reproduce here — so this treats it as a hazard to
remove rather than a law. A consumer's way through was one bun process per
test file, which multiplies the whole per-file setup cost (~1.5s before a
single assertion) by the file count; --isolate and --parallel=N do not help,
since both reuse a worker across files.

The bundle is now cached on disk under the config's .devflare/test-bundles/.
A process that starts with a warm cache never calls Bun.build at all, which is
what makes batching possible; where it does build, a second context in that
process reuses the result rather than repeating it.

WHAT MAKES A HIT HONEST. Content hashes of the loaded graph are necessary and
not sufficient, because they cannot see a change in RESOLUTION. So an entry
also carries: the package.json / tsconfig.json files above every input, since
the bundler reads those as a resolver and never hands them to a load hook; a
listing of each directory an input came from, so a newly added mod.ts beside
the mod.js that was bundled counts as a change where nothing already tracked
would; and the bundler version, devflare's version and the build options, none
of which touch a file at all. The graph itself is recorded by an observing Bun
plugin whose onLoad returns nothing, so the emitted bundle stays byte-for-byte
what it was — verified, 851 == 851 characters with and without it.

What can still go stale is a resolver input that did not exist at build time
and sits outside those directories: a tsconfig.json added further up, a newly
installed package that shadows one. Deleting .devflare/test-bundles/ forces a
rebuild, and __resetDurableObjectBundleCache() does it within a process; both
are documented rather than left as folklore.

THE CACHE ROOT is derived from the CONFIG directory, never from DEVFLARE_DIR.
Concurrent test slots each get their own generated directory, so a cache
underneath it would be private per slot and the sharing that lets the first
process pay for all of them would be lost.

THE BUNDLER ENTRY now carries the pid. It is written to disk and lives for the
whole build, so two slots sharing a configDir used to overwrite each other's
entry mid-build — reproduced, 2 races in 8 — and each would then cache the
OTHER's script under its own key. Before the cache that cost one run; with it
the wrong bundle looks fresh forever, and a consumer's Durable Object class is
simply absent from the gateway with no diagnostic. It is unlinked after the
build, as resolve-service-bindings.ts already does with its own.

The unit tests sabotage Bun.build to stand in for its refusal, so any path that
still bundles fails loudly rather than quietly costing a second. Every one of
them fails against the code it guards: the two reuse tests against no cache at
all, the shadowing test against tracking file contents alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ound poller

Local email could be simulated but not observed, delivered, or received.

- `cf.email.outbox` / `sent()` / `clearOutbox()` / `onOutbound()` record every
  message a worker dispatched, as BOTH a normalized message and the raw MIME
  with its size — header and 5 MiB assertions need the document that travels.
- The `send()` builder now handles the full Workers shape: `{email,name}` and
  mixed arrays in every address field, attachments as multipart/mixed parts,
  and `{ messageId }` as the return value (it previously resolved `undefined`,
  contradicting its own `Promise<EmailSendResult>` type).
- A new local-only `email` config block picks what happens to a send:
  `capture` (default, nothing leaves the machine), `relay`, or `live`. The mode
  is NEVER inferred from credentials, so a suite on a machine with a populated
  `.env` cannot start sending real mail.
- `relay` pins every `to`/`cc`/`bcc` to one address on the assembled MIME, so
  worker code that hand-rolls its own document is pinned too, and stamps a
  marker header. The SMTP client runs in the host process; under `devflare dev`
  the composed worker posts each message to a loopback listener, because
  workerd has no raw sockets.
- `email.inbound` (opt-in) polls a mailbox over IMAP into `src/email.ts` via the
  same endpoint `cf.email.send()` uses, skipping anything carrying the relay
  marker — otherwise a shared pin-and-poll mailbox loops forever.
- Caller text reaching a header is stripped of CR/LF and an envelope address
  carrying CR/LF/`<`/`>` is refused before a socket opens; without that, a
  newline in `cc` pushes the marker out of the header block and a newline in
  `from` smuggles a second RCPT TO past the pin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ld but absent in dev

`.env.public` joins `.env.dev` and `.env` in the config-time dotenv search, as the
LOWEST-precedence tier in each directory. Values that are genuinely not secrets — a
sender address, a support forwarding target, a public API origin — previously had
nowhere to live but a deploy dashboard, invisible to the next person who clones the
repository. Being weakest is the point: a committed default can never override the
machine it is running on. Nothing validates the promise in the name, so the filename
documents intent rather than guaranteeing it, and the docs say so.

`env.NAME.absentInDev()` is the third answer to "what if it is missing", because the
other two are both wrong for a class of variable: `.optional()` lets a production
build ship without it, and `.dev(value)` hands the local runtime a placeholder. A
sender address wants neither — with none set, code that shape-checks its environment
takes its cannot-send path, which is how local development returns a sign-in code in
the response instead of mailing one nobody will read; give it a placeholder and every
laptop believes it can send. So it fails a build when absent and omits the key
entirely in dev, and the inferred type is optional because in dev the key genuinely is.

Chaining resolves rather than rejects: an explicit `.dev(value)` wins in dev (the one
that names the mode wins), `.default(value)` loses. `local-dev-vars.ts` reads a
DIFFERENT file list on purpose — it is wrangler-compatible, and wrangler has no
`.env.public` — so it carries a note against the next person who tries to unify them.
…nd reconciled at deploy

The first ZONE-scoped resources Devflare provisions. Everything until now lived
under `/accounts/{id}`; a zone is a different identifier, reached by a different
lookup and gated by a different token scope — which is why the API layer is its
own module rather than more of `account.ts`.

Keyed by DOMAIN, not by zone id, because a domain is what an author knows.
Devflare walks the labels up to the apex, so `mail.example.com` is configured
under its own name and its records land in the `example.com` zone. A relative
record name resolves against the domain it was declared under rather than the
apex — for a subdomain those differ, and the apex would be a real record in the
right zone protecting the wrong domain.

Reconciled rather than replayed: rules match on the address they claim, records
on type and name, so a second deploy is a no-op. Nothing is deleted, because a
zone always carries more than this config mentions. The two mutation stances
differ on purpose — a routing rule that exists and points elsewhere is REPORTED
(a live mail route edited by hand is more likely deliberate than stale), while a
declared DNS record is REWRITTEN (which is what makes a staged DMARC rollout a
config edit instead of a dashboard visit).

Enabling Email Routing is never inferred. It rewrites the zone MX records and so
changes where all mail for the domain is delivered, which is too large a side
effect to follow from adding a forwarding rule; `enable: true` is the
authorization, and without it the deploy fails and says what to do.

An adversarial review of the first draft found five defects that a live deploy
could reach. All are fixed here, each with a test:

- A type and name pair is an RRSET, not a unique record. Taking the first match
  and rewriting it destroyed whichever unrelated record Cloudflare returned
  first — an apex TXT routinely holds SPF *and* a vendor verification string —
  and for SPF it left the domain with two, which is a permanent error for the
  whole domain under RFC 7208. Now it resolves the ambiguity only where it can
  do so safely (its own comment, or a content match) and otherwise refuses.
- PREVIEW deploys wrote to the real zone. The docs claimed they did not, and
  nothing enforced it: `environment` was never forwarded into the materialized
  path. A rule or record belongs to the whole domain and has no branch-scoped
  form, so a preview would have redirected production mail from a feature branch
  and left the change behind when the branch was deleted.
- The orphan report was unreachable on the zone path. The reconciler returned a
  result, and a return value is lost on a throw — so enabling Email Routing and
  then failing on the next call told the operator only what Cloudflare said, and
  never that their MX records had just been rewritten. The accumulator is now
  passed in.
- The catch-all comparison ignored `enabled` while its own writer always set it,
  so a catch-all that said the right thing while switched OFF was reported as
  correct. Cloudflare's default on a freshly enabled zone is exactly that.
- Two literal NUL bytes were written into the reconciler by a `join('\0')` that
  landed as the byte rather than the escape. Runtime behaviour was correct and
  biome exited 0, but git classified the one file that can rewrite a domain's MX
  records as BINARY — undiffable, unblamable, ungreppable. A new gate scans every
  source file for control characters, with both controls: that it reaches the
  tree, and that its detector fires.

And four smaller ones: a duplicate address in one pass created two rules racing
for the same mail (now rejected by the schema and by the reconciler); `ttl` and
MX `priority` were declarable but never reconciled while the docs promised they
were; `--dry-run` swallowed a config error and exited 0; and the two
highest-blast-radius HTTP calls had no test.

Three pre-existing gaps closed alongside: `Would create:` omitted Vectorize and
Hyperdrive, `schema-types-email.ts` was never added to the type-documentation
gate, and both "nothing to prepare" shortcuts asked only about bindings — so a
config declaring nothing but a DMARC record returned success having done nothing.
…ding onboarding

Email Sending is a different Cloudflare product from Email Routing that happens
to share a zone. Routing is INBOUND: what happens to mail arriving for a domain.
Sending is OUTBOUND: whether a `send_email` binding may send FROM an address
there. A domain doing both is onboarded to both, separately, and a subdomain gets
its own DKIM key rather than inheriting the apex one.

Declaring `emailSending` makes every deploy CHECK it. That turns the failure mode
from silent into loud: a binding that is perfectly fine in config and rejected at
send time, on a domain nobody remembered to onboard, is otherwise only
discoverable by sending mail and watching it fail.

`enable: true` performs the onboarding, opt-in for the same reason
`emailRouting.enable` is — Cloudflare writes AND LOCKS records in the zone (`MX`
and SPF on `cf-bounce.<domain>`, DKIM at `cf-bounce._domainkey.<domain>`, and a
DMARC policy at `_dmarc.<domain>`). Which also means a DMARC record declared
under `dns` may already exist and belong to Cloudflare; the docs now say so.

Readiness is REPORTED, never waited on. Cloudflare writes the records at
onboarding and they take minutes to propagate, so a fresh domain is legitimately
unready and failing there would fail a deploy that did everything right. `ready`
and `unlocked` both pass — `unlocked` means the records are correct and only a
managed lock was cleared — while the two real failures become a warning carrying
Cloudflare's own error codes, because a sender whose DKIM never landed otherwise
looks provisioned while every message fails authentication.

Shapes are hand-written against Cloudflare's OpenAPI paths rather than taken from
the `cloudflare` SDK, which lags: it has no `/dns/status`, and that endpoint is
the one that makes this diagnosable. One caveat is stated in the code rather than
discovered: the schema declares key-based auth for these operations and omits API
tokens, unlike the sibling send endpoints. If a token-authenticated onboarding is
refused, the error says so and names `wrangler email sending enable <domain>`,
which needs running once.

The new mutation is stubbed under `--dry-run` like the other five, and the test
that proves it was mutation-checked by deleting that stub.
…nciled at deploy

Declares which platform's events Cloudflare should publish onto which Queue. The
queue is named rather than identified and Devflare resolves the id, so declaring
it in `bindings.queues` means the same deploy provisions it first — a subscription
cannot create its own, and Cloudflare offers no create-if-missing.

Matched on (source, queue), never on name. A name is cosmetic and one created by
hand in the dashboard will not carry the one Devflare would write, so matching on
it would add a second subscription beside the working one and both would deliver,
doubling every event onto the queue. Sources are compared with their keys sorted,
because Cloudflare is under no obligation to echo fields in the order they were
sent and a raw JSON comparison would create a duplicate on every deploy. Nothing
is deleted or updated: a subscription is a live delivery path.

`source` is a PASSTHROUGH, and that is the considered choice rather than laziness.
Cloudflare publishes eight source types in its API schema and ships ten in
wrangler; Email Sending, generally available and documented since July 2026,
appears in neither. A modelled union would reject sources that work.

Which leaves what to put in it for an undocumented source, and the honest answer
is that guessing is unsafe: the sample event payloads use camelCase while every
verified request shape uses snake_case, so `zoneId` versus `zone_id` is a coin
flip that fails at deploy time inside a tool whose whole promise is that the
config is correct. So Devflare does not guess and does not ask the author to
either — when a declared subscription matches nothing, it prints the `source` of
every subscription the account already has, exactly as Cloudflare stores it.
Create one in the dashboard, deploy once, copy the field names out of the output.

The envelope itself is verified three ways that agree: Cloudflare's OpenAPI
schema, the generated SDK, and wrangler's own implementation. So are the two
things easiest to get wrong, both now documented — `events` takes the SHORT form
(`message.delivered`), not the `cf.email.sending.message.delivered` that arrives
ON the queue, which subscribes to nothing and says nothing; and the destination
needs a queue id rather than a name.

Preview deploys provision none, for the same reason they provision no zone
resources: a subscription is account-wide and would outlive its branch. Both
"nothing to prepare" shortcuts learned about it too — the second time that trap
has bitten, so the comment now says the guards enumerate what they know about and
anything added to the sequence must be added there. A positive control in the
dry-run test is what caught it, again.
…ication email nobody got

Creating an Email Routing rule does not create the destination address it points
at. Cloudflare accepts the rule against an address it has never heard of and then
silently drops every message: no error, no bounce, no log. And adding the ADDRESS
is what triggers the verification email — so a deploy that created only the rule
left the operator waiting for a link that was never sent. Observed, not theorised.

A deploy now ensures every address named by a rule's `forward` or the catch-all's
runs FIRST, on the account, before any rule that needs it. Adding one is reported
AND warned about, because the rule is live and dropping mail until somebody
clicks. An address that exists but is unverified warns rather than failing: that
click belongs to whoever owns the mailbox and may be days away, and failing there
would make a correct configuration unshippable until someone read their email.

Absence of a `verified` timestamp IS the unverified state — Cloudflare publishes
no boolean for it, so a `=== false` here would never be true.

The dry-run stub is mutation-verified, and that check earned its keep: the first
attempt to add the assertion put it in the wrong test, because a plain string
replace takes the FIRST occurrence and an identical line appeared earlier in the
file. The guard passed, the mutant survived, and only running the mutation showed
the assertion was never in `expectNoMutation` at all.
…at is gone

The bridge is served from inside workerd, so every runtime reload is a bridge
outage, and the app could not tell one from `devflare dev` never having started —
both are a refused socket. The flat 3s connect budget was the compromise that
fact forced, and it could not be right: the coordinator needs longer than 6s just
to DECLARE a death, while a budget long enough to cover a rebuild would hang every
request when nothing is running at all.

`devflare dev` now publishes a runtime-status channel from the coordinator process
itself — a loopback listener on an ephemeral port, which no workerd restart can
take away — and hands its URL to the app it spawns. The connect asks on each
failed attempt: `reloading`/`starting` buys a 30s wait that collapses the moment
the promise stops, while `failed`, `stopping` or no answer at all fails the
request immediately. An app started outside `devflare dev` gets no URL and behaves
exactly as before. The state is settled per request against a live probe, because
the window this exists to cover is the one where every cached answer says `ready`.

The failure also stops arriving under someone else's name. `event.platform` was
dropped when it could not be built, so a binding read reached the app's own
"binding is missing. Run the app via `devflare dev`" — the one thing the developer
was already doing. The platform is now always attached and its `env` refuses, with
devflare naming the real cause and keeping the transport error as `cause`. Serving
continues either way: a page that touches no binding has no business failing over
a reload.

Two things found while in there, both fixed and pinned: an auto-reconnect timer
outlived the client that scheduled it (`autoReconnect = false` is read when the
reconnect is scheduled, not when it fires), and a loopback listener's `close()`
waited out every open socket — which, for a channel an app polls over keep-alive,
is a dev server that never exits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…he exit

`close()` on the outbound-email service called only `server.close()`, which refuses
new connections and then waits out every socket still open. The composed worker
posts its deliveries there over a keep-alive connection and
`disposeDevServerState()` awaits this close, so a connection still open at teardown
stalled the dev server's exit for however long that socket happened to live.

Same defect, same fix as the runtime-status listener in 1a8bd5c: drop the open
connections first. Both of that fix's bun gotchas apply and are pinned in the
comment — `closeAllConnections()` only counts BEFORE `close()` under bun's
`node:http` (measured 1ms vs 2973ms the other way round), and bun takes the
listener down with the connections, so the following `close()` reports
`ERR_SERVER_NOT_RUNNING`, which is the requested outcome rather than a failure.

The test MEASURES the close while a mid-request socket is held, because the defect
never failed a close — it finished, seconds late. Verified against both mutants:
dropping `closeAllConnections()` reds it at 3005ms, and dropping the
`ERR_SERVER_NOT_RUNNING` tolerance reds it too, so neither line is decoration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Onboarding a sending domain makes Cloudflare write and LOCK records of its own, and
one of them lands on a name a config legitimately wants: the DMARC policy sits at
`_dmarc.<domain>` — the zone apex, not the `cf-bounce` subdomain the rest of the
managed set uses. So `dns: [{ type: 'TXT', name: '_dmarc', … }]` beside
`emailSending.enable` is a collision, and a write to a locked record answers HTTP 400
code 1046: a bare Cloudflare error naming neither the record nor the remedy.

Reported rather than thrown, and the asymmetry with the ambiguous-record-set error
beside it is the point. That one throws because guessing could DESTROY an unrelated
record; this one destroys nothing — it only means a value the config claims to control
is not in effect, which must not block shipping the worker the deploy is about.

The warning says the part that is easy to miss: without your own `rua=`, DMARC
aggregate reports go nowhere, so there is never any evidence on which to tighten the
policy — and that is invisible by construction, since a domain with a working `p=none`
looks exactly like one that is collecting nothing.

A locked record whose content already matches stays an ordinary converged result;
`meta` is opaque in Cloudflare's published schema and these keys are observed rather
than documented, so a record saying nothing about itself is treated as ordinary.
Three mutants — guard always false, always true, and placed before the content
comparison — each killed by exactly one of the three new tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ce, or nothing boots

Miniflare made `namespace_id` required in 4.20260730.0 — counters are keyed by
the namespace, not by the binding name — and devflare kept emitting the older
shape, dropping the authored `namespaceId`. Options are validated in the
constructor, so this did not degrade the limiter: every `devflare dev` and
`createTestContext()` on a config with `bindings.rateLimits` died at boot with
"Unexpected options passed to new Miniflare() constructor", taking every other
binding down with it.

The wrangler compiler was always right. Only the Miniflare-options side dropped
it, in three places, because the same mapping had been copy-pasted into the dev
server, the bridge and the test harness — they now all call the one
`buildRateLimitsConfig`.

The floor stays at ^4.20260424.0: the older schema is a non-strict z.object that
strips the extra key, verified against the installed 4.20260424.0 and
4.20260625.0 dists, so emitting it is safe across the whole declared range.

Covered by a unit assertion that pins the built options to the authored
namespaceId, and by an integration test that boots createTestContext() on two
rate limiters and asserts the limiter enforces — the boot that was missing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…sal, not a bridge socket

The gateway forwarded an unmatched WS upgrade to the app worker but kept the
answer only when it was 101 with a webSocket. Every refusal — the 401 from an
expired token, the 403, the 404, the 500 — was discarded, and the request fell
through to handleBridgeWebSocket, devflare's own RPC socket. So the browser was
told 101, believed it had reached its route, and spoke its own protocol at the
bridge dispatcher, which answered each frame with

  ERROR  [Gateway] Error: SyntaxError: Unexpected token 'p', "ping" is not valid JSON

once per liveness probe while the real refusal reached nobody.

The fallback is deleted rather than special-cased for 401, because it could
never have fired legitimately: the bridge socket belongs to the Node-side
bridge client, and that client cannot coexist with an app worker.
APP_SERVICE_BINDING is non-null only when shouldRunMainWorker is true, which
requires !enableVite (miniflare-dev-config.ts:149), while that client IS the
Vite/SvelteKit dev handle dialing ws://localhost:<bridgePort>
(sveltekit/platform.ts:370) — and it holds per gateway inside a workspace too,
where every app builds its own. An app that serves its own WebSocket at any
path, '/' included, now keeps it; a gateway carrying an app binding miniflare
did not bind answers 500 and says so.

The dispatcher also stops reporting the residual case as a raw SyntaxError. A
text frame that is not JSON is not a bridge frame, so the peer is not a devflare
client; it now says that with a truncated preview and keeps the SyntaxError as
the cause. Nothing is swallowed — the diagnostic names the situation instead of
reading like a devflare serialization bug.

Two tests join the pass-through they are the other half of: the app's own 401
(body included) reaching a caller that carries the upgrade header, and a real
browser socket to a refused route failing its handshake instead of opening.
Against the previous gateway the first reads 400 and the second opens. The other
two gateway variants were checked and are unaffected — miniflare-gateway.ts is
HTTP-only and simple-context-gateway-script.ts has no app worker.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

1 participant