Design decisions#

Decisions that could reasonably have gone the other way, and why they went this way. If you are about to propose a change to one of these, this is the argument you are arguing against.

Principles#

  1. Core has zero third party dependencies. Only the standard library. Anything pulling in an external module goes in its own module.
  2. Every HTTP facing type implements http.Handler. This is what makes every router adapter possible.
  3. WebSub is the default, PubSubHubbub 0.4 is opt in. The differences live in one table in mode.go, never forked into parallel code paths.
  4. Every exported symbol has a doc comment. Comments explain what is not obvious. A comment restating the line below it is noise; one explaining why the spec demands something is not.

One package, not three#

The three roles share Subscription, Mode, Discovery, Sign, Verify, SubscriptionStore, and every option. Splitting into websub/subscriber, websub/hub, and websub/publisher would mean either a base package that all three import anyway, which is indirection rather than separation, or duplicating WithMode, WithLogger, and WithHTTPClient three times.

It also costs the caller:

sub, err := websub.NewSubscriber(cb)         // one import
sub, err := subscriber.New(cb)               // split: two, because
sub.OnNotify(func(s *websub.Subscription))   // this type is still shared

And pkg.go.dev renders one page per package, so one package means everything is discoverable in one place. This is what net/http does with Client, Server, and Request. Separation is at the file level.

Options are sealed interfaces, not functions#

The usual Go pattern is type Option func(*config). That does not work here, because a package cannot have three functions named WithMode. The alternatives were three differently named variants of every shared option, WithSubscriberMode and friends, or one option type per role that shares an implementation.

So each option is an unexported type implementing one or more sealed interfaces:

type Option interface {
	SubscriberOption
	PublisherOption
	HubOption
}

func WithMode(m Mode) Option              // applies to all three
func WithSecret(s string) SubscribeOption // applies to one call

The compiler now rejects an option passed to a constructor it does not apply to, and there is exactly one WithLogger to learn. The cost is that the interfaces are sealed, so you cannot write an option of your own. That is the intent: options are API surface, and third party ones would be API surface nobody agreed to.

Constructors return errors#

NewSubscriber, NewPublisher, and NewHub all take a URL and all return an error. A URL that is relative, unparseable, or missing a host is a programming error that cannot be recovered from, and the alternatives were panicking like regexp.MustCompile or deferring the failure to the first call that needs the URL. Deferring it is worse: the error surfaces far from its cause, and Subscribe would have to report two unrelated kinds of failure.

NewHub takes the hub’s own URL rather than an option for the same reason. Every content distribution request must carry a rel=hub Link header pointing back at the hub, and a hub cannot infer its own public URL from an incoming request when it is behind a proxy. Making it optional would make a spec requirement optional.

Subscription is a plain value, and Await is how you wait#

WebSub verification is asynchronous. Subscribe returns after the hub answers 202, which is before the subscription works. Something has to represent “not yet”.

Subscription could have carried a mutex and been updated in place, but it also has to be storable in a SubscriptionStore, which means copyable, which rules out a mutex. So it is a snapshot: a plain struct with a State field, safe to copy and to hand to a backend. The live state lives inside the Subscriber, and Await blocks until the handshake settles:

pending, err := sub.Subscribe(ctx, topic)
active, err := sub.Await(ctx, pending)

Subscribe could have blocked until active and avoided the second call. It does not, because the callback server has to be running to answer the hub, and a blocking Subscribe called before the server started would hang instead of failing.

Background work does not inherit the request context#

A hub answers a subscription request with 202 and then verifies out of band. That work outlives the request by design, so it starts from context.Background() rather than context.WithoutCancel(r.Context()).

The first version did carry the request context, for its values. That is a bug on any server that pools request state: fasthttp, which fiber runs on, recycles its request context the moment the handler returns, so reading values off it later is a use after free. The race detector found it when the conformance suite was run through the fiber adapter.

The cost is that request scoped values, a trace ID put there by middleware, do not reach delivery logs. Hooks is the seam for that.

Lease renewal is on by default#

A WebSub lease is finite, and hubs are required to enforce expiry and forbidden from issuing perpetual leases. A subscription nobody renews therefore stops delivering at some point, and nothing reports that it did. There is no error, no callback, no status change the subscriber would notice without asking. That is a bad default to hand someone.

So a subscriber renews at 80% of whatever lease the hub granted, leaving the remaining fifth as room for a failed attempt to be retried. Renewal reuses the same callback, which is what makes the hub treat it as overriding the existing subscription rather than creating a second one.

The cost is that a Subscriber holds a timer per active subscription, which is why Close exists. WithoutAutoRenew turns it off for programs that manage subscription lifetime themselves or do not run continuously.

SubscriptionStore has six methods, not four#

Save, Delete, ListByTopic, and ListExpiring are the obvious four. Two more earned their place:

  • Get, because a hub looks up one subscription on every re-subscribe, and doing that by scanning ListByTopic is linear in subscriber count on every request against every backend.
  • Topics, because Hub.Topics needs it and no combination of the others provides it.

Both take a context and return an error, as does everything else on the interface, because a real backend can fail and an interface that cannot say so forces every implementation to lie.

Bad signatures are dropped, not rejected#

When content arrives with a missing or mismatched X-Hub-Signature on a subscription that has a secret, the subscriber discards it, logs a warning, and answers 2xx.

Answering 4xx feels more correct and is worse. The spec asks subscribers to ignore invalid content locally, and the callback to acknowledge delivery. A 4xx makes a well behaved hub retry content it has no way to fix, which turns one misconfigured secret into a retry storm. It also tells an attacker probing with forged signatures exactly when they get closer.

The conformance suite is a package, not a test file#

tests/conformance holds regular Go files, not _test.go files, so other modules can import it. Each adapter runs the identical cases through its own wrapper, which is where wrapping bugs live.

It sits outside internal/ deliberately. Anyone writing an adapter for a router this repo does not cover can import the suite and prove theirs is correct, the same way storage/storetest works for backends.

Config has both a Wrap hook, for routers that are themselves http.Handler, and a Serve hook, for frameworks that are not. Fiber needs the second one: testing it through fiber’s own net/http bridge both hides losses a deployed app would not have and introduces ones it would.

Tests live in their own package#

Each module’s tests are in a tests/ directory in a separate package, exercising only the exported API. That is the same surface the adapters wrap, so a case written once can be replayed through an adapter unchanged, and a test that needs an unexported detail is usually pointing at a gap in the public API.

The cost is that coverage needs -coverpkg=./..., because the tests are no longer in the package they cover.

Publishing sends both hub.url and hub.topic#

WebSub explicitly leaves publisher to hub notification unspecified and points at what public hubs already do, which is hub.mode=publish with hub.url. Some hubs read hub.topic instead. Sending both costs one form field and removes a class of “it works with one hub and not another” bug.

What is deliberately not here#

  • A metrics library. Hooks are plain callbacks. A metrics/prometheus module can wrap them if there is demand. Building it speculatively would put a dependency in front of everyone for the benefit of some.
  • A logging interface. *slog.Logger has been in the standard library since Go 1.21. A bespoke interface would be one more thing to learn for no gain.
  • chi and gorilla/mux adapters. Both accept http.Handler natively. Publishing empty modules for them would imply they are needed.
  • Automatic retries in the subscriber. A subscriber that cannot reach a hub should surface that to its caller, which knows whether retrying is appropriate. The hub retries deliveries because nobody else can.