This is a Clojure and ClojureScript implementation of WebSub, the W3C standard for pushing a feed or a page to its subscribers through a hub as soon as it changes, so that they don't have to poll it. It covers all three roles of the standard: the subscriber, the publisher and the hub.
The same code runs on the JVM, in Node and in the browser. A page can
discover the hub of a topic and publish to it, with a :send of your own
where CORS stops the request, but it can't subscribe, since a subscriber
needs a callback URL that the hub can reach.
This library was spun out of podcast-clj, a library for making podcast software, where servers use it to get new episodes pushed to them instead of polling every feed. Like podcast-clj, it was developed with assistance from frontier LLMs.
It requires Clojure 1.11+ and Java 11+. Add it from
Clojars to the :deps in
your deps.edn:
dk.simongray/websub-clj {:mvn/version "0.7.0"}
For unreleased changes, depend on the latest commit on master instead.
In shadow-cljs, a Git dependency only works through deps.edn, so set
:deps true in your shadow-cljs.edn too. To read feeds in Node, set
globalThis.DOMParser to a DOMParser, e.g. the one from
@xmldom/xmldom.
Find the hubs of a topic with websub/discover!:
(require '[dk.simongray.websub :as websub])
(websub/discover! "https://simon.grays.blog/feed")
;; => {:status 200
;; :hubs ["https://pubsubhubbub.superfeedr.com/"]
;; :topic "https://simon.grays.blog/feed"}
If you fetch the topic yourself, give the response to websub/discover
instead, for the same map.
WebSub takes the topic from a feed's rel="self" link, and many RSS
feeds leave it out. The map then has the hubs but no topic. To take the
URL that you fetched as the topic instead, give {:url-as-topic? true}.
Subscribe to the topic at one of its hubs, with a callback URL that nobody can guess and a secret for the hub to sign what it sends:
(def secret
(websub/token))
(websub/call! (websub/request :subscribe
"https://pubsubhubbub.superfeedr.com/"
"https://simon.grays.blog/feed"
(str "https://app.example.net/websub/" (websub/token))
{:secret secret}))
;; => {:status 202 :url "https://pubsubhubbub.superfeedr.com/"}
A secret only goes over https, so a hub with an http URL gets the request
at its https URL. When that fails, the call throws
:dk.simongray.websub/https-failed, and {:secret-over-http? true}
sends the request over http instead.
Serve the callback URL with a Ring handler. The hub first checks that you asked for the subscription, and then it delivers each new version of the topic:
(def deliveries
(atom []))
(def callback
(websub/handler {:wanted-pred (fn [{:keys [topic]}]
(= topic "https://simon.grays.blog/feed"))
:deliver! (fn [{:keys [body]}]
(swap! deliveries conj body))
:secret secret}))
The :wanted-pred says whether you asked for a subscription to a topic.
The :deliver! function gets the bytes of each delivery that the hub
signed with your secret, so return from it quickly, and read the feed
afterwards.
A hub names the topic of a delivery in a Link header, and when it sends
none, the delivery has no topic. So give each subscription a callback URL
of its own, which the :request of a delivery has.
A hub keeps a subscription for the length of its lease, so you subscribe
again before the lease ends. To keep track of the leases, keep your
subscriptions in a map by an id of your own, e.g. the feed's. Record each
request with websub/asked before you send it, and take the handler's
options from the map:
(def subscriptions
(atom {}))
(defn now
"The instant now."
[]
(java.util.Date.))
(defn subscribe!
"Ask the hub with the `request` for the subscription of `id`."
[id request]
(swap! subscriptions websub/asked id request (now))
(websub/call! request))
(def callback
(websub/handler
(merge (websub/handler-options #(deref subscriptions))
{:deliver! (fn [{:keys [body request]}]
(let [[id] (websub/subscription-of @subscriptions
request)]
(swap! deliveries conj [id body])))
:verified! #(swap! subscriptions websub/verified % (now))
:denied! #(swap! subscriptions websub/denied % (now))})))
The handler then confirms only the requests in the map, and it updates
the map with websub/verified and websub/denied. A confirmed
subscription is :active, with its lease and the time to renew it. A
denied one is :denied, since WebSub says that a denial is final.
Every hour or so, send the requests that websub/due gives:
(defn new-callback
"A new callback URL for the `subscription` of `id`."
[id subscription]
(str "https://app.example.net/websub/" (websub/token)))
(doseq [[id request] (websub/due @subscriptions (now) new-callback)]
(subscribe! id request))
That renews each subscription whose time has come at a new callback URL, as WebSub advises. It also asks again for subscriptions whose lease ran out, or whose request the hub hasn't checked within an hour. The old callback takes deliveries until the hub confirms the new one, and after that it answers 410, which ends the old subscription. A topic can move, though, so to follow it, discover the topic again and subscribe with a request of your own.
To keep the map in a file or a database, write it as EDN with
websub/format-subscriptions, which reveals the secrets, and read it back
with websub/parse-subscriptions.
Name the hub in a Link header of the topic, or in the feed or page itself
with the links of websub/links, and tell the hub each time the topic
changes:
(websub/link-header ["https://pubsubhubbub.superfeedr.com/"]
"https://simon.grays.blog/feed")
;; => "<https://pubsubhubbub.superfeedr.com/>; rel=\"hub\", <https://simon.grays.blog/feed>; rel=\"self\""
(websub/publish! ["https://pubsubhubbub.superfeedr.com/"]
"https://simon.grays.blog/feed")
;; => [{:hub "https://pubsubhubbub.superfeedr.com/" :status 204}]
A hub consists of its Ring handler, a store of subscriptions and a function of yours that runs its jobs, e.g. on a thread pool or from a queue in a database:
(require '[dk.simongray.websub.hub :as hub])
(declare opts)
(defn schedule!
"Run the `job` at its :at, on a thread of its own."
[job]
(future
(when-let [at (:at job)]
(Thread/sleep (max 0 (- (inst-ms at) (System/currentTimeMillis)))))
(hub/run-job! job opts)))
(def opts
{:url "https://hub.example.net/"
:store (hub/memory-store)
:schedule! schedule!})
(def hub-handler
(hub/handler opts))
The handler answers subscribers and publishers right away. The work that
follows, the check of a subscription or the delivery of a topic that
changed, goes to your schedule! as a job. A delivery that fails comes
back as a retry, with the :at to run it.
A store is a map of two functions, so one over a database takes a few
lines. The defaults of the options, e.g. the leases and the retries, are
in hub/default-options, and the docstrings of hub/handler and
hub/run-job! describe the rest.
A queue in a database keeps the jobs through a restart. Write a job as
text with hub/format-job, which reveals its secret and puts its bytes
in base64url, and read it back with hub/parse-job. For example, with
next.jdbc and this table in
PostgreSQL:
CREATE TABLE hub_jobs (id BIGSERIAL PRIMARY KEY,
run_at TIMESTAMPTZ NOT NULL,
job TEXT NOT NULL);
(require '[next.jdbc :as jdbc])
(defn schedule!
"Queue the `job` in the database `db`, to run at its :at, or at once."
[db job]
(jdbc/execute! db ["INSERT INTO hub_jobs (run_at, job) VALUES (?, ?)"
(or (:at job) (java.util.Date.))
(hub/format-job job)]))
(defn run-due-jobs!
"Run the jobs in the database `db` that are due, by the hub's `opts`."
[db opts]
(doseq [{:hub_jobs/keys [id job]}
(jdbc/execute! db [(str "SELECT id, job FROM hub_jobs"
" WHERE run_at <= ? ORDER BY run_at")
(java.util.Date.)])]
(hub/run-job! (hub/parse-job job) opts)
(jdbc/execute! db ["DELETE FROM hub_jobs WHERE id = ?" id])))
Give the hub (partial schedule! db) as its :schedule!, and call
run-due-jobs! from one worker every few seconds. A job is deleted once
it has run, so one that a crash stops runs again. The text holds the
secrets of subscriptions, so keep the table as safe as the store.
websub/default-options and hub/default-options
list their defaults, which follow the spec.:send of your own..cljc and depends on
wary-fetch for requests,
webcrypto-clj for their
signatures,
qname-hiccup for feeds,
html-pieces for pages,
whatwg-url for their URLs and
whatwg-mime to tell a feed
from a page.clojure -X:test # the tests on the JVM
npm install # once, for ClojureScript
clojure -M:cljs compile test # the tests in Node
clojure -M:cljs compile browser-test # the tests in a browser
node test/browser-run.mjs # run them in a headless Chrome
On the JVM, a test also runs a subscriber, a publisher and a hub against each other over HTTP on localhost.
The websub-clj project is licensed under the MIT licence.
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |