@setu-ts/cloudflare-plugin

Cloudflare Workers platform bindings for Setu-TS.

The framework has served traffic on Workers since the Hono migration, but had no way to reach the platform’s own primitives. This plugin publishes a Worker’s bindings under CAPABILITIES.CLOUDFLARE and, optionally, serves the committed cache, storage, queue, and messaging capabilities from KV, R2, and Cloudflare Queues. It also ships the queue and scheduled handler exports a Worker needs, and a response cache over the platform’s own edge cache.

Zero npm dependencies. Nothing in this package imports cloudflare:workers — the application passes env in, which keeps the package type-checkable on Deno, Node, and Bun, and trivially testable with a plain object.

Installation

npm install @setu-ts/cloudflare-plugin
# Deno: deno add npm:@setu-ts/cloudflare-plugin

Usage

import { env, waitUntil } from 'cloudflare:workers';
import { createApplication } from '@setu-ts/kernel';
import { RuntimePlugin } from '@setu-ts/runtime';
import { CloudflarePlugin } from '@setu-ts/cloudflare-plugin';
import { CAPABILITIES, type ICacheStore } from '@setu-ts/common';
import type { ICloudflareBindings } from '@setu-ts/cloudflare-plugin';

const app = createApplication({
  plugins: [
    // Populates runtime.env with the Worker's string variables and secrets.
    RuntimePlugin({ env }),
    CloudflarePlugin({
      env,
      waitUntil,
      cache: { binding: 'CACHE_KV', prefix: 'cache:' },
      storage: { binding: 'UPLOADS' },
      requireBindings: ['CACHE_KV', 'UPLOADS'],
    }),
  ],
});

app.router.get('/settings', async (ctx) => {
  const cache = ctx.services.get<ICacheStore>(CAPABILITIES.CACHE);
  const cf = ctx.services.get<ICloudflareBindings>(CAPABILITIES.CLOUDFLARE);

  const cached = await cache.get<Settings>('settings');
  if (cached !== null) return ctx.response.json(cached);

  const settings = await loadSettings(cf.d1('DB'));
  await cache.set('settings', settings, 30);
  cf.waitUntil(recordCacheMiss()); // finishes after the response
  return ctx.response.json(settings);
});

export default { fetch: app.fetch };

setu new --runtime cloudflare-workers scaffolds the env wiring and a wrangler.toml with commented binding stanzas.

Requirements

compatibility_date must be 2025-08-08 or later — that is when Cloudflare shipped import { waitUntil } from 'cloudflare:workers'. env has been importable at module scope for longer; only waitUntil needs the newer date.

Options

OptionTypeDefaultDescription
envRecord<string,unknown>—Required. The Worker’s env.
waitUntilWaitUntilHost—The platform sink. Omit off Workers. Without it on Workers, work passed to bindings.waitUntil() may be cut off when the response is sent, and one warning says so at startup — pass import { waitUntil } from 'cloudflare:workers' from your entry through the factory.
requireBindingsstring[][]Bindings that must exist; register() throws naming absent ones.
cache.bindingstring—KV namespace serving CAPABILITIES.CACHE.
cache.namestring'default'Derives cache.<name> when not 'default'.
cache.prefixstring—Key prefix. Required to call clear().
cache.defaultTtlSecondsnumber—TTL applied when set omits one.
storage.bindingstring—R2 bucket serving CAPABILITIES.STORAGE.
storage.namestring'default'Derives storage.<name> when not 'default'.
storage.prefixstring—Object-key prefix.
queue.bindingstring—Queues producer binding serving CAPABILITIES.QUEUE.
queue.namestring'default'Derives queue.<name> when not 'default'.
queue.maxDelaySecondsnumber86400A larger delayMs throws rather than being truncated.
messaging.bindingstring—Queues producer binding serving CAPABILITIES.MESSAGING.
messaging.namestring'default'Derives messaging.<name> when not 'default'.
messaging.rpc.bindingstring—DO namespace serving RPC reply inboxes. Absent, RPC throws.
messaging.rpc.defaultTimeoutMsnumber5000Reply budget when RequestOptions.timeoutMs is omitted.
durableObject.bindingstring—DO namespace serving CAPABILITIES.REALTIME_BACKPLANE.
durableObject.namestring'default'Derives realtime-backplane.<name> when not 'default'.
durableObject.topicstring'realtime'The object every replica shares. Differ it per application.

Queues, Cron Triggers, and the edge cache

Cloudflare invokes queue and scheduled as module-level exports — fetch is not involved — so your Worker assembles them beside it:

import {
  cacheApiMiddleware,
  CloudflarePlugin,
  createQueueHandler,
  createScheduledHandler,
  WorkersCron,
} from '@setu-ts/cloudflare-plugin';
import { CAPABILITIES, type IQueue } from '@setu-ts/common';

const app = createApplication({
  plugins: [
    RuntimePlugin({ env }),
    CloudflarePlugin({ env, waitUntil, queue: { binding: 'JOBS' } }),
  ],
});

// A route cached in the datacenter it was served from.
app.router.get('/catalog', {
  handler: listCatalog,
  middleware: [cacheApiMiddleware({ ttlSeconds: 300 })],
});

await app.start();

const queue = app.services.get<IQueue>(CAPABILITIES.QUEUE);
queue.process<{ to: string }>(
  'send-welcome',
  async (job) => {
    await mailer.send({ to: job.data.to, subject: 'Welcome', text: 'Welcome!' });
  },
  { concurrency: 5 },
);

// The expression must match wrangler.toml `[triggers] crons` exactly.
// An unmatched trigger FAILS the invocation. A logger adds a structured
// warning naming the registered expressions; it never changes the outcome.
const cron = new WorkersCron({ logger });
cron.on('0 3 * * *', async () => {
  await queue.add('rebuild-reports', {});
});

export default {
  fetch: app.fetch,
  queue: createQueueHandler(app),
  scheduled: createScheduledHandler(cron),
};

wrangler.toml needs the matching stanzas:

[[queues.producers]]
queue = "jobs"
binding = "JOBS"

[[queues.consumers]]
queue = "jobs"

[triggers]
crons = ["0 3 * * *"]

Messaging

CAPABILITIES.MESSAGING over Cloudflare Queues, with request/reply through a Durable Object.

app.register(
  CloudflarePlugin({
    env,
    messaging: { binding: 'MESSAGES', rpc: { binding: 'REPLY_INBOX' } },
  }),
);

const broker = app.services.get<IMessageBroker>(CAPABILITIES.MESSAGING);
await broker.subscribe<{ id: string }>('user.created', async (user) => {
  /* … */
});
await broker.respond<number, number>('double', (n) => n * 2);

// Consuming is a MODULE export. Combine with a job queue by routing on the
// queue name, which your `wrangler.toml` owns and this package cannot see.
export default { fetch: app.fetch, queue: createMessagingHandler(app) };
[[queues.producers]]
binding = "MESSAGES"
queue = "messages"

# REQUIRED for request/reply: the platform default of 5s alone exhausts the
# default reply budget, so every request() would time out.
[[queues.consumers]]
queue = "messages"
max_batch_size = 1
max_batch_timeout = 0

[[durable_objects.bindings]]
name = "REPLY_INBOX"
class_name = "ReplyInboxObject"

[[migrations]]
tag = "v1"
new_classes = ["ReplyInboxObject"]

The rpc arm needs a Durable Object class your application exports, delegating to ReplyInboxObjectCore exactly as the realtime backplane’s class does — see the class you export.

Three things to know before you rely on it:

  • publish reaches one consumer Worker, not every subscriber in the cluster. Cloudflare allows exactly one active consumer per queue. Fan-out happens across the handlers registered inside that consumer; cross-service fan-out means one queue per consuming service.
  • subscribe registers, it does not start receiving. Delivery begins when the Worker exports the handler above and the queue is declared under [[queues.consumers]].
  • A publish nobody subscribed to is acked, not retried. Retrying ordinary pub/sub would burn the queue’s retry budget and dead-letter every fire-and-forget message. An unreadable body is still retried, and a subscriber that throws is retried.

Without the rpc arm, request and respond throw CloudflareUnsupportedError naming the binding to add — publish and subscribe are unaffected.

Sessions

SessionPluginOptions.store is read when the plugin is constructed, before any application exists, so a store published in the registry could never reach it. Build one yourself:

import { createRuntimeServices } from '@setu-ts/runtime';
import { SessionPlugin } from '@setu-ts/session-plugin';
import { type IKvNamespace, KvSessionStore } from '@setu-ts/cloudflare-plugin';

const runtime = createRuntimeServices({ env });

SessionPlugin({
  secret: String(env.SESSION_SECRET),
  mode: 'sign', // the payload lives in KV, so the cookie holds only an opaque id
  store: new KvSessionStore(env.SESSIONS as IKvNamespace, runtime),
});

D1

D1Adapter implements the committed IDatabaseAdapter, so a Worker serves CAPABILITIES.DATABASE through the ordinary repository and Unit-of-Work surface. Like the session store, you build it and hand it over — DatabasePlugin’s options are read before any application exists.

import { DatabasePlugin } from '@setu-ts/database-plugin';
import { D1Adapter, type ID1Database } from '@setu-ts/cloudflare-plugin';

DatabasePlugin({
  type: 'custom',
  adapter: new D1Adapter(env.DB as ID1Database, {
    // Optional: entity name → table / primary key. Unmapped entities use their
    // own name as the table and `id` as the key.
    tables: { User: { table: 'users' } },
  }),
});

D1 has no interactive transaction. It rejects BEGIN TRANSACTION, and batch() is its only unit of atomicity. db.transaction(...) therefore buffers every write and flushes it as one batch() at commit — atomic, and rolled back whole if any statement fails. Two consequences to plan around:

  • Reads inside a transaction do not see that transaction’s own writes. They run immediately against committed state. Read what you need first, then write.
  • create() inside a transaction needs an explicit primary key. A deferred INSERT cannot hand a generated key back to a caller awaiting create() before the flush, so it throws rather than returning a row with a missing id. Outside a transaction create() uses RETURNING * and gives you the real persisted row, generated columns included.

Values are always bound; table and column names are validated and quoted, and any statement that would exceed D1’s 100-bound-parameter limit is refused with an error naming the count rather than failing inside D1.

Migrations stay a wrangler d1 migrations concern — the adapter has no migrate().

D1 honours the same two portable refusal rules @setu-ts/database-plugin answers: a primary-key change through update (or updateWhere) is refused with PrimaryKeyChangeError (400) instead of moving the row, and a cursor D1 cannot use — malformed, made under a different sort, or of the wrong shape for the target — is refused with InvalidCursorError (400) before any query runs. Both classes are exported from @setu-ts/common.

Durable Objects — realtime backplane and distributed lock

Both features need a Durable Object class your application exports, plus a wrangler stanza. No plugin option can export a class on your behalf, so this is wiring you write once.

The class you export

This package ships the behaviour as two plain cores. Your class extends DurableObject and delegates — that keeps cloudflare:workers out of the package, which matters because the specifier is unresolvable on every other runtime and would break type-checking everywhere.

import { DurableObject } from 'cloudflare:workers';
import { DistributedLockObjectCore, RealtimeBackplaneObjectCore } from '@setu-ts/cloudflare-plugin';

export class RealtimeBackplaneObject extends DurableObject {
  #core = new RealtimeBackplaneObjectCore(this.ctx);

  override fetch(request: Request): Promise<Response> {
    return this.#core.fetch(request);
  }
  webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): void {
    this.#core.webSocketMessage(ws, message);
  }
  webSocketClose(ws: WebSocket, code: number, reason: string): void {
    this.#core.webSocketClose(ws, code, reason);
  }
  webSocketError(ws: WebSocket): void {
    this.#core.webSocketError(ws);
  }
}

export class DistributedLockObject extends DurableObject {
  #core = new DistributedLockObjectCore(this.ctx);

  override fetch(request: Request): Promise<Response> {
    return this.#core.fetch(request);
  }
}

The wrangler stanza

[[durable_objects.bindings]]
name = "REALTIME"
class_name = "RealtimeBackplaneObject"

[[durable_objects.bindings]]
name = "LOCKS"
class_name = "DistributedLockObject"

[exports.RealtimeBackplaneObject]
type = "durable-object"
storage = "sqlite"

[exports.DistributedLockObject]
type = "durable-object"
storage = "sqlite"

storage = "sqlite" is required on the Workers Free plan. The older migrations + new_sqlite_classes flow still works, but a Worker can use only one of the two flows — do not mix them.

Realtime backplane

CloudflarePlugin({ env, waitUntil, durableObject: { binding: 'REALTIME', topic: 'chat' } });

That registers CAPABILITIES.REALTIME_BACKPLANE, which websocket-plugin and sse-plugin resolve on their own — so ws.room('lobby') and sse.channel('news') start reaching clients on other replicas with no further wiring. Register either this arm or RealtimeBackplanePlugin, never both: the kernel rejects two providers of one capability token.

The guarantee is narrower than “a durable subscription”, deliberately. A Worker isolate is evicted at Cloudflare’s discretion and its outbound WebSockets go with it, so no Worker can hold a subscription indefinitely. What makes that sound rather than lossy is the coupling: the members the subscription serves are client WebSockets held by the same isolate, and an HTTP-triggered Worker stays alive as long as its clients stay connected — so losing the isolate loses the subscription and its members together. The socket opens lazily on first publish and reopens after any failure.

Two applications sharing one namespace must set different topic values, or each receives the other’s frames.

Subscriber throws and returned promise rejections are isolated through common’s shared dispatch. The default reporter reads the logger at call time: first failure per handler at warn, repeats at debug with a count, and recovery at info. Errors use describeError, so CR/LF cannot forge log lines; frame data is never logged. A directly constructed DurableObjectBackplane accepts onHandlerError(error, frame) in DurableObjectBackplaneOptions to receive every failure instead of default logging. Hook and logger failures are swallowed.

Distributed lock

DurableObjectLock implements scheduler-plugin’s IDistributedLock seam, so the lock can be handed to SchedulerPlugin on a Node/Deno replica set — the composition it exists for. Do not register SchedulerPlugin on Workers: register() refuses there with a SchedulerUnavailableError, naming WorkersCron and [triggers] crons as the replacement, because timers cannot fire on an isolate that is evicted between invocations. Workers use WorkersCron, above.

// On a Node/Deno replica set — where SchedulerPlugin runs.
import { DurableObjectLock } from '@setu-ts/cloudflare-plugin';
import { SchedulerPlugin } from '@setu-ts/scheduler-plugin';

const lock = new DurableObjectLock(env.LOCKS as IDurableObjectNamespace, {
  runtime,
  keyPrefix: 'reports:',
});

// An injected lock wins outright — `enabled: true` is not needed.
app.register(SchedulerPlugin({ distributedLock: { lock } }));

One Durable Object per lock key. Correctness comes from the platform rather than an algorithm: a Durable Object processes one event at a time and holds back delivery while a storage operation runs, so the read-compare-write is atomic with no transaction and no Redlock-style quorum. The holder is persisted in the object’s storage, never in memory, because an object is evicted after 70–140 seconds of inactivity and a lock TTL routinely outlives that.

A non-2xx from the object throws rather than reporting “not acquired” — a 404 means the binding points at the wrong class, and folding that into contention would silently disable every scheduled job.

Behaviour worth knowing before you deploy

  • KV rejects a TTL under 60 seconds. ICacheStore.set accepts any TTL, so the value carries its own deadline: a 5-second entry reads as a miss on time even though the key survives up to a minute longer. Short TTLs are correct; they are just not free of storage.
  • KV writes are eventually consistent. This suits read-heavy caching, not coordination. Reach for a Durable Object when you need a consistent read after write.
  • A read never deletes a key the store does not own, and a deliberately cached null survives a read — negative caching works. get still answers null for it, because ICacheStore has no way to distinguish a cached absence from a miss, but has and delete report it as present.
  • clear() needs a prefix and costs one delete per key. The binding has no bulk delete, so the sweep pages list (1000 keys at a time) and deletes each one. Without a prefix it would delete keys the store does not own, so it throws instead.
  • R2 cannot presign. getSignedUrl rejects. Serve the object through a route (getStream gives you a zero-copy download) or put a custom domain in front of the bucket.
  • Binding methods only work inside a request. Cloudflare prohibits I/O in global scope. The plugin holds bindings at register() and never reads through them there, and the health indicator performs no binding I/O.
  • An unroutable queue message is retried, never acked. A body that is not one of this package’s job envelopes, or a job name with no registered processor, goes back for redelivery and is reported through the logger. Acking would discard it permanently and silently. Your queue’s max_retries and dead-letter settings in wrangler.toml then decide what happens.
  • queue.addRecurring throws. Cloudflare Queues has no recurring message. Declare the schedule in [triggers] crons and let a WorkersCron handler enqueue the work.
  • There is no IScheduler on Workers, deliberately. every and delay arm timers, and the isolate is evicted between invocations, so they would silently never fire; pause/resume/ remove need state that does not survive an invocation; and getNextRun is owned by wrangler.toml. WorkersCron is a small honest surface instead of an IScheduler where six of eight methods throw.
  • A cron expression registered here but absent from wrangler.toml never fires. Nothing in the process can read that file. cron.expressions() lets you assert your own coverage. An unmatched trigger fails the invocation; the logger you pass to WorkersCron adds a structured warning naming the registered expressions, but the outcome is the same without one. Matching is exact — whitespace is not normalized.
  • Only GET requests touch the edge cache. The cache key is a URL, which the Cache API resolves as a GET request, so a POST to a cached path would otherwise be served the cached GET body and never reach your handler. Non-GET requests pass through with X-Cache-Api: BYPASS. This matters most if you add the middleware globally rather than to one GET route.
  • A failed cache write never fails your response. Cache.put rejects for an oversized body or a quota error; the response is already built by then, so the failure is reported and the request succeeds uncached.
  • The write lands with or without a waitUntil host. When the plugin received no host the middleware awaits Cache.put itself before the response returns — the write delays the response instead of being abandoned when the isolate is released (V9-23). Passing waitUntil moves the write off the response path; the plugin warns at startup when it is missing on Workers.
  • caches.default is per-datacenter. cacheApiMiddleware is a latency optimisation, not a shared store: a hit in one colo says nothing about another, and a delete does not evict globally. It reports under X-Cache-Api, so it composes with cache-plugin’s cacheMiddleware (which reports under X-Cache and reads a store every colo shares) rather than colliding with it.
  • The default key carries the resolved tenant and locale. The key is a URL string the platform matches with no request headers, so Vary cannot separate entries here; ctx.request.tenant and ctx.request.locale are appended to the URL text as setu-cache-tenant and setu-cache-locale parameters instead. The middleware must run after the tenant and locale middleware (globally, at a higher priority number than theirs — 40 and 45 by default, or whatever middlewarePriority and middleware.priority are set to), and a request no key can keep apart from another — a fragment, un-normalized URL text, an unencodable id, or a reserved parameter on a request that lacks the value — is served uncached. A custom key must include both itself.
  • The edge cache refuses some responses, and the middleware skips them rather than failing. Non-GET, 206, Vary: *, and an uncleared Set-Cookie all make caches.default.put throw; those are checked first. Cache-Control: private=Set-Cookie is the platform’s opt-in. Streaming responses are never cached.
  • A cache HIT is replayed as a stream, so app.inject() cannot read its body — drive cached routes in tests with app.fetch and a web Request, which is what a Worker invokes anyway.
  • Not verified against a live Worker. Every binding is exercised against a fake built from the documented signatures, including KV’s 60-second floor and R2’s void delete. CI has no Cloudflare account.

Not in this package

  • Hyperdrive, Vectorize, Workers AI, Analytics Engine. Reachable now through bindings.get<T>('NAME'); each becomes a first-class port only when an application needs one.

Documentation

License

MIT

Exports

ExportKind
assessCacheabilityfunction
asUpgradeResponsefunction
cacheApiMiddlewarefunction
CloudflarePluginfunction
createDefaultDurableObjectWebSocketHostfunction
createMessagingHandlerfunction
createQueueHandlerfunction
createScheduledHandlerfunction
isD1Databasefunction
isDurableObjectNamespacefunction
isKvNamespacefunction
isQueueProducerfunction
isR2Bucketfunction
CloudflareBindingMissingErrorclass
CloudflareObjectNotFoundErrorclass
CloudflareRemoteHandlerErrorclass
CloudflareRequestTimeoutErrorclass
CloudflareUnsupportedErrorclass
D1Adapterclass
DistributedLockObjectCoreclass
DurableObjectBackplaneclass
DurableObjectIdempotencyStoreclass
DurableObjectLockclass
IdempotencyObjectCoreclass
KvCacheStoreclass
KvSessionStoreclass
R2Storageclass
RealtimeBackplaneObjectCoreclass
ReplyInboxObjectCoreclass
WorkersBrokerclass
WorkersCronclass
WorkersQueueclass
BrokerRuntimeinterface
CacheabilityInputinterface
CacheApiMiddlewareOptionsinterface
CacheClockinterface
CloudflarePluginOptionsinterface
D1AdapterOptionsinterface
D1EntityMappinginterface
D1Resultinterface
DistributedLockObjectCoreOptionsinterface
DurableObjectArminterface
DurableObjectBackplaneOptionsinterface
DurableObjectIdempotencyStoreOptionsinterface
DurableObjectLockOptionsinterface
DurableObjectMessageEventinterface
DurableObjectUpgradeResponseinterface
DurableObjectWebSocketHostinterface
DurableObjectWebSocketPairinterface
ICacheApiinterface
ICloudflareBindingsinterface
ID1Databaseinterface
ID1PreparedStatementinterface
IdempotencyObjectCoreOptionsinterface
IDurableObjectClientSocketinterface
IDurableObjectNamespaceinterface
IDurableObjectStateinterface
IDurableObjectStorageinterface
IDurableObjectWebSocketinterface
IIdempotencyObjectStateinterface
IKvNamespaceinterface
IQueueMessageinterface
IQueueMessageBatchinterface
IQueueProducerinterface
IR2Bucketinterface
IR2Objectinterface
IR2ObjectBodyinterface
IScheduledControllerinterface
IServiceBindinginterface
JobIdSourceinterface
KvCacheOptionsinterface
KvCacheStoreOptionsinterface
KvListOptionsinterface
KvListResultinterface
KvPutOptionsinterface
KvSessionStoreOptionsinterface
MessagingHandlerOptionsinterface
QueueHandlerOptionsinterface
QueueSendOptionsinterface
R2PutOptionsinterface
R2StorageArminterface
R2StorageOptionsinterface
RealtimeBackplaneObjectCoreOptionsinterface
ReplyInboxBindinginterface
ReplyInboxObjectCoreOptionsinterface
WorkersBrokerOptionsinterface
WorkersCronOptionsinterface
WorkersMessagingArminterface
WorkersMessagingRpcArminterface
WorkersQueueArminterface
WorkersQueueOptionsinterface
CacheRefusaltype
CloudflareWorkerEnvtype
CronHandlertype
LoggerSourcetype
MessagingHandlertype
QueueHandlertype
ScheduledHandlertype
WaitUntilHosttype

Generated from the package barrel by pnpm run docs:exports; pnpm run check:docs fails when it drifts.

The Durable Object idempotency store (M109a)

One Durable Object per idempotency store key — the tier-B store @setu-ts/idempotency-plugin can use on Workers. The application exports the DO class and hands the store to the plugin; the object owns the record, its lease and its alarm.

# wrangler.toml
[[durable_objects.bindings]]
name = "IDEMPOTENCY"
class_name = "IdempotencyObject"

[[migrations]]
tag = "v1"
new_sqlite_classes = ["IdempotencyObject"]
import { DurableObject } from 'cloudflare:workers';
import { DurableObjectIdempotencyStore, IdempotencyObjectCore } from '@setu-ts/cloudflare-plugin';
import { IdempotencyPlugin } from '@setu-ts/idempotency-plugin';

// The Durable Object side — the class the binding names.
export class IdempotencyObject extends DurableObject {
  #core = new IdempotencyObjectCore(this.ctx);
  override fetch(request: Request): Promise<Response> {
    return this.#core.fetch(request);
  }
  override alarm(): Promise<void> {
    return this.#core.alarm();
  }
}

// The Worker side — one store, its `namespace` isolating this application's records.
const store = new DurableObjectIdempotencyStore(env.IDEMPOTENCY, { namespace: 'shop' });
app.register(IdempotencyPlugin({ store: { type: 'custom', store } }));

The store takes namespace (required), keyPrefix (default 'idempotency:'), binding (for error messages) and timeoutMs (default 5,000; 0 disables). It advertises maxRecordBytes of 120,000 — under the KV-backed Durable Object value limit — and has no isHealthy, because probing it would create and bill an object.

D1 payload refusals (M119e, 0.11.0)

D1 create, update and updateWhere refuse invalid payload identifiers or unknown payload columns with UnknownPayloadFieldError from @setu-ts/common (400). The SQLite unknown-column mapping applies only when the reported column belongs to the payload; precondition-column errors keep their existing behavior. A STRICT-table type refusal becomes InvalidFieldValueError (400). Both HTTP details are fixed and contain no field names or supplied values.

API reference

API Reference: @setu-ts/cloudflare-plugin

Cloudflare Workers platform bindings, published under CAPABILITIES.CLOUDFLARE, plus optional KV-backed cache, R2-backed storage, and a Queues-backed IQueue. Zero npm dependencies. Added in Milestone 52; Queues, Cron Triggers, and the Cache API response cache added in Milestone 52b.

Registration

import { env, waitUntil } from 'cloudflare:workers';
import { CloudflarePlugin } from '@setu-ts/cloudflare-plugin';

app.register(
  CloudflarePlugin({
    env, // required — the Worker's bindings and variables
    waitUntil, // the platform background-work sink
    requireBindings: ['CACHE_KV'], // fail at register() rather than at first use
    cache: { binding: 'CACHE_KV', prefix: 'cache:', defaultTtlSeconds: 300 },
    storage: { binding: 'UPLOADS', prefix: 'user-uploads/' },
    queue: { binding: 'JOBS' },
  }),
);

Consuming a queue and receiving Cron Triggers need module-level handler exports the platform invokes directly — fetch is not involved — so the application assembles them beside it:

import {
  createQueueHandler,
  createScheduledHandler,
  WorkersCron,
} from '@setu-ts/cloudflare-plugin';

await app.start();

const cron = new WorkersCron();
cron.on('0 3 * * *', () => rebuildReports(app)); // must match wrangler.toml [triggers] crons

export default {
  fetch: app.fetch,
  queue: createQueueHandler(app),
  scheduled: createScheduledHandler(cron),
};

Nothing in the package imports cloudflare:workers; the application passes env in. That specifier is unresolvable outside a Worker toolchain, so importing it here would break deno check on every other runtime — and injection is what the platform docs recommend for testability.

Options

OptionTypeDefaultConsumer / behavior
envCloudflareWorkerEnvrequiredBindingRegistry — the record every accessor reads
waitUntilWaitUntilHost—resolveWaitUntil — delegated to; omit off Workers
requireBindingsreadonly string[][]register() — throws naming every absent entry
cache.bindingstring—KvCacheStore — the KV namespace serving CAPABILITIES.CACHE
cache.namestring'default'Plugin factory — derives cache.<name> when not 'default'
cache.prefixstring—KvCacheStore — key prefix; required to call clear()
cache.defaultTtlSecondsnumber—KvCacheStore.set — applied when ttlSeconds is omitted
storage.bindingstring—R2Storage — the R2 bucket serving CAPABILITIES.STORAGE
storage.namestring'default'Plugin factory — derives storage.<name> when not 'default'
storage.prefixstring—R2Storage — object-key prefix
queue.bindingstring—WorkersQueue — the producer binding serving CAPABILITIES.QUEUE
queue.namestring'default'Plugin factory — derives queue.<name> when not 'default'
queue.maxDelaySecondsnumber86400WorkersQueue.add — a larger delayMs throws rather than being truncated
messaging.bindingstring—WorkersBroker — the producer binding serving CAPABILITIES.MESSAGING; validated at register()
messaging.namestring'default'Plugin factory — derives messaging.<name> when not 'default'
messaging.rpc.bindingstring—WorkersBroker — the Durable Object namespace serving reply inboxes; absent, request/respond throw
messaging.rpc.defaultTimeoutMsnumber5000RequestCorrelation — reply budget when RequestOptions.timeoutMs is omitted
durableObject.bindingstring—DurableObjectBackplane — the namespace serving CAPABILITIES.REALTIME_BACKPLANE; validated at register()
durableObject.namestring'default'Plugin factory — derives realtime-backplane.<name> when not 'default'
durableObject.topicstring'realtime'DurableObjectBackplane — the idFromName value every replica shares; two apps sharing a namespace must differ

Exports

ExportKind
CloudflarePluginfactory
ICloudflareBindingsinterface
CloudflarePluginOptions, KvCacheOptions, R2StorageArm, WorkersQueueArm, WorkersMessagingArm, WorkersMessagingRpcArm, DurableObjectArmtypes
KvCacheStore, KvCacheStoreOptions, CacheClockclass + types
KvSessionStore, KvSessionStoreOptionsclass + types
R2Storage, R2StorageOptionsclass + types
D1Adapter, D1AdapterOptions, D1EntityMappingclass + types
WaitUntilHost, LoggerSourcetypes
WorkersQueue, WorkersQueueOptions, JobIdSourceclass + types
createQueueHandler, QueueHandler, QueueHandlerOptionsfn + types
WorkersBroker, WorkersBrokerOptions, ReplyInboxBinding, BrokerRuntimeclass + types
createMessagingHandler, MessagingHandler, MessagingHandlerOptionsfn + types
ReplyInboxObjectCore, ReplyInboxObjectCoreOptionsclass + type
WorkersCron, WorkersCronOptions, CronHandlerclass + types
createScheduledHandler, ScheduledHandlerfn + type
cacheApiMiddleware, CacheApiMiddlewareOptions, ICacheApifn + types
assessCacheability, CacheabilityInput, CacheRefusalfn + types
RealtimeBackplaneObjectCore, RealtimeBackplaneObjectCoreOptionsclass + type
DistributedLockObjectCore, DistributedLockObjectCoreOptionsclass + type
DurableObjectBackplane, DurableObjectBackplaneOptionsclass + type
DurableObjectLock, DurableObjectLockOptionsclass + type
IdempotencyObjectCore, IdempotencyObjectCoreOptionsclass + type
DurableObjectIdempotencyStore, DurableObjectIdempotencyStoreOptionsclass + type
IIdempotencyObjectStatetype
asUpgradeResponse, DurableObjectUpgradeResponsefn + type
createDefaultDurableObjectWebSocketHost, DurableObjectWebSocketHost, DurableObjectWebSocketPairfn + types
IDurableObjectState, IDurableObjectStorage, IDurableObjectWebSocket, IDurableObjectClientSocket, DurableObjectMessageEventtypes
IKvNamespace, IR2Bucket, IR2Object, IR2ObjectBody, ID1Database, ID1PreparedStatement, D1Result, IQueueProducer, IQueueMessage, IQueueMessageBatch, IScheduledController, IServiceBinding, IDurableObjectNamespace, CloudflareWorkerEnv, KvPutOptions, KvListOptions, KvListResult, QueueSendOptionstypes
isKvNamespace, isR2Bucket, isD1Database, isQueueProducer, isDurableObjectNamespaceguards
CloudflareBindingMissingError, CloudflareUnsupportedError, CloudflareObjectNotFoundError, CloudflareRequestTimeoutError, CloudflareRemoteHandlerErrorerrors

D1Adapter — D1 as a first-class database

Implements the committed IDatabaseAdapter (from @setu-ts/common), so a Worker serves CAPABILITIES.DATABASE through the ordinary repository and Unit-of-Work surface. Constructed by the application and handed to DatabasePlugin, matching KvSessionStore: those plugin options are read when the plugin is constructed, before any application exists, so an adapter published in the service registry could never reach it.

import { env } from 'cloudflare:workers';
import { DatabasePlugin } from '@setu-ts/database-plugin';
import { D1Adapter, type ID1Database } from '@setu-ts/cloudflare-plugin';

app.register(
  DatabasePlugin({
    type: 'custom',
    adapter: new D1Adapter(env.DB as ID1Database, {
      tables: { User: { table: 'users', primaryKey: 'user_id' } },
    }),
  }),
);
OptionDefaultBehavior
tables{}Per-entity { table, primaryKey } overrides
D1EntityMapping.tablethe entity namePhysical table name; validated as a SQL identifier before quoting
D1EntityMapping.primaryKey'id'Key column used by findById / update / delete

Transactions. D1 has no interactive transaction — BEGIN TRANSACTION is rejected by the platform — and batch() is its only unit of atomicity. beginTransaction() therefore buffers every write and flushes the whole buffer as one batch() at commit(); rollback() discards the buffer and sends nothing. Two consequences, both deliberate:

  • No read-your-own-writes inside a transaction. Reads run immediately against committed state, so a row written earlier in the same transaction is not visible to a later read within it.
  • create() inside a transaction requires an explicit primary key, and throws CloudflareUnsupportedError naming the constraint when one is absent — a deferred INSERT cannot report a generated key to a caller that awaits create() before the flush. Outside a transaction create() uses RETURNING * and returns the real persisted row, generated columns included.

Identifiers and limits. Values are always bound (?N); identifiers cannot be, so table and column names are validated against [A-Za-z_][A-Za-z0-9_]* and double-quoted, throwing CloudflareUnsupportedError otherwise. D1 binds at most 100 parameters per query, and every builder whose parameter count varies with the caller’s query — select, insert, update, count — refuses a statement that would exceed it rather than letting D1 fail with a message that points at the SQL instead of the caller’s query. (Find-by-id and delete bind exactly one value and so cannot.)

The binding is validated where the adapter is built. new D1Adapter(env.DB) throws CloudflareBindingMissingError when the binding is absent or not D1-shaped, naming what arrived and pointing at the d1_databases stanza. Without that check a mistyped binding name would register cleanly, report up from the database health indicator, and fail every query with a bare TypeError. The isD1Database guard is exported alongside isKvNamespace / isR2Bucket.

Not verified against live D1. CI holds no Cloudflare account. Every generated statement is asserted verbatim, and the whole surface is driven against a real SQLite engine (the engine D1 runs), including batch rollback.

Durable Objects — realtime backplane and distributed lock

DurableObjectBackplaneOptions.onHandlerError?: (error: unknown, frame: RealtimeFrame) => void (0.11.0) receives every subscriber throw or rejection instead of default logging. The default reporter uses common’s shared dispatch, describeError, a call-time logger, first failure at warn, repeats at debug with a count, and recovery at info. Metadata includes frame kind and name, never data. Hook and logger failures are swallowed. This option belongs to direct backplane construction; the CloudflarePlugin arm uses default reporting.

Both need a Durable Object class the application exports plus a wrangler stanza; no plugin option can export a class on an application’s behalf. This package ships the behaviour as two plain cores that the exported class delegates to. A mixin taking the base class would read better but cannot be typed without any — the TypeScript mixin constructor constraint requires it, and the unknown[] form rejects a class whose constructor takes (ctx, env) — so delegation is the design, and it also keeps cloudflare:workers (unresolvable off a Worker toolchain) out of the package.

import { DurableObject } from 'cloudflare:workers';
import { RealtimeBackplaneObjectCore } from '@setu-ts/cloudflare-plugin';

export class RealtimeBackplaneObject extends DurableObject {
  #core = new RealtimeBackplaneObjectCore(this.ctx);
  override fetch(request: Request): Promise<Response> {
    return this.#core.fetch(request);
  }
  webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): void {
    this.#core.webSocketMessage(ws, message);
  }
  webSocketClose(ws: WebSocket, code: number, reason: string): void {
    this.#core.webSocketClose(ws, code, reason);
  }
  webSocketError(ws: WebSocket): void {
    this.#core.webSocketError(ws);
  }
}
[[durable_objects.bindings]]
name = "REALTIME"
class_name = "RealtimeBackplaneObject"

[exports.RealtimeBackplaneObject]
type = "durable-object"
storage = "sqlite"

storage = "sqlite" is required on the Workers Free plan. The legacy migrations + new_sqlite_classes flow still works, but a Worker may use only one of the two flows.

Backplane. The durableObject arm registers DurableObjectBackplane under CAPABILITIES.REALTIME_BACKPLANE (or realtime-backplane.<name>), which websocket-plugin and sse-plugin resolve on their own. Register this arm or RealtimeBackplanePlugin, never both — the kernel rejects two providers of one token.

The Durable Object holds zero in-memory state. Sockets are accepted with ctx.acceptWebSocket, the hibernation API, which lets the runtime evict the object and re-run its constructor while connections stay open; getWebSockets() is the only membership that survives, so it is the only membership used. The payload is re-broadcast verbatim and never parsed, which keeps the object schema-ignorant — a future widening of RealtimeFrame needs no redeploy of the application’s class.

The subscription guarantee is narrower than “durable”, deliberately. A Worker isolate is evicted at Cloudflare’s discretion and its outbound WebSockets go with it. That is sound rather than lossy because the members the subscription serves are client sockets held by the same isolate, and an HTTP-triggered Worker stays alive while its clients remain connected — so losing the isolate loses the subscription and its members together. The socket opens lazily on first publish and reopens after any failure.

Lock. DurableObjectLock structurally satisfies scheduler-plugin’s IDistributedLock without importing it (a plugin may not import a plugin), and is app-constructed then handed over, matching KvSessionStore and D1Adapter:

const lock = new DurableObjectLock(env.LOCKS as IDurableObjectNamespace, { runtime });
// `enabled: true` is NOT required — resolveLock consults `lock` before `enabled`.
app.register(SchedulerPlugin({ distributedLock: { lock } }));

One object per lock key. Correctness comes from the platform: a Durable Object processes one event at a time and holds back delivery while a storage operation runs, so the read-compare-write is atomic with no transaction and no quorum. The holder is persisted in ctx.storage, never a field, because an object is evicted after 70–140 seconds of inactivity and a lock TTL routinely outlives that. A non-2xx from the object throws rather than reporting “not acquired”: a 404 means the binding names the wrong class, and folding that into contention would silently disable every scheduled job.

Verified against real workerd (wrangler dev, 12/12 checks) — including a plain Durable Object class without extends DurableObject, a real stub.fetch WebSocket upgrade, real state.acceptWebSocket hibernation, and the real input gate serializing 8 concurrent lock contenders down to one winner. Not verified against a deployed Worker: CI holds no Cloudflare account.

ICloudflareBindings

has(name), names(), vars(), get<T>(name), kv(name), r2(name), d1(name), queue(name), service(name), durableObject(name), waitUntil(promise).

Every accessor throws CloudflareBindingMissingError for an absent name — naming the binding and listing the ones that are present — rather than returning undefined; a missing binding is a deployment error, not an expected case. Use has when absence is expected. kv, r2 and durableObject additionally validate the binding’s shape, so an R2 bucket wired into a KV option fails at register() with a message rather than at first use with a TypeError. (d1 is validated by D1Adapter’s constructor instead, where the adapter is built.)

Notes

  • Registration is opt-in and instance-named. CAPABILITIES.CLOUDFLARE is always registered; CAPABILITIES.CACHE and CAPABILITIES.STORAGE only when their arm is configured. name derives cache.<name> / storage.<name> exactly as CachePlugin does, so a KV cache can sit beside a memory one. Registering an unnamed instance beside CachePlugin() is a startup error, because the kernel rejects two providers of one token.
  • KV’s expirationTtl minimum is 60 seconds, and ICacheStore.set is unbounded. Values carry a { v, e } envelope whose logical deadline is checked against runtime.now() on every read, while the physical expirationTtl is floored at 60 so KV can still reclaim the key. A 5-second entry therefore expires in 5 seconds. The same envelope backs KvSessionStore.
  • clear() requires a prefix. The binding has no bulk delete, so the sweep pages list (1000 keys maximum) and deletes each key. Without a prefix it would delete keys the store does not own, so it throws CloudflareUnsupportedError instead.
  • A read never deletes a key the store does not own. The envelope decoder reports three outcomes, not two — live, this store’s expired entry, and neither — and only the middle one is swept. That is what makes a shared namespace safe, and it is also why a deliberately cached null survives: get answers null for it (the contract has no other way to say so) while has and delete report it as present, and no path removes it.
  • KV is eventually consistent. Suitable for read-heavy caching, not for coordination.
  • R2Storage.getSignedUrl rejects. The R2 Workers binding exposes no presign operation at all. getStream is implemented, so serving through a route is a zero-copy alternative.
  • R2Storage.delete heads first. R2’s delete returns void and reports nothing, so the committed Promise<boolean> costs one extra round trip rather than a constant true.
  • KvSessionStore is constructed by the application, not registered by the plugin: SessionPluginOptions.store is read at plugin construction, before any application exists.
  • No binding I/O at registration. Cloudflare prohibits I/O outside a request context, so the plugin only captures and shape-checks bindings at register(). The cloudflare health indicator performs no binding I/O either — a KV read per probe interval is billable. It reports degraded when runtime.platform() is not cloudflare-workers.
  • waitUntil reports its failures. A rejection handler is attached whether or not a host was injected, so background work never fails silently. With no host the promise still runs: no runtime off Workers cuts work off at the response.
  • Compatibility date. import { waitUntil } from 'cloudflare:workers' shipped 2025-08-08; setu new --runtime cloudflare-workers scaffolds a later date.
  • Unverified against a live Worker. Every binding is exercised against a fake built from the documented signatures — including KV’s 60-second floor and R2’s void delete — but CI holds no Cloudflare account.
  • Queues: addRecurring throws. Cloudflare Queues has no recurring message. The error names Cron Triggers and WorkersCron as the platform’s own mechanism. add and process map directly: AddJobOptions.delayMs is converted to the platform’s whole-second delaySeconds rounded up (so a job is never delivered early) and refused above maxDelaySeconds, while ProcessOptions.concurrency bounds how many of one batch’s messages for that name run at a time — per name, so one processor’s limit never throttles another’s. AddJobOptions.headers travels in the envelope and is delivered as IJob.headers, so a job enqueued on Workers joins a trace exactly as one on the other adapters does; a malformed map is dropped and the job runs untraced rather than being retried until the queue discards it.
  • A job’s id comes from this package, not the platform. producer.send() resolves to void, so the id add returns is minted from runtime.uuid() and travels inside a { v, name, id, data } envelope — which is also what carries the job name, since a Cloudflare message body is arbitrary JSON and IQueue.process dispatches by name. The id the caller receives is therefore the id the processor sees as job.id.
  • An unroutable message is RETRIED, never acked. A body that is not a readable envelope, or a name with no registered processor, is returned for redelivery and reported through the logger — acking it would discard it permanently and silently, which is the failure a queue exists to prevent. A processor that throws is likewise retried, leaving the queue’s own max_retries and dead-letter configuration to decide what happens next. AddJobOptions.maxAttempts is enforced at dispatch, because Cloudflare’s max_retries is queue-wide configuration rather than per message.
  • publish reaches one consumer Worker, not every subscriber in the cluster. Cloudflare allows exactly one active consumer per queue, so two Workers cannot both receive one published message; fan-out happens across the handlers registered inside that consumer. A topology needing cross-service fan-out binds one queue per consuming service. This is a platform property, not a limitation of the adapter, and it is the one place WorkersBroker cannot match a socket broker.
  • subscribe registers; it does not start receiving. A Cloudflare queue consumer is a module-level export, so delivery happens only once the application exports createMessagingHandler(app) as queue AND declares the queue under [[queues.consumers]] — the same split WorkersQueue has between process and dispatch. Within one delivery the selection matches InMemoryBroker: every subscriber that named no queue is called, plus exactly one member of each named group, round-robin.
  • A publish nobody subscribed to is ACKED, not retried — the one place this deliberately departs from WorkersQueue. A job name with no processor is a mistake, but publishing to a topic nobody listens on is ordinary pub/sub, and retrying would burn the queue’s 100-retry budget and dead-letter every fire-and-forget message. A body that is not a readable envelope is still retried, and a subscriber that throws is retried.
  • request/respond need the messaging.rpc arm, and throw CloudflareUnsupportedError without it. A queue reaches its one consumer Worker and never the caller waiting for a reply, so RPC needs a second addressable path: the caller holds a WebSocket to a Durable Object named after its own inbox, and the responder POSTs the reply there. That costs a namespace binding and a DO class the application exports (delegating to ReplyInboxObjectCore), which is why it is opt-in rather than always on. A request whose topic has no responder is answered with a failure rather than left to time out, and a responder that throws is relayed to the caller and acked — never retried, since the caller has already been told and a redelivery would re-run side effects.
  • One queue export serves EVERY queue the Worker consumes. Cloudflare distinguishes them only by batch.queue — the queue name from wrangler.toml, which no plugin option can see. A Worker consuming both a message queue and a job queue must route on it: feeding job batches to createMessagingHandler fails the envelope guard and retries them until the queue dead-letters them, and leaving a produced queue unconsumed discards every IQueue.add() silently. setu new --template microservice --runtime cloudflare-workers emits the routing and a consumer stanza for both queues.
  • A queue carrying RPC MUST set max_batch_timeout = 0. The platform default is 5 seconds and RequestOptions.timeoutMs defaults to 5000, so on a default queue essentially every request() times out. setu new --template microservice --runtime cloudflare-workers emits the correct stanza.
  • The RPC errors are this package’s own classes. CloudflareRequestTimeoutError and CloudflareRemoteHandlerError mirror messaging-plugin’s RequestTimeoutError and RemoteHandlerError but are distinct identities, because AI_GUIDELINES §2.2 forbids a plugin importing another plugin and common carries no error class to promote them into. Which one an application catches is never ambiguous: both providers claim CAPABILITIES.MESSAGING, so the kernel’s duplicate-provider check guarantees exactly one is registered.
  • Cron Triggers do NOT register CAPABILITIES.SCHEDULER, deliberately. Of IScheduler’s eight methods only cron is expressible on Workers: every and delay arm a timer and the isolate is evicted between invocations (the same reason scheduler-plugin cannot run on Workers); pause/resume/remove need state that does not survive between invocations; and getNextRun is owned by the wrangler.toml [triggers] block. An IScheduler where six of eight methods throw would violate Liskov substitution, so WorkersCron is a purpose-built registry reached directly instead — which is why createScheduledHandler takes it while createQueueHandler takes the application.
  • A WorkersCron expression must match wrangler.toml exactly. Nothing in the process can read that file, so an expression registered here but absent from [triggers] crons never fires, and a configured trigger with nothing registered here is reported on every occurrence. expressions() exists so an application can assert its own coverage. Matching is exact — whitespace is not normalized.
  • cacheApiMiddleware is a different layer from cache-plugin’s cacheMiddleware, and the two compose: this one serves from the datacenter the request landed in with no round trip, while cacheMiddleware reads an ICacheStore every colo shares. It therefore reports under X-Cache-Api (HIT/MISS/BYPASS), never X-Cache, so an operator can tell which layer answered. caches.default is per-datacenter: a hit rate measured in one location says nothing about another, and a delete does not evict globally.
  • The default key carries the resolved tenant and locale. The key is a URL string, which the platform matches with no request headers, so Vary cannot separate entries in this cache — and before the tenant was keyed, tenant A’s cached page was served to tenant B for the TTL. When ctx.request.tenant and/or ctx.request.locale is set, the default key is the request URL’s own text with setu-cache-tenant=<encoded id> and then setu-cache-locale=<encoded locale> concatenated after it, in that order. The query is never re-serialized and the parameter never set: set would strip a client-supplied copy from the key while the handler still saw it, and re-serializing would fold encoding variants (?p=%32 and ?p=2) into one entry, either way letting one client choose what another is served. Without a tenant or locale the key is the URL unchanged, except as below. Four kinds of request are served uncached (X-Cache-Api: BYPASS) because no key could keep them apart from another: a URL whose text is not its own WHATWG serialization (Deno, and Node for some targets, deliver the request target as sent, while the Cache API parses a key before matching), a URL carrying a fragment (delivered by Deno, Node and Bun, ignored by the Cache API when matching), a tenant id or locale that is not well-formed UTF-16 (encodeURIComponent cannot encode a lone surrogate), and a request lacking a tenant or locale whose URL already contains setu-cache-tenant= or setu-cache-locale= (its key would equal a tenanted or localized request’s). Workers normalizes the URL and strips the fragment, so the first two do not arise there. The tenant and locale are the ones present when the middleware runs, so the same conditions as cache-plugin apply: the middleware must run after the tenant (40) and locale (45) middleware (globally, at a higher priority number than theirs; per route, listed after them), and a replaceTenant or replaceLocale made inside the handler is not reflected — such a route must not be cached here. A custom key replaces this and must include the tenant and locale itself.
  • The platform’s cache refusals are checked before the write, not discovered by it. caches.default.put throws for a non-GET request, status 206, Vary: *, and an uncleared Set-Cookie; assessCacheability reports each as a CacheRefusal and the middleware skips the write. The 206 and Vary: * checks are unconditional — an operator may legitimately configure cacheableStatuses: [200, 206], at which point only the explicit rule stops the platform throwing. Cache-Control: private=Set-Cookie is the platform’s documented opt-in and clears the set-cookie refusal.
  • Only GET requests touch the edge cache — on the read as well as the write. The cache key is a URL string, which the Cache API resolves as a GET request, so consulting it for a POST would serve the cached GET body and skip the handler entirely: a mutation silently discarded behind a 200. Any non-GET request passes straight through with X-Cache-Api: BYPASS, which matters most when the middleware sits on the global pipeline rather than a single GET route.
  • A failed cache write never fails the request. Cache.put rejects for an oversized response or a quota error, and by then the response already exists — so both write paths report and continue rather than turning a 200 into a 500. With the plugin registered waitUntil carries that reporting; without it the middleware logs through CAPABILITIES.LOGGER when one is present.
  • A cache HIT is replayed as a stream, so a cached response of any size reaches the client without being buffered — which means app.inject() cannot read its body. Drive a cached route with app.fetch and a web Request, the entry point a Worker invokes anyway. A streaming response is never cached: teeing it would double the memory the stream exists to avoid.
  • The cache write rides waitUntil when the plugin is registered AND the application passed a host, so it never delays the response. When CloudflarePlugin received no waitUntil the registry has no sink, so the middleware awaits the write inline — the write lands either way; waitUntil decides only whether it delays the response. With CAPABILITIES.CLOUDFLARE absent the middleware awaits it inline rather than abandoning it, and with no cache handle at all it passes through with BYPASS rather than throwing.
  • Not in this package. D1 as a database backend is Milestone 52c — the seam a backend implements (IDatabaseAdapter) lives inside database-plugin and is not a committed common port, so shipping it means a contract promotion. A Durable-Object realtime backplane and distributed lock are Milestone 52d: both need the application to export a DO class plus a wrangler migration stanza.

Agent card · TypeDoc API reference