websub

package module
v1.0.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 26, 2026 License: Apache-2.0 Imports: 25 Imported by: 0

README

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.

Documentation

Overview

Package websub implements WebSub and PubSubHubbub 0.4 for all three conformance classes: subscriber, publisher, and hub.

A Subscriber discovers a topic's hub, subscribes, answers the hub's verification challenge, and receives content distribution requests. A Publisher advertises its hub to discovering subscribers and pings that hub when a topic changes. A Hub accepts subscriptions, verifies intent, and fans content out.

All three implement net/http.Handler, so they mount directly on net/http.ServeMux, chi, and gorilla/mux. The adapter modules cover gin, echo, and fiber.

ModeWebSub is the default. Pass WithMode for ModePuSH04, which mainly relaxes lease handling. See mode.go for the full set of differences.

This module has no dependencies outside the standard library. Storage backends live in storage/*, and a protocol-speaking fake hub for your own tests lives in websubtest.

Index

Constants

View Source
const (
	DefaultMinLease           = 5 * time.Minute
	DefaultMaxLease           = 30 * 24 * time.Hour
	DefaultDeliveryAttempts   = 3
	DefaultDeliveryBackoff    = time.Second
	DefaultDeliveryConcurrent = 16
)

Hub defaults. All are overridable by option.

View Source
const DefaultLease = 10 * 24 * time.Hour

DefaultLease is requested when the caller does not specify one. It matches the 10 day value PubSubHubbub 0.4 suggests, which sits inside the range hubs commonly accept.

View Source
const DefaultMaxBodySize = 8 << 20

DefaultMaxBodySize caps how much of a content distribution request body is read when WithMaxBodySize is not given.

View Source
const DefaultRenewAt = 0.8

DefaultRenewAt is the fraction of a granted lease after which a subscription is renewed. The remaining fifth is slack: enough for a failed attempt to be retried before the subscription actually lapses.

View Source
const DefaultSignatureAlgorithm = SHA256

DefaultSignatureAlgorithm is what a hub signs with unless configured otherwise. WebSub requires hubs to support SHA1 but recommends against it.

View Source
const MaxDiscoveryBody = 1 << 20

MaxDiscoveryBody caps how much of a discovery response this package reads. Discovery only needs headers and, for document based mechanisms, the head of the document, so a hostile or runaway topic URL cannot exhaust memory.

View Source
const MaxSecretLength = 200

MaxSecretLength is the longest secret WebSub permits. Subscribers must not send more, and hubs may reject more.

https://www.w3.org/TR/websub/#subscriber-sends-subscription-request

View Source
const SignatureHeader = "X-Hub-Signature"

SignatureHeader carries the HMAC of a content distribution request body.

https://www.w3.org/TR/websub/#authenticated-content-distribution

Variables

View Source
var (
	// ErrDiscoveryFailed means no hub and topic pair could be found for a
	// URL, either because the document advertised neither or because it
	// could not be fetched.
	ErrDiscoveryFailed = errors.New("websub: could not discover hub/topic")

	// ErrInvalidSignature means a content distribution request carried an
	// X-Hub-Signature that did not match the body, or omitted the header
	// entirely on a subscription that was created with a secret.
	ErrInvalidSignature = errors.New("websub: signature verification failed")

	// ErrSubscriptionDenied means the hub rejected the subscription, either
	// with a non-2xx response to the request or with a denial notification
	// delivered to the callback afterward.
	ErrSubscriptionDenied = errors.New("websub: hub denied subscription")

	// ErrLeaseOutOfRange means the requested lease fell outside the bounds
	// the hub is configured to accept and could not be clamped.
	ErrLeaseOutOfRange = errors.New("websub: requested lease outside allowed range")

	// ErrUnknownSubscription means a verification challenge or content
	// distribution request arrived for a topic and callback the subscriber
	// has no pending or active subscription for.
	ErrUnknownSubscription = errors.New("websub: no such subscription")
)

Sentinel errors returned by this package. Compare with errors.Is; the errors returned to callers usually wrap these with more detail.

Functions

func Sign

func Sign(alg SignatureAlgorithm, secret string, body []byte) (string, error)

Sign returns an X-Hub-Signature value for body, keyed by secret.

func Verify

func Verify(header, secret string, body []byte) error

Verify checks an X-Hub-Signature header value against body and secret. It returns nil only if the header is well formed, names a supported algorithm, and matches.

An empty header is an error whenever a secret is configured. WebSub requires subscribers to reject unsigned content on an authenticated subscription rather than fall back to trusting it.

func WithMaxBodySize

func WithMaxBodySize(n int64) interface {
	SubscriberOption
	HubOption
}

WithMaxBodySize caps how much of a content distribution request body is read, in bytes. Bodies larger than the cap are rejected rather than truncated, since a truncated body would fail signature verification anyway. The default is 8 MiB.

Types

type DenialError

type DenialError struct {
	Topic  string
	Reason string
}

DenialError is returned when a hub denies a subscription. WebSub allows a hub to state a reason, which is carried here when one was given.

https://www.w3.org/TR/websub/#subscriber-receives-denial

func (*DenialError) Error

func (e *DenialError) Error() string

func (*DenialError) Is

func (e *DenialError) Is(target error) bool

type Discovery

type Discovery struct {
	// Topic is the canonical topic URL, taken from the rel=self link. It
	// falls back to the URL discovery finished at, following redirects, when
	// no rel=self is advertised.
	Topic string

	// Hubs are the advertised rel=hub URLs, in the order found. WebSub
	// permits more than one; a subscriber that wants redundancy subscribes
	// at several, and one that does not uses the first.
	Hubs []string

	// ContentType is the media type of the discovery response, with
	// parameters stripped.
	ContentType string
}

Discovery is what a topic URL advertises about itself.

https://www.w3.org/TR/websub/#discovery

func Discover

func Discover(ctx context.Context, topicURL string) (*Discovery, error)

Discover fetches topicURL and reports the hub and canonical topic it advertises, using http.DefaultClient and ModeWebSub rules. Subscribers should call Subscriber.Discover instead so the configured client, mode, and logger apply.

func (*Discovery) Hub

func (d *Discovery) Hub() string

Hub returns the first advertised hub, or the empty string if the topic advertised none.

type Hooks

type Hooks struct {
	// OnSubscribe fires after a subscription is verified and stored.
	OnSubscribe func(sub HubSubscription)

	// OnUnsubscribe fires after a subscription is verified as removed.
	OnUnsubscribe func(sub HubSubscription)

	// OnPublish fires once per publish, before any delivery is attempted.
	OnPublish func(topic string)

	// OnDeliveryFailure fires for each delivery attempt that fails, so it
	// can fire several times for one subscriber across retries.
	OnDeliveryFailure func(sub HubSubscription, err error)
}

Hooks are optional callbacks a Hub invokes as it works, for metrics or auditing. Any nil field is skipped, so set only the ones you need.

Hooks run on the goroutine handling the event, including delivery goroutines. Keep them cheap and do not block in them.

type Hub

type Hub struct {
	// contains filtered or unexported fields
}

Hub accepts subscriptions, verifies that subscribers meant to make them, and fans published content out to them.

It is an http.Handler serving one endpoint, the hub URL itself, which takes both subscription requests and publish notifications:

hub, err := websub.NewHub("https://example.com/hub")
mux.Handle("/hub", hub)

A Hub is safe for concurrent use.

func NewHub

func NewHub(hubURL string, opts ...HubOption) (*Hub, error)

NewHub returns a Hub served at hubURL.

The URL is required because every content distribution request has to carry a rel=hub Link header pointing back here, and the hub cannot infer its own public URL from an incoming request in the general case.

func (*Hub) AddSniffer

func (h *Hub) AddSniffer(topic string, fn func(topic, contentType string, body []byte))

AddSniffer registers a callback that sees content published to topic before it is distributed. An empty topic sees everything.

Sniffers cannot change or block the content. They exist for hubs that also want to archive, index, or count what passes through.

func (*Hub) AddValidator

func (h *Hub) AddValidator(fn func(*HubSubscription) (ok bool, reason string))

AddValidator registers a check run against every subscription request before the hub verifies it. Returning false denies the subscription, and the reason is passed on to the subscriber in the denial notification.

Validators run in the order they were added, and the first denial wins. Use them for the policy this package cannot know: which topics this hub serves, whether a callback host is allowed, rate limits.

func (*Hub) Publish

func (h *Hub) Publish(ctx context.Context, topic, contentType string, body []byte) error

Publish distributes content for topic to every subscriber.

With an empty body the hub fetches topic itself and uses whatever content type the response carries, which is the WebSub flow. With a body, that content is distributed as given.

Publish blocks until every delivery has been attempted and returns the failures joined together. Deliveries that fail are retried according to the hub's retry policy before being reported.

func (*Hub) ServeHTTP

func (h *Hub) ServeHTTP(w http.ResponseWriter, r *http.Request)

ServeHTTP handles subscription requests and publish notifications. Both arrive as a POST to the hub URL and are told apart by hub.mode.

func (*Hub) Shutdown

func (h *Hub) Shutdown(ctx context.Context) error

Shutdown waits for in-flight verification and delivery work to finish, or for ctx to end. It does not stop the HTTP server the hub is mounted on; stop that first, then call this.

func (*Hub) Subscriptions

func (h *Hub) Subscriptions(ctx context.Context, topic string) ([]Subscription, error)

Subscriptions returns every subscription for topic.

func (*Hub) Topics

func (h *Hub) Topics(ctx context.Context) ([]string, error)

Topics returns every topic with at least one subscription.

func (*Hub) URL

func (h *Hub) URL() string

URL returns the hub's own URL, as advertised to subscribers.

type HubOption

type HubOption interface {
	// contains filtered or unexported methods
}

HubOption configures a Hub at construction.

func WithDeliveryConcurrency

func WithDeliveryConcurrency(n int) HubOption

WithDeliveryConcurrency caps how many subscribers a hub delivers to at once during a single publish.

func WithDeliveryRetry

func WithDeliveryRetry(attempts int, backoff time.Duration) HubOption

WithDeliveryRetry sets how hard a hub tries to deliver content. attempts counts the first try, so 1 means no retry. The delay doubles after each failure, starting at backoff.

Be careful raising these. A hub retrying aggressively across many subscribers is a denial of service against itself.

func WithHooks

func WithHooks(h Hooks) HubOption

WithHooks attaches metric callbacks to a hub.

func WithLeaseBounds

func WithLeaseBounds(minLease, maxLease time.Duration) HubOption

WithLeaseBounds sets the range of lease durations a hub grants. Requests outside it are clamped rather than refused, which is what the spec has in mind when it says a hub may respect the requested value or not.

WebSub forbids perpetual leases, so a hub in ModeWebSub always grants something inside these bounds.

func WithSignatureAlgorithm

func WithSignatureAlgorithm(alg SignatureAlgorithm) HubOption

WithSignatureAlgorithm sets the hash a hub signs content with. The default is DefaultSignatureAlgorithm. WebSub allows sha1 for compatibility with older subscribers but recommends against it.

func WithStore

func WithStore(s SubscriptionStore) HubOption

WithStore sets where a hub keeps subscription state. The default is NewMemoryStore, which loses everything on restart. Use a backend from storage/* for anything that outlives a process.

type HubSubscription

type HubSubscription struct {
	Subscription

	// CreatedAt is when the hub first verified this subscription.
	CreatedAt time.Time

	// LastDelivery is when the hub last attempted delivery, zero if never.
	LastDelivery time.Time

	// LastStatus is the HTTP status of the last delivery attempt, zero if
	// the attempt failed before a response arrived.
	LastStatus int

	// Failures counts consecutive failed deliveries. It resets on success
	// and drives retry backoff.
	Failures int
}

HubSubscription is a subscription as tracked by a Hub, with the delivery bookkeeping a subscriber has no reason to carry.

type Link struct {
	// URL is the link target, resolved against the document it came from.
	URL string

	// Rels are the lowercased values of the rel parameter. RFC 8288 allows
	// several in one parameter, separated by whitespace.
	Rels []string
}

Link is one entry of an HTTP Link header.

https://www.rfc-editor.org/rfc/rfc8288

func ParseLinkHeader

func ParseLinkHeader(values []string, base *url.URL) []Link

ParseLinkHeader parses the values of one or more Link headers. Relative targets are resolved against base when it is non-nil. Entries that are not parseable are skipped rather than failing the whole header, which is what RFC 8288 asks of consumers.

func (Link) HasRel

func (l Link) HasRel(rel string) bool

HasRel reports whether l carries rel.

type Mode

type Mode int

Mode selects which specification applies where WebSub and PubSubHubbub 0.4 differ. The public API is the same under either one.

const (
	// ModeWebSub applies W3C WebSub semantics: leases are always finite and
	// verification is always asynchronous.
	//
	// https://www.w3.org/TR/websub/
	ModeWebSub Mode = iota

	// ModePuSH04 applies PubSubHubbub 0.4 semantics: a subscription with no
	// lease is permanent, and synchronous verification is tolerated.
	//
	// https://pubsubhubbub.github.io/PubSubHubbub/pubsubhubbub-core-0.4.html
	ModePuSH04
)

func (Mode) String

func (m Mode) String() string

String implements fmt.Stringer.

type Option

type Option interface {
	SubscriberOption
	PublisherOption
	HubOption
}

Option configures any of the three roles. Options that only make sense for one of them implement the narrower interface instead.

func WithHTTPClient

func WithHTTPClient(c *http.Client) Option

WithHTTPClient sets the client used for outbound requests: discovery and subscription requests for a subscriber, publish notifications for a publisher, and content delivery for a hub. The default is a client with a 30 second timeout.

Give a hub a client with a generous connection pool. It fans one publish out to every subscriber at once.

func WithLogger

func WithLogger(l *slog.Logger) Option

WithLogger sets the logger. The default discards everything, so nothing is written unless you ask for it.

func WithMode

func WithMode(m Mode) Option

WithMode selects the specification whose semantics apply. The default is ModeWebSub.

func WithUserAgent

func WithUserAgent(ua string) Option

WithUserAgent sets the User-Agent sent on outbound requests. WebSub asks hubs to identify themselves as a hub, which the default does.

type Publisher

type Publisher struct {
	// contains filtered or unexported fields
}

Publisher advertises a topic's hub to discovering subscribers and tells that hub when the topic changes.

A publisher does not serve content itself. Your own handler does that; Publisher.Advertise gives you the headers it needs to carry, and Publisher.Middleware attaches them for you.

A Publisher is safe for concurrent use.

func NewPublisher

func NewPublisher(baseURL string, opts ...PublisherOption) (*Publisher, error)

NewPublisher returns a Publisher for content served under baseURL.

Topic URLs passed to its methods may be relative, in which case they resolve against baseURL. That keeps the canonical URL in one place rather than repeated at every call site.

At least one hub is required, through WithHubs, since a publisher with no hub has nothing to advertise or notify.

func (*Publisher) Advertise

func (p *Publisher) Advertise(topic string) http.Header

Advertise returns the Link headers a response for topic must carry so a subscriber can discover where to subscribe: one rel=hub per configured hub, and exactly one rel=self holding the canonical topic URL.

https://www.w3.org/TR/websub/#discovery

func (*Publisher) Hubs

func (p *Publisher) Hubs() []string

Hubs returns the hubs this publisher advertises and notifies.

func (*Publisher) Middleware

func (p *Publisher) Middleware(topic string, next http.Handler) http.Handler

Middleware returns a handler that adds the discovery headers for topic to everything next writes.

http.Handle("/feed", publisher.Middleware("/feed", feedHandler))

func (*Publisher) Publish

func (p *Publisher) Publish(ctx context.Context, topic, contentType string, body []byte) error

Publish tells every configured hub that topic has new content.

With an empty body this is a ping: the hub fetches the topic itself, which is the mechanism WebSub describes and the one to prefer. With a body, the content is posted to the hub directly, which some hubs accept as an optimization and which PubSubHubbub 0.4 deployments use to publish resources the hub cannot reach.

Every hub is notified even if an earlier one fails. The returned error joins whatever went wrong, so errors.Is still works against it.

func (*Publisher) TopicURL

func (p *Publisher) TopicURL(topic string) string

TopicURL resolves topic against the publisher's base URL and returns the canonical, absolute form. This is the value advertised as rel=self, and it is what subscribers send back as hub.topic.

type PublisherOption

type PublisherOption interface {
	// contains filtered or unexported methods
}

PublisherOption configures a Publisher at construction.

func WithHubs

func WithHubs(hubURLs ...string) PublisherOption

WithHubs sets the hubs a Publisher advertises and notifies. WebSub allows more than one, and expects a publisher that advertises several to notify all of them, which Publisher.Publish does.

type SignatureAlgorithm

type SignatureAlgorithm string

SignatureAlgorithm names the hash a hub uses to sign distributed content. The wire format is the algorithm name, an equals sign, then the HMAC in lowercase hex.

const (
	SHA1   SignatureAlgorithm = "sha1"
	SHA256 SignatureAlgorithm = "sha256"
	SHA384 SignatureAlgorithm = "sha384"
	SHA512 SignatureAlgorithm = "sha512"
)

Algorithms WebSub permits. SHA1 exists for PubSubHubbub 0.4 hubs. It is accepted on verify but never chosen for signing.

func (SignatureAlgorithm) Valid

func (a SignatureAlgorithm) Valid() bool

Valid reports whether a names an algorithm this package can compute.

type SubscribeOption

type SubscribeOption interface {
	// contains filtered or unexported methods
}

SubscribeOption configures a single call to Subscriber.Subscribe.

func WithHub

func WithHub(hubURL string) SubscribeOption

WithHub subscribes at a known hub instead of discovering one. The topic URL is still used as given unless WithTopic overrides it.

func WithLease

func WithLease(d time.Duration) SubscribeOption

WithLease requests a lease duration. Hubs are free to grant less, and the granted value is what lands in Subscription.Lease. Under ModePuSH04 a zero duration requests a permanent subscription; under ModeWebSub it falls back to DefaultLease.

func WithSecret

func WithSecret(secret string) SubscribeOption

WithSecret sets the shared key the hub signs content with. Content that does not verify against it is dropped rather than delivered.

WebSub caps a secret at MaxSecretLength bytes, which Subscriber.Subscribe enforces, and says one should only be sent over an HTTPS callback, which it logs a warning about rather than refusing.

https://www.w3.org/TR/websub/#authenticated-content-distribution

func WithTopic

func WithTopic(topicURL string) SubscribeOption

WithTopic sets the canonical topic URL to send as hub.topic, for the case where it differs from the URL passed to Subscribe and discovery is being skipped.

type Subscriber

type Subscriber struct {
	// contains filtered or unexported fields
}

Subscriber subscribes to topics and receives their content.

It is an http.Handler: mount it at the path prefix its callback URL points at, since each subscription gets its own callback below that prefix. With net/http that is a trailing slash pattern.

sub, err := websub.NewSubscriber("https://example.com/websub/")
mux.Handle("/websub/", sub)

A Subscriber is safe for concurrent use.

func NewSubscriber

func NewSubscriber(callbackURL string, opts ...SubscriberOption) (*Subscriber, error)

NewSubscriber returns a Subscriber that receives content at callbackURL.

Each subscription appends an unguessable segment to callbackURL, so the URL given here is a prefix rather than the exact URL any single hub delivers to. It must be absolute, since it is sent to hubs verbatim.

func (*Subscriber) Await

func (s *Subscriber) Await(ctx context.Context, sub *Subscription) (*Subscription, error)

Await blocks until the hub finishes verifying the pending request for sub, and returns the resulting state. It returns immediately for a subscription that is already settled.

A denied subscription returns a snapshot in state StateDenied alongside a *DenialError, which matches ErrSubscriptionDenied.

func (*Subscriber) Close

func (s *Subscriber) Close() error

Close stops the renewal timers this subscriber holds. It does not unsubscribe anything: the subscriptions stay live at their hubs until their leases run out, which is what you want across a restart.

A Subscriber with automatic renewal on holds one timer per active subscription, so a long lived program that creates subscribers should close them. ServeHTTP keeps working afterward; only renewal stops.

func (*Subscriber) Discover

func (s *Subscriber) Discover(ctx context.Context, topicURL string) (*Discovery, error)

Discover reports the hub and canonical topic that topicURL advertises, using this subscriber's HTTP client and mode.

func (*Subscriber) Forget

func (s *Subscriber) Forget(sub *Subscription)

Forget drops a settled subscription from memory. Denied and unsubscribed subscriptions are kept so [Await] and [Subscriptions] can still report them; a long lived process that churns through subscriptions should call Forget once it no longer cares.

func (*Subscriber) OnNotify

func (s *Subscriber) OnNotify(fn func(sub *Subscription, contentType string, body []byte))

OnNotify sets the handler for content distribution requests. It replaces any handler set earlier.

The handler runs on the goroutine serving the delivery, so the hub waits on it. Hand slow work to another goroutine. It is called only after the signature is verified, or not at all if the subscription has no secret.

func (*Subscriber) ServeHTTP

func (s *Subscriber) ServeHTTP(w http.ResponseWriter, r *http.Request)

ServeHTTP handles both requests a hub makes to a callback URL: the GET that verifies intent or reports a denial, and the POST that delivers content.

func (*Subscriber) Subscribe

func (s *Subscriber) Subscribe(ctx context.Context, topic string, opts ...SubscribeOption) (*Subscription, error)

Subscribe requests a subscription to topic.

Unless WithHub is given, topic is first fetched to discover its hub and canonical URL. Subscribe returns once the hub has accepted the request, which for WebSub is before the subscription is usable: the hub verifies intent out of band with a request that ServeHTTP answers. Use [Await] to wait for that to finish.

func (*Subscriber) Subscriptions

func (s *Subscriber) Subscriptions() []Subscription

Subscriptions returns a snapshot of every subscription this subscriber knows about, including ones that were denied or have been unsubscribed.

func (*Subscriber) Unsubscribe

func (s *Subscriber) Unsubscribe(ctx context.Context, sub *Subscription) error

Unsubscribe asks the hub to end sub. As with Subscribe, the hub verifies intent out of band, so the subscription is not gone when Unsubscribe returns. Use [Await] to wait for the hub to confirm.

type SubscriberOption

type SubscriberOption interface {
	// contains filtered or unexported methods
}

SubscriberOption configures a Subscriber at construction.

func WithRenewAt

func WithRenewAt(fraction float64) SubscriberOption

WithRenewAt sets when a lease is renewed, as a fraction of the duration the hub granted. The default is DefaultRenewAt, so a one hour lease is renewed after 48 minutes, leaving room for a failed attempt to be retried before the subscription actually lapses.

Values outside the open interval (0, 1) are rejected: renewing at or after expiry is not renewal, and renewing immediately is a loop.

func WithoutAutoRenew

func WithoutAutoRenew() SubscriberOption

WithoutAutoRenew stops a subscriber renewing its leases.

Renewal is on by default because WebSub leases are finite and hubs are required to enforce them, so a subscription that is never renewed just stops delivering, with nothing anywhere reporting that it did. Turn it off if your program manages subscription lifetime itself, or if it is not running continuously.

type Subscription

type Subscription struct {
	// Topic is the canonical topic URL, as resolved by discovery. This is
	// the self URL the publisher advertises, which is not always the URL
	// discovery started from.
	Topic string

	// Hub is the hub URL that serves Topic.
	Hub string

	// Callback is the subscriber URL the hub delivers to. It carries the
	// per-subscription path segment the subscriber generates, so two
	// subscriptions never share a callback.
	Callback string

	// Secret is the shared key used to sign content distribution requests.
	// Empty means the subscription is unauthenticated. WebSub caps this at
	// 200 bytes and requires HTTPS callbacks when it is set.
	//
	// https://www.w3.org/TR/websub/#authenticated-content-distribution
	Secret string

	// Lease is the duration the hub granted, which may be shorter than the
	// duration requested. Zero means permanent, which only [ModePuSH04]
	// allows.
	Lease time.Duration

	// ExpiresAt is when the lease elapses. Zero for permanent subscriptions.
	ExpiresAt time.Time

	// State is the handshake state at the time this snapshot was taken.
	State SubscriptionState
}

Subscription is one subscriber's registration for one topic at one hub.

It is a snapshot, safe to copy and to hand to a SubscriptionStore. It is not updated in place as the handshake proceeds; use Subscriber.Await or [Subscriber.Lookup] to read current state.

func (Subscription) Expired

func (s Subscription) Expired(now time.Time) bool

Expired reports whether the lease elapsed as of now. Permanent subscriptions never expire.

type SubscriptionState

type SubscriptionState int

SubscriptionState tracks a subscription through the verification of intent handshake.

https://www.w3.org/TR/websub/#verifying-intent

const (
	// StatePending means the hub accepted the request but has not yet
	// verified intent. WebSub hubs answer with 202 Accepted and verify out
	// of band, so this is the state Subscribe normally returns in.
	StatePending SubscriptionState = iota

	// StateActive means the hub verified intent and the subscription is
	// receiving content.
	StateActive

	// StateDenied means the hub refused the subscription, either in its
	// response or with a later denial notification to the callback.
	StateDenied

	// StateExpired means the lease elapsed without renewal.
	StateExpired

	// StateUnsubscribed means the subscription was torn down, either by the
	// subscriber or by the hub.
	StateUnsubscribed
)

func (SubscriptionState) String

func (s SubscriptionState) String() string

String implements fmt.Stringer.

type SubscriptionStore

type SubscriptionStore interface {
	// Save writes sub, replacing any existing entry with the same topic and
	// callback. Re-subscribing to an active subscription is legal in both
	// specs and lands here as an update, not an error.
	Save(ctx context.Context, sub Subscription) error

	// Get returns the subscription for topic and callback. It returns an
	// error wrapping [ErrUnknownSubscription] if there is none.
	Get(ctx context.Context, topic, callback string) (Subscription, error)

	// Delete removes the subscription for topic and callback. Deleting one
	// that does not exist is not an error, since unsubscribe is idempotent.
	Delete(ctx context.Context, topic, callback string) error

	// ListByTopic returns every subscription for topic, in no particular
	// order.
	ListByTopic(ctx context.Context, topic string) ([]Subscription, error)

	// Topics returns every topic with at least one subscription, in no
	// particular order.
	Topics(ctx context.Context) ([]string, error)

	// ListExpiring returns every subscription whose lease elapses before
	// the given time, in no particular order. Permanent subscriptions are
	// never returned. Hubs use it to sweep dead subscriptions; subscribers
	// use it to renew leases ahead of expiry.
	ListExpiring(ctx context.Context, before time.Time) ([]Subscription, error)
}

SubscriptionStore holds subscription state for a Hub, and for a Subscriber that needs its subscriptions to survive a restart.

Implementations must be safe for concurrent use. A hub calls Save on every verified subscription and ListByTopic on every publish, so ListByTopic is the method worth indexing for.

Backends in storage/* implement this interface. To write your own, run it against the shared suite in storage/storetest.

func NewMemoryStore

func NewMemoryStore() SubscriptionStore

NewMemoryStore returns an in-memory SubscriptionStore. It is the default for NewHub and is intended for development, tests, and single-process hubs that can afford to lose state on restart.

Directories

Path Synopsis
adapter
echo module
fiber module
gin module
storage
postgres module
redis module
sqlite module
storetest module
tests
conformance
Package conformance holds the WebSub and PubSubHubbub 0.4 conformance cases for this package, written so they can be replayed against a websub handler mounted behind any router.
Package conformance holds the WebSub and PubSubHubbub 0.4 conformance cases for this package, written so they can be replayed against a websub handler mounted behind any router.
websubtest module

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL