1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
|
+++
title = 'What’s next for the indie web?'
subtitle = 'Building a new Webmention receiver, and more communities'
slug = 'weepinbell'
tags = ["Clojure","IndieWeb","Lisp"]
categories = ''
date = 2026-10-18 17:31:05
draft = false
toc = true
fedi = 'https://akk.eltr.ac/objects/14a4784a-95f4-41cd-afe3-a5248ae12519'
+++
I have been blogging in English for some time, but this is my first time participating in an indie web conversation. So I think I should introduce myself first.
I'm Eltrac. I'm mainly a web developer but I like all kinds of thing about programming. I code in Go, Clojure and Lua, and I explore new tech all the time. I usually blog more than three times a week, but most of my posts are written in Chinese. I recently became an active member of [Codeberg e.V.](https://codeberg.org/Codeberg-e.V.) and have been working on my own FOSS project [weepinbell](https://codeberg.org/eltrac/weepinbell) for a while. It's a new **Webmention receiver** written in Clojure.
In this post, I will talk about why I developed weepinbell, how I designed its architecture, what I think of the IndieWeb movement and many things.
## Why weepinbell?
I wrote to [Brennan](https://brennan.day/) recently and he was really nice to mention weepinbell [on his blog](https://brennan.day/i-love-sharing-cool-stuff-made-by-people/#other-cool-things). In that email, I talked about how I set a new goal for weepinbell (i.e. an alternative to webmention.io) because of Aaron Parecki rewriting webmention.io with Claude. Later, in [Aaron's response](https://aaronparecki.com/2026/10/17/5/rebuilding-my-indieweb-projects) to [Brennan's new post](https://brennan.day/the-indieweb-now-has-genai-and-claude-built-in-what-do-we-do/), he mentioned weepinbell as "a direct response to my rewrite".
I would like to make things clear. I didn't start developing weepinbell because of Aaron's rewrite. I started early in this May to learn Clojure. I've been blogging (in Chinese) for years and of course I love the concept of IndieWeb. Naturally, my practice project for learning a new language involves IndieWeb technologies.
The motive is that I want to get my hands dirty with the Webmention standard. I read through the [W3C specification document](https://www.w3.org/TR/webmention/) and started putting things in *parentheses*. You can still find snippets from that document in my code as comments. I bascially code against the specification. It was a fun experience for me.
```clojure
(if-let [error-msg (check-request source target allowlist)]
;; respond if request verification failed
(bad-request {:status 400
:message error-msg})
;; proceed if request is ok
(do
;; The receiver SHOULD check that target is a valid
;; resource for which it can accept Webmentions.
;; This check SHOULD happen synchronously to reject
;; invalid Webmentions before more in-depth verification begins.
;;
;; Webmention verification SHOULD be handled asynchronously
;; to prevent DoS (Denial of Service) attacks.
(put! wmc new-wm)
;; respond with 201 when Webmention passed checks and
;; was sent into queue, include a Location header
(let [wm-id (digest (str source target))
wm-url (str (:base-url config) "/api/mention/" wm-id)
response {:status 201
:message "Webmention is accepted and pending for verification."
:url wm-url}]
(created wm-url response))))))
```
This snippet is from [receiver.clj](https://codeberg.org/eltrac/weepinbell/src/commit/fef78a78f2672701362c83032e0784f6cd94af05/src/weepinbell/webserver/receiver.clj#L47-L68).
After the initial *quick and rough* implementation, I stopped working on weepinbell for a few months. And it had been receiving Webmentions for my blog during that time. Everything worked and I was content. It was bascially a personal tool.
Then the webmention.io rewrite came to my attention.
Why does it bother me? I don't even use webmention.io any more. I see some of my friends seeking alternatives, and... there aren't many to choose from! There is [webmentiond](https://github.com/zerok/webmentiond/) by Horst Gutmann and some other implementations here and there. My friend [Chlorine](https://chlo.is/) developed [CircleAt](https://codeberg.org/chlorine/circleat) in Rust. And I'm sure there are many more. But frankly, none of them truly count as "alternative to webmention.io".
Don't get me wrong. I think many of them are great for their purpose, i.e. small service one can self-host. However, they can be quite difficult for the non-tech indie web folks and those who simply do not own a server. ==They need a public instance.==
## Refactoring for scalability
I decided to continue development of weepinbell and make it "the next webmention.io", at least that's what I'm going for.
So I took a closer look at my code. They're not that messy, well partitioned (for the most part) and maintainable. I can swap some of the parts easily because I used hook functions and vtables.[^1] The architecture is flexible enough, and the *Lisp power* also makes modifying software really easy.
The following part will be somewhat technical. Feel free to jump to the next section if you don't want to read about system design. The bottom line is: ==weepinbell would be lightweight enough for people to self-host; it would also be scalable enough to support large traffic.==
I'm a Go programmer by the living.[^2] And Clojure's [`core.async`](https://clojuredocs.org/clojure.core.async) library greatly resembles Go's concurrey programming style, which means my skills of building scalable applications in Go translates.
Webmention is not big data. Storage is definitely not the bottleneck. In fact, up until [a few commits ago](https://codeberg.org/eltrac/weepinbell/commit/1db02376cd50e604973d17fd4a04f6e1969fc53c), weepinbell does **not even support any type of database**. It unloaded everything in a single file and then loaded it back when needed. And it just... worked, for months.
Of course this is neither performant nor safe. If I want to build something reliable, I need a database. If you have taken a look at that commit I linked, you might notice that the business logic is **untouched**. All that's added is a new storage implementation, a new config option, and a few lines in the main component (`core.clj`).
I'll probably elaborate on architecture flexibility in another post. That's something I'm really proud of. The gist is that using a new type of database and ORM is easy, and will be just as easy if I ever decided to redo the storage layer again.
```clojure
;; all I need to make sure is that the new storage implementation
;; returns a valid storage vtable, like this:
(defn use-store [db-file]
(let [ds (make-datasource db-file)]
(create-tables ds)
{:append (partial insert-webmention ds)
:update (partial update-webmention ds)
:delete (partial delete-webmention ds)
:get (partial get-webmention ds)
:get-by-domain (partial get-webmentions-by-domain ds)}))
```
Beside a new SQLite implementation, I added a *storage worker* that handles I/O asynchronously. So when processing a new webmention, the *processor* won't have to wait for DB transaction (or appending to a file; it does not care about what's going on underneath) to end. It simply delegates to the *storage worker* and keeps processing the next webmention.
The *processor* delegates by putting an operation object into a *channel*. It's similar to what I already have for receiver. When a new webmention request is sent to the *web server*, receiver does some basic checks (e.g. does `source` and `target` look like real URLs?) and then put the request into the *webmention queue*. A *processor* will pick it up and make sure `source` has truly mentioned `target`, parse microformats, wrap data into a valid record, and hand it over to *storage worker*.
Reading from a channel is **thread-safe**. And since I/O is handled internally by *storage worker*, it can also be thread-safe, *without the need for a mutex lock*. So we can add as many processors as we want to handle webmentions concurrently.
The overall architecture looks like this:

{.dark:invert}
That's **horizontal scaling**. For self-hosters, simply set `pipeline-concurrency` to `1`, and there will be only one processor handling webmentions, using a reasonable amount of RAM and CPU.
For large groups or someone who wants to host a public instance, change a number in config and you're good to go.
All of that is compiled to a single `.jar`.[^3]
## The microformats nightmare
Before I move on to a non-tech topic, there's one thing I have to *complain*: Using microformats markup is easy. Parsing it is really not.
Here is an example snippet from [microformats.io](https://microformats.io/).
```html
<div class="h-card">
<a class="p-name u-url" href="http://blog.lizardwrangler.com/">Mitchell Baker</a>
(<a class="p-org h-card" href="http://mozilla.org/">Mozilla Foundation</a>)
</div>
```
It's just regular HTML with standardized class names like `h-card` and `p-name`. It's easy to add a few classes to your HTML. However, it's still hard to parse structural data from HTML with *just* a few more classes.
Good thing there are already some parsers available... right?
> Production ready parsers are available for: Go, Node, PHP, Python, Ruby.
Right... I'm using Clojure...
Clojure can use Java code, since it runs on top of it. I found [mf2j](https://github.com/karadaisy/mf2j), an "In-development Java parser for microformats2". It hasn't been updated since... 10 years ago.
There is a [Clojure implementation](https://codeberg.org/alinnow/microformats). It's archived, with its last update committed 11 years ago.
Do I implement a new one myself? Do I pick up where they left off? How do I make sure I don't burn out and abandon it as well? After all, Clojure is a niche language and IndieWeb is also kind of niche. I can't imagine there being another Clojurist building something that relies on microformats. At least, I don't know anyone like that.
I briefly considered running a micro-service that wraps the Go parser, and weepinbell can talk to this service using RESTful API or gRPC. But it feels heavy and everything just won't fit in a single `.jar`.
So how can Clojure run programs written in a non-JVM language? What else runs on JVM? Scala, Groovy... *Wait, will WebAssembly work?*
Yes! **WebAssembly runs on JVM.** There is a native JVM WebAssembly runtime called [chicory](https://github.com/dylibso/chicory).
I spent an afternoon studying [Extism](https://github.com/extism/) and chirocy. Then I wrote a tiny little Go wrapper of Will Norris' [parser library](https://github.com/willnorris/microformats).
```go
package main
import (
"bytes"
"github.com/extism/go-pdk"
"willnorris.com/go/microformats"
)
//go:wasmexport parse
func parse() int32 {
input := pdk.Input()
r := bytes.NewReader(input)
data := microformats.Parse(r, nil)
pdk.OutputJSON(data)
return 0
}
```
Then I compiled it to `.wasm` using [tinygo](https://tinygo.org/) compiler, bundled the `.wasm` inside `.jar`, and called the Extism plugin from Clojure using chicory. Voilà! Here's [mf2clj](https://codeberg.org/eltrac/mf2clj), a Clojure wrapper of WebAssembly compiled from a Go wrapper of an existing microformats parser.
By the way, I made a little logo just for fun.

You use it just like a normal library in Clojure. And it's surprisingly fast.
```clojure
(require '[mf2clj.core :as mf2clj])
(mf2clj/parse html-string)
```
More surprisingly, bundling a `.wasm` binary and the WebAssembly runtime inside the `.jar` is not as heavy as I thougt it would be. The `.jar` only grew 2 MB in size. *Am I really running Java?*
Nevertheless, the nightmare isn't over yet. In the begining, I settled for the incomplete `mf2j` library and used it for a while. At that time, I called the JSON output "*data structure from Hell*". I still do —— Everything is an array! Everything is deep inside something's `properties` or `children`! Nothing is deterministic! Every website's format is different!
```json
{
"items": [{
"type": ["h-card"],
"properties": {
"url": ["http://blog.lizardwrangler.com/"],
"name": ["Mitchell Baker"],
"org": [{
"value": "Mozilla Foundation",
"type": ["h-card"],
"properties": {
"name": ["Mozilla Foundation"],
"url": ["http://mozilla.org/"]
}
}]
}
}],
"rels": {},
"rel-urls": {}
}
```
In the end, I had to write a smaller parser to parse output a the original parser. I called this feature *microformats reduction*. I remember webmention.io having something similar.
```json
"microformats": {
"author": {
"name": "Mitchell Baker",
"url": "http://blog.lizardwrangler.com/",
"photo": "https://blog.lizardwrangler.com/photo.png"
},
"type": "mention",
"published": "2026-07-25T00:00:00-07:00",
"content": {
"html": "..."
"text": "..."
}
}
```
If something does not exists, it'll just be `null`. The format is not final yet, I will make some adjustments. This can be turned off by `?microformats=raw` when querying webmentions.
Also, I think more than half of the bugs I found in weepinbell have something to do with microformats-related features. *Maybe we should switch to Open Graph and JSON-LD, folks?*
Anyway, I'm just glad it's finally working.
## Slowing down
At this point, I've almost finished everything I have planned for the `v1.0.0` release: a proper database, horizontal scalability, up-to-date microformats parser. As for hosting a public instance, that'll involve authenticating users and making a dashboard for them to manage webmentions. That would be a separate project.
I plan to develop a service that sits in front of weepinbell, handling logging in and stuffs. I mentioned the idea in the project's [PHILOSOPHY.md](https://codeberg.org/eltrac/weepinbell/src/branch/master/doc/PHILOSOPHY.md).
==I want to keep the core of weepinbell minimal.== Those who want to be their own sysadmins don't have to care about authentication. They don't even need a frontend. They only need to self-host one `.jar` and use API to query webmentions, use RSS to receive notifications and use webhook to automate stuff. These are the built-ins.
A *platform* like webmention.io needs more than that. If I want to keep weepinbell "for both individuals and large groups", I have to separate things. Also, this makes everything modular and swappable, a pretty good architecture decision.
My writing to Brennan was an attempt to get more Clojure developers on the team. *Well, that didn't go well.* For the new platform, which will be named *Ringabell*[^4], I want to use more mainstream stack, probably Go, so that it's easier for people to get involved.
For now, I'm still working on weepinbell's test suite. The current test coverage is more than 83%, but there are still some edge cases that haven't been covered by tests. I had to test a lot of paths by hand. Also it'll take some time for the new SQLite implementation to be stable.
I don't think we need to or should move fast when it comes to developing reliable software, with or without LLMs.
## Independence, and now decentralization
In reply to Brennan's recent question, ["What do we do now?"](https://brennan.day/the-indieweb-now-has-genai-and-claude-built-in-what-do-we-do/#what-do-we-do-now), I think it's finanlly time to get more decentralized.
Decentralization is never about moving away from the corporate web (that's independence). It's about not relying on a [single point](https://indieweb.org/single_point_of_aaron), no matter what the point is and who it involves.
I agree with Brennan on not conflating IndieWeb and the lower-case indie web. The [IndieWeb camp](https://indieweb.org/) is a nice community and a great introduction to the indie web. But I think we should form more indie web communities other than the IndieWeb camp.
[omg.lol](http://omg.lol/) is an example. It's a fun web community by [Neatnik](https://neatnik.net/). They provide infrastructure like a simple profile page, Fediverse instance and email forwarding. Brennan is also building [folk.zone](https://folk.zone/), which hosts a fork of webmention.io and many more.
Those two may not be exactly alternatives to the IndieWeb camp, but they can be. And there can be more for people to choose from.
Tech folks can self-host their own infrastructure if they want. For the non-tech folks, there should be more alternatives to what the IndieWeb camp has to offer. Poeple who are capable can host public services and take a small community.
After all, communities are what brings the indie web together. We can and should build more communities (this is probably more like polycentricity rather than decentralization). We can have the *LispyWeb* for lisp programmers, *ArtsyWeb* for artists, and *Indie Indie Literature Club!*[^5] for book lovers. Those are just some random names I came up with.
Perhaps the upcoming *Ringabell* might be the next cozy small community. Who knows? To everyone who's nice enough to finish reading this, ==I hope we can all *find our people*.==
[^1]: A vtable in Clojure is basically a hashmap whose values are functions. It's a technique mentioned in one of Uncle Bob's books, *Functional Design*.
[^2]: Actually I'm at the very begining of my career. Don't think of me as an expert of anything. XD
[^3]: Clojure runs on JVM, so it compiles to JVM bytecode, just like Java.
[^4]: Meaning "ring a bell".
[^5]: The referrence is a galgame called "[Doki Doki Literature Club](https://en.wikipedia.org/wiki/Doki_Doki_Literature_Club!)". :3
|