CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
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 { | |
| 44fe49b | 153 | // PWA removed 2026-05-16. The body served at /sw.js is now a self- |
| 154 | // unregister script. Browsers with the old SW installed fetch this | |
| 155 | // body on their next SW update check (every navigation, since | |
| 156 | // Cache-Control: no-store on the response) and the SW unregisters | |
| 157 | // itself. Combined with the layout-side kill-switch script, every | |
| 158 | // browser self-recovers within one page load. | |
| 159 | // | |
| 160 | // SW_VERSION is still pinned + preserved in the body so the existing | |
| 161 | // cache-bust tests keep asserting against a non-empty literal. The | |
| 162 | // cache-prefix delete + clients.claim are also preserved (they nuke | |
| 163 | // any caches the old SW left behind). Final step: unregister. | |
| b1be050 | 164 | const safe = version.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); |
| 44fe49b | 165 | return `// gluecron service worker — self-unregister edition (2026-05-16) |
| b1be050 | 166 | const SW_VERSION = "${safe}"; |
| 167 | const CACHE_PREFIX = "gluecron-"; | |
| 168 | const CURRENT_CACHE = CACHE_PREFIX + SW_VERSION; | |
| 169 | ||
| 170 | self.addEventListener("install", (e) => { | |
| 171 | self.skipWaiting(); | |
| 172 | }); | |
| 173 | ||
| 174 | self.addEventListener("activate", (e) => { | |
| 44fe49b | 175 | e.waitUntil((async () => { |
| 176 | try { | |
| 177 | const names = await caches.keys(); | |
| 178 | await Promise.all( | |
| 179 | names.filter((n) => n.startsWith(CACHE_PREFIX)).map((n) => caches.delete(n)) | |
| 180 | ); | |
| 181 | } catch (_) {} | |
| 182 | try { await self.clients.claim(); } catch (_) {} | |
| 183 | try { await self.registration.unregister(); } catch (_) {} | |
| 184 | })()); | |
| b1be050 | 185 | }); |
| 186 | ||
| 44fe49b | 187 | // No fetch handler — once this SW activates and unregisters itself the |
| 188 | // browser never invokes it again. Every request goes straight to the | |
| 189 | // network. | |
| b1be050 | 190 | `; |
| 191 | } | |
| 192 | ||
| eae38d1 | 193 | pwa.get("/sw.js", (c) => { |
| 194 | c.header("content-type", "application/javascript"); | |
| b1be050 | 195 | // no-store on /sw.js itself: browsers must re-fetch the SW source on |
| 196 | // every update check so the new SW_VERSION can actually propagate. | |
| 197 | c.header("cache-control", "no-store"); | |
| e1c7aa6 | 198 | c.header("pragma", "no-cache"); |
| b1be050 | 199 | // Service-Worker-Allowed required for root-scope SW served from root. |
| eae38d1 | 200 | c.header("service-worker-allowed", "/"); |
| b1be050 | 201 | const version = buildSwVersion(); |
| 202 | return c.body(buildVersionedServiceWorker(version)); | |
| eae38d1 | 203 | }); |
| 204 | ||
| 205 | /** | |
| 206 | * Inline script registering the SW. Loaded once at the bottom of every page. | |
| 207 | * Kept tiny so we don't bloat TTI. | |
| 208 | */ | |
| 209 | export const PWA_REGISTER_SNIPPET = ` | |
| 210 | if ('serviceWorker' in navigator) { | |
| 211 | window.addEventListener('load', function() { | |
| 212 | navigator.serviceWorker.register('/sw.js').catch(function() {}); | |
| 213 | }); | |
| 214 | } | |
| 215 | `.trim(); | |
| 216 | ||
| 534f04a | 217 | // --------------------------------------------------------------------------- |
| 218 | // Block M2 — additive routes + a SECOND service worker dedicated to push + | |
| 219 | // offline support. The original `/sw.js` keeps its v4 self-nuke behaviour | |
| 220 | // (locked) so the install/activate/unregister contract is preserved. The new | |
| 221 | // SW lives at `/sw-push.js` and is registered separately by the install | |
| 222 | // banner / settings page when the user opts into push. | |
| 223 | // --------------------------------------------------------------------------- | |
| 224 | ||
| 225 | /** | |
| 904927d | 226 | * Push + offline service worker — SELF-NUKE EDITION (2026-05-16). |
| 534f04a | 227 | * |
| 904927d | 228 | * Background: previously this SW handled push/notification/offline-fetch |
| 229 | * AND was auto-registered by the layout at scope `/`. Combined with the | |
| 230 | * `/sw.js` auto-registration at the same scope it caused the AA reload | |
| 231 | * loop (two different script URLs at scope `/` keep replacing each | |
| 232 | * other → layout's `updatefound → reload` hook fires every page load, | |
| 233 | * input wiped, admin dashboard unusable). See commit d7ba05d for the | |
| 234 | * layout-side fix. | |
| 235 | * | |
| 236 | * The layout-side fix alone does not recover browsers that ALREADY have | |
| 237 | * the old push SW registered — the loop reloads the page before the new | |
| 238 | * layout's JS gets a chance to run its `getRegistrations() → unregister` | |
| 239 | * cleanup. So we also need the SW itself to clean up. | |
| 240 | * | |
| 241 | * What this body does now: on install, claim every client, delete every | |
| 242 | * `gluecron-*` cache the old version created, then call | |
| 243 | * `self.registration.unregister()` so the registration goes away | |
| 244 | * permanently. Browsers fetch this updated body on their next SW | |
| 245 | * update check (which fires on every navigation because `/sw-push.js` | |
| 246 | * is served with `Cache-Control: no-store`), so trapped browsers | |
| 247 | * auto-recover within one page load post-deploy. | |
| 248 | * | |
| 249 | * Push notifications: temporarily disabled. The folding of push + | |
| 250 | * notificationclick handlers into the locked `/sw.js` body is tracked | |
| 251 | * as a follow-up. Until then `/settings/push` will subscribe against | |
| 252 | * the existing `/sw.js` registration, which silently won't display | |
| 253 | * notifications — but won't loop either. That's the right trade-off | |
| 254 | * while the platform is in firefighting mode. | |
| 534f04a | 255 | */ |
| 904927d | 256 | export const PUSH_SERVICE_WORKER_SRC = `// gluecron push SW — self-unregister (2026-05-16 AA-loop kill) |
| 257 | self.addEventListener('install', (e) => { | |
| 258 | self.skipWaiting(); | |
| 534f04a | 259 | }); |
| 904927d | 260 | self.addEventListener('activate', (e) => { |
| 261 | e.waitUntil((async () => { | |
| 534f04a | 262 | try { |
| 904927d | 263 | const keys = await caches.keys(); |
| 264 | await Promise.all( | |
| 265 | keys.filter((k) => k && k.indexOf('gluecron-offline') === 0).map((k) => caches.delete(k)) | |
| 266 | ); | |
| 267 | } catch (_) {} | |
| 268 | try { await self.clients.claim(); } catch (_) {} | |
| 269 | try { await self.registration.unregister(); } catch (_) {} | |
| 534f04a | 270 | })()); |
| 271 | }); | |
| 904927d | 272 | // No fetch / push / notificationclick handlers — by design. Once this SW |
| 273 | // activates and unregisters, the browser never invokes it again. The | |
| 274 | // scope is freed and /sw.js becomes the sole controller at /. | |
| 534f04a | 275 | `; |
| 276 | ||
| 277 | pwa.get("/sw-push.js", (c) => { | |
| 278 | c.header("content-type", "application/javascript"); | |
| 279 | // Same caching policy as /sw.js so updates propagate immediately. | |
| 280 | c.header("cache-control", "no-cache, no-store, must-revalidate"); | |
| 281 | c.header("pragma", "no-cache"); | |
| 282 | c.header("service-worker-allowed", "/"); | |
| 283 | return c.body(PUSH_SERVICE_WORKER_SRC); | |
| 284 | }); | |
| 285 | ||
| 286 | /** Offline fallback — minimal, theme-consistent. */ | |
| 287 | export const OFFLINE_HTML = `<!doctype html> | |
| 81201cc | 288 | <html lang="en" data-theme="light"> |
| 534f04a | 289 | <head> |
| 290 | <meta charset="utf-8" /> | |
| 291 | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| 292 | <title>Offline — gluecron</title> | |
| 293 | <style> | |
| 294 | :root { --bg:#0d1117; --fg:#c9d1d9; --muted:#8b949e; --accent:#58a6ff; --border:#30363d; } | |
| 295 | html, body { margin:0; padding:0; background:var(--bg); color:var(--fg); | |
| 296 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; } | |
| 297 | main { max-width: 480px; margin: 12vh auto 0; padding: 24px; text-align:center; } | |
| 298 | h1 { font-size: 22px; margin: 0 0 12px; } | |
| 299 | p { color: var(--muted); line-height: 1.5; } | |
| 300 | a.btn { | |
| 301 | display:inline-block; margin-top:18px; padding:10px 18px; | |
| 302 | background:transparent; border:1px solid var(--border); border-radius:6px; | |
| 303 | color:var(--accent); text-decoration:none; | |
| 304 | } | |
| 305 | .pulse { | |
| 306 | width:10px; height:10px; border-radius:50%; | |
| 307 | background:#f85149; display:inline-block; margin-right:8px; | |
| 308 | box-shadow:0 0 12px rgba(248,81,73,0.6); | |
| 309 | } | |
| 310 | </style> | |
| 311 | </head> | |
| 312 | <body> | |
| 313 | <main> | |
| 314 | <h1><span class="pulse"></span>You're offline</h1> | |
| 315 | <p>We couldn't reach gluecron. Your last-known dashboard is still in cache — reconnect to refresh it.</p> | |
| 316 | <a class="btn" href="/dashboard">Retry</a> | |
| 317 | </main> | |
| 318 | </body> | |
| 319 | </html> | |
| 320 | `; | |
| 321 | ||
| 322 | pwa.get("/offline.html", (c) => { | |
| 323 | c.header("content-type", "text/html; charset=utf-8"); | |
| 324 | c.header("cache-control", "public, max-age=300"); | |
| 325 | return c.body(OFFLINE_HTML); | |
| 326 | }); | |
| 327 | ||
| 328 | // --- API: VAPID public key -------------------------------------------------- | |
| 329 | pwa.get("/pwa/vapid-public-key", async (c) => { | |
| 330 | try { | |
| 331 | const key = await getVapidPublicKey(); | |
| 332 | return c.json({ key }); | |
| 333 | } catch (err) { | |
| 334 | console.error("[pwa] vapid public key failed:", err); | |
| 335 | return c.json({ error: "vapid_unavailable" }, 500); | |
| 336 | } | |
| 337 | }); | |
| 338 | ||
| 339 | // --- API: subscribe --------------------------------------------------------- | |
| 340 | pwa.post("/pwa/subscribe", requireAuth, async (c) => { | |
| 341 | const user = c.get("user")!; | |
| 342 | let body: any; | |
| 343 | try { | |
| 344 | body = await c.req.json(); | |
| 345 | } catch { | |
| 346 | return c.json({ error: "invalid_json" }, 400); | |
| 347 | } | |
| 348 | const endpoint = typeof body?.endpoint === "string" ? body.endpoint : ""; | |
| 349 | const p256dh = | |
| 350 | typeof body?.keys?.p256dh === "string" ? body.keys.p256dh : ""; | |
| 351 | const auth = typeof body?.keys?.auth === "string" ? body.keys.auth : ""; | |
| 352 | if (!endpoint || !p256dh || !auth) { | |
| 353 | return c.json({ error: "invalid_subscription" }, 400); | |
| 354 | } | |
| 355 | const ua = c.req.header("user-agent") ?? null; | |
| 356 | try { | |
| 357 | await subscribeUser( | |
| 358 | user.id, | |
| 359 | { endpoint, keys: { p256dh, auth } }, | |
| 360 | ua ?? undefined | |
| 361 | ); | |
| 362 | } catch (_) { | |
| 363 | return c.json({ error: "subscribe_failed" }, 500); | |
| 364 | } | |
| 365 | return c.json({ ok: true }, 201); | |
| 366 | }); | |
| 367 | ||
| 368 | // --- API: unsubscribe ------------------------------------------------------- | |
| 369 | pwa.post("/pwa/unsubscribe", requireAuth, async (c) => { | |
| 370 | const user = c.get("user")!; | |
| 371 | let body: any; | |
| 372 | try { | |
| 373 | body = await c.req.json(); | |
| 374 | } catch { | |
| 375 | return c.json({ error: "invalid_json" }, 400); | |
| 376 | } | |
| 377 | const endpoint = typeof body?.endpoint === "string" ? body.endpoint : ""; | |
| 378 | if (!endpoint) return c.json({ error: "missing_endpoint" }, 400); | |
| 379 | await unsubscribeUser(user.id, endpoint); | |
| 380 | return c.body(null, 204); | |
| 381 | }); | |
| 382 | ||
| 383 | // --- API: send a test push to the calling user ------------------------------ | |
| 384 | pwa.post("/pwa/test", requireAuth, async (c) => { | |
| 385 | const user = c.get("user")!; | |
| 386 | const result = await sendPushToUser(user.id, { | |
| 387 | title: "Gluecron test notification", | |
| 388 | body: "If you can read this, push delivery is working on this device.", | |
| 389 | url: "/notifications", | |
| 390 | tag: "gluecron-test", | |
| 391 | }); | |
| 392 | return c.json(result); | |
| 393 | }); | |
| 394 | ||
| eae38d1 | 395 | export default pwa; |