Watching a coding agent wait through the same remote deployment twenty times gets old very quickly. It will happily make another small revision, deploy again and pay the delay again. I wanted my own Worker experiments, the agent’s inner loop and CI to share one path which stayed on the laptop.

That is why I built open-cloud, a TypeScript implementation of the Cloudflare developer platform which runs on Bun, SQLite and the local filesystem. I can keep one process alive, deploy Workers into it programmatically, dispatch requests, inspect the resulting state with ordinary tools and throw the data away when the run is over.

I needed more than something resembling D1. Worker code had to receive D1, KV, R2 and service bindings through the same env object it uses on Cloudflare, without a local-only adapter creeping into the application.

One Worker, four bindings

The smallest useful proof uses two Workers. One is a downstream service which echoes a request. The application Worker declares four bindings:

bindings: {
  DB: { type: "d1", name: "DB", resourceId: "main-db" },
  CACHE: { type: "kv", name: "CACHE", resourceId: "main-kv" },
  FILES: { type: "r2", name: "FILES", resourceId: "main-bucket" },
  DOWNSTREAM: { type: "service", name: "DOWNSTREAM", resourceId: "downstream" },
}

The Worker knows nothing about the local runtime. It creates a table and inserts a row through env.DB, stores a value and metadata through env.CACHE, writes a file through env.FILES, then calls the other Worker through env.DOWNSTREAM.fetch():

await env.DB.exec("CREATE TABLE IF NOT EXISTS events (id INTEGER PRIMARY KEY, name TEXT)")
await env.DB.prepare("INSERT INTO events (name) VALUES (?)").bind("launch").run()
const rows = await env.DB.prepare("SELECT id, name FROM events ORDER BY id").all()

await env.CACHE.put("latest", "launch", { metadata: { kind: "event" } })
const cached = await env.CACHE.getWithMetadata("latest")

await env.FILES.put("brief.txt", "worker bindings survived local dispatch")
const file = await env.FILES.get("brief.txt")

const service = await (await env.DOWNSTREAM.fetch("/echo?source=brief")).json()

The response contained the database row, the 39 bytes written to R2 and the downstream service result. My favourite detail was inside the KV value:

"cached": {
  "value": "launch",
  "metadata": { "kind": "event" },
  "cacheStatus": null
}

I had not put cacheStatus into the Worker. It came from the binding implementation. That stray field is a small but useful test of the approach: application code is exercising a reusable KV-shaped object rather than receiving whatever fields a test author happened to remember when inventing a response.

A painterly collage of a workbench holding a bucket, a queue ribbon, a database cylinder, and a key, wired to a small distant cloud.
The bindings, close enough to touch.

Why I did not want application mocks

An application-specific mock creates a second vocabulary. Production code calls env.DB.prepare(...); the test imports a friendly helper which may behave differently. A person can remember which world they are in. An agent will quite reasonably learn the interface that makes the nearest test pass, even when that interface teaches it the wrong lesson about production.

open-cloud puts the substitution at the platform boundary instead. D1 maps prepare, bind, run and all to bun:sqlite. KV stores namespace values and metadata in SQLite. R2 keeps bytes in the filesystem and metadata beside them. A service binding is a synthetic { fetch } object which captures the target Worker and routes the request back through the runtime.

Bindings are assembled when a Worker is deployed. Stateful services are cached by their type and resourceId, so two Workers bound to main-db genuinely see the same local database. The storage layout is documented in implementation.md; when an insert looks wrong, I can open the SQLite file rather than interpret a provider dashboard through another layer of abstraction.

This also gives CI the same route through the application as local development. It can deploy the Worker, bind resources from configuration, send a request and inspect both response and state. There is no special test-only version of the program to keep honest.

The fidelity I traded away

wrangler dev and Miniflare remain the better tools when the bug depends on workerd. They run Cloudflare’s actual runtime and reproduce semantics a Bun implementation cannot. open-cloud makes a different trade: it keeps a broad set of services in one persistent, inspectable process and aims at their public binding shapes rather than bit-for-bit platform parity.

Regional routing, propagation delays, proprietary rate limits and the production control plane are outside that bargain. The default runtime also imports trusted Worker modules into the main Bun process, so it provides speed rather than isolation. Isolate mode gives each Worker a separate thread and heap when the code needs a firmer boundary.

The project’s benchmark baseline measured about 45 microseconds for an in-process dispatch and 280 microseconds in isolate mode on a two-core x64 Linux VM, with roughly 80 MB of RSS at idle. Those figures describe the emulator, not Cloudflare and not an application running on it.

During exploratory work, the process stays up while an agent deploys, calls, inspects and revises a Worker as many times as the problem requires. Once a test depends on workerd or Cloudflare’s distributed behaviour, I run it on Cloudflare.

Chris Chabot · April 2026