Skip to content
Go back

Garrul: 24 More Releases, Mostly Cheaper

By KingPin 13 min read
Garrul: 24 More Releases, Mostly Cheaper
Contents

Same disclosure as last time: I wrote Garrul, it runs the comment section on this blog, and what follows is one guy reporting on his own work. Treat it as a status update, not a review.

The short version: I shipped 24 releases since the last status post, v2.23.0 through v2.40.0, and the biggest ones made Garrul cheaper to run. Fewer Worker requests per pageview, fewer D1 rows read, fewer KV writes, and a dashboard that finally tells you where the usage goes. There was no roadmap this round, so this is a themed recap instead of a scorecard. Cost comes first because that’s the part a self-hoster can act on today.

Cheaper by Counting Things

If you run a comment system on Cloudflare’s developer platform, you pay for three things: Worker requests, D1 rows read and written, and KV operations. The biggest releases in this stretch tried to spend less of one of them.

v2.27.0 went after the mount. The config response now carries form_token_enabled, and the widget skips the form-token request when it’s false. A default install (no SPAM_FORM_TS_SECRET, or SPAM_HONEYPOT_MIN_MS=0) mounts in one Worker request. The same release changed how reactions and votes load: batched IN lists for the comments on the returned page only, instead of the whole post. Engagement rows for comments nobody asked to see stop being read.

v2.38.0 moved a file off the Worker entirely. /embed.js can now be served from Workers Static Assets through an [assets] block. A static asset request doesn’t run your Worker code, so it stops counting against the Worker request quota. That is why the change is a config edit instead of a code change. The same release stopped minting the form-timing token at widget mount. The token now comes on first composer focus, so a reader who only reads never pays for it. Settings moved from a KV cache to a 60 s per-isolate memo, reply notifications fan out in one query, and thread paging seeks on new indexes (migration 0030). The trade: settings edits can take up to 60 s to reach every isolate. I’ll take that for the KV reads it removes.

v2.39.0 was the big one, with five changes:

  1. /embed/?slug=<slug> is now a static iframe shell served by Workers Static Assets, so an iframe pageview no longer runs the Worker for the shell. /embed/<slug> is unchanged.
  2. Page vote and reaction totals are stored on write. Nothing counts them per pageview anymore.
  3. Sessions are read once per request and refreshed at half-life. /feed/:slug and /c/:id are edge-cached for 5 minutes.
  4. Anonymous comment posts no longer write KV, because the Workers AI verdict cache is gone.
  5. Siteverify, Akismet and Workers AI calls now time out at 5 s, 3 s and 5 s.

The last item looks like reliability work, but a slow third party that hangs a Worker invocation is a reliability problem too, and your 2 AM self does not enjoy debugging a comment form that spins forever because Akismet had a bad night.

Why rows read is the number that matters

D1 bills rows read, not queries. The v2.26.1 security review (below) showed why: an unindexed lookup reads the whole table, and a loop of random tokens against it becomes a bill. A query that scans 10,000 rows costs 10,000 reads even though you only sent one request.

So v2.39.0 made /admin/usage count D1 rows read and written, not queries. It also added a Workers AI neurons panel. Every anti-spam control on /admin/settings now states its per-comment cost next to the toggle. If you’re deciding whether to turn on Akismet and the AI check together, the page tells you what each one adds per comment. One caveat: dashboard figures can trail a moderation action by up to a minute, though the moderation queue itself stays live.

The config lines

Two edits in your own wrangler.toml cover most of this. npm run upgrade does not sync wrangler.toml keys, so you add them by hand:

wrangler.toml
# name, main, routes, workers_dev and the rest of the top-level keys stay up here
minify = true
# [assets] goes at the END of the file
[assets]
directory = "./public"

The ordering gotcha is real. In TOML, a key written below a table header belongs to that table. Put minify = true under [assets] and the key belongs to the [assets] table, not to the Worker config. Keep [assets] below the top-level keys (name, main, routes, workers_dev) and you’re fine. Add it before you run npm run upgrade so the upgrade’s deploy picks it up. If you add it later, run npm run deploy. Skip it entirely and nothing breaks. You just keep paying Worker requests for a JavaScript file.

v2.40.0 added minify = true to wrangler.example.toml, which makes the upload about a third smaller with no behavior change. If you serve from a custom domain, also set workers_dev = false (and preview_urls = false) so the workers.dev hostname isn’t public. That one is hygiene, not savings, but it’s the same file and you’re already in there.

One more constraint on the static shell: its CSP comes from the top-level ALLOWED_ORIGINS in wrangler.toml at build time. Change the list and you must redeploy. With ENV = "dev", [env.*] tables, or no ALLOWED_ORIGINS, the Worker serves that URL itself.

Moderation and Spam

The cost work paid for itself, but a comment system still has to survive actual commenters.

v2.30.0 brought pinned comments (one approved top-level comment per post, with a Pinned badge), an opt-in staff badge for moderator replies, and six new reaction kinds you can choose and order per surface. It also added a site-wide export at /admin/operator, or GET /admin/api/export?format=json|csv. An export too big for D1’s per-request query budget answers 413 export_too_large, and the fallback is a command you run yourself:

Terminal window
npm run db:export

v2.30.1 fixed a hole in the first-comment hold. SPAM_FIRST_COMMENT_MODERATE held only a new author’s very first comment, so a second comment posted while the first sat in the queue went live. It now holds an author’s comments until you approve one of them. A comment you marked spam or deleted no longer counts as history either.

v2.31.0 added reserved names (a list under Admin, Settings, Moderation) and gave each IP-and-name pair its own anonymous identity. Two people behind one IP posting under different names no longer share an author. Bans stay IP-wide and are now checked before Turnstile.

v2.37.0 fixed a thread-structure problem. Marking a comment spam, holding it, or deleting it used to hide its approved replies too. Now the parent renders as an anonymous “[removed by a moderator]” placeholder, and the replies survive. When the whole thread is garbage, the Spam thread action marks a comment and every reply under it spam in one atomic step, up to 100 comments. Each changed row gets a thread.spam audit entry with prev_status, so you can undo it.

v2.38.0 changed the AI check. Workers AI spam checks now use Cloudflare’s Clef-flash decision model and score each comment. The cut-off is SPAM_AI_THRESHOLD, from 1 to 100 with a default of 70, and it’s also editable on /admin/settings. If you want to know how that class of model behaves, I wrote up a measured comparison separately. Here I only report what Garrul now calls.

Locales and Importers

This section is short because most of it is a list.

v2.28.0 added six locales: Italian, Japanese, Norwegian Bokmål, Dutch, Polish and Portuguese. Polish and Norwegian Bokmål were checked by native speakers, and they’re the only non-English locales that auto-select from your host page’s <html lang>. The other four are machine-seeded and opt-in through data-lang. A contributor added Traditional Chinese (zh-Hant) in v2.29.0, also machine-seeded and opt-in. v2.36.0 added Arabic, the first right-to-left locale. The widget sets its direction from the locale. Select it with data-lang="ar". An Arabic <html lang> alone still gives you English until a native speaker reviews the translation.

On the import side, v2.23.0 added dumpers for isso and Cusdis. Neither ships an export command, so a small Node script reads the source’s own database and writes a JSON dump the importer takes:

Terminal window
npm run dump-isso
npm run import-isso
npm run dump-cusdis
npm run import-cusdis

The isso dumper reads comments.db with Node’s built-in SQLite, so there are no new dependencies. Both imports are idempotent: re-running the same dump inserts zero rows. v2.36.1 later fixed a stranded-page problem for threads whose links the read API would reject, giving them a stable synthetic slug. Earlier imports may still have stranded pages, and the repo’s AGENTS-OPERATE.md has the SQL to find and move them.

Setup, Upgrades, and the Security Review

Setup. v2.32.0 made npm run setup hostname-first. It asks for the hostname, picks your Cloudflare account once, reuses database and KV ids that belong to that account, and needs far fewer prompts. It also checks ALLOWED_ORIGINS, PUBLIC_BASE_URL, OAUTH_CALLBACK_BASE and ADMIN_EMAILS as you type, and exits nonzero when a required check fails. npm run owner-link prints a single-use admin sign-in link.

Upgrades. Three releases made npm run upgrade refuse to guess:

Then v2.35.0 made the upgrade print the notes for every release since your installed version. The newest three show in full and older ones show as one title line each, capped at 100 lines. It costs one extra GitHub API request:

Terminal window
GH_TOKEN=$(gh auth token) npm run upgrade

The security review. An external review of 2.26.0 turned up three cases where an existing mitigation was real but did not reach the case found. The fixes in v2.26.1:

  1. subscriptions.token had no index, so lookups scanned the table on two endpoints with no session. Migration 0024 adds the index, and the lookup rejects anything that isn’t 64 lowercase hex before it reaches D1.
  2. The OAuth binding cookie is __Host- prefixed in production. An unprefixed name is writable by any host under your eTLD+1, so a sibling subdomain could plant its own state and cookie pair.
  3. posts.published_at, which anchors AUTO_CLOSE_DAYS, is now written only by the request that creates the post row, so a later comment can’t stamp an old date and close a thread for good.

Earlier, v2.25.0 shipped a hardening pass of its own: the markdown renderer moved to v3 (inline text escaped unconditionally, tag allowlist fails closed), commenter-supplied post URLs are followed only on an ALLOWED_ORIGINS origin, and the emailed subscription confirm link now needs a button press so mail-scanner prefetch can’t confirm it. The original audit has the background on how I approach this.

What Broke or Needed a Manual Step

You should know where the sharp edges are before you upgrade.

If something here bites you anyway, file the issue. I’d rather hear about it than have you patch around it quietly.

So, Cheaper

Twenty-four releases, and the pattern in the biggest ones is plain. A default install mounts in one Worker request. The script that loads the widget can sit on Static Assets. The iframe shell does too. Vote totals are stored, not recounted. The dashboard counts the rows you actually pay for. None of that is a headline feature, and that’s how I like it. A comment system you can forget about because it does less work per pageview is the kind worth self-hosting. If you want the origin story, why I built Garrul covers it. The next 24 will probably be more of the same: fewer things Garrul does per pageview.

Common Questions

How do I upgrade Garrul across several versions?

Run npm run upgrade. Since v2.35.0, Garrul prints the release notes for every version between your installed one and the target. The newest three show in full and older ones show as title lines. Check for manual steps first, such as the v2.39.0 post-deploy SQL command and the v2.25.0 rerender.

How do I move comments from isso to Garrul?

Run npm run dump-isso to read your comments.db into a JSON dump, then npm run import-isso or upload it on the admin operator page. Garrul added isso support in v2.23.0. The import is idempotent, so re-running the same dump inserts zero rows. Cusdis works the same way with its own dump and import commands.

Does Garrul support right-to-left languages?

Yes, Arabic (ar) is supported since v2.36.0, and Garrul sets the widget’s direction from the locale. Select it with data-lang="ar". An Arabic <html lang> alone still selects English, because the machine-seeded Arabic translation waits for a native speaker to review it.

How do I serve Garrul’s embed script from Workers Static Assets?

Add an [assets] block with directory = "./public" at the end of your wrangler.toml, below the top-level keys. Garrul v2.38.0 introduced this, and npm run upgrade prints the block. Run npm run deploy if you add it after upgrading. Without the block, Garrul still works and the Worker serves /embed.js.

Where do I see Garrul’s D1 usage?

Garrul shows D1 usage at /admin/usage. Since v2.39.0, the page counts rows read and rows written instead of queries, because D1 bills rows read. It also adds a Workers AI neurons panel, and each anti-spam control on /admin/settings states its per-comment cost.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it'll show up above once verified.


Previous Post
step-ca: Let's Encrypt for Your LAN
Next Post
Container Restart Storms at 2 AM

Discussion

Powered by Garrul . Sign in with GitHub or Google, or post anonymously.

Related Posts