@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
| Option | Type | Default | Description |
|---|---|---|---|
env | Record<string,unknown> | — | Required. The Worker’s env. |
waitUntil | WaitUntilHost | — | 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. |
requireBindings | string[] | [] | Bindings that must exist; register() throws naming absent ones. |
cache.binding | string | — | KV namespace serving CAPABILITIES.CACHE. |
cache.name | string | 'default' | Derives cache.<name> when not 'default'. |
cache.prefix | string | — | Key prefix. Required to call clear(). |
cache.defaultTtlSeconds | number | — | TTL applied when set omits one. |
storage.binding | string | — | R2 bucket serving CAPABILITIES.STORAGE. |
storage.name | string | 'default' | Derives storage.<name> when not 'default'. |
storage.prefix | string | — | Object-key prefix. |
queue.binding | string | — | Queues producer binding serving CAPABILITIES.QUEUE. |
queue.name | string | 'default' | Derives queue.<name> when not 'default'. |
queue.maxDelaySeconds | number | 86400 | A larger delayMs throws rather than being truncated. |
messaging.binding | string | — | Queues producer binding serving CAPABILITIES.MESSAGING. |
messaging.name | string | 'default' | Derives messaging.<name> when not 'default'. |
messaging.rpc.binding | string | — | DO namespace serving RPC reply inboxes. Absent, RPC throws. |
messaging.rpc.defaultTimeoutMs | number | 5000 | Reply budget when RequestOptions.timeoutMs is omitted. |
durableObject.binding | string | — | DO namespace serving CAPABILITIES.REALTIME_BACKPLANE. |
durableObject.name | string | 'default' | Derives realtime-backplane.<name> when not 'default'. |
durableObject.topic | string | '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:
publishreaches 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.subscriberegisters, 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 deferredINSERTcannot hand a generated key back to a caller awaitingcreate()before the flush, so it throws rather than returning a row with a missing id. Outside a transactioncreate()usesRETURNING *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.setaccepts 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
nullsurvives a read — negative caching works.getstill answersnullfor it, becauseICacheStorehas no way to distinguish a cached absence from a miss, buthasanddeletereport it as present. clear()needs a prefix and costs one delete per key. The binding has no bulk delete, so the sweep pageslist(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.
getSignedUrlrejects. Serve the object through a route (getStreamgives 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_retriesand dead-letter settings inwrangler.tomlthen decide what happens. queue.addRecurringthrows. Cloudflare Queues has no recurring message. Declare the schedule in[triggers] cronsand let aWorkersCronhandler enqueue the work.- There is no
IScheduleron Workers, deliberately.everyanddelayarm timers, and the isolate is evicted between invocations, so they would silently never fire;pause/resume/removeneed state that does not survive an invocation; andgetNextRunis owned bywrangler.toml.WorkersCronis a small honest surface instead of anISchedulerwhere six of eight methods throw. - A cron expression registered here but absent from
wrangler.tomlnever 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 toWorkersCronadds 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
POSTto a cached path would otherwise be served the cached GET body and never reach your handler. Non-GET requests pass through withX-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.putrejects 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
waitUntilhost. When the plugin received no host the middleware awaitsCache.putitself before the response returns — the write delays the response instead of being abandoned when the isolate is released (V9-23). PassingwaitUntilmoves the write off the response path; the plugin warns at startup when it is missing on Workers. caches.defaultis per-datacenter.cacheApiMiddlewareis a latency optimisation, not a shared store: a hit in one colo says nothing about another, and adeletedoes not evict globally. It reports underX-Cache-Api, so it composes withcache-plugin’scacheMiddleware(which reports underX-Cacheand 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
Varycannot separate entries here;ctx.request.tenantandctx.request.localeare appended to the URL text assetu-cache-tenantandsetu-cache-localeparameters 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 whatevermiddlewarePriorityandmiddleware.priorityare 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 customkeymust include both itself. - The edge cache refuses some responses, and the middleware skips them rather than failing.
Non-GET, 206,
Vary: *, and an unclearedSet-Cookieall makecaches.default.putthrow; those are checked first.Cache-Control: private=Set-Cookieis 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 withapp.fetchand a webRequest, 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
| Export | Kind |
|---|---|
assessCacheability | function |
asUpgradeResponse | function |
cacheApiMiddleware | function |
CloudflarePlugin | function |
createDefaultDurableObjectWebSocketHost | function |
createMessagingHandler | function |
createQueueHandler | function |
createScheduledHandler | function |
isD1Database | function |
isDurableObjectNamespace | function |
isKvNamespace | function |
isQueueProducer | function |
isR2Bucket | function |
CloudflareBindingMissingError | class |
CloudflareObjectNotFoundError | class |
CloudflareRemoteHandlerError | class |
CloudflareRequestTimeoutError | class |
CloudflareUnsupportedError | class |
D1Adapter | class |
DistributedLockObjectCore | class |
DurableObjectBackplane | class |
DurableObjectIdempotencyStore | class |
DurableObjectLock | class |
IdempotencyObjectCore | class |
KvCacheStore | class |
KvSessionStore | class |
R2Storage | class |
RealtimeBackplaneObjectCore | class |
ReplyInboxObjectCore | class |
WorkersBroker | class |
WorkersCron | class |
WorkersQueue | class |
BrokerRuntime | interface |
CacheabilityInput | interface |
CacheApiMiddlewareOptions | interface |
CacheClock | interface |
CloudflarePluginOptions | interface |
D1AdapterOptions | interface |
D1EntityMapping | interface |
D1Result | interface |
DistributedLockObjectCoreOptions | interface |
DurableObjectArm | interface |
DurableObjectBackplaneOptions | interface |
DurableObjectIdempotencyStoreOptions | interface |
DurableObjectLockOptions | interface |
DurableObjectMessageEvent | interface |
DurableObjectUpgradeResponse | interface |
DurableObjectWebSocketHost | interface |
DurableObjectWebSocketPair | interface |
ICacheApi | interface |
ICloudflareBindings | interface |
ID1Database | interface |
ID1PreparedStatement | interface |
IdempotencyObjectCoreOptions | interface |
IDurableObjectClientSocket | interface |
IDurableObjectNamespace | interface |
IDurableObjectState | interface |
IDurableObjectStorage | interface |
IDurableObjectWebSocket | interface |
IIdempotencyObjectState | interface |
IKvNamespace | interface |
IQueueMessage | interface |
IQueueMessageBatch | interface |
IQueueProducer | interface |
IR2Bucket | interface |
IR2Object | interface |
IR2ObjectBody | interface |
IScheduledController | interface |
IServiceBinding | interface |
JobIdSource | interface |
KvCacheOptions | interface |
KvCacheStoreOptions | interface |
KvListOptions | interface |
KvListResult | interface |
KvPutOptions | interface |
KvSessionStoreOptions | interface |
MessagingHandlerOptions | interface |
QueueHandlerOptions | interface |
QueueSendOptions | interface |
R2PutOptions | interface |
R2StorageArm | interface |
R2StorageOptions | interface |
RealtimeBackplaneObjectCoreOptions | interface |
ReplyInboxBinding | interface |
ReplyInboxObjectCoreOptions | interface |
WorkersBrokerOptions | interface |
WorkersCronOptions | interface |
WorkersMessagingArm | interface |
WorkersMessagingRpcArm | interface |
WorkersQueueArm | interface |
WorkersQueueOptions | interface |
CacheRefusal | type |
CloudflareWorkerEnv | type |
CronHandler | type |
LoggerSource | type |
MessagingHandler | type |
QueueHandler | type |
ScheduledHandler | type |
WaitUntilHost | type |
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
| Option | Type | Default | Consumer / behavior |
|---|---|---|---|
env | CloudflareWorkerEnv | required | BindingRegistry — the record every accessor reads |
waitUntil | WaitUntilHost | — | resolveWaitUntil — delegated to; omit off Workers |
requireBindings | readonly string[] | [] | register() — throws naming every absent entry |
cache.binding | string | — | KvCacheStore — the KV namespace serving CAPABILITIES.CACHE |
cache.name | string | 'default' | Plugin factory — derives cache.<name> when not 'default' |
cache.prefix | string | — | KvCacheStore — key prefix; required to call clear() |
cache.defaultTtlSeconds | number | — | KvCacheStore.set — applied when ttlSeconds is omitted |
storage.binding | string | — | R2Storage — the R2 bucket serving CAPABILITIES.STORAGE |
storage.name | string | 'default' | Plugin factory — derives storage.<name> when not 'default' |
storage.prefix | string | — | R2Storage — object-key prefix |
queue.binding | string | — | WorkersQueue — the producer binding serving CAPABILITIES.QUEUE |
queue.name | string | 'default' | Plugin factory — derives queue.<name> when not 'default' |
queue.maxDelaySeconds | number | 86400 | WorkersQueue.add — a larger delayMs throws rather than being truncated |
messaging.binding | string | — | WorkersBroker — the producer binding serving CAPABILITIES.MESSAGING; validated at register() |
messaging.name | string | 'default' | Plugin factory — derives messaging.<name> when not 'default' |
messaging.rpc.binding | string | — | WorkersBroker — the Durable Object namespace serving reply inboxes; absent, request/respond throw |
messaging.rpc.defaultTimeoutMs | number | 5000 | RequestCorrelation — reply budget when RequestOptions.timeoutMs is omitted |
durableObject.binding | string | — | DurableObjectBackplane — the namespace serving CAPABILITIES.REALTIME_BACKPLANE; validated at register() |
durableObject.name | string | 'default' | Plugin factory — derives realtime-backplane.<name> when not 'default' |
durableObject.topic | string | 'realtime' | DurableObjectBackplane — the idFromName value every replica shares; two apps sharing a namespace must differ |
Exports
| Export | Kind |
|---|---|
CloudflarePlugin | factory |
ICloudflareBindings | interface |
CloudflarePluginOptions, KvCacheOptions, R2StorageArm, WorkersQueueArm, WorkersMessagingArm, WorkersMessagingRpcArm, DurableObjectArm | types |
KvCacheStore, KvCacheStoreOptions, CacheClock | class + types |
KvSessionStore, KvSessionStoreOptions | class + types |
R2Storage, R2StorageOptions | class + types |
D1Adapter, D1AdapterOptions, D1EntityMapping | class + types |
WaitUntilHost, LoggerSource | types |
WorkersQueue, WorkersQueueOptions, JobIdSource | class + types |
createQueueHandler, QueueHandler, QueueHandlerOptions | fn + types |
WorkersBroker, WorkersBrokerOptions, ReplyInboxBinding, BrokerRuntime | class + types |
createMessagingHandler, MessagingHandler, MessagingHandlerOptions | fn + types |
ReplyInboxObjectCore, ReplyInboxObjectCoreOptions | class + type |
WorkersCron, WorkersCronOptions, CronHandler | class + types |
createScheduledHandler, ScheduledHandler | fn + type |
cacheApiMiddleware, CacheApiMiddlewareOptions, ICacheApi | fn + types |
assessCacheability, CacheabilityInput, CacheRefusal | fn + types |
RealtimeBackplaneObjectCore, RealtimeBackplaneObjectCoreOptions | class + type |
DistributedLockObjectCore, DistributedLockObjectCoreOptions | class + type |
DurableObjectBackplane, DurableObjectBackplaneOptions | class + type |
DurableObjectLock, DurableObjectLockOptions | class + type |
IdempotencyObjectCore, IdempotencyObjectCoreOptions | class + type |
DurableObjectIdempotencyStore, DurableObjectIdempotencyStoreOptions | class + type |
IIdempotencyObjectState | type |
asUpgradeResponse, DurableObjectUpgradeResponse | fn + type |
createDefaultDurableObjectWebSocketHost, DurableObjectWebSocketHost, DurableObjectWebSocketPair | fn + types |
IDurableObjectState, IDurableObjectStorage, IDurableObjectWebSocket, IDurableObjectClientSocket, DurableObjectMessageEvent | types |
IKvNamespace, IR2Bucket, IR2Object, IR2ObjectBody, ID1Database, ID1PreparedStatement, D1Result, IQueueProducer, IQueueMessage, IQueueMessageBatch, IScheduledController, IServiceBinding, IDurableObjectNamespace, CloudflareWorkerEnv, KvPutOptions, KvListOptions, KvListResult, QueueSendOptions | types |
isKvNamespace, isR2Bucket, isD1Database, isQueueProducer, isDurableObjectNamespace | guards |
CloudflareBindingMissingError, CloudflareUnsupportedError, CloudflareObjectNotFoundError, CloudflareRequestTimeoutError, CloudflareRemoteHandlerError | errors |
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' } },
}),
}),
);
| Option | Default | Behavior |
|---|---|---|
tables | {} | Per-entity { table, primaryKey } overrides |
D1EntityMapping.table | the entity name | Physical 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 throwsCloudflareUnsupportedErrornaming the constraint when one is absent — a deferredINSERTcannot report a generated key to a caller that awaitscreate()before the flush. Outside a transactioncreate()usesRETURNING *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.CLOUDFLAREis always registered;CAPABILITIES.CACHEandCAPABILITIES.STORAGEonly when their arm is configured.namederivescache.<name>/storage.<name>exactly asCachePlugindoes, so a KV cache can sit beside a memory one. Registering an unnamed instance besideCachePlugin()is a startup error, because the kernel rejects two providers of one token. - KV’s
expirationTtlminimum is 60 seconds, andICacheStore.setis unbounded. Values carry a{ v, e }envelope whose logical deadline is checked againstruntime.now()on every read, while the physicalexpirationTtlis floored at 60 so KV can still reclaim the key. A 5-second entry therefore expires in 5 seconds. The same envelope backsKvSessionStore. clear()requires a prefix. The binding has no bulk delete, so the sweep pageslist(1000 keys maximum) and deletes each key. Without a prefix it would delete keys the store does not own, so it throwsCloudflareUnsupportedErrorinstead.- 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
nullsurvives:getanswersnullfor it (the contract has no other way to say so) whilehasanddeletereport it as present, and no path removes it. - KV is eventually consistent. Suitable for read-heavy caching, not for coordination.
R2Storage.getSignedUrlrejects. The R2 Workers binding exposes no presign operation at all.getStreamis implemented, so serving through a route is a zero-copy alternative.R2Storage.deleteheads first. R2’sdeletereturns void and reports nothing, so the committedPromise<boolean>costs one extra round trip rather than a constanttrue.KvSessionStoreis constructed by the application, not registered by the plugin:SessionPluginOptions.storeis 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(). Thecloudflarehealth indicator performs no binding I/O either — a KV read per probe interval is billable. It reportsdegradedwhenruntime.platform()is notcloudflare-workers. waitUntilreports 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-workersscaffolds 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:
addRecurringthrows. Cloudflare Queues has no recurring message. The error names Cron Triggers andWorkersCronas the platform’s own mechanism.addandprocessmap directly:AddJobOptions.delayMsis converted to the platform’s whole-seconddelaySecondsrounded up (so a job is never delivered early) and refused abovemaxDelaySeconds, whileProcessOptions.concurrencybounds 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.headerstravels in the envelope and is delivered asIJob.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 tovoid, so the idaddreturns is minted fromruntime.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 andIQueue.processdispatches by name. The id the caller receives is therefore the id the processor sees asjob.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_retriesand dead-letter configuration to decide what happens next.AddJobOptions.maxAttemptsis enforced at dispatch, because Cloudflare’smax_retriesis queue-wide configuration rather than per message. publishreaches 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 placeWorkersBrokercannot match a socket broker.subscriberegisters; it does not start receiving. A Cloudflare queue consumer is a module-level export, so delivery happens only once the application exportscreateMessagingHandler(app)asqueueAND declares the queue under[[queues.consumers]]— the same splitWorkersQueuehas betweenprocessanddispatch. Within one delivery the selection matchesInMemoryBroker: every subscriber that named noqueueis 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/respondneed themessaging.rpcarm, and throwCloudflareUnsupportedErrorwithout 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 responderPOSTs the reply there. That costs a namespace binding and a DO class the application exports (delegating toReplyInboxObjectCore), 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
queueexport serves EVERY queue the Worker consumes. Cloudflare distinguishes them only bybatch.queue— the queue name fromwrangler.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 tocreateMessagingHandlerfails the envelope guard and retries them until the queue dead-letters them, and leaving a produced queue unconsumed discards everyIQueue.add()silently.setu new --template microservice --runtime cloudflare-workersemits 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 andRequestOptions.timeoutMsdefaults to 5000, so on a default queue essentially everyrequest()times out.setu new --template microservice --runtime cloudflare-workersemits the correct stanza. - The RPC errors are this package’s own classes.
CloudflareRequestTimeoutErrorandCloudflareRemoteHandlerErrormirrormessaging-plugin’sRequestTimeoutErrorandRemoteHandlerErrorbut are distinct identities, because AI_GUIDELINES §2.2 forbids a plugin importing another plugin andcommoncarries no error class to promote them into. Which one an application catches is never ambiguous: both providers claimCAPABILITIES.MESSAGING, so the kernel’s duplicate-provider check guarantees exactly one is registered. - Cron Triggers do NOT register
CAPABILITIES.SCHEDULER, deliberately. OfIScheduler’s eight methods onlycronis expressible on Workers:everyanddelayarm a timer and the isolate is evicted between invocations (the same reasonscheduler-plugincannot run on Workers);pause/resume/removeneed state that does not survive between invocations; andgetNextRunis owned by thewrangler.toml[triggers]block. AnISchedulerwhere six of eight methods throw would violate Liskov substitution, soWorkersCronis a purpose-built registry reached directly instead — which is whycreateScheduledHandlertakes it whilecreateQueueHandlertakes the application. - A
WorkersCronexpression must matchwrangler.tomlexactly. Nothing in the process can read that file, so an expression registered here but absent from[triggers] cronsnever 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. cacheApiMiddlewareis a different layer fromcache-plugin’scacheMiddleware, and the two compose: this one serves from the datacenter the request landed in with no round trip, whilecacheMiddlewarereads anICacheStoreevery colo shares. It therefore reports underX-Cache-Api(HIT/MISS/BYPASS), neverX-Cache, so an operator can tell which layer answered.caches.defaultis per-datacenter: a hit rate measured in one location says nothing about another, and adeletedoes 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
Varycannot separate entries in this cache — and before the tenant was keyed, tenant A’s cached page was served to tenant B for the TTL. Whenctx.request.tenantand/orctx.request.localeis set, the default key is the request URL’s own text withsetu-cache-tenant=<encoded id>and thensetu-cache-locale=<encoded locale>concatenated after it, in that order. The query is never re-serialized and the parameter neverset:setwould strip a client-supplied copy from the key while the handler still saw it, and re-serializing would fold encoding variants (?p=%32and?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 (encodeURIComponentcannot encode a lone surrogate), and a request lacking a tenant or locale whose URL already containssetu-cache-tenant=orsetu-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 ascache-pluginapply: 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 areplaceTenantorreplaceLocalemade inside the handler is not reflected — such a route must not be cached here. A customkeyreplaces 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.putthrows for a non-GET request, status 206,Vary: *, and an unclearedSet-Cookie;assessCacheabilityreports each as aCacheRefusaland the middleware skips the write. The 206 andVary: *checks are unconditional — an operator may legitimately configurecacheableStatuses: [200, 206], at which point only the explicit rule stops the platform throwing.Cache-Control: private=Set-Cookieis the platform’s documented opt-in and clears theset-cookierefusal. - 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
POSTwould serve the cached GET body and skip the handler entirely: a mutation silently discarded behind a 200. Any non-GET request passes straight through withX-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.putrejects 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 registeredwaitUntilcarries that reporting; without it the middleware logs throughCAPABILITIES.LOGGERwhen 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 withapp.fetchand a webRequest, 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
waitUntilwhen the plugin is registered AND the application passed a host, so it never delays the response. WhenCloudflarePluginreceived nowaitUntilthe registry has no sink, so the middleware awaits the write inline — the write lands either way;waitUntildecides only whether it delays the response. WithCAPABILITIES.CLOUDFLAREabsent the middleware awaits it inline rather than abandoning it, and with no cache handle at all it passes through withBYPASSrather than throwing. - Not in this package. D1 as a database backend is Milestone 52c — the seam a backend implements
(
IDatabaseAdapter) lives insidedatabase-pluginand is not a committedcommonport, 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.