local-d1-proxy helps Cloudflare D1 projects run multiple local processes against the same local database state.
If you have a local app server reading from D1 while a background worker writes to the same persisted Wrangler state, both processes may ask Wrangler/Miniflare for their own local D1 binding. In that shape, reads can hang, retry, or fail with errors like D1_ERROR: Failed to parse body as JSON, D1DatabaseObject.queryExecute, SQLITE_BUSY, or SQLITE_BUSY_SNAPSHOT. The same pattern also works for CLIs, test runners, or other local consumers that need that shared D1 state.
This package runs one local D1 owner process and gives participating local consumers a client for the common D1 API subset over HTTP. Application code keeps the common D1 call shape: prepare() and batch(), plus prepared statement methods like bind(), first(), raw(), and run().
The Mechanism section describes the ownership invariant and HTTP protocol in more detail.
N.b. this is not a production gateway or a SQLite tuning layer; it is merely a local coordination process to make Miniflare errors less painful in local development.
flowchart LR
App["web app"] --> Proxy["local-d1-proxy"]
Worker["background worker"] --> Proxy
CLI["CLI/tests"] --> Proxy
Proxy --> Wrangler["Wrangler getPlatformProxy"]
Wrangler --> D1["local D1 binding"]
The important boundary is ownership: only the proxy opens local D1. Other participating processes receive a D1 subset client backed by HTTP.
bun install
bun run src/cli.ts serve \
--project-dir /path/to/project \
--config /path/to/project/wrangler.jsonc \
--persist-to /path/to/project/.wrangler/state/v3 \
--binding DBThe --config and --persist-to values should match the project state used by D1 local development and other Workers local data bindings.
Inside nix develop, the flake prepends this repository's bin/ directory, so the command is shorter:
local-d1-proxy serve \
--project-dir /path/to/project \
--config /path/to/project/wrangler.jsonc \
--persist-to /path/to/project/.wrangler/state/v3 \
--binding DBThe direct bun run src/cli.ts ... form remains supported for projects that do not use Nix.
The proxy has no authentication. Keep --host at the default 127.0.0.1 unless exposing local D1 access is intentional; any client that can reach POST /d1 can execute SQL against that local D1 binding.
export LOCAL_D1_PROXY_URL=http://127.0.0.1:8789import { make_d1_http_proxy_database } from "local-d1-proxy/client"
const database = make_d1_http_proxy_database({
proxy_url: process.env.LOCAL_D1_PROXY_URL!
})
const result = await database.prepare("select ? as value").bind("example").all()In deployed Cloudflare environments, keep using the real D1 binding.
Use the launchd helper when the proxy should be available before either the app server or background worker starts. That keeps both processes pointed at the same local D1 owner without requiring a manually managed terminal session.
local-d1-proxy install-service \
--project-dir /path/to/project \
--config /path/to/project/wrangler.jsonc \
--persist-to /path/to/project/.wrangler/state/v3 \
--binding DBlocal-d1-proxy status-service --project-dir /path/to/project
local-d1-proxy uninstall-service --project-dir /path/to/projectThe default launchd label is derived from the resolved --project-dir, including a short path hash to avoid same-basename collisions. Pass --label to pin an explicit project-specific name.
Local D1 is SQLite-backed, but application code does not talk to SQLite directly. It talks through Wrangler/Miniflare's D1 runtime. In a multi-process dev setup, separate processes can accidentally create separate local D1 owners against the same persisted state. Under concurrent reads and writes, that ownership split can produce slow reads, transient internal D1 failures, or noisy retry storms.
A checked-in stress run exercises this ownership split directly and records the proxy-owned path completing cleanly.
The invariant is simple: exactly one local process opens the D1 binding. Local consumers that need the shared database use a D1 subset client that sends parameterized operations to that owner over HTTP.
The owner process:
- calls Wrangler
getPlatformProxy(...) - resolves the configured D1 binding once
- serves
GET /health, returning{ "ok": true, "result": { "ready": true } }when the configured D1 binding can be resolved - serves
POST /d1 - executes
all,first,raw,run, andbatchthrough the real binding
Required owner inputs:
- Wrangler config path
- Miniflare persist path, usually
.wrangler/state/v3 - D1 binding name
- proxy host and port
POST /d1 accepts a single statement:
{
"method": "all",
"sql": "select ? as value",
"params": ["example"]
}or a batch:
{
"method": "batch",
"statements": [
{
"sql": "insert into events (id) values (?)",
"params": ["evt_1"]
}
]
}SQL and parameters are transported separately. Consumers must keep using bind parameters; do not interpolate values into SQL strings.
For a dev server, worker, or CLI:
- Check
GET /health. - If unhealthy, start or kick the proxy service.
- Start the consumer with
LOCAL_D1_PROXY_URLset. - Ensure the consumer does not also create its own local D1 owner.
nix develop
bun install
bun testThe flake intentionally includes Bun and Git only. Wrangler is an npm dependency imported by the proxy process; projects that need the Wrangler CLI directly should install it in the consuming project.