Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

10 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

beam

beam — tunnel public webhooks straight to localhost

🛰️ Tunnel public webhooks straight to localhost — on Cloudflare Workers + Durable Objects, with a single-binary Go CLI.
Like ngrok/Smee.io, but it's your edge, your domain, your token.

CI Documentation Cloudflare Workers Durable Objects Go 1.22+ Single binary Bearer token auth MIT licensed

📖 Docs · Why · How it works · Install · Quickstart · Send · Tail mode · Config · Deploy · Design


🤔 Why?

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 to your machine. beam does the same job, except the whole thing is ~250 lines you own:

  • 🛰️ a stable public URLhttps://beam.example.com/webhook/<name> for any provider (GitHub, Stripe, Linear…),
  • 🔌 delivered over one WebSocket to a local CLI, replayed to whatever port you point it at,
  • 🔑 gated by your own token — only someone with the secret can claim a name and receive its traffic,
  • with nothing to run but the edge — a Durable Object holds the socket; there's no origin server, no daemon, no account but your Cloudflare one.

One worker, one Durable Object class, one Go binary. That's the system.

✨ Highlights

🛰️ Stable public endpoint <your-domain>/webhook/<name> — a name maps 1:1 to a Durable Object via idFromName.
🔌 WebSocket bridge The DO holds your CLI's socket and pushes each delivery to it as a JSON frame.
💤 Hibernation API acceptWebSocket + ping/pong auto-response — idle tunnels cost ~nothing and survive evictions.
🔑 Token-gated listen The Authorization: Bearer token guards claiming a name; constant-time compared against a Worker secret.
🔐 Optional per-webhook key --key <secret> locks the delivery side too — callers must send ?key=… or get 401; the param is stripped before forwarding.
🌍 All methods, all paths GET/POST/PUT/PATCH/DELETE/HEAD pass through with verb, sub-path, and query preserved.
Fire-and-forget v1 Senders get an immediate 202; the local response isn't relayed (most providers only want a 2xx).
👁️ --tail inspector Print incoming requests to stdout — pretty JSON, sorted headers — like tail -f for your webhook.
🧾 --body-only Drop the metadata and emit just the body, ready to pipe into jq.
🗂️ Zero-flag config ~/.config/arjia-beam/config supplies token + server, so beam webhook listen <name> Just Works.
🔁 Resilient CLI Auto-reconnect with exponential backoff, ping/pong keepalive, clean SIGINT shutdown.
📦 Single binary Pure Go (kong + gorilla/websocket). go build and it's on your PATH.
🔒 No third party Your domain, your token, your Cloudflare account. No tunnel vendor in the middle.

🛠️ How it works

sequenceDiagram
    participant S as Sender<br/>(GitHub/Stripe)
    participant W as Worker
    participant DO as DurableObject("name")
    participant C as beam CLI
    participant L as localhost:3000

    C->>W: GET /webhook/name  (WS upgrade + Bearer token)
    W->>W: constant-time check vs API_TOKEN secret
    W->>DO: route by idFromName("name")
    DO-->>C: 101 Switching Protocols + {type:"connected"}

    Note over S,L: …later, a webhook arrives…
    S->>W: POST /webhook/name/orders?id=7
    W->>DO: forward (delivery path — no auth)
    DO-->>C: {type:"webhook", method, path, query, headers, body} over WS
    DO-->>S: 202 Accepted   (fire-and-forget)
    C->>L: replay POST /orders?id=7
Loading

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 the WebSocket is exactly the one a delivery routes to — no shared store, no pub/sub.

📦 Install

Prebuilt binary — grab the archive for your OS/arch from the Releases page, extract beam, and drop it on your PATH.

With Go:

# Build the CLI onto your PATH (~/go/bin is already there for most Go installs):
go build -o ~/go/bin/beam ./cli

# Or install straight from the repo (lands at ~/go/bin/cli — rename to beam):
go install github.com/Arjia-Labs/beam/cli@latest
mv ~/go/bin/cli ~/go/bin/beam

# Verify:
beam --version
beam --help

Deploying the edge worker is covered in Deploy your own. If you just want to use an already-deployed beam, the CLI is all you need.

🚀 Quickstart

beam.example.com below is a placeholder — use the domain of your own beam deployment (see Deploy your own). There is no built-in default server, so server (via --server, BEAM_SERVER, or the config file) is always required.

# 1. Tell the CLI who you are (once) — see Config file below.
mkdir -p ~/.config/arjia-beam
printf 'token=YOUR_TOKEN\nserver=https://beam.example.com\n' > ~/.config/arjia-beam/config
chmod 600 ~/.config/arjia-beam/config

# 2. Listen — forward everything to your local app.
beam webhook listen myhook --forward http://localhost:3000

# 3. From anywhere, hit the public URL:
curl -X POST https://beam.example.com/webhook/myhook -d '{"hello":"world"}'
#    → your local server gets POST / with that body.

Any request to https://beam.example.com/webhook/myhook is replayed to your forward target — all HTTP methods are accepted and the original verb is preserved. Sub-paths and query strings carry through:

…/webhook/myhook/github/callback?x=1  →  http://localhost:3000/github/callback?x=1

⌨️ CLI flags

flag env default meaning
--forward BEAM_FORWARD http://localhost:3000 local URL to replay requests to
--server BEAM_SERVER — (required) your beam deployment's base URL
--token BEAM_TOKEN API token (required)
--key BEAM_KEY optional per-webhook delivery secret (?key=…)
--tail false print requests to stdout instead of forwarding
--body-only false with --tail, print only the request body
--insecure false skip TLS verify (local dev)

📤 Send mode

beam webhook send <name> fires a delivery at a hub without you retyping the full URL — it reuses server (and key) from your config file, so the hub name is all you need. It's a tiny curl preset for beam: handy for manually re-firing an event while you debug a listener.

# Body inline (Content-Type defaults to application/json):
beam webhook send myhook -d '{"event":"order.created","amount":1999}'

# Body from a file or stdin (@file / @- / -):
beam webhook send myhook -d @payload.json
jq -n '{ping:true}' | beam webhook send myhook -d -

# Sub-path, query, method, and extra headers all pass through:
beam webhook send myhook --path /github/callback --query 'id=7' \
  -X PUT -H 'X-Signature: abc123' -d @body.json

The delivery side is open, so send needs no API token — only --key if the listener declared one (it's added as ?key=… for you). The response status is printed to stderr and the body to stdout; a 4xx/5xx exits non-zero.

⌨️ Send flags

flag env default meaning
--server BEAM_SERVER — (required) your beam deployment's base URL
--key BEAM_KEY delivery secret, sent as ?key=… if required
-X, --method POST HTTP method
-d, --data body; @file reads a file, @-/- reads stdin
-H, --header extra Name: Value header (repeatable)
--path sub-path appended to the hook
--query raw query string (id=7&x=1)
--insecure false skip TLS verify (local dev)

👁️ Tail mode

Instead of replaying to a local server, print each incoming request — method, path, sorted headers, pretty-printed JSON body — to stdout, like tail -f for your webhook. Operational logs stay on stderr, so you can redirect the request stream on its own:

beam webhook listen myhook --tail > requests.log
── POST /orders?id=7  2026-06-14T22:07:12+08:00 ──
content-type: application/json
x-signature: abc123
…
{
  "event": "order.created",
  "amount": 1999
}

Add --body-only to drop the metadata and emit just the body (pretty-printed when JSON) — handy for piping into jq:

beam webhook listen myhook --tail --body-only | jq .event

🗂️ Config file

The CLI auto-loads ~/.config/arjia-beam/config (or $XDG_CONFIG_HOME/arjia-beam/config) so you never have to remember the token. Simple key=value lines, # comments allowed. Precedence: flag / env var > config file > built-in default.

# ~/.config/arjia-beam/config   (chmod 600 — it holds your token)
token=your-api-token
server=https://beam.example.com   # your own deployment's domain (required)
# key=per-webhook-secret          # optional; callers must then send ?key=...
# forward=http://localhost:8080   # optional default forward target

With that in place:

beam webhook listen myhook            # zero flags → forward to localhost:3000
beam webhook listen myhook --tail     # zero flags → just watch requests

🔒 Security model

  • 🔑 The token guards the listen side only — claiming a name and receiving its traffic over the WebSocket. Without a valid Authorization: Bearer <token>, the upgrade is rejected 401. The Worker compares it constant-time against a secret, so the name itself is the only public surface.

  • 📨 The delivery side is open by default — external providers must be able to POST without auth, so the unguessable name is the capability. If you need delivery authenticity, verify the provider's signature (e.g. GitHub's X-Hub-Signature-256) in your local app — all headers are forwarded.

  • 🔐 Optional per-webhook secret — pass --key <secret> when you listen and deliveries must then arrive with a matching ?key=<secret> (constant-time checked) or get 401. The key param is stripped before forwarding, so it never reaches your local app's logs. Use it for providers that let you set the URL yourself:

    beam webhook listen myhook --key s3cr3t
    # → POST https://beam.example.com/webhook/myhook?key=s3cr3t   ✅ 202
    # → POST https://beam.example.com/webhook/myhook              ❌ 401

    The key lives on the active listener's socket (it survives hibernation), so it's enforced for as long as you're connected. key is config/env-driven too (BEAM_KEY / key= in the config file).

☁️ Deploy your own

cd worker
npm install

# The token the CLI must present to claim a name. Generate one:
openssl rand -hex 32
npx wrangler secret put API_TOKEN          # paste it

npm run deploy                              # publishes the worker

By default this publishes to your *.workers.dev subdomain. To serve it on your own subdomain instead, uncomment the routes block in wrangler.jsonc, set the pattern to a domain you own (the apex zone must be on the same Cloudflare account), and redeploy:

"routes": [
  { "pattern": "beam.example.com", "custom_domain": true }   // auto-provisions DNS + TLS
]

For local dev, copy .dev.vars.example.dev.vars, set API_TOKEN, then npm run dev.

📁 Layout

worker/                  ☁️  Cloudflare Worker + Durable Object (TypeScript)
  ├── src/index.ts          router · token auth · WebhookHub DO (hibernation API)
  ├── wrangler.jsonc        DO binding · sqlite migration · custom-domain route
  ├── .dev.vars.example     local API_TOKEN
  └── package.json          wrangler scripts (dev/deploy/tail)
cli/                     ⌨️  Go CLI — `beam webhook listen|send <name>`
  ├── main.go               kong command tree + dispatch
  ├── listen.go             connect · reconnect · ping · forward · --tail printer
  ├── send.go               `webhook send` — fire a delivery, reusing config
  ├── config.go             ~/.config/arjia-beam/config loader
  └── go.mod                kong + gorilla/websocket

🧠 Design notes

flowchart TB
    subgraph clients["Senders & operator"]
        ext["webhook providers<br/>(GitHub · Stripe · Linear)"]
        cli["beam CLI<br/>(kong + gorilla/ws)"]
    end
    subgraph edge["Cloudflare edge"]
        w["Worker<br/>route · auth · forward"]
        do["DurableObject &quot;name&quot;<br/>holds the WebSocket"]
    end
    local["localhost:3000<br/>(your app)"]

    ext -->|POST /webhook/name| w
    cli -->|WS upgrade + Bearer| w
    w -->|idFromName| do
    do <-->|JSON frames| cli
    cli -->|replay| local
Loading
  • 🪪 Name = identity. idFromName(name) makes the routing trivially consistent: the DO that accepted the socket is the one a delivery lands on. No registry.
  • 💤 Hibernation by default. acceptWebSocket + a ping/pong auto-response pair means an idle tunnel isn't pinned in memory and reconnects survive DO eviction.
  • 🔐 Constant-time token check in the Worker, before the upgrade reaches the DO — auth failures never touch durable state.
  • 📦 Base64 envelope. Bodies ride inside a JSON frame as base64 so binary payloads survive; the CLI decodes and replays verbatim. (WS frame cap ≈ 1 MiB.)
  • 🎀 kong for the CLI struct, with env-var fallbacks layered under the config file.

🙅 Not in scope (v1)

beam is deliberately tiny. It does not (yet):

  • 🔁 relay the local response — v1 is fire-and-forget; the sender always gets 202. Round-trip responses are the obvious next step.
  • 🗃️ persist or replay history--tail shows live traffic; it doesn't store a log you can re-fire.
  • 📦 stream large bodies — the WebSocket frame cap (~1 MiB) bounds payload size.
  • 👥 arbitrate multiple listeners — several CLIs on one name all receive a copy (fan-out), by design.

📜 License

MIT.

Your edge, your domain, your token. 🛰️

About

Tunnel public webhooks straight to localhost — Cloudflare Workers + Durable Objects, with a single-binary Go CLI

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages