The Disassembled Web · 005 · The Ghost
Part of The Disassembled Web
------------------------------------------------------------------------
V3SS3L · 005 · THE GHOST
------------------------------------------------------------------------
TRANSMISSION LOG -- NODE z3r0
09:14 Registered the worker. It installs in the
background, a quiet thread the page never sees.
09:17 Cached seven files. HTML. CSS. The font.
The entire library fits in 47 kilobytes.
09:18 Files cached: ./zines/V3SS3L/001.html, ./assets/css/reset.css, ./assets/css/typography.css, ./assets/css/a11y.css, ./assets/css/theme.css, ./assets/css/settings.css, ./assets/fonts/Terminus.woff2
09:21 Opened DevTools. Network tab. Checked "Offline."
Held my breath.
09:21 Refreshed.
09:21 It loaded.
09:23 Pulled the ethernet cable to be sure.
Refreshed again. Still there.
09:24 Sat in the silence for a long time.
The web told us we need the wire. The web was wrong. A ghost
sits between the browser and the void now. It remembers what
it has seen. It does not need to be told twice.
If I cannot read it offline, I do not truly possess it.
Now I possess it.
-- z3r0
------------------------------------------------------------------------
z3r0’s cache listing overflows the 72-character convention — if the <pre> block clips or scrollbar-wraps that line, we’ve found the edge of our layout assumptions.
We have built a reader that respects the user’s eyes (themes), the user’s device (responsiveness), and the user’s needs (settings—though those settings vanish the moment the tab closes). But we are failing the user in a more fundamental way: we demand a constant connection.
If the train goes into a tunnel, the Void Reader vanishes. That is unacceptable for a tool meant to be a library.
To fix this, we need to introduce a Service Worker—a script that the browser runs in the background, separate from the web page. It intercepts network requests and lets us decide how to fulfill them. We are going to tell the browser: “When you ask for a file, ask me first.”
This is also the moment we cross a threshold. Every line of this project so far has been HTML and CSS. No runtime logic. No scripting. We resisted the Siren’s Call in the last post and proved that the platform could carry us further than most developers assume. But the platform’s declarative tools cannot intercept a network request. Offline capability requires a script.
The JavaScript we introduce here is not the JavaScript we refused. It arrives as infrastructure, not as interface. The Service Worker runs in a separate thread; it has no access to the DOM, no onClick handlers, no UI logic. It’s plumbing, not paint. The constraint hasn’t been abandoned—it’s been narrowed. We now know exactly where CSS ends and scripting begins, because we walked all the way to the edge before stepping over.
A Critical Warning: The Localhost Trap
Before we proceed: Service Workers do not work on the file:// protocol. If you have been double-clicking your .html file to open it, this step will fail. Browser security policies block Service Workers on local files.
To use a Service Worker, you must serve your files over HTTP. Run a local server from your project root:
python3 -m http.server
The Strategy: Cache First
We will implement a Cache-First strategy:
- Install: When the app first loads, download the core assets (HTML, CSS, fonts) and store them in a local Cache.
- Fetch: When the browser requests a file, the Service Worker checks the cache first.
- If it’s there, serve it immediately. Offline support. Instant speed.
- If it’s not there, go to the network and fetch it.
This is different from Network-First (always try the network, fall back to cache) or Stale-While-Revalidate (serve cached content and update it in the background). Cache-First is the simplest strategy: check the box, hand it over.
Step 1: The Manifest
Before we can be a proper PWA, we need a manifest.json. This file tells the browser about our app—its name, its icons, its theme colors.
touch manifest.json
{
"name": "Void Reader",
"short_name": "Void Reader",
"start_url": "./zines/V3SS3L/001.html",
"display": "standalone",
"background_color": "#121212",
"theme_color": "#121212",
"icons": [
{
"src": "assets/images/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "assets/images/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
Note: You’ll need to create or download two placeholder images (192x192 and 512x512) and place them in assets/images/. For now, a black square will suffice.
Link the manifest in zines/V3SS3L/001.html inside the <head>:
<link rel="manifest" href="../../manifest.json" />
Step 2: The Ghost Script
The service worker file lives at the project root. This placement is critical—we’ll explain why in a moment.
touch sw.js
Open sw.js. We define the cache name and the list of assets to pre-cache:
const CACHE_NAME = "void-reader-v1";
const ASSETS_TO_CACHE = [
"./zines/V3SS3L/001.html",
"./assets/css/reset.css",
"./assets/css/typography.css",
"./assets/css/a11y.css",
"./assets/css/theme.css",
"./assets/css/settings.css",
"./assets/fonts/Terminus.woff2",
];
Note: paths inside the Service Worker resolve relative to the SW file’s location (the project root), not the HTML page that registered it. The ./assets/css/reset.css above means “reset.css relative to where sw.js lives”—which is the root.
Now the three lifecycle events:
// 1. INSTALL: Cache the core assets
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
console.log("[Service Worker] Caching app shell and content");
return cache.addAll(ASSETS_TO_CACHE);
}),
);
});
// 2. ACTIVATE: Clean up old caches
self.addEventListener("activate", (event) => {
event.waitUntil(
caches
.keys()
.then((keyList) => {
return Promise.all(
keyList.map((key) => {
if (key !== CACHE_NAME) {
console.log(
"[Service Worker] Removing old cache",
key,
);
return caches.delete(key);
}
}),
);
})
.then(() => self.clients.claim()),
);
});
// 3. FETCH: Intercept network requests (Cache-First)
self.addEventListener("fetch", (event) => {
event.respondWith(
caches.match(event.request).then((response) => {
// Return the cached response if found
if (response) {
return response;
}
// Otherwise, go to the network
return fetch(event.request);
}),
);
});
The logic: On install, open a cache named void-reader-v1 and store our assets. On activate, clean up any old caches from previous versions. On fetch, check the cache first—if the file is there, serve it without touching the network. If it’s not cached, go to the network.
One known limitation: if both cache and network fail (an uncached resource requested while offline), the user sees a raw browser error. We could add a .catch() returning a simple offline message, but for now we accept this—all our pre-cached assets will always cache-hit. We’ll revisit error handling when we have more complex network needs.
Step 3: The Registration
The Service Worker is a separate thread. We need to tell our HTML page to wake it up.
Add this script at the very bottom of the <body> in zines/V3SS3L/001.html:
<script>
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker
.register("../../sw.js")
.then((reg) => console.log("Service Worker registered.", reg))
.catch((err) =>
console.log("Service Worker registration failed.", err),
);
});
}
</script>
Scope: Why the Ghost Lives at the Root
The Service Worker’s scope is the set of pages it can control. By default, scope is the directory containing the SW file. Since sw.js lives at the project root, its scope is /, covering every page in the project.
This is why the SW must live at the root. If you moved sw.js into assets/js/ for organizational neatness, its scope would become /assets/js/—and it could only intercept requests from pages under that path. Our zine at /zines/V3SS3L/001.html would be outside the scope, invisible to the Ghost.
The registration path (../../sw.js) is relative to the HTML file. It resolves to the project root, where sw.js lives. The browser calculates the scope from the SW file’s location, not from the HTML file that registered it.
The Disconnect Test
- Reload the page. Open DevTools (F12).
- Check the Console: “Service Worker registered.”
- Go to the Application tab. Click Service Workers. Verify it’s running.
- Check Cache Storage. You should see
void-reader-v1containing your files. - The Moment of Truth: Go to the Network tab. Check Offline.
- Refresh the page.
If we did this right, the dinosaur does not appear. The page loads. The font renders. The settings drawer works. The theme toggles function.
The network is dead, but the vessel is alive.
$ git add manifest.json sw.js zines/V3SS3L/001.html
$ git commit -m "Implement Service Worker with Cache-First strategy for offline capability"
$ git tag -a v3ss3l-005 -m "V3SS3L 005: The Ghost — Service Worker, Cache-First strategy, offline capability"