Conversation
🦋 Changeset detectedLatest commit: 1c7f506 The changes in this PR will be included in the next version bump. This PR includes no changesetsWhen 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
Bot
had a problem deploying
to
testing branch preview / next
April 11, 2026 19:05
Failure
github-actions
Bot
had a problem deploying
to
testing branch preview / next
April 14, 2026 08:36
Failure
github-actions
Bot
had a problem deploying
to
testing branch preview / next
April 14, 2026 10:09
Failure
✅ Documentation PR preview deployed successfullyThis pull request gets a stable PR-scoped preview link that is updated in place on every preview run.
Logs and details
|
✅ Testing branch preview deployed successfullyThis 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
|
github-actions
Bot
had a problem deploying
to
testing branch preview / next
April 14, 2026 12:06
Failure
github-actions
Bot
had a problem deploying
to
testing branch preview / next
April 14, 2026 13:21
Failure
github-actions
Bot
had a problem deploying
to
documentation branch preview / next
April 14, 2026 17:21
Failure
github-actions
Bot
had a problem deploying
to
testing branch preview / next
April 14, 2026 17:28
Failure
…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>
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.
No description provided.