Skip to content

WebSub

CI Go Reference

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

Why this one

  • 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 on net/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.

Subscriber

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.

Publisher

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

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.

Routers

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.

Storage

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))

Spec conformance

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.

Subscriber cases

  • Discovery from Link headers, HTML <link>, Atom, and RSS, in that priority order, including multiple hubs, combined rel values, and relative URLs resolved against the URL discovery finished at
  • hub.topic is the self URL 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.challenge with 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.reason surfaced as an error
  • X-Hub-Signature over 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

Publisher cases

  • Advertises one rel=self and at least one rel=hub, discoverable by a real subscriber
  • Both publishing methods: ping, and posting content directly
  • hub.url and hub.topic both 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

Hub cases

  • 202 for a valid subscription request, 4xx for a malformed one
  • Verification of intent with hub.mode, hub.topic, hub.challenge, and hub.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=hub and rel=self Link headers and the topic's own content type
  • X-Hub-Signature generated 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

Testing your own code

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/websubtest

Examples

Runnable programs for every role and router are in examples, each a self-contained main.go.

Packages

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.

Documentation

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.

Contributing

See CONTRIBUTING.md.

Security issues go through SECURITY.md, not the public issue tracker.

License

Apache-2.0. See LICENSE.

About

The WebSub / PubSubHubbub toolkit Go deserves. Subscriber, publisher, hub. Plug into any router. Built by daily users of WebSub

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

1 star

Watchers

1 watching

Forks

Releases

Used by

Contributors

Languages