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.
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+. For the latest release, add it
from Clojars to the
:deps in your deps.edn:
dk.simongray/websub-clj {:mvn/version "0.2.1"}
For changes that aren't released yet, use the SHA of the latest commit on
master instead:
dk.simongray/websub-clj
{:git/url "https://github.com/simongray/websub-clj"
:git/sha "…"}
For ClojureScript, shadow-cljs only reads Git dependencies from
deps.edn, so also set :deps true in your shadow-cljs.edn. 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 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 discover for the
same map. A feed that names a hub but no rel="self" link gives the hubs
with a nil :topic, since WebSub takes the topic from that link. Many
RSS feeds leave it out, so give {:url-as-topic? true} to take the URL
that you fetched as the topic instead. Then subscribe to the topic at a
hub, giving it a callback URL that nobody can guess and a secret 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/"}
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 the
:topic, and :deliver! gets the bytes of each delivery signed with the
secret. Return from it quickly, and read the feed afterwards.
Once :wanted-pred says yes, the handler confirms the subscription to
the hub and calls :verified!, if you give one, with the :mode, the
:topic and the :lease-seconds of the subscription. Keep the lease
there, and before it ends, at the time that websub/next-renewal gives,
subscribe again with a new callback URL. Discover the topic first, since
it may have moved.
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, and gives the
work that follows to schedule!: a check of a subscription, or the
delivery of a topic that changed. A delivery that fails goes to
schedule! again as a retry, with the :at to run it. A store is a map
of two functions, so one for a database takes a few lines. The options
that have defaults, e.g. the leases and the retries, are in
hub/default-options, and the docstrings of hub/handler and
hub/run-job! describe the others.
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,
qname-hiccup for feeds and
html-pieces for pages.clojure -X:test # the tests on the JVM
npm install # once, for the Node tests
clojure -M:cljs compile test # the tests in Node
clojure -M:cljs compile browser-test # for a browser, from target/browser-test
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 |