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); + }); });