Bindings Guide

Every Cloudflare resource the Worker talks to — a database, a KV namespace, a bucket, a queue — arrives as a binding on the per-request Env object. This guide covers the accessor classes in src/core/bindings/ that wrap them, and how to wire each one in wrangler.json. How their settings are chosen is Register the configuration surface's job.

Four places are involved, and the split is deliberate:

Where Holds
@bayudwiyansatria/core configure and resolve — the one abstraction settings are read through
src/constants/Defaults.ts cloudflareDefaults — the default binding name and settings for each
src/core/bindings/ One *Binding accessor per Cloudflare binding, over the Binding base
your Worker's config/ Overrides — plain data replacing individual default fields

Why an accessor class

Bindings live on ctx.env, not on a module-level import, so they cannot be captured once at startup. The accessor holds the name of a binding — handed to it already resolved — and reads the binding from the Env given to each call:

import { KVBinding } from '@bayudwiyansatria/cloudflare'

// Created once, at module scope. Binding name and default TTL come from the configuration layer.
const cache = new KVBinding()

// The binding itself is read per request.
api.get('/profile/:id', async ctx => {
  const profile = await cache.getJson<Profile>(ctx.env, `profile:${ctx.req.param('id')}`)
  return ctx.json(profile)
})

What that buys over reaching into ctx.env.KV directly:

  • A named failure. A binding that was never declared in wrangler.json throws MissingBindingError naming the binding, instead of Cannot read properties of undefined.
  • Settings in one place. TTLs, models, queue delays, and topK values are configured once, in the configuration layer, rather than repeated at every call site — and overridable from config/ without editing core/.
  • Renaming stays local. Change the binding name in one override and every call site follows.

Available bindings

Class Config key Default binding Wraps wrangler.json key
AIBinding ai AI Ai ai.binding
AiSearchBinding aiSearch AI_SEARCH AiSearchInstance ai_search
AiSearchNamespaceBinding aiSearchNamespace AI_SEARCH_NAMESPACE AiSearchNamespace ai_search_namespaces
AnalyticsBinding analytics TELEMETRY AnalyticsEngineDataset analytics_engine_datasets
BrowserBinding browser BROWSER Fetcher browser
D1Binding d1 DB D1Database d1_databases
DurableObjectBinding durableObject DO DurableObjectNamespace durable_objects.bindings
FlagshipBinding flagship FLAGS Flagship flagship
HyperdriveBinding hyperdrive HYPERDRIVE Hyperdrive hyperdrive
KVBinding kv KV KVNamespace kv_namespaces
PipelineBinding pipeline PIPELINE Pipeline pipelines
QueueBinding queue QUEUE Queue queues.producers
R2Binding r2 BUCKET R2Bucket r2_buckets
RateLimitBinding rateLimit RATE_LIMITER RateLimit ratelimits
VectorizeBinding vectorize VECTORIZE Vectorize vectorize
VpcServiceBinding vpcService VPC_SERVICE Fetcher vpc_services
VpcNetworkBinding vpcNetwork VPC_NETWORK VpcNetwork vpc_networks
WorkflowBinding workflow WORKFLOW Workflow workflows

The default binding names come from src/constants/Defaults.ts; any of them can be pointed elsewhere from config/.

SecurityMiddleware is configured the same way (key security) but wraps no binding — it applies CORS, IP capture, and API-key auth to the Hono app. See SecurityMiddleware.

All of the accessors are re-exported from the barrel:

import { D1Binding, KVBinding, R2Binding } from '@/core/bindings'

Capability services

Business code does not use these accessors directly. Each one is wrapped by a capability service in src/core/services/, which is what a Worker's own services/ actually calls:

Capability Offers Returns
AIService ask, embed, logId text, vectors
AnalyticsService request, event — telemetry only, never logs boolean
D1Service all, first, write, batch rows, { changes, lastRowId }
DurableObjectService call, create the object's parsed reply
HyperdriveService connectionString, credentials, target connection details
KVService get, set, remove, remember, keys the cached value
QueueService enqueue, enqueueAll, backlog boolean, metrics
R2Service upload, uploadJson, download, exists, remove, list bodies, { key, size }
RateLimitService admit boolean
VectorizeService index, search, similar, remove matches, mutation ids

Eight accessors have no service beside them, for three different reasons. BrowserBinding would need @cloudflare/puppeteer to offer anything beyond the binding itself, and this package does not put a browser automation library in the dependency tree of a Worker that installed it for a KV accessor. WorkflowBinding has no kernel capability to implement — the kernel names a CacheStore, a DataStore, a MessageQueue, and durable execution is not among them. FlagshipBinding has no kernel capability either, though it is the likeliest of the three to earn one: flag evaluation has many providers and none of its vocabulary is Cloudflare's. The two AI Search accessors are in the same position — managed retrieval is not among the kernel's capabilities either, and neither is record archival, which is what leaves PipelineBinding and the two Workers VPC accessors in the same place. Every one of the eight says so in its own documentation, so the asymmetry does not read as an oversight.

They return plain values rather than APIResponse — that contract belongs at the HTTP boundary, not inside the platform layer — and every one answers isAvailable(env) so a caller can degrade when an optional resource is not bound.

import { KVService } from '@bayudwiyansatria/cloudflare'

const cache = new KVService()

const article = await cache.remember(env, `article:${id}`, () => load(env, id), 600)

See the Quick Start for how a route, a business service, and these capabilities fit together.

Shared API

Every accessor extends Binding and inherits:

Member Behaviour
name The binding name this instance resolves
isBound(env) true when the binding is present — use it to degrade gracefully
resolve(env) The binding itself; throws MissingBindingError when it is missing
tryResolve(env) The binding, or null when it is missing

Every helper method takes env as its first argument and calls resolve internally, so a missing binding always fails the same way.

D1

"d1_databases": [{ "binding": "DB", "database_name": "app", "database_id": "<id>" }]
const db = new D1Binding()

const user = await db.first<User>(ctx.env, 'SELECT * FROM users WHERE id = ?', id)
const active = await db.all<User>(ctx.env, 'SELECT * FROM users WHERE active = ?', 1)
const result = await db.run(ctx.env, 'UPDATE users SET name = ? WHERE id = ?', name, id)

await db.batch(ctx.env, [
  db.prepare(ctx.env, 'INSERT INTO audit (action) VALUES (?)', 'rename'),
  db.prepare(ctx.env, 'UPDATE users SET name = ? WHERE id = ?', name, id)
])

Bind values are always passed separately from the SQL — exec is the one path without binding support, so keep it for migrations and never build a statement there from user input.

KV

"kv_namespaces": [{ "binding": "KV", "id": "<id>" }]
const cache = new KVBinding() // expirationTtl: 300 by default

await cache.putJson(ctx.env, 'session:42', { userId: 42 }) // expires in 300s
await cache.putJson(ctx.env, 'flag:beta', true, { expirationTtl: 86400 }) // per-call override
const session = await cache.getJson<Session>(ctx.env, 'session:42')
const page = await cache.list(ctx.env, { prefix: 'session:' })

expirationTtl from the constructor is applied only when a call sets neither expirationTtl nor expiration.

R2

"r2_buckets": [{ "binding": "BUCKET", "bucket_name": "app-assets" }]
const storage = new R2Binding()

await storage.put(ctx.env, 'reports/2026-07.csv', csv)
await storage.putJson(ctx.env, 'reports/2026-07.json', report) // sets application/json
const csvBody = await storage.getText(ctx.env, 'reports/2026-07.csv')
const listing = await storage.list(ctx.env, { prefix: 'reports/' })

get returns the object with its body; head returns metadata only, which is the cheaper check for existence.

Queues

"queues": { "producers": [{ "binding": "QUEUE", "queue": "jobs" }] }
const jobs = new QueueBinding<EmailJob>()

await jobs.send(ctx.env, { to: 'user@example.com', template: 'welcome' })
await jobs.sendBatch(ctx.env, pendingJobs)

sendBatch bills as a single write regardless of how many messages it carries — prefer it in loops.

Pipelines

"pipelines": [{ "binding": "PIPELINE", "pipeline": "raw-records" }]
const archive = new PipelineBinding<RawRecord>()

// The only copy — a failure should be heard.
await archive.send(ctx.env, records)

// A second sink beside the real one — a failure must not fail the run.
await archive.sendSafe(ctx.env, rejected)

Two methods, and choosing between them is the whole decision. send throws like every other accessor here. sendSafe logs and returns false, exactly as AnalyticsBinding.writeSafe does, because a pipeline's usual job is a second, independent sink beside the one doing the real work — archiving the raw record a provider returned while the coerced one goes on to storage. 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. Ask whether the record has anywhere else to be. An audit trail that is the only copy should throw.

send resolves when the pipeline has accepted the records, not when they are written — the batching is what happens after this returns, so a successful send is a promise to land them rather than evidence that it happened.

An empty array is passed through rather than short-circuited: whether an empty batch is worth a call is the pipeline's business, and a caller that filtered everything out has already decided to send.

Against R2 and Queues

Writing to R2 directly means the producer owns buffering — how many records make a file, how long to wait through a slow hour, what happens to a partial batch when the isolate is evicted. Ingestion that arrives in bursts is exactly where that goes wrong, and it goes wrong quietly. A pipeline moves that policy onto the destination, where it applies to every producer.

It is not Queues either, though both take a batch and return quickly. A queue exists so another Worker can act on each message; a pipeline exists so nothing has to. If the records will be read as files later rather than processed one at a time, a queue's consumer is a step whose only job is writing them down.

The transformation entrypoint stays in your Worker

A pipeline can run records through a PipelineTransformationEntrypoint before landing them. Like a Workflow's WorkflowEntrypoint, that is a class the consuming Worker exports and names in its own wrangler.json, so this package wraps the producer side only — a base class that must be exported from your entry has nothing an adapter can add.

Workers AI

"ai": { "binding": "AI" }
const ai = new AIBinding() // uses the configured model or the package default

const answer = await ai.run(ctx.env, { prompt: 'Summarise this changelog' })
const embedding = await ai.run(ctx.env, { text: [body] }, '@cf/baai/bge-base-en-v1.5')

The first call uses the model from the ai configuration, which defaults to @cf/meta/llama-3.1-8b-instruct. Override ai.model during configure() to choose another deployment-wide default. The second call names its model explicitly, which always wins. Set gateway in the same configuration override to route calls through an AI Gateway, then read logId(ctx.env) to correlate a response with its gateway log entry.

Vectorize

"vectorize": [{ "binding": "VECTORIZE", "index_name": "documents" }]
const index = new VectorizeBinding() // topK: 5 by default

await index.upsert(ctx.env, [{ id: doc.id, values: embedding, metadata: { title: doc.title } }])
const matches = await index.query(ctx.env, embedding)

Writes are asynchronous — insert, upsert, and deleteByIds return a mutation id, not the applied state.

Hyperdrive

"hyperdrive": [{ "binding": "HYPERDRIVE", "id": "<id>" }]
const hyperdrive = new HyperdriveBinding()

const client = new Client({ connectionString: hyperdrive.connectionString(ctx.env) })

The connection string carries credentials scoped to the running Worker instance — never log it or return it to a client.

Durable Objects

"durable_objects": { "bindings": [{ "name": "DO", "class_name": "Counter" }] }
const counters = new DurableObjectBinding()

const stub = counters.stubByName(ctx.env, `room:${roomId}`)
const response = await stub.fetch('https://do/increment')

A Durable Object binding also needs the class exported from the Worker entry and a migration entry in wrangler.json — see Cloudflare's Durable Objects docs.

Analytics Engine

"analytics_engine_datasets": [{ "binding": "TELEMETRY", "dataset": "worker_logs" }]
const analytics = new AnalyticsBinding()

analytics.writeSafe(ctx.env, { indexes: ['/api/v1/articles'], blobs: ['POST', '201'], doubles: [42, 1] })

writeSafe swallows failures and returns false — telemetry should never fail a request. Use write when a failed write should surface.

Most code should reach for AnalyticsService rather than this binding, and should send metrics only: logs belong to the observability logstream. See the Observability Guide.

Rate limiting

"ratelimits": [{ "name": "RATE_LIMITER", "namespace_id": "1001", "simple": { "limit": 100, "period": 60 } }]
const limits = new RateLimitService()

if (!(await limits.admit(ctx.env, ctx.get('clientIp')))) {
  return ctx.json({ message: 'Too many requests', success: false, data: null }, 429)
}

Unlike every other binding here, the interesting settings are not in config/ and cannot be: limit and period are fixed in wrangler.json at deploy time, not passed at runtime. Only the binding name is a code-side decision. A Worker needing two different rates declares two bindings and constructs one accessor per binding — namespace_id is what separates them, and it must be unique within the Worker.

Three things to know before relying on it:

  • Calling admit is the increment. There is no way to ask without counting, so check exactly once per request.
  • Enforcement is per Cloudflare location and eventually consistent. The effective global rate is higher than the configured one, by roughly the number of locations seeing traffic. Treat the number as a floor.
  • It fails open by default. With no binding declared every request is admitted, so an unconfigured limiter degrades to no limit rather than to no service. Set rateLimit.failOpen to false from config/ where the limiter is the only thing standing between an unauthenticated endpoint and someone else's bill.

That imprecision is fine for shedding abuse and wrong for anything that must balance — never use it as a quota a customer is billed against.

Where this earns its place is the endpoint that cannot be addressed by an API key: a browser-facing route, or anything named in excludedRoutes. The origin allowlist says who may call; this says how often. See the SecurityMiddleware.

Workflows

"workflows": [{ "binding": "WORKFLOW", "name": "data-processing", "class_name": "DataWorkflow" }]
const workflow = new WorkflowBinding<DataRequest>()

const run = await workflow.create(ctx.env, { source: 'upload', objectKey })
const state = await workflow.status(ctx.env, run.id)

Like a Durable Object binding, the wrangler.json entry names a class the Worker itself exports — here a WorkflowEntrypoint whose run(event, step) holds the steps. That class is the Worker's, not this package's: a base class that must be exported from the consumer's own entry has nothing an adapter can add.

This accessor is the producer side. create returns as soon as the instance is accepted, so an endpoint that starts an hour-long run answers in milliseconds and the caller polls status for the outcome. Passing an id is the only idempotency Cloudflare offers — a second start under an existing id throws rather than running twice.

createBatch accepts up to 100 instances. Each instance payload is also subject to Cloudflare's current Workflow event payload limit. Nothing here chunks on the caller's behalf; the right chunk size depends on payload size, which only the caller knows. See Cloudflare's Workers API and Workflow limits.

successRetention and errorRetention in config/ bound how long finished instances keep their output. A call that names retention itself owns both halves of it rather than inheriting one from the configuration.

Browser Rendering

"browser": { "binding": "BROWSER" }
const browser = new BrowserBinding()

if (!browser.isBound(ctx.env)) {
  return ctx.json({ message: 'This deployment cannot render pages', success: false, data: null }, 503)
}

const session = await puppeteer.launch(browser.resolve(ctx.env))

The only accessor here that carries no methods of its own, and the only one whose binding is typed as Fetcher — which is what the runtime hands over, and is structurally what @cloudflare/puppeteer asks for, so the resolved binding passes straight in without a cast. Driving the browser is the consumer's job and its dependency; what this buys is a MissingBindingError naming BROWSER instead of undefined failing somewhere inside a browser driver.

Flagship

"flagship": [{ "binding": "FLAGS", "app_id": "<app id>" }]
const flags = new FlagshipBinding()

// The second argument is the deployed behaviour, and the answer if Flagship is unreachable.
const channel = await flags.string(ctx.env, 'release-channel', ctx.env.RELEASE_CHANNEL ?? 'stable')
const enabled = await flags.boolean(ctx.env, 'new-checkout', false, { cohort })

The one accessor here that does not throw on a missing binding, and the exception is deliberate. Everywhere else a missing binding means the request cannot be served and should say so. A flag decides between two paths that both work, so a Worker that cannot reach Flagship should take the path it took before flags existed. boolean, string, number and object therefore answer with their fallback when the binding is absent, when evaluation fails, and when the flag's type does not match — an evaluation error is logged and swallowed, the way AnalyticsBinding.writeSafe treats a failed telemetry write.

Which makes the fallback the most important argument in the call. It is the deployed behaviour, not boilerplate: pass what the Worker used before the flag existed — for a vars entry being migrated, that entry. resolve(env) is still inherited and still throws, for a caller that genuinely cannot proceed without a live evaluation.

The app_id lives in wrangler.json rather than in settings, so a Worker reading two Flagship apps declares two bindings and constructs one accessor per binding. context in config/ is merged into every evaluation — the deployment-wide half of a targeting rule, such as the service name or the environment — and a call's own context wins on any key both name.

Only the four value methods are exposed. The platform's *Details variants carry variant and reason for experiment analysis, which nothing here does yet; the untyped get returns unknown, which the typed methods strictly improve on. Both remain reachable through resolve(env).

Two bindings, and which one to declare is the whole decision.

One corpus, fixed at deploy time:

"ai_search": [{ "binding": "AI_SEARCH", "instance_name": "documentation" }]
const documentation = new AiSearchBinding()

const { chunks } = await documentation.search(ctx.env, { query: 'deployment configuration' })
const answer = await documentation.chat(ctx.env, { messages })

A corpus per tenant, created as tenants arrive:

"ai_search_namespaces": [{ "binding": "AI_SEARCH_NAMESPACE", "namespace": "tenants" }]
const corpora = new AiSearchNamespaceBinding()

const tenant = corpora.instance(ctx.env, `tenant-${id}`)
const { chunks } = await tenant.search({ query })

instance_name must exist at deploy time, which is exactly what the namespace binding removes — there, a request names the instance, so a corpus created when a customer signs up is reachable without a deploy. The default binding names differ (AI_SEARCH and AI_SEARCH_NAMESPACE) because a Worker may declare both, and two bindings cannot share a name on one env.

instance() is synchronous and local — it names an instance rather than fetching it, so one that does not exist fails on the first operation instead. It hands back the platform's own AiSearchInstance, so items, jobs and the rest stay reachable without this package in the way.

search retrieves passages and generates nothing; chat does both and returns the chunks its answer came from, which is what makes a citation possible. Streaming is chatStream rather than stream: true on chat, because the two return different things and a boolean that changes a return type is a discriminated union pretending to be an option.

A namespace search fans out and reports partial failure rather than hiding it: chunks come back tagged with their instance, and errors names the instances that did not answer. Ignoring errors means reading a result set that silently lost a corpus.

options in config/ supplies retrieval defaults — result count, score floor, reranking — all-or-nothing, like Workflows' retention: a request naming ai_search_options owns the whole object, since deep-merging four nested sub-objects would give a call site an effective configuration it could not predict from reading itself.

Not env.AI.autorag()

The older path to this product hung off the Workers AI binding, as env.AI.autorag(id) and env.AI.aiSearch(). Both are deprecated in the @cloudflare/workers-types version this package pins, in favour of the standalone bindings above. AIBinding therefore does not expose them — routing AI Search through it would have adopted a deprecated surface on the day it was written.

Against Vectorize

Both do retrieval, and the difference is who owns the pipeline. With Vectorize a Worker owns the embedding model, the chunking, and the index lifecycle; with AI Search it owns none of them and asks in words instead of vectors. Neither replaces the other — the question is whether running an embedding pipeline is work you want.

Workers VPC

Two bindings, and choosing between them is a security decision before it is an ergonomic one.

One private service, fixed at deploy time:

"vpc_services": [{ "binding": "VPC_SERVICE", "service_id": "<service id>", "remote": true }]
const internal = new VpcServiceBinding()

const response = await internal.fetch(ctx.env, 'https://internal-api.example.com/data')

A whole private network, destination chosen per call:

"vpc_networks": [{ "binding": "VPC_NETWORK", "network_id": "cf1:network", "remote": true }]
const network = new VpcNetworkBinding()

const response = await network.fetch(ctx.env, 'http://10.0.1.50:8080/api/data')
const socket = await network.connect(ctx.env, '10.0.1.50:6379')

Both reach services on a private network — an internal API, a database, something in an AWS VPC or on a rack — through a Cloudflare Tunnel, so the private side needs no inbound firewall rule and nothing is exposed to the internet.

Prefer a VPC Service wherever one endpoint will do. Its service_id fixes the destination at deploy time, so a Worker holding it can reach exactly one private endpoint and no request it handles can talk it into reaching another. A VPC Network trades that away: any address on the bound tunnel or mesh is in range, which means an address derived from an incoming request is a server-side request forgery with your private network behind it. Cloudflare's own answer to constraining that is a VPC Service per destination rather than a filter, and this package offers no allowlist setting for the same reason — a security control enforced in library code is one a caller routes around by calling resolve(env).

connect() is plaintext TCP at the time of writing, for the services that are not HTTP — Redis, Memcached, MQTT. A private service expecting TLS needs it terminated on the private side. The socket's lifetime belongs to the caller; nothing here wraps it, because closing and draining it depends on the protocol being spoken and this package knows none.

The one type this package declares itself

VpcServiceBinding is Binding<Fetcher> — a VPC Service binding offers fetch alone, which is Fetcher structurally, the same reasoning that types Browser Rendering.

VpcNetworkBinding could not do that, because connect has no existing type to borrow. Workers VPC is in beta and @cloudflare/workers-types declares nothing for it at the version this package pins, so VpcNetwork is written here — the only binding shape in the package not taken from Cloudflare's own types. It is kept to the two documented methods deliberately: a hand-written type cannot be checked against the runtime, so a smaller invented surface is a smaller thing to be wrong about. When the real type ships, that file should be deleted rather than maintained.

Adding a binding of your own

  1. Declare it in wrangler.json.
  2. Add the field to the Env interface in src/types/CloudflareEnv.ts.
  3. Declare its settings in src/types/ — an interface extending BindingSettings, plus an entry on CloudflareConfiguration — and give it defaults in src/constants/Defaults.ts. It is overridable from a Worker's config/ from this point on; see Register the configuration surface.
  4. Add a *Binding to src/core/bindings/ extending Binding<T>, taking its settings through lazySettings and exposing only the operations your code needs:
import { Binding } from '@/core/bindings/Binding'
import { lazySettings } from '@/utils/lazySettings'

import type { EmailSettings } from '@/types/EmailSettings'

export class EmailBinding extends Binding<SendEmail> {
  constructor(settings?: EmailSettings) {
    const settle = lazySettings<EmailSettings>('email', settings)

    // A thunk, never a value: resolving here would throw on import.
    super(() => settle().binding)
  }

  public async send(env: Env, message: EmailMessage): Promise<void> {
    await this.resolve(env).send(message)
  }
}
  1. Re-export it from src/core/bindings/index.ts.
  2. If it deserves a worked example, add a *Service beside the others in src/core/services/ and re-export it from that directory's barrel. Anything with business meaning belongs in the consuming Worker's own services/ instead.
  3. If the capability is one another platform could also provide, declare an interface for it in the kernel's types/capabilities/ and have the service implement it. Take the runtime handle as a type parameter — interface MailSender<TRuntime> — so the kernel stays free of @cloudflare/workers-types, and state in the interface whether an unavailable implementation degrades or throws. Skip this step for something genuinely Cloudflare-shaped; an interface with one possible implementation is ceremony, not abstraction.

results matching ""

    No results matching ""