Skip to content
JazzmoonPublic

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

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