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 | |
| 120 | * deploy pipeline can rotate it without a rebuild. Falls back to a stable | |
| 121 | * per-process `dev-<pid>` string when unset; a one-shot warn() is logged | |
| 122 | * so operators notice misconfigured deploys. | |
| 123 | */ | |
| 124 | ||
| 125 | // One-shot warning latch — exported only for tests to reset between cases. | |
| 126 | let _missingShaWarned = false; | |
| 127 | export function _resetSwShaWarningForTests(): void { | |
| 128 | _missingShaWarned = false; | |
| 129 | } | |
| 130 | ||
| 131 | export function buildSwVersion(): string { | |
| 132 | const sha = process.env.BUILD_SHA?.trim(); | |
| 133 | if (sha) return sha; | |
| 134 | if (!_missingShaWarned) { | |
| 135 | _missingShaWarned = true; | |
| 136 | console.warn( | |
| 137 | "[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." | |
| 138 | ); | |
| 139 | } | |
| 140 | // Dev fallback: stable per-process so reloads don't churn the cache | |
| 141 | // while developing locally, but distinct from any real SHA. | |
| 142 | return `dev-${process.pid}`; | |
| 143 | } | |
| 144 | ||
| 145 | export function buildVersionedServiceWorker(version: string): string { | |
| 146 | // Escape backslashes + double-quotes so the version is safe inside a | |
| 147 | // double-quoted JS string literal. Real SHAs are hex, but the dev | |
| 148 | // fallback could in principle contain anything — belt + braces. | |
| 149 | const safe = version.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); | |
| 150 | return `// gluecron service worker — Block S2 (deploy-SHA-pinned cache bust) | |
| 151 | const SW_VERSION = "${safe}"; | |
| 152 | const CACHE_PREFIX = "gluecron-"; | |
| 153 | const CURRENT_CACHE = CACHE_PREFIX + SW_VERSION; | |
| 154 | ||
| 155 | self.addEventListener("install", (e) => { | |
| 156 | // Activate immediately — don't wait for every tab to close. Pairs with | |
| 157 | // the layout's updatefound→reload hook so the user sees the new HTML | |
| 158 | // on the very next page load instead of "forever until DevTools". | |
| 159 | self.skipWaiting(); | |
| 160 | }); | |
| 161 | ||
| 162 | self.addEventListener("activate", (e) => { | |
| 163 | e.waitUntil( | |
| 164 | caches.keys().then((names) => | |
| 165 | Promise.all( | |
| 166 | names | |
| 167 | .filter((n) => n.startsWith(CACHE_PREFIX) && n !== CURRENT_CACHE) | |
| 168 | .map((n) => caches.delete(n)) | |
| 169 | ) | |
| 170 | ).then(() => self.clients.claim()) | |
| 171 | ); | |
| 172 | }); | |
| 173 | ||
| 174 | // No fetch handler — every request goes straight to the network. The | |
| 175 | // version-pinned cache machinery is in place for future opt-in caching | |
| 176 | // without re-introducing the stale-HTML bug. | |
| 177 | `; | |
| 178 | } | |
| 179 | ||
| eae38d1 | 180 | pwa.get("/sw.js", (c) => { |
| 181 | c.header("content-type", "application/javascript"); | |
| b1be050 | 182 | // no-store on /sw.js itself: browsers must re-fetch the SW source on |
| 183 | // every update check so the new SW_VERSION can actually propagate. | |
| 184 | c.header("cache-control", "no-store"); | |
| e1c7aa6 | 185 | c.header("pragma", "no-cache"); |
| b1be050 | 186 | // Service-Worker-Allowed required for root-scope SW served from root. |
| eae38d1 | 187 | c.header("service-worker-allowed", "/"); |
| b1be050 | 188 | const version = buildSwVersion(); |
| 189 | return c.body(buildVersionedServiceWorker(version)); | |
| eae38d1 | 190 | }); |
| 191 | ||
| 192 | /** | |
| 193 | * Inline script registering the SW. Loaded once at the bottom of every page. | |
| 194 | * Kept tiny so we don't bloat TTI. | |
| 195 | */ | |
| 196 | export const PWA_REGISTER_SNIPPET = ` | |
| 197 | if ('serviceWorker' in navigator) { | |
| 198 | window.addEventListener('load', function() { | |
| 199 | navigator.serviceWorker.register('/sw.js').catch(function() {}); | |
| 200 | }); | |
| 201 | } | |
| 202 | `.trim(); | |
| 203 | ||
| 534f04a | 204 | // --------------------------------------------------------------------------- |
| 205 | // Block M2 — additive routes + a SECOND service worker dedicated to push + | |
| 206 | // offline support. The original `/sw.js` keeps its v4 self-nuke behaviour | |
| 207 | // (locked) so the install/activate/unregister contract is preserved. The new | |
| 208 | // SW lives at `/sw-push.js` and is registered separately by the install | |
| 209 | // banner / settings page when the user opts into push. | |
| 210 | // --------------------------------------------------------------------------- | |
| 211 | ||
| 212 | /** | |
| 213 | * Push + offline service worker. Strictly additive to the v4 self-nuke SW. | |
| 214 | * Handles three things: | |
| 215 | * 1. `push` event → display a notification (title/body/url/tag). | |
| 216 | * 2. `notificationclick` → focus an existing tab on `url` or open a new one. | |
| 217 | * 3. `fetch` event → serve `/offline.html` as the fallback when the | |
| 218 | * network fails on an HTML navigation. Non-HTML fetches passthrough. | |
| 219 | * | |
| 220 | * The cache name is unique so we don't collide with anything `/sw.js` | |
| 221 | * historically touched. | |
| 222 | */ | |
| 223 | export const PUSH_SERVICE_WORKER_SRC = `// gluecron push + offline service worker (Block M2) | |
| 224 | const CACHE = 'gluecron-offline-v1'; | |
| 225 | const OFFLINE_URL = '/offline.html'; | |
| 226 | ||
| 227 | self.addEventListener('install', (event) => { | |
| 228 | event.waitUntil((async () => { | |
| 229 | const cache = await caches.open(CACHE); | |
| 230 | try { await cache.add(new Request(OFFLINE_URL, { cache: 'reload' })); } catch (_) {} | |
| 231 | self.skipWaiting(); | |
| 232 | })()); | |
| 233 | }); | |
| 234 | ||
| 235 | self.addEventListener('activate', (event) => { | |
| 236 | event.waitUntil((async () => { | |
| 237 | // Drop any cache that isn't our current one. | |
| 238 | const keys = await caches.keys(); | |
| 239 | await Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))); | |
| 240 | await self.clients.claim(); | |
| 241 | })()); | |
| 242 | }); | |
| 243 | ||
| 244 | self.addEventListener('push', (event) => { | |
| 245 | let data = { title: 'Gluecron', body: '', url: '/', tag: 'gluecron', icon: '/icon.svg' }; | |
| 246 | if (event.data) { | |
| 247 | try { data = Object.assign(data, event.data.json()); } | |
| 248 | catch (_) { try { data.body = event.data.text(); } catch (_) {} } | |
| 249 | } | |
| 250 | event.waitUntil(self.registration.showNotification(data.title, { | |
| 251 | body: data.body, | |
| 252 | icon: data.icon, | |
| 253 | tag: data.tag, | |
| 254 | data: { url: data.url }, | |
| 255 | })); | |
| 256 | }); | |
| 257 | ||
| 258 | self.addEventListener('notificationclick', (event) => { | |
| 259 | event.notification.close(); | |
| 260 | const target = (event.notification.data && event.notification.data.url) || '/'; | |
| 261 | event.waitUntil((async () => { | |
| 262 | const all = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); | |
| 263 | for (const c of all) { | |
| 264 | try { | |
| 265 | const u = new URL(c.url); | |
| 266 | if (u.pathname === target || c.url === target) { | |
| 267 | await c.focus(); | |
| 268 | return; | |
| 269 | } | |
| 270 | } catch (_) {} | |
| 271 | } | |
| 272 | await self.clients.openWindow(target); | |
| 273 | })()); | |
| 274 | }); | |
| 275 | ||
| 276 | self.addEventListener('fetch', (event) => { | |
| 277 | const req = event.request; | |
| 278 | if (req.method !== 'GET') return; | |
| 279 | const accept = req.headers.get('accept') || ''; | |
| 280 | // Only intervene on top-level HTML navigations. Everything else (CSS, | |
| 281 | // images, API, /api/*, /.git/*, login/logout) passes straight through. | |
| 282 | if (req.mode !== 'navigate' && !accept.includes('text/html')) return; | |
| 283 | event.respondWith((async () => { | |
| 284 | try { | |
| 285 | return await fetch(req); | |
| 286 | } catch (_) { | |
| 287 | const cache = await caches.open(CACHE); | |
| 288 | const cached = await cache.match(OFFLINE_URL); | |
| 289 | if (cached) return cached; | |
| 290 | return new Response('Offline', { status: 503, headers: { 'content-type': 'text/plain' } }); | |
| 291 | } | |
| 292 | })()); | |
| 293 | }); | |
| 294 | `; | |
| 295 | ||
| 296 | pwa.get("/sw-push.js", (c) => { | |
| 297 | c.header("content-type", "application/javascript"); | |
| 298 | // Same caching policy as /sw.js so updates propagate immediately. | |
| 299 | c.header("cache-control", "no-cache, no-store, must-revalidate"); | |
| 300 | c.header("pragma", "no-cache"); | |
| 301 | c.header("service-worker-allowed", "/"); | |
| 302 | return c.body(PUSH_SERVICE_WORKER_SRC); | |
| 303 | }); | |
| 304 | ||
| 305 | /** Offline fallback — minimal, theme-consistent. */ | |
| 306 | export const OFFLINE_HTML = `<!doctype html> | |
| 307 | <html lang="en" data-theme="dark"> | |
| 308 | <head> | |
| 309 | <meta charset="utf-8" /> | |
| 310 | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| 311 | <title>Offline — gluecron</title> | |
| 312 | <style> | |
| 313 | :root { --bg:#0d1117; --fg:#c9d1d9; --muted:#8b949e; --accent:#58a6ff; --border:#30363d; } | |
| 314 | html, body { margin:0; padding:0; background:var(--bg); color:var(--fg); | |
| 315 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; } | |
| 316 | main { max-width: 480px; margin: 12vh auto 0; padding: 24px; text-align:center; } | |
| 317 | h1 { font-size: 22px; margin: 0 0 12px; } | |
| 318 | p { color: var(--muted); line-height: 1.5; } | |
| 319 | a.btn { | |
| 320 | display:inline-block; margin-top:18px; padding:10px 18px; | |
| 321 | background:transparent; border:1px solid var(--border); border-radius:6px; | |
| 322 | color:var(--accent); text-decoration:none; | |
| 323 | } | |
| 324 | .pulse { | |
| 325 | width:10px; height:10px; border-radius:50%; | |
| 326 | background:#f85149; display:inline-block; margin-right:8px; | |
| 327 | box-shadow:0 0 12px rgba(248,81,73,0.6); | |
| 328 | } | |
| 329 | </style> | |
| 330 | </head> | |
| 331 | <body> | |
| 332 | <main> | |
| 333 | <h1><span class="pulse"></span>You're offline</h1> | |
| 334 | <p>We couldn't reach gluecron. Your last-known dashboard is still in cache — reconnect to refresh it.</p> | |
| 335 | <a class="btn" href="/dashboard">Retry</a> | |
| 336 | </main> | |
| 337 | </body> | |
| 338 | </html> | |
| 339 | `; | |
| 340 | ||
| 341 | pwa.get("/offline.html", (c) => { | |
| 342 | c.header("content-type", "text/html; charset=utf-8"); | |
| 343 | c.header("cache-control", "public, max-age=300"); | |
| 344 | return c.body(OFFLINE_HTML); | |
| 345 | }); | |
| 346 | ||
| 347 | // --- API: VAPID public key -------------------------------------------------- | |
| 348 | pwa.get("/pwa/vapid-public-key", async (c) => { | |
| 349 | try { | |
| 350 | const key = await getVapidPublicKey(); | |
| 351 | return c.json({ key }); | |
| 352 | } catch (err) { | |
| 353 | console.error("[pwa] vapid public key failed:", err); | |
| 354 | return c.json({ error: "vapid_unavailable" }, 500); | |
| 355 | } | |
| 356 | }); | |
| 357 | ||
| 358 | // --- API: subscribe --------------------------------------------------------- | |
| 359 | pwa.post("/pwa/subscribe", requireAuth, async (c) => { | |
| 360 | const user = c.get("user")!; | |
| 361 | let body: any; | |
| 362 | try { | |
| 363 | body = await c.req.json(); | |
| 364 | } catch { | |
| 365 | return c.json({ error: "invalid_json" }, 400); | |
| 366 | } | |
| 367 | const endpoint = typeof body?.endpoint === "string" ? body.endpoint : ""; | |
| 368 | const p256dh = | |
| 369 | typeof body?.keys?.p256dh === "string" ? body.keys.p256dh : ""; | |
| 370 | const auth = typeof body?.keys?.auth === "string" ? body.keys.auth : ""; | |
| 371 | if (!endpoint || !p256dh || !auth) { | |
| 372 | return c.json({ error: "invalid_subscription" }, 400); | |
| 373 | } | |
| 374 | const ua = c.req.header("user-agent") ?? null; | |
| 375 | try { | |
| 376 | await subscribeUser( | |
| 377 | user.id, | |
| 378 | { endpoint, keys: { p256dh, auth } }, | |
| 379 | ua ?? undefined | |
| 380 | ); | |
| 381 | } catch (_) { | |
| 382 | return c.json({ error: "subscribe_failed" }, 500); | |
| 383 | } | |
| 384 | return c.json({ ok: true }, 201); | |
| 385 | }); | |
| 386 | ||
| 387 | // --- API: unsubscribe ------------------------------------------------------- | |
| 388 | pwa.post("/pwa/unsubscribe", requireAuth, async (c) => { | |
| 389 | const user = c.get("user")!; | |
| 390 | let body: any; | |
| 391 | try { | |
| 392 | body = await c.req.json(); | |
| 393 | } catch { | |
| 394 | return c.json({ error: "invalid_json" }, 400); | |
| 395 | } | |
| 396 | const endpoint = typeof body?.endpoint === "string" ? body.endpoint : ""; | |
| 397 | if (!endpoint) return c.json({ error: "missing_endpoint" }, 400); | |
| 398 | await unsubscribeUser(user.id, endpoint); | |
| 399 | return c.body(null, 204); | |
| 400 | }); | |
| 401 | ||
| 402 | // --- API: send a test push to the calling user ------------------------------ | |
| 403 | pwa.post("/pwa/test", requireAuth, async (c) => { | |
| 404 | const user = c.get("user")!; | |
| 405 | const result = await sendPushToUser(user.id, { | |
| 406 | title: "Gluecron test notification", | |
| 407 | body: "If you can read this, push delivery is working on this device.", | |
| 408 | url: "/notifications", | |
| 409 | tag: "gluecron-test", | |
| 410 | }); | |
| 411 | return c.json(result); | |
| 412 | }); | |
| 413 | ||
| eae38d1 | 414 | export default pwa; |