2026-09-11 — The eight bindings that had no accessor

This started in a broader library review and was the only gap found here.

The review was looking for duplicated code. This was the opposite: not something written twice, but something written nowhere. The library had ten binding accessors, while a consumer used an eleventh binding directly.

Seven more followed, for three further reasons, and all eight are worth reading together because they are the same gap at different stages of its life.

How the gap was visible

Not from this package. Everything here looked consistent, because the missing accessor left no trace in a repository that never had it.

It showed in a consuming Worker's wrangler.json:

"browser": { "binding": "BROWSER" }

The corresponding browser service took a BrowserWorker and was handed env.BROWSER raw. Every other resource in the consumer went through an accessor, and the difference was invisible from either side alone.

What the accessor is actually worth

It carries no methods. BrowserBinding is Binding<Fetcher> with a constructor, and everything it offers — resolve, tryResolve, isBound, name — is inherited.

That still buys the thing the direct read cannot. env.BROWSER on a deployment that forgot the binding is undefined, which reaches puppeteer.launch and fails inside a browser automation library, in that library's vocabulary. The same case through the accessor is a MissingBindingError naming BROWSER. One of those points at the wrangler.json line that is missing; the other does not.

It also moves the binding name into configuration, so a Worker calling it something else stops hard-coding that string at the call site.

Why there is no BrowserService

Every other binding here has a capability service beside it, and this one does not. The asymmetry is deliberate and the class documents it, because an undocumented asymmetry reads as an oversight and invites someone to "finish" it.

Doing anything with a browser beyond holding the binding means driving it, and driving it means @cloudflare/puppeteer — a real runtime dependency, not a type. Taking it on would put a browser automation library in the dependency tree of every Worker that installs this package to get a KV accessor. The whole reason hono sits behind ./middlewares as an optional peer is that this package refuses to make one consumer's dependency everyone's.

The second reason is the one that also kept Retry out of the kernel: the driving has exactly one consumer. A capability with one implementation and one caller has not earned an interface yet.

If a second appears, the shape to copy is already here. A ./browser subpath, @cloudflare/puppeteer as an optional peer, an ESLint rule confining the import to that directory — ./middlewares end to end.

Fetcher, not BrowserWorker

@cloudflare/puppeteer exports a BrowserWorker type, and using it would have been the more literal choice. It is also Fetcher structurally, and naming Fetcher means the accessor needs no dependency at all — the binding type comes from @cloudflare/workers-types, which is already a peer.

A consumer that does use puppeteer passes the resolved binding straight in; the types line up without a cast.

Workflows and Flagship — the same gap, caught early

Workflows and Flagship were added before known consumers depended on them. The intended shapes were already clear: a Workflow producer needs to create runs and inspect their status, while a flag accessor needs typed evaluation with a safe deployed fallback.

Without accessors, a consumer reaches for a binding such as env.WORKFLOW directly because it is the shortest path and nothing stops it — exactly the pattern Browser Rendering exposed. Adding the accessors first avoids a later migration.

This is the cheapest an accessor is ever going to be, and the only moment it can be added without anything depending on it being wrong.

The Worker-side integration was already answerable from the pinned types. Flagship is a global in the @cloudflare/workers-types version this package pins, and the binding is a flagship array in wrangler.json carrying a binding and an app_id. That is everything an accessor needs.

Why one of them has methods and the others do not

BrowserBinding is Binding<Fetcher> with a constructor and nothing else. WorkflowBinding carries create, createBatch, get and status; FlagshipBinding carries four evaluation methods. The asymmetry is not a judgement about which binding matters more — it is the dependency question answered three times with different inputs.

Doing anything with a browser means @cloudflare/puppeteer. Doing everything with a Workflow means Workflow, WorkflowInstance and WorkflowInstanceCreateOptions; doing everything with Flagship means Flagship and FlagshipEvaluationContext. Those are all in @cloudflare/workers-types — already a peer. Two surfaces are free and one is not.

The method names are the platform's on purpose. An accessor that renames create to start makes Cloudflare's own documentation stop describing this class, and the reader pays that cost every time. status(env, id) is the single addition, because a status endpoint reads a run on every request and (await get(id)).status() is two awaits to say one thing.

Retention is settings, not an argument

WorkflowSettings carries successRetention and errorRetention beside the binding name, which is the same call KV's expirationTtl made: how long a finished thing is kept is a deployment policy, not a per-call decision, and a caller who forgets it should get a bounded answer rather than the longest period the account allows.

The merge is all-or-nothing. A call that names retention at all owns both halves of it, so asking for a longer error retention on one run does not silently inherit the configured success retention beside it. Half-merging two fields of one policy object is the kind of helpfulness that is impossible to predict from the call site.

The one accessor that answers instead of throwing

The hardest decision here was Flagship's, and it goes against the grain of everything else in the package.

MissingBindingError exists because undefined reaching a database client fails somewhere unhelpful, and naming the binding is the difference between a stack trace and a wrangler.json line to add. That reasoning is sound for every resource that carries data. It is wrong for a flag.

A flag decides between two paths that both work. If it did not, it would not be a flag — it would be a dependency. So a Worker that cannot reach Flagship has an obviously correct thing to do: take the path it took before the flag existed. Throwing there would turn the flag service into a new way for the Worker to be down, which inverts the entire reason to adopt one. The product is bought to make rollbacks cheaper.

So boolean, string, number and object each take a fallback and return it in all three failure modes: no binding, a failed evaluation, a type mismatch. The platform already handles the last two if it can be reached at all; this class adds the first, and catches what the platform raises. AnalyticsBinding.writeSafe set the precedent — telemetry should never fail a request either.

The cost is real and worth stating rather than hiding. Swallowing an evaluation error means a misconfigured app ID reads as "every flag returned its fallback", which is indistinguishable from "every flag is off" at the call site. console.error carries the reason and isAvailable(env) answers the binding half, but a caller who needs to tell the two apart has to drop to resolve(env) and the platform's *Details methods, which report reason. That is the right place for the sharp edge: rare, and reachable.

What follows from the posture is that the fallback argument is not boilerplate — it is the deployed behaviour, and choosing it carelessly is how a flag outage becomes an incident. A safe default is the behaviour used before the flag was introduced.

AI Search — a gap with no consumer at all, and a deprecation attached

The first four accessors were each pulled by a concrete use case. AI Search was added to make the library's binding coverage internally consistent.

Two things made it worth the space anyway.

It is two bindings, not one. ai_search names one instance at deploy time; ai_search_namespaces opens a namespace so a request can name the instance. Those are different products in practice — one corpus fixed at deploy, versus a corpus per tenant created as tenants arrive — and shipping only the first would have recreated, inside a single feature, exactly the gap the previous four accessors closed.

The obvious implementation would have been wrong. AIBinding has existed since 1.0.0, and AI Search used to hang off it: env.AI.autorag(id), env.AI.aiSearch(). Adding two methods there would have been a smaller diff than two new classes. Both are marked @deprecated in the @cloudflare/workers-types version this package already pins, in favour of exactly the standalone bindings these classes wrap — so the smaller diff would have adopted a deprecated surface on the day it was written, and the deprecation notice was sitting in a file already on disk.

That is the second time in one session that the answer was in the pinned type definitions rather than anywhere else. Flagship's binding and AI Search's deprecation were both already described by the installed type definitions.

Streaming is a method, not a flag

AiSearchInstance.chatCompletions is overloaded: stream: true returns a ReadableStream, anything else returns a parsed response. That is a reasonable shape for a platform type and a bad one to pass through, because the parameter that changes the return type is buried in an options object several fields deep.

So this package splits it into chat and chatStream, each forcing the flag it means. A boolean argument that changes a return type is a discriminated union pretending to be an option, and the caller pays for the pretence at every call site.

The spec worth having is the one where a caller passes stream: true to chat. It is overridden, because the method's return type has already promised otherwise, and quietly honouring the flag would hand back a ReadableStream typed as a parsed response.

Partial failure that says so

The multi-instance search fans out across instances and returns an errors array naming the ones that did not answer, alongside chunks tagged with the instance they came from. Nothing in this package changes that, and the accessor's documentation points at it in as many words: a caller that ignores errors is reading a result set that silently lost a corpus.

Worth writing down because the failure mode is invisible in every way that matters — the call resolves, the shape is right, the chunks are real. Only the count is wrong, and only against a number nobody has.

Pipelines — the first binding whose type is not global

Every accessor before this one wrapped a type from the ambient scope. Fetcher, Workflow, Flagship, AiSearchInstance — all declared at the top level of @cloudflare/workers-types, all reachable without an import.

Pipeline is not. It lives inside declare module "cloudflare:pipelines", so the accessor imports it:

import type { Pipeline, PipelineRecord } from 'cloudflare:pipelines'

That is a runtime module specifier this package cannot bundle, which raised the only real question of the change: whether the packaging would survive it. It did, and not by luck — rollup.config.ts has listed /^cloudflare:/ in external since 1.0.0, against no binding that needed it. Someone anticipated this.

The check is worth describing because the unit tests cannot make it. They import from src/, so a mis-bundled lib/ passes every one of them — the same blind spot assertExternals exists to cover. So lib/index.d.ts was read directly to confirm the module import survived as an import rather than being inlined, and both minified bundles to confirm they carry no trace of it, the import being type-only. One line in, zero lines out, which is exactly right.

There is a consumer-facing consequence worth stating: a Worker's tsconfig.json must resolve cloudflare:pipelines for this declaration to typecheck. Every Worker that already has @cloudflare/workers-types does, since that is where the module is declared — the peer dependency was always required, and this is the first place a module rather than a global depends on it.

Two ways to send, and the safe one is the reason

Pipeline offers exactly one method, send. This accessor offers two.

The library's own precedent settled it. AnalyticsBinding has write and writeSafe, because telemetry should never fail a request. A pipeline's usual job has the same shape: it often sits beside an existing delivery path as an independent archive. A sink like that failing the run that produced the records inverts its purpose — the archive exists to explain a bad run and would instead be causing one.

So sendSafe swallows a missing binding, a rejected batch and an unreachable pipeline alike, logs, and reports through its return value. send throws like everything else here.

The rule for choosing between them is one question: does the record have anywhere else to be? An audit trail that is the only copy should throw, and the class says so rather than leaving sendSafe to look like the better-behaved option.

Two smaller decisions, both in the direction of not being clever. send passes an empty array through instead of short-circuiting, because whether an empty batch is worth a call belongs to the pipeline and a caller that filtered everything out has already decided to send. And nothing chunks a large batch, for the same reason createBatch does not.

Workers VPC — the first one where the types ran out

Six accessors in, a rhythm had set in: check the pinned @cloudflare/workers-types, find the binding type, wrap it. Twice the type definitions answered a question that looked open — Flagship's binding existed when a plan called it blocked, AI Search's deprecation was sitting in a file already on disk.

Workers VPC broke the rhythm. grep -i vpc over the pinned types returns nothing at all.

The product is real and documented — two binding kinds, vpc_services for one pre-registered host and port, and vpc_networks for a whole Cloudflare Tunnel or Mesh with the destination chosen per call. It is also in beta, and Cloudflare says plainly that features and APIs may change before general availability. So the question was not how to wrap the type but whether to write one.

Half the answer was free. A VPC Service binding offers fetch and nothing else, which is Fetcher structurally — the same observation that types BrowserBinding, and it means VpcServiceBinding invents nothing while naming a real type.

The other half was not. connect(address): Promise<Socket> has no type to borrow, so VpcNetwork is written in this package: the only binding shape here not taken from Cloudflare's own definitions.

What a hand-written binding type actually costs

Worth stating rather than assuming, because it is a different kind of risk from everything else in this release.

Every other type here is checked against the runtime by whoever maintains @cloudflare/workers-types. A type written locally is checked against a documentation page read once. If Cloudflare changes the surface before GA — renames connect, adds a required argument, returns something other than a Socket — this package compiles happily and the consumer finds out in production. No test can catch it either, since the tests supply the fake.

Three things keep that bounded.

It is confined to one file, and VpcServiceBinding is untouched by it. It is kept to exactly the two documented methods, because a smaller invented surface is a smaller thing to be wrong about — no convenience wrapper around the socket, no options argument the docs do not show. And the interface says in its own documentation that it should be deleted rather than maintained: when the real type ships, the accessor imports it and this goes.

That last part matters most. The failure mode for a stand-in type is not being wrong on the day it is written — it is still being there two years later, subtly diverged, with nobody remembering it was ever meant to be temporary.

The security asymmetry between the two bindings

The two VPC bindings are not two conveniences over one product. They differ in what a compromised request can reach.

A VPC Service binding has its destination fixed in wrangler.json as a service_id. A Worker holding it reaches one private endpoint, and no input it processes can change that. A VPC Network binding reaches anything on the bound tunnel or mesh, with the address supplied per call — so an address derived from an incoming request is server-side request forgery with a private network behind it.

The tempting move is to add an allowlist setting: a list of permitted hosts, checked before the call. This package does not, and the settings interface says why. A control enforced in library code is one a caller routes around by calling resolve(env) and using the binding directly — which is a supported operation on every accessor here. It would read as a security boundary while being a convention, and a convention that looks like a boundary is worse than neither.

Cloudflare's own answer is the right one to point at: bind a VPC Service per destination, and the enforcement lives where it cannot be bypassed.

Only four of the twelve methods

Flagship exposes twelve: four value getters, four *Details variants, an untyped get, and their overloads. This accessor exposes four.

The binding guide's own instruction is to expose "only the operations your code needs", and the trim follows it. The *Details variants carry variant and reason for experiment analysis. get returns unknown, which the four typed methods strictly improve on. Neither is lost: resolve(env) returns the binding, and the platform's full surface with it.

The risk of trimming is being wrong about what a consumer needs, and that risk is bounded here — adding a method later is a minor version, while removing one is a major.

What else this turned up

The package had been describing itself wrongly since 1.0.0, in three places that all agreed with each other: src/index.ts, the README, and the bindings guide each said "ten accessors" and "twelve settings shapes". The Browser work did not catch it, which is its own small lesson — a count in prose is a fact that no build step checks.

The bindings guide had a sharper version of the same problem. Its "adding a binding of your own" walkthrough used a hypothetical BrowserBinding with a screenshot method, invented as an illustration back when no such class existed. As of this release it names a real class that has no such method. The walkthrough now builds an EmailBinding, which this package genuinely does not provide — and the reason it drifted is worth remembering when picking the next example.

What adoption found, after publishing

A consuming Worker that read env.BROWSER directly exposed the need for BrowserBinding. Pointing it at the accessor produced compile errors before the adoption itself began, even though the release notes had said there were no breaking changes.

The cause is one this package had not thought about. CloudflareEnv is designed to be extended:

export interface Env extends CloudflareEnv {
  BROWSER?: BrowserWorker // from @cloudflare/puppeteer
}

That was correct and necessary before this release, and the comment beside it said so: "Cloudflare does not put this one in CloudflareEnv, so it is declared here." True when written. False as of 1.3.0 — and the moment the base interface declares the same member, the derived one has to be assignable to it. BrowserWorker is { fetch: typeof fetch }, narrower than Fetcher, so it is not. TS2430 on the interface, then TS2345 and TS2344 at every site where that Env was passed as a CloudflareEnv.

Adding a member to a base interface is not an additive change for anyone who already declared that member. Obvious once stated; invisible from inside the package, where every new member looked purely additive and the release notes said so eight times over.

The fix in the consumer is a deletion — the inherited declaration supersedes the local one, and Fetcher is assignable to BrowserWorker, so the call site passing the binding into puppeteer.launch never changed. The fix here is honesty: the breaking-changes section of a published release now describes it, and names all eight members to check rather than only the one that bit.

The issue affects any consumer that already declares one of the eight newly inherited binding names; consumers that do not declare those names are unaffected.

Adopting the accessor kept the better error

The direct read it replaced was not naive. Both call sites already checked the binding and threw something more useful than MissingBindingError would have been:

Browser transport is enabled but no BROWSER binding is declared. Add { "browser": { "binding": "BROWSER" } } to wrangler.json, or select the fetch transport.

MissingBindingError says the binding named BROWSER is missing. It does not know that a variable asked for the browser transport, that fetch is the alternative, or that the alternative is the thing currently being refused by the source. Swapping resolve(env) in for the check would have been a straight downgrade in what an operator reads at 3am.

So the adoption uses isBound(env) for the test, keeps the message, and calls resolve(env) only to obtain the binding. What it buys is the part the accessor is actually for: the binding name comes from configuration, so browser.name fills the message and the string BROWSER no longer appears at the call site at all.

That is the general shape for adopting an accessor into code that was already careful. The accessor is not always more informative than what it replaces; it is always the better place for the name to live.

Version

Minor, and one version for all eight. The publishing guide states the rule directly — "adding a binding accessor is a minor change" — and 1.3.0 had not been published when any of the later seven landed, so an unpublished number absorbed them rather than seven more being spent. Eight accessors, seventeen exports, no removals.

That is the whole argument for publishing on a cadence rather than per change, incidentally. Each of these would have been a defensible minor release on its own, and seven of the eight would have been a version number spent on something a consumer would have upgraded past without reading.

Public repository documentation review

The repository's living Markdown was reviewed before public release. The README now focuses on the package, its supported adapters, authenticated GitHub Packages installation, a self-contained quick start, and clear routes to documentation, support, contribution, and security guidance.

The review also corrected drift in the installation, local development, quick-start, migration, publishing, and binding guides. Examples now match current method signatures and generated artifacts, Workflow batching distinguishes the 100-instance API limit from the per-event payload limit, and stale or nonexistent paths and commands were removed.

Community files and GitHub templates now ask for library-specific reproduction details, compatibility impact, safe log handling, and private vulnerability reporting. Duplicate pull request templates were removed so contributors see one canonical checklist.

Finally, npm test now checks Markdown formatting and validates local file links and heading anchors. Documentation drift that can be detected mechanically is therefore part of the same gate as lint and unit tests.

results matching ""

    No results matching ""