diff --git a/.gitignore b/.gitignore
index ce90d73..dd9c856 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,6 +6,7 @@ node_modules
public/viz/dist.js
public/editor/dist.js
public/ui.js
+public/sw.js
public/dist.css
public/htmx.min.js
public/katex.css
diff --git a/knip.config.ts b/knip.config.ts
index 2117d7a..6a8fe60 100644
--- a/knip.config.ts
+++ b/knip.config.ts
@@ -6,6 +6,7 @@ const config: KnipConfig = {
"public/editor/editor.ts",
"public/viz/viz-hydrate.ts",
"public/ui.ts",
+ "public/sw.ts",
"deploy/gen-oauth-jwk.ts",
// Tailwind's CLI input — reached from build:css, never imported from TS.
"public/style.css",
diff --git a/package.json b/package.json
index 0771b2d..9d26c3e 100644
--- a/package.json
+++ b/package.json
@@ -5,13 +5,14 @@
"private": true,
"scripts": {
"dev": "bun --watch src/server/index.ts & bun --watch src/firehose/index.ts & bun run dev:assets",
- "dev:assets": "bun run build:css --watch & bun run build:editor --watch & bun run build:viz --watch & bun run build:ui --watch",
- "build": "bun run build:css && bun run build:editor && bun run build:viz && bun run build:ui && bun run build:htmx",
+ "dev:assets": "bun run build:css --watch & bun run build:editor --watch & bun run build:viz --watch & bun run build:ui --watch & bun run build:sw --watch",
+ "build": "bun run build:css && bun run build:editor && bun run build:viz && bun run build:ui && bun run build:htmx && bun run build:sw",
"build:css": "mkdir -p public/fonts && cp node_modules/katex/dist/fonts/* public/fonts/ && cp node_modules/katex/dist/katex.min.css public/katex.css && bunx @tailwindcss/cli -i public/style.css -o public/dist.css",
"build:editor": "bun build public/editor/editor.ts --minify --outfile public/editor/dist.js",
"build:viz": "bun build public/viz/viz-hydrate.ts --minify --outfile public/viz/dist.js",
"build:ui": "bun build public/ui.ts --minify --outfile public/ui.js",
"build:htmx": "cp node_modules/htmx.org/dist/htmx.min.js public/htmx.min.js",
+ "build:sw": "bun build public/sw.ts --minify --outfile public/sw.js",
"build:icons": "bun run scripts/gen-icons.ts",
"backfill": "bun run scripts/backfill.ts",
"moderate": "bun run scripts/moderate.ts",
@@ -20,7 +21,7 @@
"lint": "biome check src/ public/ tests/",
"lint:fix": "biome check --write src/ public/ tests/",
"format": "biome format --write src/ public/ tests/",
- "typecheck": "tsc --noEmit && tsc --noEmit -p public/tsconfig.json",
+ "typecheck": "tsc --noEmit && tsc --noEmit -p public/tsconfig.json && tsc --noEmit -p public/sw.tsconfig.json",
"knip": "knip"
},
"devDependencies": {
diff --git a/public/offline.html b/public/offline.html
new file mode 100644
index 0000000..d2c2f59
--- /dev/null
+++ b/public/offline.html
@@ -0,0 +1,103 @@
+
+
+
+
+
+Offline — Lichen
+
+
+
+
+
+
+
+
+
You're offline
+
Lichen needs a connection to load pages. Check your network and try again.
+
+
+
Tabs you already have open will keep working.
+
+
+
diff --git a/public/sw.ts b/public/sw.ts
new file mode 100644
index 0000000..e8541f2
--- /dev/null
+++ b/public/sw.ts
@@ -0,0 +1,109 @@
+///
+
+// Service worker: static-asset cache + branded offline fallback.
+//
+// Source of truth for public/sw.js, produced by `bun run build:sw`. Served from
+// the origin root (/sw.js, see src/server/routes/pwa.ts) because a worker's scope
+// is capped at the path it is served from — under /public/ it could only control
+// /public/*.
+//
+// Deliberately conservative: it never caches HTML. Lichen renders private wikis
+// and per-session UI server-side, so a cached page could leak a private wiki onto
+// a shared device or show a logged-out view to a signed-in user. Offline reading
+// is Phase 3 and needs the access-control work described in TODO-PWA.md.
+
+const sw = self as unknown as ServiceWorkerGlobalScope;
+
+// Bump to evict every entry from previous versions on activate.
+const CACHE = "lichen-v1";
+
+const OFFLINE_URL = "/public/offline.html";
+
+// Auth, session and dynamic endpoints. Navigations to these are still allowed to
+// fall back to the offline page; this list keeps them out of any cache lookup.
+const BYPASS =
+ /^\/(login|logout|atproto-oauth-callback|client-metadata\.json|jwks\.json|search|set-locale|set-theme|profile-redirect|dev\/login|api|blob|og|collab)(\/|$)/;
+
+sw.addEventListener("install", (event) => {
+ event.waitUntil(
+ caches
+ .open(CACHE)
+ .then((cache) => cache.add(OFFLINE_URL))
+ .then(() => sw.skipWaiting()),
+ );
+});
+
+sw.addEventListener("activate", (event) => {
+ event.waitUntil(
+ caches
+ .keys()
+ .then((keys) =>
+ Promise.all(
+ keys.filter((key) => key !== CACHE).map((key) => caches.delete(key)),
+ ),
+ )
+ .then(() => sw.clients.claim()),
+ );
+});
+
+// Assets carry a ?v= that changes every deploy, so entries are keyed by the
+// full URL rather than matched with ignoreSearch. A new hash simply misses and
+// refetches; matching loosely would serve last deploy's CSS against this deploy's
+// markup, which is the exact bug the hash exists to prevent.
+async function staleWhileRevalidate(
+ event: FetchEvent,
+ request: Request,
+): Promise {
+ const cache = await caches.open(CACHE);
+ const cached = await cache.match(request);
+
+ const network = fetch(request)
+ .then(async (response) => {
+ if (response.ok) await cache.put(request, response.clone());
+ return response;
+ })
+ .catch(() => cached);
+
+ // When the cached copy answers first the revalidation is still in flight, so it
+ // has to be held open explicitly — otherwise the worker can be terminated
+ // before cache.put lands and the entry never refreshes.
+ if (cached) {
+ event.waitUntil(network);
+ return cached;
+ }
+ return (await network) ?? Response.error();
+}
+
+async function networkFirst(request: Request): Promise {
+ try {
+ return await fetch(request);
+ } catch {
+ const cache = await caches.open(CACHE);
+ const offline = await cache.match(OFFLINE_URL);
+ // The offline page is precached on install, so a miss means the cache was
+ // evicted; a plain error beats hanging the navigation.
+ return offline ?? Response.error();
+ }
+}
+
+sw.addEventListener("fetch", (event) => {
+ const request = event.request;
+
+ if (request.method !== "GET") return;
+ // HTMX fragments are partials, not documents — answering one with the offline
+ // page would splice a full HTML page into the middle of the UI.
+ if (request.headers.get("HX-Request") === "true") return;
+
+ const url = new URL(request.url);
+ if (url.origin !== sw.location.origin) return;
+ if (BYPASS.test(url.pathname)) return;
+
+ if (url.pathname.startsWith("/public/")) {
+ event.respondWith(staleWhileRevalidate(event, request));
+ return;
+ }
+
+ if (request.mode === "navigate") {
+ event.respondWith(networkFirst(request));
+ }
+});
diff --git a/public/sw.tsconfig.json b/public/sw.tsconfig.json
new file mode 100644
index 0000000..1cad6a3
--- /dev/null
+++ b/public/sw.tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "../tsconfig.json",
+ "compilerOptions": {
+ "lib": ["ESNext", "WebWorker"]
+ },
+ "include": ["./sw.ts"],
+ "exclude": []
+}
diff --git a/public/tsconfig.json b/public/tsconfig.json
index 3db5b7b..b1ac07d 100644
--- a/public/tsconfig.json
+++ b/public/tsconfig.json
@@ -4,5 +4,7 @@
"lib": ["ESNext", "DOM", "DOM.Iterable"]
},
"include": ["./**/*.ts", "../src/types.d.ts"],
- "exclude": []
+ // The service worker runs off the DOM lib's globals and needs WebWorker
+ // instead — the two redeclare `self`, so it gets its own config.
+ "exclude": ["./sw.ts"]
}
diff --git a/public/ui.ts b/public/ui.ts
index 479bf30..7748e3f 100644
--- a/public/ui.ts
+++ b/public/ui.ts
@@ -385,6 +385,14 @@
}
});
+ // Service worker: static-asset cache + offline fallback. Registration failure
+ // is non-fatal — the site is fully server-rendered and works without it.
+ if ("serviceWorker" in navigator) {
+ window.addEventListener("load", () => {
+ navigator.serviceWorker.register("/sw.js").catch(() => {});
+ });
+ }
+
// HTMX response-error toast.
if (document.body) {
document.body.addEventListener("htmx:responseError", () => {
diff --git a/src/server/routes/pwa.ts b/src/server/routes/pwa.ts
index 30e288b..bf19d63 100644
--- a/src/server/routes/pwa.ts
+++ b/src/server/routes/pwa.ts
@@ -1,3 +1,5 @@
+import { existsSync } from "node:fs";
+import { join } from "node:path";
import { Elysia } from "elysia";
import { assetUrl } from "../../lib/assets.ts";
import { LIMITS } from "../../lib/limits.ts";
@@ -51,13 +53,34 @@ const MANIFEST = {
const MANIFEST_BODY = JSON.stringify(MANIFEST);
-export const pwaRoutes = new Elysia().get(
- "/manifest.webmanifest",
- () =>
- new Response(MANIFEST_BODY, {
+// Built by `bun run build:sw`. Served from the root, not /public, so the worker's
+// scope covers the whole origin — a worker only controls the path it is served
+// from and below.
+const SW_PATH = join(import.meta.dir, "..", "..", "..", "public", "sw.js");
+
+export const pwaRoutes = new Elysia()
+ .get(
+ "/manifest.webmanifest",
+ () =>
+ new Response(MANIFEST_BODY, {
+ headers: {
+ "Content-Type": "application/manifest+json; charset=utf-8",
+ "Cache-Control": `public, max-age=${LIMITS.cacheTtl.manifest}`,
+ },
+ }),
+ )
+ .get("/sw.js", () => {
+ // Absent before the first build (and in tests, which don't run one).
+ if (!existsSync(SW_PATH)) {
+ return new Response("Not found", { status: 404 });
+ }
+ return new Response(Bun.file(SW_PATH), {
headers: {
- "Content-Type": "application/manifest+json; charset=utf-8",
- "Cache-Control": `public, max-age=${LIMITS.cacheTtl.manifest}`,
+ "Content-Type": "text/javascript; charset=utf-8",
+ // Caddy's catch-all replaces this with max-age=2 in production; it
+ // still matters for dev, staging and any non-Caddy deployment. A
+ // long-lived worker script is the classic PWA footgun.
+ "Cache-Control": "no-cache",
},
- }),
-);
+ });
+ });
diff --git a/tests/server/routes/pwa.test.ts b/tests/server/routes/pwa.test.ts
index 5d303e5..20a3b5d 100644
--- a/tests/server/routes/pwa.test.ts
+++ b/tests/server/routes/pwa.test.ts
@@ -1,4 +1,4 @@
-import { describe, expect, test } from "bun:test";
+import { beforeAll, describe, expect, test } from "bun:test";
import { existsSync } from "node:fs";
import { join } from "node:path";
import { createTestApp } from "./helpers.ts";
@@ -6,6 +6,18 @@ import { createTestApp } from "./helpers.ts";
const app = createTestApp();
const PUBLIC_DIR = join(import.meta.dir, "..", "..", "..", "public");
+const SW_SOURCE = join(PUBLIC_DIR, "sw.ts");
+
+// The /sw.js route serves build output, and the suite runs without `bun run
+// build` — so build it here rather than let the assertions depend on whether
+// someone happened to build first.
+beforeAll(async () => {
+ await Bun.build({
+ entrypoints: [SW_SOURCE],
+ outdir: PUBLIC_DIR,
+ minify: true,
+ });
+});
interface ManifestIcon {
src: string;
@@ -65,4 +77,35 @@ describe("pwa route", () => {
expect(existsSync(join(PUBLIC_DIR, path ?? ""))).toBe(true);
}
});
+
+ // Served from the root on purpose: a worker's scope cannot rise above the path
+ // it is served from, so at /public/sw.js it could only control /public/*.
+ test("GET /sw.js serves the worker from the origin root", async () => {
+ const res = await app.handle(new Request("http://localhost/sw.js"));
+
+ expect(res.status).toBe(200);
+ expect(res.headers.get("Content-Type")).toContain("text/javascript");
+ });
+
+ // A worker script pinned in a browser cache is the classic PWA footgun: the
+ // old worker keeps serving until the header expires.
+ test("/sw.js is not cached by the browser", async () => {
+ const res = await app.handle(new Request("http://localhost/sw.js"));
+
+ expect(res.headers.get("Cache-Control")).toContain("no-cache");
+ });
+
+ // cache.add rejects on a 404, which fails the whole install step — so a wrong
+ // OFFLINE_URL doesn't degrade the fallback, it stops the worker activating at
+ // all. Read the constant from source so the two can't drift apart.
+ test("the offline page the worker precaches exists at that URL", async () => {
+ const source = await Bun.file(SW_SOURCE).text();
+ const offlineUrl = source.match(/OFFLINE_URL\s*=\s*"([^"]+)"/)?.[1];
+
+ expect(offlineUrl).toBeDefined();
+ expect(offlineUrl?.startsWith("/public/")).toBe(true);
+
+ const path = (offlineUrl ?? "").slice("/public/".length);
+ expect(existsSync(join(PUBLIC_DIR, path))).toBe(true);
+ });
});