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 testsThe 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: 204The 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.