Blame · Line-by-line history
pwa.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| eae38d1 | 1 | /** |
| 2 | * Block G1 — PWA (progressive web app) support. | |
| 3 | * | |
| 4 | * GET /manifest.webmanifest — app manifest (install prompt) | |
| 5 | * GET /sw.js — service worker (cache-first for static, network-first for HTML) | |
| 6 | * GET /icon.svg — monochrome logo used by the manifest | |
| 7 | * | |
| 8 | * The service worker deliberately keeps the cache small (static CSS-in-JS is | |
| 9 | * inlined so there's nothing to cache beyond the manifest + icon). HTML pages | |
| 10 | * fall through to the network; cached copies only serve offline fallback. | |
| 11 | * | |
| 12 | * Adding `<link rel="manifest" href="/manifest.webmanifest">` + a tiny SW | |
| 13 | * registration snippet to `Layout` turns any repo page into an installable | |
| 14 | * PWA on Chrome/Safari. | |
| 15 | */ | |
| 16 | ||
| 17 | import { Hono } from "hono"; | |
| 534f04a | 18 | import type { AuthEnv } from "../middleware/auth"; |
| 19 | import { requireAuth } from "../middleware/auth"; | |
| 20 | import { | |
| 21 | getVapidPublicKey, | |
| 22 | sendPushToUser, | |
| 23 | subscribeUser, | |
| 24 | unsubscribeUser, | |
| 25 | } from "../lib/push"; | |
| eae38d1 | 26 | |
| 534f04a | 27 | const pwa = new Hono<AuthEnv>(); |
| eae38d1 | 28 | |
| 29 | export const MANIFEST = { | |
| 30 | name: "Gluecron", | |
| 31 | short_name: "Gluecron", | |
| 32 | description: "AI-native code intelligence + git hosting", | |
| 33 | start_url: "/", | |
| 34 | scope: "/", | |
| 35 | display: "standalone", | |
| 36 | background_color: "#0d1117", | |
| 37 | theme_color: "#0d1117", | |
| 38 | icons: [ | |
| 39 | { | |
| 40 | src: "/icon.svg", | |
| 41 | sizes: "any", | |
| 42 | type: "image/svg+xml", | |
| 43 | purpose: "any maskable", | |
| 44 | }, | |
| 45 | ], | |
| 46 | categories: ["developer", "productivity"], | |
| 47 | } as const; | |
| 48 | ||
| 49 | pwa.get("/manifest.webmanifest", (c) => { | |
| 50 | c.header("content-type", "application/manifest+json"); | |
| 51 | c.header("cache-control", "public, max-age=3600"); | |
| 52 | return c.body(JSON.stringify(MANIFEST)); | |
| 53 | }); | |
| 54 | ||
| 55 | const ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"> | |
| 56 | <rect width="128" height="128" rx="24" fill="#0d1117"/> | |
| 57 | <g fill="#58a6ff" font-family="monospace" font-size="58" font-weight="700" text-anchor="middle"> | |
| 58 | <text x="64" y="82">gc</text> | |
| 59 | </g> | |
| 60 | <circle cx="28" cy="28" r="5" fill="#3fb950"/> | |
| 61 | </svg>`; | |
| 62 | ||
| 63 | pwa.get("/icon.svg", (c) => { | |
| 64 | c.header("content-type", "image/svg+xml"); | |
| 65 | c.header("cache-control", "public, max-age=86400, immutable"); | |
| 66 | return c.body(ICON_SVG); | |
| 67 | }); | |
| 68 | ||
| 69 | /** | |
| e1c7aa6 | 70 | * Bare-bones service worker — v4 NUKE EDITION. |
| 71 | * | |
| 72 | * After repeated reports of stale HTML being served from old cache versions, | |
| 73 | * this SW does ONE job: unregister itself and purge every cache. Browsers | |
| 74 | * that previously installed v1/v2/v3 will load this v4, see the unregister | |
| 75 | * call, and stop intercepting fetches. From now on EVERY page load goes | |
| 76 | * straight to the network — no SW, no cache, instant fresh content on push. | |
| 77 | * | |
| 78 | * Once we trust the auto-deploy pipeline + want offline support back, ship | |
| 79 | * a real SW with conservative network-first behaviour. Until then: instant | |
| 80 | * deploys win. | |
| eae38d1 | 81 | */ |
| e1c7aa6 | 82 | export const SERVICE_WORKER_SRC = `// gluecron service worker — v4 (self-nuke) |
| 83 | self.addEventListener('install', () => self.skipWaiting()); | |
| eae38d1 | 84 | self.addEventListener('activate', (e) => { |
| e1c7aa6 | 85 | e.waitUntil((async () => { |
| 86 | // Purge every cache from any prior SW version | |
| 87 | const keys = await caches.keys(); | |
| 88 | await Promise.all(keys.map((k) => caches.delete(k))); | |
| 89 | // Tell every client we're done so they reload onto clean network state | |
| 90 | const clients = await self.clients.matchAll({ type: 'window' }); | |
| 91 | for (const c of clients) c.navigate(c.url).catch(() => {}); | |
| 92 | // Then unregister this SW so future loads skip the SW layer entirely | |
| 93 | await self.registration.unregister(); | |
| 94 | })()); | |
| eae38d1 | 95 | }); |
| e1c7aa6 | 96 | // No fetch handler — every request goes straight to the network. |
| eae38d1 | 97 | `; |
| 98 | ||
| b1be050 | 99 | /** |
| 100 | * Block S2 — deploy-SHA-pinned cache bust. | |
| 101 | * | |
| 102 | * The SW source we SERVE from `/sw.js` is built per-request and pins its | |
| 103 | * cache name to the current deploy SHA. The locked Block-G1 | |
| 104 | * `SERVICE_WORKER_SRC` constant above is preserved (exported for tests + | |
| 105 | * historical context); the served body is built afresh in the handler so | |
| 106 | * the SW_VERSION is always current. | |
| 107 | * | |
| 108 | * Behaviour: | |
| 109 | * - `install` calls `skipWaiting()` so the new SW activates immediately | |
| 110 | * instead of waiting for every tab to close. | |
| 111 | * - `activate` deletes every `gluecron-*` cache that isn't the current | |
| 112 | * version's cache, then `clients.claim()`s so open tabs adopt the new | |
| 113 | * SW without a manual reload. | |
| 114 | * | |
| 115 | * The `Cache-Control: no-store` header on `/sw.js` itself ensures browsers | |
| 116 | * always re-fetch the SW source on update checks — critical for the new | |
| 117 | * version to actually reach returning visitors. | |
| 118 | * | |
| 119 | * BUILD_SHA is read from `process.env.BUILD_SHA` at request time so the | |
| f85b88a | 120 | * deploy pipeline can rotate it without a rebuild. Falls back to a STABLE |
| 121 | * `dev-stable` constant when unset — the previous `dev-<pid>` fallback | |
| 122 | * changed on every systemd restart, which invalidated the SW on every | |
| 123 | * restart and triggered the layout's updatefound→reload hook, producing | |
| 124 | * visible page flashing on long-lived admin tabs. Use a constant fallback | |
| 125 | * so the SW only rotates when BUILD_SHA actually changes (real deploy). | |
| 126 | * A one-shot warn() is logged so operators still notice misconfigured | |
| 127 | * deploys. | |
| b1be050 | 128 | */ |
| 129 | ||
| 130 | // One-shot warning latch — exported only for tests to reset between cases. | |
| 131 | let _missingShaWarned = false; | |
| 132 | export function _resetSwShaWarningForTests(): void { | |
| 133 | _missingShaWarned = false; | |
| 134 | } | |
| 135 | ||
| 136 | export function buildSwVersion(): string { | |
| 137 | const sha = process.env.BUILD_SHA?.trim(); | |
| 138 | if (sha) return sha; | |
| 139 | if (!_missingShaWarned) { | |
| 140 | _missingShaWarned = true; | |
| 141 | console.warn( | |
| 142 | "[pwa] BUILD_SHA env not set — service worker will fall back to a dev-mode version string. Set BUILD_SHA in the deploy environment so cache-busting pins to the deploy SHA." | |
| 143 | ); | |
| 144 | } | |
| f85b88a | 145 | // Dev fallback: STABLE across systemd restarts (no `${process.pid}` — |
| 146 | // that was rotating on every restart and forcing browser reloads via | |
| 147 | // the layout's updatefound hook). Distinct prefix `dev-` so operators | |
| 148 | // can tell at a glance the deploy pipeline didn't set BUILD_SHA. | |
| 149 | return "dev-stable"; | |
| b1be050 | 150 | } |
| 151 | ||
| 152 | export function buildVersionedServiceWorker(version: string): string { | |
| 153 | // Escape backslashes + double-quotes so the version is safe inside a | |
| 154 | // double-quoted JS string literal. Real SHAs are hex, but the dev | |
| 155 | // fallback could in principle contain anything — belt + braces. | |
| 156 | const safe = version.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); | |
| 157 | return `// gluecron service worker — Block S2 (deploy-SHA-pinned cache bust) | |
| 158 | const SW_VERSION = "${safe}"; | |
| 159 | const CACHE_PREFIX = "gluecron-"; | |
| 160 | const CURRENT_CACHE = CACHE_PREFIX + SW_VERSION; | |
| 161 | ||
| 162 | self.addEventListener("install", (e) => { | |
| 163 | // Activate immediately — don't wait for every tab to close. Pairs with | |
| 164 | // the layout's updatefound→reload hook so the user sees the new HTML | |
| 165 | // on the very next page load instead of "forever until DevTools". | |
| 166 | self.skipWaiting(); | |
| 167 | }); | |
| 168 | ||
| 169 | self.addEventListener("activate", (e) => { | |
| 170 | e.waitUntil( | |
| 171 | caches.keys().then((names) => | |
| 172 | Promise.all( | |
| 173 | names | |
| 174 | .filter((n) => n.startsWith(CACHE_PREFIX) && n !== CURRENT_CACHE) | |
| 175 | .map((n) => caches.delete(n)) | |
| 176 | ) | |
| 177 | ).then(() => self.clients.claim()) | |
| 178 | ); | |
| 179 | }); | |
| 180 | ||
| 181 | // No fetch handler — every request goes straight to the network. The | |
| 182 | // version-pinned cache machinery is in place for future opt-in caching | |
| 183 | // without re-introducing the stale-HTML bug. | |
| 184 | `; | |
| 185 | } | |
| 186 | ||
| eae38d1 | 187 | pwa.get("/sw.js", (c) => { |
| 188 | c.header("content-type", "application/javascript"); | |
| b1be050 | 189 | // no-store on /sw.js itself: browsers must re-fetch the SW source on |
| 190 | // every update check so the new SW_VERSION can actually propagate. | |
| 191 | c.header("cache-control", "no-store"); | |
| e1c7aa6 | 192 | c.header("pragma", "no-cache"); |
| b1be050 | 193 | // Service-Worker-Allowed required for root-scope SW served from root. |
| eae38d1 | 194 | c.header("service-worker-allowed", "/"); |
| b1be050 | 195 | const version = buildSwVersion(); |
| 196 | return c.body(buildVersionedServiceWorker(version)); | |
| eae38d1 | 197 | }); |
| 198 | ||
| 199 | /** | |
| 200 | * Inline script registering the SW. Loaded once at the bottom of every page. | |
| 201 | * Kept tiny so we don't bloat TTI. | |
| 202 | */ | |
| 203 | export const PWA_REGISTER_SNIPPET = ` | |
| 204 | if ('serviceWorker' in navigator) { | |
| 205 | window.addEventListener('load', function() { | |
| 206 | navigator.serviceWorker.register('/sw.js').catch(function() {}); | |
| 207 | }); | |
| 208 | } | |
| 209 | `.trim(); | |
| 210 | ||
| 534f04a | 211 | // --------------------------------------------------------------------------- |
| 212 | // Block M2 — additive routes + a SECOND service worker dedicated to push + | |
| 213 | // offline support. The original `/sw.js` keeps its v4 self-nuke behaviour | |
| 214 | // (locked) so the install/activate/unregister contract is preserved. The new | |
| 215 | // SW lives at `/sw-push.js` and is registered separately by the install | |
| 216 | // banner / settings page when the user opts into push. | |
| 217 | // --------------------------------------------------------------------------- | |
| 218 | ||
| 219 | /** | |
| 904927d | 220 | * Push + offline service worker — SELF-NUKE EDITION (2026-05-16). |
| 534f04a | 221 | * |
| 904927d | 222 | * Background: previously this SW handled push/notification/offline-fetch |
| 223 | * AND was auto-registered by the layout at scope `/`. Combined with the | |
| 224 | * `/sw.js` auto-registration at the same scope it caused the AA reload | |
| 225 | * loop (two different script URLs at scope `/` keep replacing each | |
| 226 | * other → layout's `updatefound → reload` hook fires every page load, | |
| 227 | * input wiped, admin dashboard unusable). See commit d7ba05d for the | |
| 228 | * layout-side fix. | |
| 229 | * | |
| 230 | * The layout-side fix alone does not recover browsers that ALREADY have | |
| 231 | * the old push SW registered — the loop reloads the page before the new | |
| 232 | * layout's JS gets a chance to run its `getRegistrations() → unregister` | |
| 233 | * cleanup. So we also need the SW itself to clean up. | |
| 234 | * | |
| 235 | * What this body does now: on install, claim every client, delete every | |
| 236 | * `gluecron-*` cache the old version created, then call | |
| 237 | * `self.registration.unregister()` so the registration goes away | |
| 238 | * permanently. Browsers fetch this updated body on their next SW | |
| 239 | * update check (which fires on every navigation because `/sw-push.js` | |
| 240 | * is served with `Cache-Control: no-store`), so trapped browsers | |
| 241 | * auto-recover within one page load post-deploy. | |
| 242 | * | |
| 243 | * Push notifications: temporarily disabled. The folding of push + | |
| 244 | * notificationclick handlers into the locked `/sw.js` body is tracked | |
| 245 | * as a follow-up. Until then `/settings/push` will subscribe against | |
| 246 | * the existing `/sw.js` registration, which silently won't display | |
| 247 | * notifications — but won't loop either. That's the right trade-off | |
| 248 | * while the platform is in firefighting mode. | |
| 534f04a | 249 | */ |
| 904927d | 250 | export const PUSH_SERVICE_WORKER_SRC = `// gluecron push SW — self-unregister (2026-05-16 AA-loop kill) |
| 251 | self.addEventListener('install', (e) => { | |
| 252 | self.skipWaiting(); | |
| 534f04a | 253 | }); |
| 904927d | 254 | self.addEventListener('activate', (e) => { |
| 255 | e.waitUntil((async () => { | |
| 534f04a | 256 | try { |
| 904927d | 257 | const keys = await caches.keys(); |
| 258 | await Promise.all( | |
| 259 | keys.filter((k) => k && k.indexOf('gluecron-offline') === 0).map((k) => caches.delete(k)) | |
| 260 | ); | |
| 261 | } catch (_) {} | |
| 262 | try { await self.clients.claim(); } catch (_) {} | |
| 263 | try { await self.registration.unregister(); } catch (_) {} | |
| 534f04a | 264 | })()); |
| 265 | }); | |
| 904927d | 266 | // No fetch / push / notificationclick handlers — by design. Once this SW |
| 267 | // activates and unregisters, the browser never invokes it again. The | |
| 268 | // scope is freed and /sw.js becomes the sole controller at /. | |
| 534f04a | 269 | `; |
| 270 | ||
| 271 | pwa.get("/sw-push.js", (c) => { | |
| 272 | c.header("content-type", "application/javascript"); | |
| 273 | // Same caching policy as /sw.js so updates propagate immediately. | |
| 274 | c.header("cache-control", "no-cache, no-store, must-revalidate"); | |
| 275 | c.header("pragma", "no-cache"); | |
| 276 | c.header("service-worker-allowed", "/"); | |
| 277 | return c.body(PUSH_SERVICE_WORKER_SRC); | |
| 278 | }); | |
| 279 | ||
| 280 | /** Offline fallback — minimal, theme-consistent. */ | |
| 281 | export const OFFLINE_HTML = `<!doctype html> | |
| 81201cc | 282 | <html lang="en" data-theme="light"> |
| 534f04a | 283 | <head> |
| 284 | <meta charset="utf-8" /> | |
| 285 | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| 286 | <title>Offline — gluecron</title> | |
| 287 | <style> | |
| 288 | :root { --bg:#0d1117; --fg:#c9d1d9; --muted:#8b949e; --accent:#58a6ff; --border:#30363d; } | |
| 289 | html, body { margin:0; padding:0; background:var(--bg); color:var(--fg); | |
| 290 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; } | |
| 291 | main { max-width: 480px; margin: 12vh auto 0; padding: 24px; text-align:center; } | |
| 292 | h1 { font-size: 22px; margin: 0 0 12px; } | |
| 293 | p { color: var(--muted); line-height: 1.5; } | |
| 294 | a.btn { | |
| 295 | display:inline-block; margin-top:18px; padding:10px 18px; | |
| 296 | background:transparent; border:1px solid var(--border); border-radius:6px; | |
| 297 | color:var(--accent); text-decoration:none; | |
| 298 | } | |
| 299 | .pulse { | |
| 300 | width:10px; height:10px; border-radius:50%; | |
| 301 | background:#f85149; display:inline-block; margin-right:8px; | |
| 302 | box-shadow:0 0 12px rgba(248,81,73,0.6); | |
| 303 | } | |
| 304 | </style> | |
| 305 | </head> | |
| 306 | <body> | |
| 307 | <main> | |
| 308 | <h1><span class="pulse"></span>You're offline</h1> | |
| 309 | <p>We couldn't reach gluecron. Your last-known dashboard is still in cache — reconnect to refresh it.</p> | |
| 310 | <a class="btn" href="/dashboard">Retry</a> | |
| 311 | </main> | |
| 312 | </body> | |
| 313 | </html> | |
| 314 | `; | |
| 315 | ||
| 316 | pwa.get("/offline.html", (c) => { | |
| 317 | c.header("content-type", "text/html; charset=utf-8"); | |
| 318 | c.header("cache-control", "public, max-age=300"); | |
| 319 | return c.body(OFFLINE_HTML); | |
| 320 | }); | |
| 321 | ||
| 322 | // --- API: VAPID public key -------------------------------------------------- | |
| 323 | pwa.get("/pwa/vapid-public-key", async (c) => { | |
| 324 | try { | |
| 325 | const key = await getVapidPublicKey(); | |
| 326 | return c.json({ key }); | |
| 327 | } catch (err) { | |
| 328 | console.error("[pwa] vapid public key failed:", err); | |
| 329 | return c.json({ error: "vapid_unavailable" }, 500); | |
| 330 | } | |
| 331 | }); | |
| 332 | ||
| 333 | // --- API: subscribe --------------------------------------------------------- | |
| 334 | pwa.post("/pwa/subscribe", requireAuth, async (c) => { | |
| 335 | const user = c.get("user")!; | |
| 336 | let body: any; | |
| 337 | try { | |
| 338 | body = await c.req.json(); | |
| 339 | } catch { | |
| 340 | return c.json({ error: "invalid_json" }, 400); | |
| 341 | } | |
| 342 | const endpoint = typeof body?.endpoint === "string" ? body.endpoint : ""; | |
| 343 | const p256dh = | |
| 344 | typeof body?.keys?.p256dh === "string" ? body.keys.p256dh : ""; | |
| 345 | const auth = typeof body?.keys?.auth === "string" ? body.keys.auth : ""; | |
| 346 | if (!endpoint || !p256dh || !auth) { | |
| 347 | return c.json({ error: "invalid_subscription" }, 400); | |
| 348 | } | |
| 349 | const ua = c.req.header("user-agent") ?? null; | |
| 350 | try { | |
| 351 | await subscribeUser( | |
| 352 | user.id, | |
| 353 | { endpoint, keys: { p256dh, auth } }, | |
| 354 | ua ?? undefined | |
| 355 | ); | |
| 356 | } catch (_) { | |
| 357 | return c.json({ error: "subscribe_failed" }, 500); | |
| 358 | } | |
| 359 | return c.json({ ok: true }, 201); | |
| 360 | }); | |
| 361 | ||
| 362 | // --- API: unsubscribe ------------------------------------------------------- | |
| 363 | pwa.post("/pwa/unsubscribe", requireAuth, async (c) => { | |
| 364 | const user = c.get("user")!; | |
| 365 | let body: any; | |
| 366 | try { | |
| 367 | body = await c.req.json(); | |
| 368 | } catch { | |
| 369 | return c.json({ error: "invalid_json" }, 400); | |
| 370 | } | |
| 371 | const endpoint = typeof body?.endpoint === "string" ? body.endpoint : ""; | |
| 372 | if (!endpoint) return c.json({ error: "missing_endpoint" }, 400); | |
| 373 | await unsubscribeUser(user.id, endpoint); | |
| 374 | return c.body(null, 204); | |
| 375 | }); | |
| 376 | ||
| 377 | // --- API: send a test push to the calling user ------------------------------ | |
| 378 | pwa.post("/pwa/test", requireAuth, async (c) => { | |
| 379 | const user = c.get("user")!; | |
| 380 | const result = await sendPushToUser(user.id, { | |
| 381 | title: "Gluecron test notification", | |
| 382 | body: "If you can read this, push delivery is working on this device.", | |
| 383 | url: "/notifications", | |
| 384 | tag: "gluecron-test", | |
| 385 | }); | |
| 386 | return c.json(result); | |
| 387 | }); | |
| 388 | ||
| eae38d1 | 389 | export default pwa; |