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.
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
Seven 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.
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.
Three caches, and only one of them is invalidated
| Layer | Holds | Cleared by | Cost of a miss |
|---|---|---|---|
pageCache | whole rendered pages, keyed by path | every write, entirely | a re-render |
render_cache | post bodies, keyed by commit + media + markdown | nothing; a change is a different key | 92 to 383 ms |
| gzip map | compressed bytes, keyed by the bytes | nothing; same input, same output | 0.09 ms |
| Cloudflare | whatever s-maxage=60 let it keep | purgeEdge(), the whole zone | one 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.
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 the same way now. What separates them is the budget, and the sign-in screen is where it changes.
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. Until September 2026 the admin was the opposite, a React application that arrived empty and then asked the server what to draw; it is the same shape as the reading page now, server-rendered with islands, and only the owner ever downloads a byte of it, so its weight is still irrelevant to readers and to search engines. What it buys is that the owner stopped paying for a framework too.
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.
| Rule | Enforced in | |
|---|---|---|
| 1 | The cache is cleared completely after every write. No per-tag, per-path or per-kind invalidation exists. | server/cache.ts |
| 2 | Posts and pages share one /{slug} namespace. A trashed row still reserves its slug. | content/slugs.ts |
| 3 | Image 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 |
| 4 | Write 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 |
| 5 | Raw HTML in user content is escaped, never executed. javascript:, data: and vbscript: links are dropped. | utils.ts |
| 6 | Every delete is a soft delete. Every live read filters through one shared SQL fragment; Trash reads its complement. | store/db.ts |
| 7 | Analytics writes go through the flush buffer, never straight from a handler. A request never waits on an analytics write. | analytics/buffer.ts |
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.
| Endpoint | What it hands back |
|---|---|
| /:slug + Accept: text/markdown | the post's Markdown source; browsers asking for HTML are unaffected |
| /api/md/:slug | the same document at an explicit path |
| /api/mcp | the 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.xml | content 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.
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".A third door, added in 2.2.4: the owner's own model. The admin has an assistant page that calls the same tool functions the MCP server wraps, so an answer it gives and an answer a connected client gives come out of one implementation. Four providers sit behind one table, holding Anthropic, OpenAI, Gemini and DeepSeek, the last speaking OpenAI's protocol verbatim, so it is a base URL rather than a fourth dialect, and that table is read by the chat loop and by every other job that wants a model, which is why a key that works in the chat box also writes the alt text for an upload. Answers stream: each provider frames its fragments differently (OpenAI sends deltas, Anthropic names its events and builds blocks by index) and the differences are isolated in one file. The key is the owner's, held on their own server, and nothing in this path is reachable without it.
What makes a setting addressable. There are 158 writable leaves in the settings
tree, and until 2.2.4 the only way to write one was a screen with a field on it. They carry a
dotted path now, with the type each one takes: list_settings hands an agent the
whole list and update_settings takes any path from it, writing through
saveSettings, the same function the form posts to, sanitising and clamping
and refusing exactly as it does for a person. What made that safe to open is a test that sets
every path one at a time and watches the other 157 not move. The admin's own command palette
is a second index over the same tree, 108 controls that carry a human name.
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.
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, nine static checks (file size, hardcoded CSS values, stray NUL bytes, unguarded routes, type roles, admin kit usage, bundle budgets, the MCP tool contract, documentation freshness) and 2,544 tests across 185 files. Forty-five seconds on the machine that wrote it, two and a half minutes on a cold CI runner. 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 seventy-nine 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.