A small Hono app for testing whether WebSub subscribers find a hub when the
discovery links are named and placed unconventionally — inline namespace declarations, at the
bottom of the <channel> after all the items, per
this gist — alongside a
control feed with textbook markup, so a silent reader can be pinned on the format or on the
reader.
Hub: https://rpc.rsscloud.io/websub (docs)
All three feeds are identical RSS 2.0 — same items, no rssCloud elements — except for how they declare, name, and place the WebSub discovery links.
/feed-classic.xml — the control. Textbook markup: xmlns:atom on the <rss> root, links at the
top of the channel before the items.
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
…
<atom:link rel="hub" href="https://rpc.rsscloud.io/websub"/>
<atom:link rel="self" href="…/feed-classic.xml"/>
<item>…</item>If a reader misses this one, the markup isn't the problem — the reader or the platform is. The other two only mean something relative to it.
/feed-atom.xml — conventional element name, inline namespace, below the items:
<atom:link xmlns:atom="http://www.w3.org/2005/Atom" rel="hub" href="https://rpc.rsscloud.io/websub"/>
<atom:link xmlns:atom="http://www.w3.org/2005/Atom" rel="self" href="…/feed-atom.xml"/>/feed-websub.xml — same namespace URI bound to a websub prefix instead:
<websub:link xmlns:websub="http://www.w3.org/2005/Atom" rel="hub" href="https://rpc.rsscloud.io/websub"/>
<websub:link xmlns:websub="http://www.w3.org/2005/Atom" rel="self" href="…/feed-websub.xml"/>A namespace-aware parser should treat all three as the same element. A parser that string-matches
on atom:link will miss /feed-websub.xml; one that stops scanning at the first <item> will
miss both of the bottom-placed variants. That's the thing under test — with /feed-classic.xml
there to tell you whether the reader was ever going to work at all.
Readers that resolve a bare hostname — Micro.blog does this — read the homepage's <head> and
take the first <link rel="alternate"> they find. A single homepage listing all three feeds
can therefore only ever hand such a reader the control, which makes the other two variants
untestable through that path.
So each feed gets its own hostname under BASE_URL, and each of those homepages advertises that
feed and nothing else:
| Hostname | Advertises |
|---|---|
websub.example.com (apex) |
All three, side by side — the comparison view |
classic.websub.example.com |
/feed-classic.xml only |
atom.websub.example.com |
/feed-atom.xml only |
websub.websub.example.com |
/feed-websub.xml only |
The label is the feed id, so a fourth variant gets a hostname for free. Everything a feed emits
is canonical to its own host — the autodiscovery link, the rel="self" in the feed, and the
hub.url published for it. A subscriber that follows rel="self" as the canonical topic (as the
spec says it should) lands where it started rather than being bounced to the apex, so a
cross-host mismatch can't be confused for the discovery failure you're actually hunting.
Every feed is still served from every hostname; the hostname only decides what a page advertises.
/post and /ping also still act on all three feeds regardless of which host you trigger them
from.
This needs the three hostnames resolving to the server and a reverse proxy that passes the
original Host through (X-Forwarded-Host is honoured and takes precedence). Three A records or
one wildcard, whichever you prefer — listing the hosts explicitly keeps TLS simple, since a
wildcard certificate has to be issued over a DNS-01 challenge while named hosts can use HTTP-01.
With Caddy the whole proxy config is:
classic.websub.example.com, atom.websub.example.com, websub.websub.example.com, websub.example.com {
reverse_proxy localhost:3000
}
Caddy preserves the incoming Host by default, so nothing further is needed. nginx does not —
add proxy_set_header Host $host; there.
Locally nothing is needed at all: *.localhost resolves to loopback on macOS and most Linux
setups, so http://atom.localhost:3000 just works.
Set FEED_SUBDOMAINS=0 to go back to serving everything from BASE_URL.
npm install
npm start # http://localhost:3000The hub has to be able to fetch the feeds, so for a real end-to-end test expose the server
publicly and set BASE_URL — each feed's hostname derives from it, and those are what end up in
the rel="self" link and the hub.url you publish:
BASE_URL=https://your-tunnel.example npm startThe homepage warns when BASE_URL is still localhost.
For local development you can put settings in a .env file instead — copy
.env-sample to .env and edit. npm start and npm run dev load it via
node --env-file-if-exists, which needs Node 22.9+. Containers ignore .env;
they take config from -e flags or compose environment:.
| Route | What it does |
|---|---|
/ |
On a feed's hostname: that feed alone, advertised in the <head> as rel="alternate". On the apex: all three side by side. Both show the discovery markup each feed emits, recent feed fetches, and current config |
/feed-classic.xml |
Control feed — root xmlns:atom, atom:link above the items |
/feed-atom.xml |
Feed with inline-namespaced atom:link below the items |
/feed-websub.xml |
Feed with inline-namespaced websub:link below the items |
/post |
Form — adds an item to all feeds, then POSTs hub.mode=publish for each and shows the hub's response |
/ping |
Notifies the hub for all feeds without changing them |
Feed fetches are logged to stdout and listed on the homepage with their User-Agent, so you can watch the hub come back and re-read after a publish.
docker build -t websub-debug .
docker run -d -p 3000:3000 -e BASE_URL=https://websub.example.com websub-debugOr run the published image without a checkout:
docker run -d -p 3000:3000 \
-e BASE_URL=https://websub.example.com \
ghcr.io/rsscloud/websub-debug:latestPosted items are held in memory, so there's no volume — restarting resets both
feeds to the single seeded item. examples/dockge/compose.yaml is a
ready-to-paste Dockge stack.
To publish a multi-platform image to ghcr.io/rsscloud/websub-debug:
npm run docker:dry-run # preview the tags
npm run docker:build-push # build linux/amd64 + linux/arm64 and pushSee scripts/README.md for tagging and auth details.
| Variable | Default | Purpose |
|---|---|---|
PORT |
3000 |
Listen port |
BASE_URL |
http://localhost:$PORT |
Public apex URL. Each feed's hostname is derived from it, and those are what appear in rel="self" and hub.url |
HUB_URL |
https://rpc.rsscloud.io/websub |
Hub endpoint |
SEND_LINK_HEADERS |
off | Set to 1 to also advertise hub/self via HTTP Link headers |
FEED_SUBDOMAINS |
on | Set to 0 to serve every feed from BASE_URL instead of giving each its own hostname |
SEND_LINK_HEADERS is off by default on purpose: Link headers are the discovery method the hub
docs call primary, so leaving them on would let a subscriber find the hub without ever parsing the
feed — which would defeat the experiment. Turn it on when you want to verify that path separately.