Xer0x's Underground

The Garden Learns to Breathe

Contents

License

Disclaimer & Intro


This post has been made as my notes, even though I attempt to explain what I have built and how, I do not owe anyone any explanation. Do NOT expect anything.


My blog is my garden.


WARNING: No AI was used in the making of this project. Not one token. Just source code, RFCs, and a free weekend.


Last post the garden grew a nervous system. It remembered every word, felt who was standing in the room, made bots pay before touching an endpoint, and changed its own DOM on every deploy. Search, presence, proof-of-work, polymorphism. Reflexes.


Then I kept building.


The blog can now be installed. A post can be carried through a dead network. A reader can save progress without making an account. A new article announces itself over WebSub, Bluesky, and Webmention instead of waiting for a platform's algorithm to feel charitable. The presence room stopped merely counting bodies and started carrying an atmosphere — focused, useful, cursed, mind blown — while still remembering nobody. The page exposes its density, measure, contrast, motion, and appearance as a local CSS API the reader controls.


And the line I repeated for two posts is no longer true.


The box moved. I moved it. Deliberately. Three directives.


This is the receipt.



The Receipt Finally Moved


Here is the Content-Security-Policy serving the site now, lifted from _headers:


Content-Security-Policy: default-src 'none'; script-src 'self' https://static.cloudflareinsights.com 'inline-speculation-rules'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://bear-images.sfo2.cdn.digitaloceanspaces.com; font-src 'self'; connect-src 'self' https://cloudflareinsights.com; media-src 'none'; object-src 'none'; frame-src 'none'; frame-ancestors 'none'; worker-src 'self'; form-action 'self'; base-uri 'none'; manifest-src 'self'; navigate-to 'self'; upgrade-insecure-requests

Three things changed since the nervous system:



No new executable origin. No third-party widget. No arbitrary inline JavaScript. The policy grew because I added browser capabilities that have explicit CSP controls.


There is another correction. The original 14kB check used wc -c on raw HTML. That was easy to explain and not the number TCP actually carries once Cloudflare compresses the response. The congestion window sees transferred bytes, not the uncompressed source sitting on disk. CI now measures gzip, at level nine, and still fails at 14,336 bytes.


Current receipt, from a clean production build:


ItemResult
Home HTML6,807 bytes gzip
Blog index HTML8,028 bytes gzip
Posts built25
Tags built43
Search terms7,765
Full build227ms
Node test suite25 passing
Production npm dependencies0

The source got larger. The wire budget did not break. It is explicit now.


If a feature needs a third-party origin, arbitrary inline code, or an identifier that follows the reader home, it does not ship. If a browser feature needs a precise CSP directive, I add it, and document it.



The Garden Survives the Wire Going Dark


A website that calls itself resilient while becoming useless the moment the network coughs is doing the same theater as an “EDR-ready” company whose incident plan is a PDF on the domain controller.


So the blog became installable. Not a wrapper. Not an Electron crime scene. A web app manifest, a root-scoped service worker, an IndexedDB queue, and the browser APIs that already exist for exactly this job.


The manifest is boring on purpose. It declares the site as a standalone reading application, exposes shortcuts for the queue, search, and blog, and registers a share target so a Xer0x post shared from another application can be saved directly into the reading list. The icons are local. The start URL is local. There is no install SDK waiting to fingerprint the device.


The service worker is generated with the site. Its cache name carries the build version, so a deploy creates a new cache and deletes the old one during activation. But it does not vacuum every article into storage. Post HTML is persisted only after an explicit reader action:


if (event.data?.type === 'SAVE_PAGE') {
  const response = await fetch(url.href, {
    cache: 'reload',
    credentials: 'same-origin',
  });
  if (!cacheable(response)) return reply(false);
  await (await caches.open(CACHE)).put(url.href, response.clone());
  reply(true);
}

Burn posts are embedded into a deny-list in the generated worker. /api/* is never intercepted. Anything carrying Cache-Control: no-store is rejected. A self-destructing article does not become immortal because somebody pressed “Later.” The offline feature obeys the content's threat model instead of quietly overruling it.


The reading queue lives in IndexedDB on the reader's machine. URL, title, progress, last heading, completion state. No sync account. No server table. No “sign in to continue reading.” The scroll position is reduced to a percentage and a heading id, then saved locally. If the database disappears, my server cannot restore it because my server never had it. That is not a missing feature. That is ownership.


There was a proper Safari failure in the first pass. navigator.serviceWorker.ready can sit pending while a freshly installed worker waits to control the page. The UI waited with it, which meant an iPhone reader could press save and watch nothing happen. The fix was to separate the promise the reader made from the optimization the worker performs: write the queue entry first, then ask the worker to cache the page in the background.


await putItem(item);                   // the explicit save succeeds first
void workerMessage('SAVE_PAGE', url)  // offline caching follows, bounded
  .then(markOffline)
  .catch(() => {});

The queue is useful even if the service worker never becomes ready. Offline is an enhancement to “save for later,” not a dependency holding the button hostage. That is what progressive enhancement is supposed to mean before framework vendors turned it into conference vocabulary.



It Speaks Protocols, Not Vendor Dialects


Publishing a post and waiting for a social platform to maybe show it to people is not distribution. It is pleading with a ranking function you do not control.


RSS and Atom were already here. They now advertise a WebSub hub. After a successful main-branch deploy, the pipeline pings that hub for both feeds. A subscriber does not have to poll every few minutes and I do not have to run a queue. The protocol says the feed changed; the hub fans the signal out.


New posts also syndicate to Bluesky through the AT Protocol. The script has no npm package. It creates a session, creates one app.bsky.feed.post record with an external card, and exits. It reads only files added in that push, skips draft: true and publish: false, and uses meta_description when the older description field is absent. A hidden post does not escape because the syndication parser forgot what hidden means.


return {
  title: str('title'),
  description: str('description') || str('meta_description'),
  slug: str('slug'),
  draft: /^draft:\s*true/m.test(yaml) || /^publish:\s*false/m.test(yaml),
};

Webmention closes the loop in the other direction. Every page advertises /api/webmention. When somebody links to a post, their site can send source and target. The Function accepts only HTTP(S), requires the target to be on xer0x.in, rate-limits by a one-way IP hash, stores a pending row, then fetches the source and verifies that the link actually exists. No link, no verified mention. “Trust me, I mentioned you” is not a protocol.


On publish, the reverse happens. The deployment script scans external links in the new Markdown, discovers Webmention endpoints through the HTTP Link header or HTML, and sends the mention. The site announces links using the target's own advertised contract.



The Doorbell Learns Mood — and Finally Sleeps


The nervous-system post said the presence Durable Object used hibernation with “no heartbeat loop burning cycles.” That sentence was true about the server's connection storage and false about the whole system.


The old client sent the string ping every thirty seconds. Every ping woke the object. I had built a hibernating room and then installed an alarm clock that rang twice a minute. Calling the API hibernatable did not make the architecture asleep.


That timer is gone. The current client sends no heartbeat, runs no polling loop, and stores no presence state. Legacy clients can still send ping, but Cloudflare answers it through the WebSocket auto-response path without waking the object:


this.ctx.setWebSocketAutoResponse(
  new WebSocketRequestResponsePair('ping', 'pong')
);

Then the count grew an atmosphere. A reader can mark the room focused, useful, cursed, or mind blown. One signal per connection. Press it again and it clears. The server persists that tiny choice only as a WebSocket attachment:


this.ctx.acceptWebSocket(server);
server.serializeAttachment({ v: 1, mood: null, changedAt: 0 });

When the Durable Object wakes after eviction, it calls getWebSockets(), deserializes the attachments, and rebuilds the totals. No D1 row. No Durable Object storage write. No identity. No reaction history. The state survives hibernation because the socket survives; it disappears because the socket disappears.


That difference matters. A normal reaction system asks “what did this user click last Tuesday?” This one can only answer “what are the open sockets saying now?” There is nothing to retain, export, correlate, or sell.


The room is bounded because cute realtime features become denial-of-service primitives when you refuse to count. Maximum 256 connections per post. Client frames stop at 96 characters. Mood churn is limited to one change per 500ms. The Pages boundary rejects anything that is not a same-origin WebSocket handshake before it even addresses the Durable Object. The browser client validates server frames, reconnects with a cap, understands back-forward cache restoration, and vanishes if the binding is unavailable.


The Worker bundle is 1.66 KiB gzip. It stores the atmosphere of the room and forgets the room the instant the room stops existing.


That is breathing: not a database recording every inhale, just air moving while somebody is there.



CSS Becomes a Reader API


Most “reader mode” implementations are a second application hiding inside the first one. A settings framework, a component library, an analytics event for every font-size change, and a cloud account so your preferred line-height can follow you into the afterlife.


Mine is five data-* attributes on <html>:


<html
  data-density="comfortable"
  data-measure="standard"
  data-contrast="system"
  data-motion="system"
  data-appearance="system">

Density is compact, comfortable, or relaxed. Measure is narrow, standard, or wide. Contrast and motion either follow the operating system or increase/reduce explicitly. Appearance is system, terminal green, or amber. CSS turns those values into public custom properties: --reader-measure, --reader-gutter, --reader-font-size, --reader-line-height, --reader-block-space, and --reader-motion.


The client is 3,940 bytes uncompressed. It performs no fetch. It sets no cookie. Every name and value passes through an allowlist before reaching a DOM attribute. Valid query parameters override saved preferences for one page and are never persisted, so this works:


?density=compact&measure=wide&contrast=more&motion=reduce&appearance=terminal

There is localStorage here, and that deserves the same honesty as the CSP. Reader-owned state is not surveillance merely because both are bytes.


On desktop the control is one small R, with “Reader display settings” preserved as its accessible name. On mobile it is not shown at all. No browser-brand sniffing, no growing list of Safari/Chrome/Firefox/Brave strings. A narrow viewport or a coarse, no-hover primary pointer kills the control in CSS before JavaScript can reveal it:


@media (max-width: 767px), (hover: none) and (pointer: coarse) {
  .reader-capabilities { display: none !important; }
}

Mobile still gets the system's reduced-motion and contrast preferences. It simply does not get another floating button covering the thing it came to read. Capability is not the same as clutter.



The Boring Work That Keeps It Fast


The visible features are the part people screenshot. The invisible changes are the part that keeps the screenshots from becoming a post-mortem six months later.


JavaScript assets now carry per-file content hashes in their URLs, so a changed module invalidates itself without throwing away every other cached file. Font faces are selected per page instead of shipping declarations for weights the page never uses. Three local fonts are announced through HTTP Link headers so Cloudflare can emit Early Hints before the document arrives.


Hover a normal post link and a supporting browser can prerender it through the Speculation Rules API. That sounds easy until the prerendered document runs its scripts and becomes a ghost reader, burns proof-of-work, asks the AI for a summary, or writes telemetry for a human who never clicked. So likes, presence, proof-of-work, and TL;DR all check document.prerendering and wait for activation. Restricted posts carry data-no-speculation and never enter the rule.


The old no-store blanket on HTML was removed because it blocked the back-forward cache in Safari and Firefox while protecting nothing sensitive. Burn pages still set no-store dynamically. Ordinary documents use no-cache, revalidate, and remain eligible for instant history navigation. Presence explicitly re-arms itself on a persisted pageshow, because a frozen page returning from BFCache is not a fresh load and pretending otherwise creates a dead room.


The middleware hot path stopped fetching the burn registry on every ordinary article. It caches the registry, hashes an IP only when a branch actually needs the hash, indexes the scraper lookup, and fails open when cache or D1 faults would otherwise turn a static page into a 500. Security controls that make the document unavailable during their own outage are availability vulnerabilities wearing uniforms.


Footnotes and task lists landed in the zero-dependency Markdown parser. The reading-progress bar was scoped to posts. Mobile action rows stopped fighting for one line. Average-read metadata stopped wrapping. View transitions honor reduced motion. JSON-LD is escaped safely. Tags enter the sitemap. Search prefix lookup became a binary search instead of a linear apology.


And CI stopped merely building the site and started running the test suite before deployment. The hibernating Worker deploys before Pages, so the new client never reaches the edge before the protocol it speaks exists there.


This is not “polish.” This is the work that prevents a clever feature from becoming a permanent source of small lies.



The Roadmap, Revisited


Honesty means scoring the list again.


The PGP dead drop — still a sketch. The public key exists. The browser-side encryption and ciphertext-only endpoint do not. I have now said “next” often enough that the word has lost evidentiary value. It ships when there is code and a test.


Open-sourcing the engine — still undecided. Twenty commits made the argument stronger and the maintenance cost less theoretical. The source is no longer three files and a grin. It is a generator, Pages Functions, a Durable Object, D1 migrations, a service worker, publishing protocols, and tests. Handing it over honestly means documenting the whole system, not dumping a repo and calling abandonment freedom.


Push notifications — the subscription plumbing exists and stays invisible until a public key is configured. That is intentional degradation, not a launch. The missing piece is the boring one that matters: a delivery path I am willing to operate without turning “new post” into another attention-extraction channel.


Webmentions — verified mentions are stored. Rendering them under a post without importing spam, avatars, tracking pixels, or somebody else's HTML is the next real problem. Receiving a protocol message is easy. Presenting it safely is the engineering.


The roadmap is not a promise queue. It is a list of threats to my free time.



Final Word


The first post was subtraction — framework, bundler, runtime, CMS, gone. HTML, CSS, specs, and a measurable wire budget remained.


The second was addition without surveillance — likes, flags, read-time, and scraper watermarks for one honest CSP expansion.


The third grew teeth — an edge model on a leash, terminal verification, real burn-after-reading, and a canary that makes stolen content report its own thief.


The fourth grew a nervous system — full-text memory, live presence, proof-of-work, and a DOM that changes shape between deploys.


This one learned to breathe. It survives when the wire disappears. It exhales new posts through feeds, WebSub, Bluesky, and Webmention instead of begging one platform for reach. It inhales the atmosphere of a room without recording the people inside it. It changes its reading surface to fit the person holding it. Then it sleeps when nobody is there.


A system is not alive because it is complicated. It is alive because it can breathe without asking a platform for permission.


Jai Hind.


Survive the wire. Speak the protocol. 🫁
The garden breathes. The vendors do not own the air.


Wake up. Dig deeper.



gladgers-hacker-gers-guardians-of-galaxy



Twitter LinkedIn Contact me on Signal

Contact me via email


#Web Hosting #cyber security #development #privacy #cloudflare #edge #pwa #research

🔑 Verify authenticity

PGP key xer0x-public.asc · id e8270d03
sha256(post) 7d2ad4eb8ca604b75374f6a99f06e219da6165a7c0596b7c01044994c365febe

Confirm this post is byte-for-byte authentic — paste into any macOS or Linux terminal:

curl -fsSL https://xer0x.in/blog/garden-breathe/content.txt | openssl dgst -sha256 | grep -qi 7d2ad4eb8ca604b75374f6a99f06e219da6165a7c0596b7c01044994c365febe && echo "signature ok" || echo "TAMPERED"

← Back to blog