The WebSub and PubSubHubbub 0.4 toolkit Go deserves. Subscriber, publisher, hub. Plugs into any router. Built by daily users of WebSub.
go get github.com/Jazzmoon/websub- No dependencies. The core module imports nothing outside the standard library. Routers and storage backends are separate modules, so you pull in only what you use.
- Every HTTP type is an
http.Handler. It mounts onnet/http, chi, and gorilla/mux with no glue at all. Adapters for gin, echo, and fiber are separate modules for the frameworks that need one. - Both specs, one API. WebSub by default, PubSubHubbub 0.4 behind
WithMode. The differences live in one file rather than two code paths. - Documented. Every exported symbol has a doc comment, with the spec section linked wherever the behavior is not obvious.
package main
import (
"context"
"log"
"net/http"
"github.com/Jazzmoon/websub"
)
func main() {
sub, err := websub.NewSubscriber("https://example.com/websub/")
if err != nil {
log.Fatal(err)
}
sub.OnNotify(func(s *websub.Subscription, contentType string, body []byte) {
log.Printf("%s published %d bytes of %s", s.Topic, len(body), contentType)
})
// Each subscription gets its own callback below this prefix, so mount
// the handler on the prefix rather than an exact path.
http.Handle("/websub/", sub)
go http.ListenAndServe(":8080", nil)
// Discovers the topic's hub from its Link headers, then subscribes.
pending, err := sub.Subscribe(context.Background(), "https://example.org/feed",
websub.WithSecret("a-random-secret"))
if err != nil {
log.Fatal(err)
}
// The hub verifies out of band by calling back. Await waits for that.
active, err := sub.Await(context.Background(), pending)
if err != nil {
log.Fatal(err)
}
log.Printf("subscribed to %s until %s", active.Topic, active.ExpiresAt)
select {}
}When a secret is set, OnNotify runs only for content whose
X-Hub-Signature verifies. Anything else is dropped and logged.
A publisher does not serve content itself. It tells subscribers where your hub is, and tells the hub when your content changed.
pub, err := websub.NewPublisher("https://example.com",
websub.WithHubs("https://hub.example.com/"))
// Middleware attaches the discovery headers to whatever already serves the
// topic, so a subscriber can find the hub.
http.Handle("/feed", pub.Middleware("/feed", feedHandler))
// An empty body is a ping: the hub fetches the topic itself.
err = pub.Publish(ctx, "/feed", "", nil)
// Or hand the hub the content directly, for a topic it cannot reach.
err = pub.Publish(ctx, "/feed", "application/atom+xml", body)hub, err := websub.NewHub("https://example.com/hub")
// Only this hub knows which topics it is willing to serve.
hub.AddValidator(func(sub *websub.HubSubscription) (bool, string) {
if !strings.HasPrefix(sub.Topic, "https://example.com/") {
return false, "this hub only serves example.com"
}
return true, ""
})
http.Handle("/hub", hub)The hub verifies every subscription out of band, clamps leases to bounds you
set, signs deliveries, retries failures with backoff, and drops expired
subscriptions. AddSniffer gives you a copy of everything published, for
archiving or metrics. Shutdown waits for in-flight verification and
delivery.
| Router | What you need |
|---|---|
net/http |
Nothing. mux.Handle("/websub/", sub) |
| chi | Nothing. r.Handle("/websub/*", sub) |
| gorilla/mux | Nothing. r.PathPrefix("/websub/").Handler(sub) |
| gin | go get github.com/Jazzmoon/websub/adapter/gin, then r.Any("/websub/*callback", websubgin.Handler(sub)) |
| echo | go get github.com/Jazzmoon/websub/adapter/echo, then e.Any("/websub/*", websubecho.Handler(sub)) |
| fiber | go get github.com/Jazzmoon/websub/adapter/fiber, then app.All("/websub/*", websubfiber.Handler(sub)) |
chi and gorilla/mux accept http.Handler natively, so there is no adapter
package to install for them. gin, echo, and fiber each get a thin wrapper
module, and each replays the full conformance suite below through its own
wrapper rather than trusting the translation by inspection.
The hub keeps subscription state in a SubscriptionStore. The built-in
implementation is in memory, which is right for development and wrong for
anything that has to survive a restart:
hub, err := websub.NewHub("https://example.com/hub", websub.WithStore(store))Durable backends are separate modules, each validated against the same
storage/storetest conformance suite so switching between them is a one
line change:
| Backend | Module | Fits |
|---|---|---|
| memory | built in | development, tests, a hub that can afford to lose state on restart |
| redis | github.com/Jazzmoon/websub/storage/redis |
several hub instances behind a load balancer, state worth keeping but not a relational history |
| postgres | github.com/Jazzmoon/websub/storage/postgres |
a hub that has to survive a restart and take concurrent writes |
| sqlite | github.com/Jazzmoon/websub/storage/sqlite |
single binary deployments, no C toolchain required |
store, err := websubredis.Open(ctx, "redis://localhost:6379/0")
// or: websubpostgres.Open(ctx, os.Getenv("DATABASE_URL"))
// or: websubsqlite.Open(ctx, "file:websub.db")
hub, err := websub.NewHub("https://example.com/hub", websub.WithStore(store))Every box below is a case in tests/conformance, run against both
WebSub and PubSubHubbub 0.4. It is written to be replayed through a wrapped
handler too, which is how the gin, echo, and fiber adapters are held to the
same standard rather than trusted by inspection.
- Discovery from
Linkheaders, HTML<link>, Atom, and RSS, in that priority order, including multiple hubs, combinedrelvalues, and relative URLs resolved against the URL discovery finished at -
hub.topicis theselfURL found during discovery, not the URL the request started at - Subscription parameters:
hub.callback,hub.mode,hub.topic,hub.lease_seconds,hub.secret, with the 200 byte secret limit - Verification of intent, echoing
hub.challengewith a safe media type - 404 for a verification request naming an unrecognized topic, mode, or callback
- The lease the hub grants wins over the lease requested
- Unsubscribe round trip
- Denial notification, with
hub.reasonsurfaced as an error -
X-Hub-Signatureover SHA-1, SHA-256, SHA-384, SHA-512 - Unsigned or mismatched content dropped locally, still acknowledged with a 2xx so the hub does not retry it
- Leases renewed before they lapse, reusing the same callback so the hub overrides the subscription rather than adding a second one
- Advertises one
rel=selfand at least onerel=hub, discoverable by a real subscriber - Both publishing methods: ping, and posting content directly
-
hub.urlandhub.topicboth sent, for hubs that read either name - Trailing slash topic URLs stay distinct
- Every advertised hub is notified, and one failing does not stop the others
- 202 for a valid subscription request, 4xx for a malformed one
- Verification of intent with
hub.mode,hub.topic,hub.challenge, andhub.lease_seconds; nothing is stored unless the subscriber confirms - Requested leases clamped to the hub's bounds; no perpetual leases under WebSub, permitted under 0.4
- Re-subscribing overrides the previous subscription rather than duplicating it
- Content distribution carries
rel=hubandrel=selfLink headers and the topic's own content type -
X-Hub-Signaturegenerated for subscriptions with a secret - Validators deny with a reason, delivered as a denial notification
- Expired subscriptions are neither delivered to nor kept
- Both accepted publishing methods
websubtest runs a real in-process hub and publisher, so code that
subscribes to or publishes topics can be tested against the actual protocol
instead of a mock:
func TestMySubscriber(t *testing.T) {
hub := websubtest.NewHub(t)
topic := websubtest.NewPublisher(t, hub, "application/atom+xml", "<feed/>")
sub, err := websub.NewSubscriber(callbackURL)
// ... mount sub, then:
active, err := sub.Subscribe(ctx, topic.URL())
topic.SetContent("application/atom+xml", "<feed>updated</feed>")
hub.Publish(t, topic.URL())
}go get github.com/Jazzmoon/websub/websubtestRunnable programs for every role and router are in examples,
each a self-contained main.go.
Every module below is tagged and versioned independently of core.
| Module | Docs |
|---|---|
github.com/Jazzmoon/websub (core) |
pkg.go.dev |
github.com/Jazzmoon/websub/adapter/gin |
pkg.go.dev |
github.com/Jazzmoon/websub/adapter/echo |
pkg.go.dev |
github.com/Jazzmoon/websub/adapter/fiber |
pkg.go.dev |
github.com/Jazzmoon/websub/storage/redis |
pkg.go.dev |
github.com/Jazzmoon/websub/storage/postgres |
pkg.go.dev |
github.com/Jazzmoon/websub/storage/sqlite |
pkg.go.dev |
github.com/Jazzmoon/websub/storage/storetest |
pkg.go.dev |
github.com/Jazzmoon/websub/websubtest |
pkg.go.dev |
See releasing.md for how tags and changelogs work across the modules.
Guides and design notes are at websub.jazzmoon.ca. API reference is on pkg.go.dev. Every exported symbol has a doc comment, with the spec section linked wherever the behavior is not obvious.
See CONTRIBUTING.md.
Security issues go through SECURITY.md, not the public issue tracker.
Apache-2.0. See LICENSE.