← Open source
Open sourceGoArjia Labs

beam

A webhook tunnel you actually own. beam gives you a stable public URL on your own Cloudflare edge and pushes every delivery down a WebSocket to a local CLI, which replays it to whatever port you point at. Same job as ngrok or Smee.io — except there's no tunnel vendor in the middle, just one Worker, one Durable Object, and one Go binary.

beam

The problem it solves

Webhooks need a public URL, but the code you're debugging runs on localhost. The usual fix is a third-party tunnel that proxies a random subdomain back to your machine — which means a vendor in the middle of your GitHub, Stripe, and Linear traffic.

beam does the same job, except the whole system is something you own: a stable public URL on your own domain (https://beam.example.com/webhook/<name>), delivered over a single WebSocket to a local CLI that replays it to whatever port you choose, gated by your own token, with nothing to run but the Cloudflare edge. One Worker, one Durable Object class, one Go binary — that's the entire thing.

beam webhook listen myhook --forward http://localhost:3000   # claim a name, forward locally
curl -X POST https://beam.example.com/webhook/myhook -d '{"hello":"world"}'
#   → your local server gets POST / with that body

How it works

beam's path: a webhook provider POSTs to your Cloudflare Worker, which routes by name to a Durable Object holding the CLI's WebSocket, which replays the request to localhost

A Durable Object is the one primitive this problem needs: a single, globally-addressable, stateful coordination point per name. idFromName("orders") always resolves to the same instance, so the DO that holds your CLI's WebSocket is exactly the one a delivery routes to — no shared store, no pub/sub, no registry. Every HTTP method, sub-path, and query string passes through with the verb preserved, and the sender gets an immediate 202 (fire-and-forget — most providers only want a 2xx).

Watch, send, and pipe

Beyond forwarding, the CLI is built for the debug loop. --tail turns it into tail -f for your webhook — each incoming request printed to stdout with method, path, sorted headers, and pretty JSON, while operational logs stay on stderr so you can redirect the stream cleanly. --body-only drops the metadata so you can pipe straight into jq. And beam webhook send re-fires a delivery without retyping the URL, reusing your config.

beam webhook listen myhook --tail --body-only | jq .event   # watch live, piped
beam webhook send myhook -d @payload.json                   # re-fire while debugging

Yours, end to end

The security model keeps the two sides separate. The token guards the listen side — claiming a name and receiving its traffic — and the Worker compares it constant-time against a secret before the upgrade ever reaches durable state. The delivery side is open by default, because external providers must POST without auth, so the unguessable name is the capability; verify the provider's signature in your local app for authenticity (all headers are forwarded). For providers that let you set the URL yourself, an optional --key <secret> locks delivery too — callers must send ?key=…, which is stripped before forwarding so it never lands in your local logs.

Deploying your own edge is a wrangler secret put and a npm run deploy; point it at a custom domain by uncommenting one route block. A zero-flag config file (~/.config/arjia-beam/config) supplies the token and server so the everyday command is just beam webhook listen <name>.

Design notes

beam is deliberately tiny — the core is a couple hundred lines. Idle tunnels lean on Cloudflare's hibernation API (acceptWebSocket plus a ping/pong auto-response), so they cost ~nothing and survive DO eviction; the CLI auto-reconnects with exponential backoff and shuts down cleanly on SIGINT. Bodies ride inside JSON frames as base64 so binary payloads survive verbatim. It's a single pure-Go binary (kong + gorilla/websocket) on the client, TypeScript on the Worker, MIT-licensed.

What it deliberately is not (in v1): it doesn't relay the local response back to the sender, persist a replayable history, stream bodies past the ~1 MiB WebSocket frame cap, or arbitrate multiple listeners on one name — several CLIs on the same name each receive a copy, by design.