This is a local development tool only. Lopata is not intended for production use and never will be. Always deploy to Cloudflare Workers for production workloads.
Alpha software. Lopata is in early development. APIs and behavior may change. We'd love for you to try it out and report any issues you find.
Drop-in replacement for wrangler dev and @cloudflare/vite-plugin, powered by Bun. Run your Cloudflare Worker code locally with real bindings — no workerd, no miniflare.
The official wrangler dev uses workerd (the Cloudflare Workers runtime) under the hood via miniflare. While this gives you high-fidelity emulation, it comes with trade-offs that hurt the daily development experience:
- Slow startup and reload — workerd is a separate process that needs to spin up an isolate, bundle your code, and establish IPC. Every code change goes through this cycle, adding noticeable latency to the feedback loop.
- Difficult debugging — because your code runs inside workerd (not your local JS runtime), setting breakpoints, inspecting variables, and stepping through code requires a remote debugging protocol. Stack traces from binding errors are often opaque.
- Vite integration friction —
@cloudflare/vite-pluginbridges two worlds (Vite's Node/Bun process and workerd), leading to subtle issues with HMR, module resolution, and error reporting. - Heavy resource usage — running workerd alongside your dev toolchain consumes significant memory and CPU, especially with Durable Objects or multiple workers.
Lopata takes a different approach: it implements all Cloudflare bindings natively in TypeScript and runs your worker code directly in Bun. Everything is in-process — bindings are backed by SQLite and the local filesystem, Durable Objects share the same memory, and there's no IPC overhead. The result is near-instant startup, fast hot-reload, easy debugging with normal breakpoints, and a built-in dashboard with real-time request tracing.
The trade-off: Lopata runs on Bun, not workerd. While the binding APIs are faithfully reimplemented (~90–95% coverage), the underlying runtime differs. Edge cases in V8 isolate semantics, request/response body handling, or undocumented Cloudflare behavior may not match exactly. For non-trivial logic, always verify your code against the official runtime (wrangler dev or a staging deployment) before shipping to production.
- All major bindings — KV, R2, D1, Durable Objects (with SQL API and WebSocket Hibernation), Workflows, Queues, Cache, Service Bindings, Static Assets, Images, Hyperdrive, Analytics Engine, Rate Limiting, Browser Run, Containers, Scheduled (Cron), Email
- Persistent local state — data lives in
.lopata/(SQLite + filesystem), survives restarts - Dashboard — browse bindings, inspect data, view request traces in real-time
- Vite plugin — integrates with React Router and other Vite-based frameworks
- Multi-worker support — run multiple workers with service bindings between them
- Hot-reload — file changes trigger instant reload with zero downtime
- Request tracing — hierarchical spans for every request, binding call, and outbound fetch
- Cloudflare module shims —
cloudflare:workers,cloudflare:workflows,@cloudflare/containers,@cloudflare/puppeteerall work out of the box - Global API compatibility —
caches,HTMLRewriter,WebSocketPair,scheduler.wait(),crypto.timingSafeEqual, and more
- Bun v1.1+
bun add -d lopata
bunx lopata devThis starts a local server on http://localhost:8787 with all bindings from your wrangler.toml / wrangler.jsonc ready to use. The dashboard is available at http://localhost:8787/__dashboard.
bun add -d lopata// vite.config.ts
import { lopata } from 'lopata/vite-plugin'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [
lopata(),
// ... your framework plugin (e.g. reactRouter())
],
})Then start your dev server with Bun:
bun --bun vite dev
# or for React Router:
bun --bun react-router devNote:
bun --bunis required to override Node.js shebangs so Bun handlesbun:sqliteand.tsimports natively.
lopata <command> [options]
Commands:
dev Start local dev server
d1 list List D1 databases
d1 execute <db> --command <sql> Execute SQL on a D1 database
d1 migrations apply [db] Apply D1 migrations
r2 object list [bucket/prefix] List R2 objects
r2 object get <bucket/key> Get an R2 object
r2 object put <bucket/key> -f Upload a file to R2
r2 object delete <bucket/key> Delete an R2 object
kv key list List KV keys
kv key get <key> Get a KV value
kv key put <key> <value> Put a KV value
kv key delete <key> Delete a KV key
queues list List queues
queues message list <queue> List queue messages
queues message send <queue> Send a message
queues message purge <queue> Purge queue messages
cache list List cache names
cache purge [--name <cache>] Purge cache entries
trace list [--limit N] List recent traces
trace get <traceId> Get trace detail
Global flags:
--config, -c <path> Path to wrangler config file
--env, -e <name> Environment name
--help, -h Show help
lopata dev [--port 8787] [--listen localhost] [--env production]The dev server exposes endpoints for triggering handlers manually:
# Trigger a scheduled handler
curl "http://localhost:8787/cdn-cgi/handler/scheduled?cron=*/5+*+*+*+*"
# Simulate an inbound email
curl -X POST "http://localhost:8787/cdn-cgi/handler/email?from=a@b.com&to=c@d.com"Lopata reads your standard Cloudflare config file, auto-detected in order: wrangler.jsonc, wrangler.json, wrangler.toml.
Imports of non-JavaScript files follow the wrangler rules and the wrangler default rules, as in a deployed worker:
| Type | Default export | Default globs |
|---|---|---|
Text |
string |
**/*.txt, **/*.html, **/*.sql |
Data |
ArrayBuffer |
**/*.bin |
CompiledWasm |
WebAssembly.Module |
**/*.wasm, **/*.wasm?module |
{
"rules": [
{ "type": "Text", "globs": ["**/*.md"], "fallthrough": true }
]
}A rule replaces the default rule of its type unless it sets fallthrough: true; an import that only a replaced rule
matches fails, as in wrangler. Globs are matched against the absolute path of the imported file. ESModule and
CommonJS rules are left to Bun. The rules apply to every worker lopata loads itself: the workers of the CLI and the
auxiliary workers of the Vite plugin. Under the Vite plugin, the main worker's imports go through Vite.
Variables are loaded in this order (later overrides earlier):
[vars]in wrangler config.dev.varsfile (dotenv format).envfile (fallback if no.dev.vars).dev.vars.<environment>for env-specific overridesprocess.env, but only for names one of the above already declares
Bun loads .env, .env.<NODE_ENV> and .env.local into process.env on its own. Those
values don't count as step 5, so .env never beats .dev.vars; only a variable exported in
the shell (or set by the container) does.
The last step lets a secret manager or a container's environment supply the value without
writing it to disk — declare the name with a placeholder in [vars] and export the real one:
API_TOKEN=… bun --bun vite # overrides "vars": { "API_TOKEN": "set me" }A host variable the worker does not declare is never injected. Workers have no ambient
process environment, so leaking PATH or HOME into a binding would only hide the
difference between local dev and a real deploy.
For projects with multiple workers and service bindings, create a lopata.config.ts:
// lopata.config.ts
import { defineConfig } from 'lopata'
export default defineConfig({
main: './wrangler.jsonc',
workers: [
{ name: 'auth-worker', config: './workers/auth/wrangler.jsonc' },
{ name: 'email-worker', config: './workers/email/wrangler.jsonc' },
],
// Optional settings:
cron: true, // Enable real cron scheduling
isolation: 'dev', // 'dev' (in-process) or 'isolated' (worker threads)
browser: { // Browser Run config, see below
headless: true,
},
// Extra directories outside the worker's own source tree that should also
// trigger a reload — handy in monorepos where the worker imports from
// sibling packages. Paths are resolved relative to lopata.config.ts.
watchExtra: ['packages/lib/src', 'packages/shared'],
})Workers can call each other via service bindings configured in their respective wrangler.jsonc. Both HTTP (binding.fetch()) and RPC (binding.myMethod()) modes are supported, including promise pipelining.
Every worker gets the same browser config, auxiliary workers and their Durable Objects included.
A durable_objects binding with script_name uses a class that another worker exports and hosts.
script_name names that worker as a service binding's service does: the main worker's wrangler name, an
auxiliary worker's name in lopata.config.ts, or under the Vite plugin its name option (default: its wrangler name).
// workers/chat/wrangler.jsonc — hosts the class
{
"name": "chat",
"durable_objects": { "bindings": [{ "name": "ROOMS", "class_name": "ChatRoom" }] },
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["ChatRoom"] }]
}
// wrangler.jsonc — the main worker uses it
{
"durable_objects": { "bindings": [{ "name": "ROOMS", "class_name": "ChatRoom", "script_name": "chat" }] }
}- Both bindings reach the same instances:
idFromName()andidFromString()give the same ids in every worker. stub.fetch()and RPC methods work in both directions, cycles included (A uses B's class, B uses A's). Values cross as in a Durable Object call within one worker: structured clone, streamedfetch()bodies, errors with theirnameandmessage.- The hosting worker owns the namespace: its storage, alarms and dashboard entry, and its reload restarts the instances.
- The hosting worker must declare a
durable_objectsbinding for the class itself (withoutscript_name). - An unknown worker or class fails the first call on the stub with an error that names it. A call made while the hosting worker is still loading waits for it; under the Vite plugin it loads the main worker's module.
A worker with assets and no main is a static site — Cloudflare runs no script for it,
and neither does lopata:
// site/wrangler.jsonc
{
"name": "my-site",
"assets": {
"directory": "./dist",
"not_found_handling": "single-page-application"
}
}Run it on its own (bunx lopata dev in that directory) or list it in lopata.config.ts
next to your scripted workers — as main or as an auxiliary worker with hosts/routes.
No worker thread is spawned and no file watcher is needed: assets are read from disk per
request, so edits appear immediately — _headers and _redirects included. A service binding to such a worker serves its assets
through binding.fetch(); it has no RPC methods.
In multi-worker setups, auxiliary workers can use Cloudflare-style routes in their wrangler.jsonc to handle specific URL patterns. The main worker acts as a fallback for unmatched requests.
// workers/api/wrangler.jsonc
{
"name": "api-worker",
"main": "src/index.ts",
"routes": [
"example.com/api/*",
{ "pattern": "example.com/webhooks/*" }
]
}Routes support trailing * wildcards (/api/* matches /api/foo and /api/foo/bar). When multiple routes match, the most specific one wins (more path segments > fewer, exact match > wildcard). The domain portion is stripped — only the path is matched locally.
For routing by hostname (e.g. subdomains), use hosts in lopata.config.ts:
export default {
main: './wrangler.jsonc',
workers: [
{
name: 'api-worker',
config: './workers/api/wrangler.jsonc',
hosts: ['api.localhost', '*.api.localhost'],
},
],
}Wildcard patterns like *.localhost match any subdomain. A * inside the first label (preview-*.localhost) matches within that label only. A pattern starting with ! excludes the hosts it matches, so ['*.localhost', '!dev-*.localhost'] takes every subdomain except the dev- ones. Requests matching a host pattern are routed to that worker regardless of path-based routes.
A worker's wrangler routes are matched by path only: the domain and zone_name are dropped, because local hostnames differ from production zones. When two workers' zones overlap only locally, give the route's worker hosts to scope its routes.
The Vite plugin is a drop-in replacement for @cloudflare/vite-plugin. It provides:
- Virtual module resolution (
cloudflare:workers,cloudflare:workflows,@cloudflare/containers) - Global Cloudflare API injection (
caches,HTMLRewriter,WebSocketPair, etc.) - Dev server middleware that intercepts requests and calls your worker's
fetch()handler - Dashboard and tracing at
/__dashboard - React Router integration with automatic loader/action instrumentation
// vite.config.ts
import { reactRouter } from '@react-router/dev/vite'
import { lopata } from 'lopata/vite-plugin'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [
lopata({
configPath: './wrangler.jsonc', // Optional, auto-detected
viteEnvironment: { name: 'ssr' }, // Optional, default 'ssr'
auxiliaryWorkers: [ // Optional, for multi-worker
{ configPath: './workers/auth/wrangler.jsonc' },
],
browser: { headless: true }, // Optional, Browser Run config (same as in lopata.config.ts)
}),
reactRouter(),
],
})The built-in dashboard is available at /__dashboard and provides:
- Traces — real-time request waterfall with filtering by path, status, and attributes
- KV — browse namespaces, keys, and values
- R2 — browse buckets and objects
- D1 — SQL browser for all D1 databases
- Durable Objects — inspect instances and their storage
- Queues — browse and manage queue messages
- Workflows — view workflow instances, status, and step history
- Cache — browse cache entries
- Analytics Engine — browse data points
- Email — view captured outbound emails
- Scheduled — manually trigger cron handlers
- AI — browse AI binding request logs
- Containers — manage running containers
| Binding | Storage | Coverage |
|---|---|---|
| KV | SQLite | 100% |
| R2 | Filesystem (.lopata/r2/) |
~95% |
| D1 | SQLite files (.lopata/d1/) |
~90% |
| Durable Objects | SQLite (KV + SQL API) | ~90% |
| Workflows | SQLite | 100% |
| Queues | SQLite | ~90% |
| Cache API | SQLite | 100% |
| Static Assets | Filesystem | ~90% |
| Service Bindings | In-process | ~85% |
| Scheduled (Cron) | In-memory timer | 100% |
| Images | Sharp | ~80% |
| Hyperdrive | TCP via Bun.connect() |
Passthrough |
| Workers AI | Proxies to Cloudflare API | Passthrough |
| Analytics Engine | SQLite | Full |
| Rate Limiting | SQLite (fixed window) | Full |
| Browser Run | Local Chrome via Puppeteer | Partial |
| Containers | Docker | Full |
| Send Email | SQLite (captured) | Full |
Overall compatibility: ~90–95% of the Cloudflare Workers API surface.
A browser binding gives the Worker the Browser Run API. Each session is a local headless Chrome
that lopata launches through puppeteer (install it as a dev dependency), or an external Chrome.
A connection's webSocket Fetcher upgrades to a WebSocket that speaks raw CDP:
const { sessionId, webSocket } = await env.BROWSER.launch({ keepAlive: 60_000 })
const response = await webSocket.fetch('https://browser-binding.invalid', {
headers: { Upgrade: 'websocket' },
})
const socket = response.webSocket!
socket.accept()
socket.send(JSON.stringify({ id: 1, method: 'Browser.getVersion' }))
// ...
await env.BROWSER.closeSession(sessionId)| Method | Locally |
|---|---|
acquire(), launch() |
Launches a browser. keepAlive (default 60 s, 10 s – 20 min) closes a session that receives no CDP command. targets: true lists its targets |
connectSession(sessionId, { targetId? }) |
Browser-level CDP endpoint, or /devtools/page/<targetId> with a targetId |
getSession(), listSessions(), history() |
Active sessions; history() also lists closed ones with NormalClosure, BrowserIdle or ChromiumCrashed |
closeSession() |
Closes the sockets and the browser, returns { status: 'closed' } |
quickAction(), getLiveView(), limits(), devtools.*, raw fetch() |
Throw "not supported locally" |
- Sessions live in the lopata process: they survive a reload, and every worker and Durable Object sees the same sessions.
- A session ends with its browser. When the Chrome exits, crashes or restarts, the session leaves
listSessions(),getSession()returnsnull, its CDP sockets close, andhistory()records it asChromiumCrashed. acquire()andlaunch()reject like Cloudflare, with an error that has astatus:400for akeepAliveoutside 10 s – 20 min,429beyond 200 concurrent sessions or 3 new sessions per second (the Workers Paid limits). The message readsUnable to create new browser: code: <status>: message: <reason>.- The
outboundByHost,guardrailsandrecordingoptions throw "not supported locally".locationandliveViewUrlExpiresInMsare ignored.
Configure the browser with browser in lopata.config.ts, or with the browser option of the Vite plugin:
| Option | Meaning |
|---|---|
headless |
Headless mode for the Chrome lopata launches (default true) |
executablePath |
Chrome executable for the Chrome lopata launches (default: puppeteer's Chrome) |
wsEndpoint |
Browser-level CDP WebSocket URL of an external Chrome (ws://host:9222/devtools/browser/<id>) |
httpEndpoint |
HTTP DevTools endpoint of an external Chrome (http://chrome:9222, puppeteer's browserURL) |
maxConcurrentSessions |
Concurrent sessions allowed (default 200) |
newSessionsPerSecond |
New sessions allowed per second (default 3) |
minKeepAlive |
Lopata only, for tests: the smallest keepAlive accepted in ms (default 10000, as on Cloudflare) |
- With
wsEndpointorhttpEndpointall sessions share one Chrome, andcloseSession()leaves it running. Set only one of them. On Cloudflare each session is its own browser. Here the sessions see each other's targets (Target.getTargets,/json/list) and share the default browser context: cookies, storage and cache. Open each page in its own browser context (Target.createBrowserContext, thenTarget.createTargetwith thatbrowserContextId) and dispose of the context when done, so that sessions stay apart. httpEndpointsuits a Chrome in another container: the WebSocket URL contains an id that changes on every Chrome restart. Each session start reads it from<httpEndpoint>/json/version, and lopata connects to the configured host and port even when Chrome reports its own (behind a port forward)./json/listuses the same host.- Chrome answers DevTools requests only when their
Hostheader is an IP address orlocalhost. For an endpoint with a hostname (chrome), lopata sendsHost: localhost:<port>. @cloudflare/puppeteerkeeps working:puppeteer.launch(env.BROWSER)returns a local puppeteerBrowser.
See FEATURES.md for the full feature list and recently added Cloudflare features for their local behavior and limitations.
All persistent state is stored in .lopata/ in your project directory:
.lopata/
data.sqlite # KV, DO, Workflows, Queues, Cache, Analytics, AI logs, Email
r2/ # R2 object storage
d1/ # D1 databases (one .db file per database)
Add .lopata/ to your .gitignore.
| Lopata | Wrangler (wrangler dev) |
|
|---|---|---|
| Runtime | Bun | workerd (via miniflare) |
| Bindings | Native TypeScript implementations | workerd built-in |
| Durable Objects | In-process (shared memory, easy debugging) | Isolated (faithful to production) |
| Module shims | Bun.plugin() virtual modules |
workerd native modules |
| Dashboard | Built-in with real-time tracing | Separate (Cloudflare dashboard) |
| Vite integration | Drop-in plugin | @cloudflare/vite-plugin |
| Config | Reads wrangler.toml/.jsonc/.json |
Same |
MIT