backend / bridgehook

BridgeHook

A Cloudflare Worker relay that gives each user a public webhook URL, stores every request in D1, and hands it to an executor on their machine (browser extension, dashboard tab or Tauri desktop app) that forwards it to localhost and signs the answer back.

2026 · live · open source

sender → worker: HTTPS POST to <id>.bridgehook.devexec → worker: ECDSA-signed pending/claim/responseexec → worker: SSE /api/me/stream (dvc_ or session)desktop → worker: signed poll every 2 s + responseworker → channeldo: wait / notify (sync replies)worker → userdo: notify event JSONworker → rldo: POST /hitworker → d1: Drizzle SQLworker → kv: quota countersworker → saas: checkout, webhooks, magic linksexec → local: fetch() forwarddesktop → local: reqwest forwardWebhook senderWebhook sender (client) Stripe, GitHub, Vapi...ExecutorsExecutors (client) Chrome extension, dashboard tab, MCP agentTauri desktop bridgeTauri desktop bridge (client) Rust, p256 signer, local SQLiteRelay Worker (Hono)Relay Worker (Hono) (edge) intake, signed executor API, /api/me, /mcp, cronChannelDOChannelDO (service) sync-mode waiters per channelUserDOUserDO (service) per-user SSE fan-out, 20 s heartbeatRateLimiterDORateLimiterDO (service) fixed-window counter per (limit, key)D1D1 (store) 9 tables: users, channels, events, devicesWorkers KVWorkers KV (store) hourly demo quota, daily event capPolar / Resend / OAuthPolar / Resend / OAuth (third party) billing, mail, sign-inlocalhost servicelocalhost service (third party) the developer's serverHTTPS POST to <id>.br…ECDSA-signed pending/…SSE /api/me/stream (d…signed poll every 2 s…wait / notify (sync r…notify event JSONPOST /hitDrizzle SQLquota counterscheckout, webhooks, m…fetch() forwardreqwest forward sender → worker: HTTPS POST to <id>.bridgehook.devexec → worker: ECDSA-signed pending/claim/responseexec → worker: SSE /api/me/stream (dvc_ or session)desktop → worker: signed poll every 2 s + responseworker → channeldo: wait / notify (sync replies)worker → userdo: notify event JSONworker → rldo: POST /hitworker → d1: Drizzle SQLworker → kv: quota countersworker → saas: checkout, webhooks, magic linksexec → local: fetch() forwarddesktop → local: reqwest forwardWebhook senderWebhook sender (client) Stripe, GitHub, Vapi...ExecutorsExecutors (client) Chrome extension, dashboard tab, MCP agentTauri desktop bridgeTauri desktop bridge (client) Rust, p256 signer, local SQLiteRelay Worker (Hono)Relay Worker (Hono) (edge) intake, signed executor API, /api/me, /mcp, cronChannelDOChannelDO (service) sync-mode waiters per channelUserDOUserDO (service) per-user SSE fan-out, 20 s heartbeatRateLimiterDORateLimiterDO (service) fixed-window counter per (limit, key)D1D1 (store) 9 tables: users, channels, events, devicesWorkers KVWorkers KV (store) hourly demo quota, daily event capPolar / Resend /OAuthPolar / Resend / OAuth (third party) billing, mail, sign-inlocalhost servicelocalhost service (third party) the developer's serverHTTPS POST to <id>.br…ECDSA-signed pending/…SSE /api/me/stream (d…signed poll every 2 s…wait / notify (sync r…notify event JSONPOST /hitDrizzle SQLquota counterscheckout, webhooks, m…fetch() forwardreqwest forward
clientedgeservicestorethird partyasync
  • [*]LanguagesTypeScript · Rust · JavaScript · SQL
  • [*]FrameworksHono · Drizzle ORM · Better-Auth · MCP TypeScript SDK · Tauri 2 · tokio · reqwest · Vitest
  • [*]StoresD1 · Durable Object storage · Workers KV · SQLite (rusqlite, desktop)
  • [*]AsyncDurable Object alarms · cron · SSE fan-out · executor pull queue
  • [*]InfraCloudflare Workers · Cloudflare Durable Objects · Cloudflare Pages · GitHub Actions
  • [*]Third partiesPolar · Resend · Google OAuth · GitHub OAuth

The relay is one Hono app on Cloudflare Workers (6,405 lines of non-test TypeScript in relay/src) backed by D1 and three Durable Object classes. Webhooks land on <channelId>.bridgehook.dev, get validated (method, path allow-list, header and streamed-body byte caps, daily plan quota), are written to an events table, and are pushed over SSE to the owner's per-user Durable Object. The sender gets a 202 straight away. The exception is a channel in sync mode, where the request is held open until an executor reports what localhost answered.

Executors never hold a shared secret. Each channel stores an ECDSA P-256 public key, and every executor call (list pending, claim, respond, delete) is signed over METHOD, path, timestamp and SHA-256 of the body, with a 60-second skew window. When several executors are online for one channel, they race through an atomic UPDATE ... WHERE claim, and a claim goes stale after 60 s so a closed tab cannot block the queue. A pending queue paged by (received_at, id) cursor means webhooks that arrive while every executor is offline are still delivered, in order.

Around that core sit Better-Auth sessions (email/password, Google, GitHub), an OAuth-style device pairing flow that mints dvc_ tokens for the extension and desktop app, scrypt password hashing with an in-place wrap of legacy PBKDF2 hashes, per-minute rate limits counted in a Durable Object, an 8-tool MCP server at /mcp for coding agents, Polar billing webhooks, and an hourly cron that expires demo channels, device codes and plan-retention events.

153 commits between April and October 2026. The data layer moved from Neon Postgres to D1, and relay/scripts/neon-to-d1.py exports the old database. The rate limiter was rebuilt on 2026-10-11 after a production measurement showed the Workers Rate Limiting binding allowed 70 of 70 requests against a limit of 10.

How it holds up

Auth
There are three realms. Better-Auth session cookies serve the dashboard, with credentialed CORS only for trusted origins. SHA-256-hashed dvc_ device tokens come from a device-code pairing flow, and agent-kind tokens work only on /mcp. Per-channel operations need ECDSA P-256 request signatures. With BETTER_AUTH_SECRET unset, everything collapses to a single implicit self-host user.
Data
9 D1 tables: user, session, account and verification for Better-Auth, plus devices, device_codes, channels, events and subscriptions. They come from 3 Drizzle migrations (0000_init creates all 9 tables; 18 CREATE TABLE/INDEX statements in total). Events carry request and response, replay_of self-reference, a claim holder and claim time, and a CHECK that only replays may point at another event.
Consistency
Claims use one atomic UPDATE with a composite WHERE: the event belongs to this channel, has no response yet, and is unclaimed, held by the caller, or stale for 60 s. The loser gets 409 and the winner's id. Demo caps are a single INSERT ... SELECT ... WHERE count < 50, so racing senders cannot overshoot. Device-token exchange mints the device and deletes the code in one D1 batch transaction, so exactly one of several concurrent exchanges succeeds. Sync waiters keep answers that arrive before their waiter for 120 s.
Failure
A sync channel returns 504 on timeout and 502 when the executor reports localhost unreachable (status 0), marking both with X-BridgeHook-Relay-Answer, and the event stays queued either way. Executors retry an event while localhost is down, and after 3 failures with localhost up they record status 0 so one payload cannot block the queue. Rate-limiter errors fail open except Durable Object overload, which fails closed. Cron deletes run in batches of 200 to stay under D1 per-query limits.
Testing
14 test files: 9 Vitest files in relay/src with 126 cases (origin policy, webhook URL parsing, sync waiters, rate limiter and its DO, password hashing, body limits, device pairing, test-event signing), 1 web drainer file with 13 cases, 3 extension node:test files with 11 cases, and a release e2e script. That makes 150 it/test cases in total, and CI runs the relay, web and extension suites. The Rust desktop crate has no #[test] functions.

Techniques

  1. Per-request ECDSA P-256 executor signaturesEach channel stores only a raw P-256 public key. Executors sign METHOD, path, timestamp and SHA-256 of the body, the relay rejects anything outside a 60 s window, and it caps the body before hashing it. The relay never holds a secret that could act on a channel. The Rust desktop app signs the same canonical string with the p256 crate.relay/src/index.ts:500 relay/src/index.ts:545 apps/desktop/src-tauri/src/crypto.rs:56
  2. Atomic multi-executor claim with stale takeoverSeveral executors (extension, tab, desktop) can serve one channel. A single UPDATE ... WHERE (unclaimed OR held by me OR claimed more than 60 s ago) AND unanswered picks the winner. A holder that re-claims refreshes its lease, and a crashed one loses it after 60 s. Losers get a 409 that names the winner.relay/src/index.ts:1147 relay/src/index.ts:127 apps/web/src/lib/drain.ts:8
  3. Sync request-response over a Durable Object rendezvousIn sync mode, intake registers a waiter in the channel's DO before waking executors. The executor's signed response settles the waiter, and the relay replays localhost's status, headers and body to the sender with hop-by-hop and Set-Cookie headers stripped and a sandbox CSP added. Answers that arrive before their waiter are held for 120 s. Timeouts are clamped to 1–100 s and return 504.relay/src/index.ts:380 relay/src/sync.ts:317 relay/src/sync.ts:203 relay/src/channel-do.ts:134
  4. Ordered offline delivery queueGET /api/channels/:id/events?pending=1 returns unanswered events oldest first, paged by an opaque (received_at, id) cursor with exact millisecond comparison. An executor that starts after the browser was closed drains it before going live, and a poison payload is recorded as status 0 after 3 tries with localhost up.relay/src/index.ts:1047 relay/src/index.ts:435 apps/web/src/lib/drain.ts:26
  5. Exact rate limits in a Durable Object, with refusal cacheThe Workers Rate Limiting binding was measured in production letting 70 of 70 requests through against a limit of 10, so it was replaced. Each (limit, key) now gets its own DO with a fixed window that stops writing after the first refusal and deletes itself by alarm. Each isolate also caches refusals until the window resets, so a flood costs one DO call per window.relay/src/rate-limiter-do.ts:1 relay/src/rate-limiter-do.ts:206 relay/src/rate-limit.ts:69 relay/src/rate-limit.ts:136
  6. Race-safe device pairing exchangeThis is an RFC 8628-style device-code flow (DV-XXXX-XXXX codes, 15 min TTL, 5 s polling). The token mint is an INSERT ... SELECT gated on the code still being approved and unexpired, run in one D1 batch with the DELETE of that code. Concurrent exchanges therefore mint exactly one device, and a failed insert never burns the approval.relay/src/routes/auth-device.ts:238 relay/src/routes/auth-device.ts:260 relay/src/identity.ts:79

Counted

  • 153Commits
    git rev-list --count HEAD
  • 6,405 linesRelay TypeScript (non-test)
    git ls-files 'relay/src/*.ts' | grep -v test | xargs wc -l
  • 8,430 linesRelay TypeScript incl. tests
    git ls-files 'relay/src/*.ts' | xargs wc -l
  • 2,380 linesDesktop Rust
    git ls-files '*.rs' | xargs wc -l
  • 33 (14 in index.ts + 19 in routes/*.ts)HTTP route handlers
    grep -E '^app\.(get|post|...)' relay/src/index.ts; grep in relay/src/routes/*.ts
  • 3 (ChannelDO, UserDO, RateLimiterDO)Durable Object classes
    relay/wrangler.toml [durable_objects]
  • 9D1 tables
    grep sqliteTable( packages/shared/src/db/*.ts
  • 3Migrations
    ls relay/migrations/*.sql
  • 8MCP tools
    grep registerTool relay/src/mcp.ts
  • 7Named rate limits
    relay/src/rate-limit.ts:43 LIMITS
  • 14 (13 unit + 1 e2e)Test files
    git ls-files | grep -E '\.test\.|\.e2e\.'
  • 150 (126 relay)Test cases
    grep -cE '^\s*(it|test)\(' over test files
  • hourlyCron
    relay/wrangler.toml [triggers] crons = ["0 * * * *"]