Skip to content
contemberPublic

About

Drop-in replacement for wrangler dev and @cloudflare/vite-plugin, powered by Bun.

Resources

Stars

3 stars

Watchers

1 watching

Forks

Repository files navigation

Lopata 🪏

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.

Why Lopata?

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-plugin bridges 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.

Features

  • 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/puppeteer all work out of the box
  • Global API compatibility — caches, HTMLRewriter, WebSocketPair, scheduler.wait(), crypto.timingSafeEqual, and more

Requirements

Quick start

Standalone dev server

bun add -d lopata
bunx lopata dev

This 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.

Vite plugin

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 dev

Note: bun --bun is required to override Node.js shebangs so Bun handles bun:sqlite and .ts imports natively.

CLI

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

Dev server flags

lopata dev [--port 8787] [--listen localhost] [--env production]

Special dev endpoints

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"

Configuration

Lopata reads your standard Cloudflare config file, auto-detected in order: wrangler.jsonc, wrangler.json, wrangler.toml.

// wrangler.jsonc
{
	"name": "my-worker",
	"main": "src/index.ts",
	"compatibility_date": "2025-01-01",
	"kv_namespaces": [
		{ "binding": "KV", "id": "..." }
	],
	"r2_buckets": [
		{ "binding": "R2", "bucket_name": "my-bucket" }
	],
	"d1_databases": [
		{ "binding": "DB", "database_name": "my-db", "database_id": "..." }
	],
	"durable_objects": {
		"bindings": [
			{ "name": "COUNTER", "class_name": "Counter" }
		]
	},
	"queues": {
		"producers": [{ "binding": "MY_QUEUE", "queue": "my-queue" }],
		"consumers": [{ "queue": "my-queue", "max_batch_size": 10 }]
	},
	"workflows": [
		{ "name": "my-workflow", "binding": "WORKFLOW", "class_name": "MyWorkflow" }
	]
}

Module rules

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.

Environment variables and secrets

Variables are loaded in this order (later overrides earlier):

  1. [vars] in wrangler config
  2. .dev.vars file (dotenv format)
  3. .env file (fallback if no .dev.vars)
  4. .dev.vars.<environment> for env-specific overrides
  5. process.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.

Multi-worker setup

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.

Durable Objects across workers

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() and idFromString() 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, streamed fetch() bodies, errors with their name and message.
  • 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_objects binding for the class itself (without script_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.

Assets-only workers

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.

Route patterns

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.

Host-based routing

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.

Vite plugin

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(),
	],
})

Dashboard

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

Supported bindings

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.

Browser Run

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() returns null, its CDP sockets close, and history() records it as ChromiumCrashed.
  • acquire() and launch() reject like Cloudflare, with an error that has a status: 400 for a keepAlive outside 10 s – 20 min, 429 beyond 200 concurrent sessions or 3 new sessions per second (the Workers Paid limits). The message reads Unable to create new browser: code: <status>: message: <reason>.
  • The outboundByHost, guardrails and recording options throw "not supported locally". location and liveViewUrlExpiresInMs are 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 wsEndpoint or httpEndpoint all sessions share one Chrome, and closeSession() 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, then Target.createTarget with that browserContextId) and dispose of the context when done, so that sessions stay apart.
  • httpEndpoint suits 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/list uses the same host.
  • Chrome answers DevTools requests only when their Host header is an IP address or localhost. For an endpoint with a hostname (chrome), lopata sends Host: localhost:<port>.
  • @cloudflare/puppeteer keeps working: puppeteer.launch(env.BROWSER) returns a local puppeteer Browser.

See FEATURES.md for the full feature list and recently added Cloudflare features for their local behavior and limitations.

Local data

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.

How it differs from Wrangler

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

License

MIT

About

Drop-in replacement for wrangler dev and @cloudflare/vite-plugin, powered by Bun.

Resources

Stars

3 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages