Architecture note · read from the source tree at v2.0.3

How it holds together

A blog engine that is one Bun process, two SQLite files, and a directory of uploads. No database server, no queue, no cache tier, no cloud account in the path. This page explains how that holds together, and what each part is protecting.

source
59,818 lines
files
442 · 400-line cap
tests
113 files
runtime deps
13 packages
a post page
114 KB, 10 requests
third parties
0 on the page

01The shape of one install

Everything below is a consequence of one choice: the whole system is a single process that owns its own files. There is nothing to connect to and nothing to keep in sync.

The runtime is Bun, the router is Hono, and storage is SQLite opened in-process through bun:sqlite. That last word does the heavy lifting: a query is a synchronous function call, not a network round trip. No connection pool, no SQLITE_BUSY retry loop, no way for a request to observe a half-applied transaction, and no mutex anywhere in the tree, because the event loop is single-threaded and there is exactly one writer by construction.

Reader or an agent https Cloudflare optional miss one Bun process · one blog ≈140 MB before a word is written Hono router src/web/app.ts · middleware, then routes page cache one Map<path, html> · src/server/cache.ts render markdown → HTML strings, no JSX quire.db 23 tables + 2 FTS analytics.db grows forever uploads/ plain files one directory · a backup is a copy
Everything a blog is, is inside that dashed line and the three boxes to its right. The two database files are split on purpose: a pageview write must never queue behind a post save, and losing a day of analytics is an annoyance where losing a day of posts is a disaster, so they get different durability settings and different backup priorities.

A blog's entire identity is three environment variables: DATA_DIR, PORT, SITE_URL. That is the fact the hosting model in section 08 is built on, and it is also why moving a blog to another machine is rsync rather than a migration.

02A read, door to door

Six middlewares in a fixed order, then a route, then a fork: is this page already a string in memory?

Route order is load-bearing. /:slug matches anything, so every fixed path (/admin, /preview/:slug, /og, the feeds) is registered before it, and Hono matches in registration order. The catch-all at the bottom serves posts and pages out of one shared slug namespace, which is invariant 2.

the middleware chain, in registration order: src/web/app.ts requestLogger times everything canonicalPath trailing slash cacheHeaders s-maxage=60 security 3 headers compression outermost redirects owner's moves route page cache: is the string here? Map<path, html>, cleared whole on any write hit miss serve the stored string ≈ 1 ms render_cache, content-addressed key = build commit + media facts + markdown hit miss body already built ≈ 1 ms marked.parse 92 to 383 ms written back gzip, memoised by content then out. 206 responses are never compressed
The amber path is the one the whole design is arranged to avoid. Rendering was assumed to cost a fraction of a millisecond; measured against the real archive it is 92 to 383 ms, almost all of it inside marked.parse and none of it in the project's own renderer. That measurement is why a second cache exists underneath the first: content-addressed, so nothing ever invalidates it, and never load-bearing: a read that throws returns null and the page renders the slow way.
Two caches, two different jobs. The page cache holds finished pages and is thrown away constantly. The render cache holds finished bodies and is never thrown away at all, because a change produces a different key. Putting the build commit in that key is what stops a deploy serving yesterday's HTML out of a cache that cannot tell it changed.

03A write, and what it empties

There is no invalidation logic. A write empties the entire page cache, unconditionally, and then the process fills it back up before telling the CDN to forget.

The retired version of this software spread invalidation across an ISR page cache, a tagged data cache, and a module that computed a per-write superset of affected paths. It was pinned by a test because it was easy to under-purge, and under-purging means a published post nobody can see. Version 2.0 removed the problem instead of managing it: clearCache() takes no arguments, so it cannot be narrowed.

a write lands save · publish · setting quire.db soft delete only clearCache() no arguments, ever every page, gone pageCache.clear() · invariant 1 the render cache is untouched by any of this flush hooks: registered from the entry point, never from inside clearCache() itself debounce 3 s an import is one burst warm: render every post 3,948 ms → 203 ms once cached purgeEdge() the only purge path Cloudflare unconfigured = no-op a write landing mid-pass sets `again` and earns another lap: it is never dropped the guard here used to read `if (running) return`, which looks like de-duplication and is not: a pass walks 77 posts in 8.4 s, and every save inside that window skipped purgeEdge. The edge went on serving the old page at Age: 824. warm first, purge second
Order is the point. Warming before purging means the edge refetches into a warm origin instead of a cold one. The same chain runs at boot, which is what makes a deploy clear the CDN without anyone remembering to. The amber loop is a bug that shipped and was traced from a one-line owner report, "saving a post does not clear the cache", to a guard that dropped every save arriving during an 8-second pass.

Three caches, and only one of them is invalidated

what each layer holds, and what makes it go away
LayerHoldsCleared byCost of a miss
pageCachewhole rendered pages, keyed by pathevery write, entirelya re-render
render_cachepost bodies, keyed by commit + media + markdownnothing; a change is a different key92 to 383 ms
gzip mapcompressed bytes, keyed by the bytesnothing; same input, same output0.09 ms
Cloudflarewhatever s-maxage=60 let it keeppurgeEdge(), the whole zoneone origin fetch

The owner can switch the first two off together in Settings, for the hour they are changing the design and want to see what they changed. Off means the cache is neither read nor written: filling it while it is off would hand back an hour-old page the moment it came back on, and public HTML goes out no-store rather than no-cache, because Cloudflare reads no-cache as "keep it, revalidate" and keeps answering from the edge.

04The module map

Four layers, and dependencies only ever point downward. Nothing in the storage layer knows a web request exists.

src/web: the router, the gate, and every HTML view HTML is built as strings, not JSX. Stylesheets are hand-written TypeScript modules, *.css.ts app.ts guard.ts article.ts listing.ts layout.ts chrome.ts admin/ every write route is owner-gated by which router it was mounted on, not by a check inside it the domain modules: each owns its own tables and its own rules contentposts, pages, slugs, series,revisions, settings, themes rendermarkdown, footnotes, math,highlighting, OG cards mediauploads, image variants,byte ranges, quota newssubscribers, broadcast,SMTP, the send log commentstree, tombstones, amarkdown subset analyticsbuffered writes only,flushed every 2 s authargon2id, TOTP, sessions,recovery codes mcptools, tokens, OAuthregistration + consent src/store: both connections, the shared query helpers, one schema, one migrations file liveOnly() lives here and only here · no value is ever interpolated into SQL · WAL, foreign keys on quire.db analytics.db
The one box in amber is the whole authorisation story. ownerRouter() applies the session check and the CSRF origin check together, at construction, so there is no router someone can create and then forget to guard. A build-time check fails on any write route registered outside a gated router unless its path appears in an exception list with the reason it is public. It caught a forgotten enrolment endpoint the first time it ran.

Two structural rules keep this map honest. Four hundred lines is the maximum for any file, warned at 380 so nobody's unrelated change gets blocked by a limit they were not thinking about. And any is banned: unknown, narrowed, except at a JSON boundary that immediately validates into a typed shape.

05Two frontends, two budgets

The reading page and the admin are built on opposite principles, and the sign-in screen is the border between them.

The public site is server-rendered HTML with no framework and no bundler. What JavaScript exists is hand-written, self-contained, and loaded as islands, some eagerly, most on first use. The admin is a React SPA, built once and embedded in the binary tree, and only the owner ever downloads a byte of it, so its weight is irrelevant to readers and to search engines.

What crosses the wire both rows on one scale: 1 px = 1 KB, transferred, first visit, nothing cached 100 300 500 KB A reader opens a post no sign-in, no cookie 114 KB in total, across 10 requests 67 KB fonts, cut per script: only the ranges these pages actually use 8 KB CSS, one hashed file, immutable · 7.8 KB JavaScript, written by hand 0 third-party requests: no CDN font host, no tracker, no analytics vendor coming back: ≈ 24 KB, because only the HTML is fetched again sign-in argon2id + TOTP + recovery codes; one account per blog, enforced in code The owner opens the admin once, then cached ≈ 440 KB · React 19, 14 pages Tailwind is kept here and only here, behind a build only the owner's browser sees the reading page is the product; the admin is never on its critical path
The gate is what makes both budgets affordable at once. A build check fails if the article page's JavaScript goes over 3 KB, so a new feature cannot quietly start costing every reader a little more forever. Meanwhile the admin can carry React, Tailwind and a full editor, because nobody who has not signed in will ever fetch it.

Some things that would normally be JavaScript are not. The reading-progress bar is CSS animation-timeline: scroll(). Link prefetching is a Speculation-Rules response header pointing at a JSON document, rather than an inline script, because the public site ships no inline script at all, and an inline rules block would be governed by the content-security policy like any other.

06The seven invariants

Each of these breaks quietly. No crash, no red test, just a page that never updates, or a deleted row that comes back.

Every one is enforced in exactly one place in code and pinned by a test or a build-time check. A change that weakens one has to update its guard in the same commit, which is what makes the weakening visible in review rather than six months later.

the load-bearing rules, each with the src/ file that enforces it
RuleEnforced in
1The cache is cleared completely after every write. No per-tag, per-path or per-kind invalidation exists.server/cache.ts
2Posts and pages share one /{slug} namespace. A trashed row still reserves its slug.content/slugs.ts
3Image references are stored store-relative. The origin is added on read and stripped on write, in the data layer only: stored bytes carry no hostname.media/blob.ts
4Write routes are owner-gated by router membership, not by a check inside the handler. A new write route is protected because of where it is mounted, or the build fails.web/guard.ts
5Raw HTML in user content is escaped, never executed. javascript:, data: and vbscript: links are dropped.utils.ts
6Every delete is a soft delete. Every live read filters through one shared SQL fragment; Trash reads its complement.store/db.ts
7Analytics writes go through the flush buffer, never straight from a handler. A request never waits on an analytics write.analytics/buffer.ts
Why number 1 is blunt on purpose. The retired version invalidated selectively and its rule was "never under-purge": a deliberate superset, because getting the set exactly right was impossible and being wrong meant an invisible post. Version 2.0 does not manage that problem, it deletes it: the page cache is one Map in one process, so throwing all of it away costs a few renders and cannot be wrong.

07The door for machines

Posts are authored in Markdown and stored as Markdown, so the most useful thing the server can do for an AI agent is hand over the source it already has.

Two surfaces do this. Content negotiation on the ordinary post URL: ask for text/markdown and you get the document as written, not a conversion of the rendered HTML. Same URL, same visibility rules. And a full MCP server at /api/mcp, whose tools are thin wrappers over the same functions the admin uses, so an agent publishing a post goes through identical slug rules, revisions, soft-delete and cache-clearing.

what a machine can reach without a browser
EndpointWhat it hands back
/:slug + Accept: text/markdownthe post's Markdown source; browsers asking for HTML are unaffected
/api/md/:slugthe same document at an explicit path
/api/mcpthe MCP transport: Streamable HTTP, stateless, a fresh server per request
/.well-known/oauth-*authorization-server and protected-resource metadata, with CORS
/llms.txt · /sitemap.xml · /feed.xmlcontent index, sitemap, feed

The security posture here is stricter than the convenience suggests, and each rule exists because the looser version was a real attack. The MCP server is off unless the owner turns it on. Tokens are shown once and stored only as a hash; every one expires after 180 days. Client registration is public (it has to be), so the registered redirect list alone is not enough protection, and a non-loopback authorization request renders a consent page showing the exact client and redirect, whose approval carries a CSRF token bound to the owner's session. A mismatched redirect is refused inline with a 400 and never redirected to, because redirecting the error to an unvalidated address is the open-redirect that hands over the account.

One protocol detail worth stating. A JSON-RPC message with no id is a notification: deliver it and answer immediately, never wait for a reply. Nothing sends one, so waiting deadlocks the request, and a connector's first move after the handshake is exactly such a notification. The symptom was a handshake that succeeded, a POST that never returned, and a client showing a spinner and then "server is currently unavailable".

08One process per blog

A hosted version of this software runs each blog as its own process with its own files. There is no tenant column, and the application never learns that other blogs exist.

The alternative was specified, costed, and rejected, and the diagram below is the reason. Because bun:sqlite is synchronous, a slow render does not yield: in a shared process, one blog's large archive is every other blog's request waiting behind it.

Rejected: one process, a database resolved per request the seam already exists and would not have been expensive to build blog a blog b blog c one process resolve the db per request 7 singletons become scoped one queue, all blogs a 77.6 ms render blocks everyone measured: 0.9 ms at 500 posts, 9.1 ms at 5,000, 77.6 ms at 20,000, and none of it yields. Isolation would also become a property of a resolver nobody can see, rather than of a file anybody can name. Chosen: the filesystem is the tenancy boundary no query can reach a file the process never opened blog a blog b blog c process · port process · port process · port DATA_DIR DATA_DIR DATA_DIR The cost is RAM, and it is known: ≈140 MB per blog before a word is written, and it does not grow with traffic; measured at +3 MB across 40 requests. A hundred blogs is ≈15 GB. The rate limiter stays per-process and in-memory, which under this model is exactly right. The rejected one needed Redis. Export stays a copy, not a query with a where clause, which is what keeps the eject path free.
The difference is one queue. Adding a tenant column to 22 tables across 183 call sites would buy an isolation boundary that one SQLite file per blog already is. What gets built instead is a control plane beside the application: provision, route a hostname, issue a certificate, hold a quota, back up, delete. The application's entire contribution to it is a storage quota and a host variable, both of which a single-owner self-host wanted anyway.

09What proves it still works

Three gates, and they are deliberately different in kind, because each one is blind to what the others catch.

The build gate

One command runs the typecheck, seven static checks (file size, hardcoded CSS values, stray NUL bytes, unguarded routes, type roles, admin kit usage, documentation freshness) and 113 test files. About two minutes. A change under the renderer also runs a golden compare against 45 fixtures of reference HTML captured from the previous implementation, by a renderer that no longer exists. Those fixtures are a contract, not a snapshot: regenerating them would not update the reference, it would destroy it, and the gate would then be checking the output against itself.

The browser gate

The build gate proves the code compiles and the seams hold. It cannot tell you that a table column collapsed to an ellipsis, or that three columns are fourteen pixels out of alignment, both of which shipped, because nobody opened the page. So a second command drives forty flows in a real browser: the reader's controls, every admin page, a draft saved and published and trashed and restored, an upload refused for being too large, the archive built. It seeds its own instance on a dedicated port and deletes it after, and it refuses to start if something is already listening there, because a tour on a busy port silently tours the other instance.

The restore gate

The browser tour proves a backup builds. It cannot open one, so a third script does: integrity check on both database files, no table with fewer rows than before the snapshot, every uploaded byte identical. A backup nobody has restored is not a backup. There are three of them, answering three different questions: an export the owner holds, scheduled snapshots on the same machine, and hourly copies off it. None can be restored from inside the admin, because an application that can overwrite every table in itself is a bigger risk than the one that removes.