Webhooks for the desktop apps: gives them an address services can reach
  • TypeScript 73.4%
  • Go 26.6%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Jonathan Warykowski c2bd84e5e3
All checks were successful
CI / worker (push) Successful in 34s
CI / go (push) Successful in 1m12s
feat: any webhook (#3)
2026-10-16 15:19:20 +00:00
.github/workflows feat: relay webhooks to the desktop apps over a WebSocket (#1) 2026-10-16 00:57:55 +00:00
worker feat: any webhook (#3) 2026-10-16 15:19:20 +00:00
.gitignore chore: initial commit 2026-10-16 11:30:49 +11:00
.golangci.yml feat: relay webhooks to the desktop apps over a WebSocket (#1) 2026-10-16 00:57:55 +00:00
go.mod feat: relay webhooks to the desktop apps over a WebSocket (#1) 2026-10-16 00:57:55 +00:00
go.sum feat: relay webhooks to the desktop apps over a WebSocket (#1) 2026-10-16 00:57:55 +00:00
README.md feat: any webhook (#3) 2026-10-16 15:19:20 +00:00
relay.go feat: any webhook (#3) 2026-10-16 15:19:20 +00:00
relay_test.go feat: any webhook (#3) 2026-10-16 15:19:20 +00:00

relay

Webhooks for the desktop apps (keybars Pro, later shepherd Pro). A desktop app has no address GitHub or Forgejo can reach, so the relay gives it one: it checks each delivery's signature, cuts the payload down to the fields a rule matches on, and passes that to the app over the WebSocket the app holds open.

  • worker/: the Cloudflare Worker at relay.jwarykowski.dev, with a Durable Object per hook.
  • The Go package: apps make and remove hooks, and hold the connection.
var Hooks = relay.Product{ID: "keybars-pro"}

h, err := Hooks.Create(ctx, "github") // h.URL and h.Secret go into GitHub's webhook settings
go Hooks.Run(ctx, h, func(ev relay.Event) { /* ev.Name: pull_request.merged */ }, onState)

Keep hooks in Product.Path() (hooks.json in the app's config directory, mode 600) with Save, never in a settings file a user might share. RELAY_SERVER points the client at another relay.

What it keeps

Each hook stores its product, provider, secret and a hash of the app's token, nothing else. Deliveries aren't stored: one that arrives while the app is offline is dropped. A hook nobody connects to for 90 days deletes itself.

From a delivery the app gets the event, the repository, the branch and the pull request or issue number:

{ "source": "github", "name": "pull_request.merged", "repo": "jwarykowski/keybars", "branch": "main", "number": 42 }
GitHub event Becomes
pull_request pull_request.opened, .merged, .closed, .review_requested (branch: the base)
pull_request_review review.approved, review.changes_requested
workflow_run ci.running, ci.passed, ci.failed (branch: the run's)
push push (branches only)
release release.published
issues issue.opened
ping ping, so the app can tell the webhook works

Forgejo's events (the "Forgejo" webhook type, signed with X-Forgejo-Signature) take the same names, so a rule can say "CI failed" for either: pull requests, pushes, releases and issues as above, reviews from pull_request_review_approved and _rejected, and action runs (Forgejo 12 or later) from action_run_failure to ci.failed and action_run_success or _recover to ci.passed, on the branch that started the run. Forgejo sends no event when a run starts, so there's no ci.running; its test delivery is a push.

Any webhook (provider webhook) is for services the relay doesn't know: the event's name is the last part of the address, /h/<id>/deploy.done, or a JSON body's "event", with "repo", "branch" and "number" if wanted. Names are letters, digits, dots, dashes and underscores. A GET works too, with the name in the address, for services that can only fetch a URL. The address is the secret unless the app turns on requireSecret; then the relay refuses anything without Authorization: Bearer <secret>.

API

POST /v1/<product>/hooks {"provider":"github"}, forgejo or webhook a new hook: { id, url, token, secret }; 10 a minute per IP
GET /v1/<product>/hooks/<id>/connect the app's WebSocket, with Authorization: Bearer <token>; send ping to keep it open
DELETE /v1/<product>/hooks/<id> forget the hook, with the token
PATCH /v1/<product>/hooks/<id> {"requireSecret":true} any webhook: refuse deliveries without the secret, with the token
POST /h/<id>, /h/<id>/<event> the provider's deliveries, signed with the secret; any webhook takes GET with an event too

Adding a product or provider

  • A product: add its ID to PRODUCTS in worker/wrangler.jsonc, then npm run deploy.
  • A provider: a Provider in worker/src/providers/ (how to check a delivery's signature, and which events it becomes), listed in providers/index.ts.

Develop

go test ./...
cd worker
npm install
npm test                  # Vitest, with a fake Durable Object state
npm run check && npm run lint && npm run fmt:check
npm run dev               # wrangler dev on :8787
RELAY_LIVE=1 RELAY_SERVER=http://127.0.0.1:8787 go test -run TestAgainstRelay ./...   # from the root, with dev running
npm run deploy