Architecture#

Modules#

This is a multi-module repository. Each module is versioned and released on its own, so nothing you do not use ends up in your build.

(repo root)      github.com/Jazzmoon/websub             the protocol, no third party imports
  tests/                                                black box test suite
  tests/conformance/                                    the spec cases, importable by anyone
adapter/gin/     github.com/Jazzmoon/websub/adapter/gin
adapter/echo/    github.com/Jazzmoon/websub/adapter/echo
adapter/fiber/   github.com/Jazzmoon/websub/adapter/fiber
storage/redis/   github.com/Jazzmoon/websub/storage/redis
storage/postgres/
storage/sqlite/
storage/storetest/                                      the shared backend suite
websubtest/      github.com/Jazzmoon/websub/websubtest  in-process hub for your own tests

The protocol implementation lives at the repository root rather than in a named subdirectory, because Go requires a module’s go.mod to physically sit wherever its declared path implies; github.com/Jazzmoon/websub is the bare repo path, so its go.mod has to be the repo root’s go.mod. Every other module here follows the more familiar pattern, where the subdirectory name is also the path suffix.

go.work ties them together for local development, so editing the root module and running an adapter’s tests picks up the change with no replace directives.

The split exists for one reason: core imports nothing outside the standard library, and it stays that way. Anything needing a router or a database driver is a separate module. Installing adapter/gin does not put echo or fasthttp in your build.

The three roles#

WebSub has three conformance classes, and this library implements all of them. They talk to each other over HTTP, and each is independently useful: you can subscribe to someone else’s hub, publish to someone else’s hub, or run a hub for someone else’s subscribers.

sequenceDiagram
    participant S as Subscriber
    participant P as Publisher
    participant H as Hub

    S->>P: GET topic
    P-->>S: Link: rel=hub, rel=self
    S->>H: POST hub.mode=subscribe
    H-->>S: 202 Accepted
    H->>S: GET ?hub.challenge=...
    S-->>H: 200, the challenge echoed
    Note over H: subscription is now active

    P->>H: POST hub.mode=publish
    H-->>P: 202 Accepted
    H->>P: GET topic
    P-->>H: the updated content
    H->>S: POST content, X-Hub-Signature
    S-->>H: 204

The two round trips that look redundant are not. The hub calling back to the subscriber is verification of intent: it stops anyone from subscribing a third party’s URL to a firehose. The hub fetching the topic is what lets a publisher notify without shipping content, and what makes the hub the only party that has to be reachable at scale.

Handler contract#

Every type that touches HTTP implements http.Handler. That single decision is what makes every adapter possible, because it is the lowest common denominator that all Go routers either speak natively or bridge to.

Type Serves Mount at
Subscriber The callback URL, both the verification GET and the content POST A prefix, because each subscription gets its own callback below it
Hub The hub URL, both subscription requests and publish notifications One exact path
Publisher Nothing of its own. Middleware wraps the handler that already serves your topic The topic path

Request flows#

Subscriber#

Subscribe fetches the topic, reads its Link headers and then, if needed, its document body, and posts a subscription request to the discovered hub. It returns as soon as the hub answers 202, which is before the subscription works. The hub then calls back, ServeHTTP answers the challenge, and Await returns the now-active subscription.

That split is deliberate: the callback has to be reachable and serving before Subscribe is called, and a Subscribe that blocked on a callback it could not receive would deadlock rather than fail.

Content arrives as a POST to the per-subscription callback. If the subscription carries a secret, the body is verified against X-Hub-Signature before OnNotify sees it. Content that fails is dropped and logged, and still acknowledged with a 2xx, because the spec asks subscribers to ignore bad content locally rather than make a hub retry something it cannot fix.

Hub#

A subscription request is validated, answered 202, and then verified out of band. Verification and delivery run detached from the request that started them: they outlive it by design, and Shutdown is what bounds them.

Publish fans out to every subscriber of a topic, bounded by WithDeliveryConcurrency, retrying failures with exponential backoff. Expired subscriptions are skipped and removed rather than delivered to.

Publisher#

Advertise returns the Link headers a topic response must carry. Middleware attaches them to an existing handler. Publish notifies every configured hub, either as a ping or with the content in the body.

Storage#

The hub keeps subscription state behind SubscriptionStore. The default is in memory, which is right for development and single process hubs and wrong for anything that has to survive a restart. Backends live in storage/* and all pass the same suite, so switching is one line.

Observability#

Logging is *slog.Logger, passed with WithLogger. The default discards everything, so importing this package never adds output to a program that did not ask for it.

Metrics are plain callbacks in Hooks: OnSubscribe, OnUnsubscribe, OnPublish, OnDeliveryFailure. No metrics library is imported, and none will be. Wire them to whatever you already use.