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 | ||
| 99 | pwa.get("/sw.js", (c) => { | |
| 100 | c.header("content-type", "application/javascript"); | |
| e1c7aa6 | 101 | // No-cache: browser must check on every page load. Critical for the v4 |
| 102 | // self-nuke SW to actually reach all returning visitors. | |
| 103 | c.header("cache-control", "no-cache, no-store, must-revalidate"); | |
| 104 | c.header("pragma", "no-cache"); | |
| eae38d1 | 105 | // Service-Worker-Allowed required for root-scope SW served from root |
| 106 | c.header("service-worker-allowed", "/"); | |
| 107 | return c.body(SERVICE_WORKER_SRC); | |
| 108 | }); | |
| 109 | ||
| 110 | /** | |
| 111 | * Inline script registering the SW. Loaded once at the bottom of every page. | |
| 112 | * Kept tiny so we don't bloat TTI. | |
| 113 | */ | |
| 114 | export const PWA_REGISTER_SNIPPET = ` | |
| 115 | if ('serviceWorker' in navigator) { | |
| 116 | window.addEventListener('load', function() { | |
| 117 | navigator.serviceWorker.register('/sw.js').catch(function() {}); | |
| 118 | }); | |
| 119 | } | |
| 120 | `.trim(); | |
| 121 | ||
| 534f04a | 122 | // --------------------------------------------------------------------------- |
| 123 | // Block M2 — additive routes + a SECOND service worker dedicated to push + | |
| 124 | // offline support. The original `/sw.js` keeps its v4 self-nuke behaviour | |
| 125 | // (locked) so the install/activate/unregister contract is preserved. The new | |
| 126 | // SW lives at `/sw-push.js` and is registered separately by the install | |
| 127 | // banner / settings page when the user opts into push. | |
| 128 | // --------------------------------------------------------------------------- | |
| 129 | ||
| 130 | /** | |
| 131 | * Push + offline service worker. Strictly additive to the v4 self-nuke SW. | |
| 132 | * Handles three things: | |
| 133 | * 1. `push` event → display a notification (title/body/url/tag). | |
| 134 | * 2. `notificationclick` → focus an existing tab on `url` or open a new one. | |
| 135 | * 3. `fetch` event → serve `/offline.html` as the fallback when the | |
| 136 | * network fails on an HTML navigation. Non-HTML fetches passthrough. | |
| 137 | * | |
| 138 | * The cache name is unique so we don't collide with anything `/sw.js` | |
| 139 | * historically touched. | |
| 140 | */ | |
| 141 | export const PUSH_SERVICE_WORKER_SRC = `// gluecron push + offline service worker (Block M2) | |
| 142 | const CACHE = 'gluecron-offline-v1'; | |
| 143 | const OFFLINE_URL = '/offline.html'; | |
| 144 | ||
| 145 | self.addEventListener('install', (event) => { | |
| 146 | event.waitUntil((async () => { | |
| 147 | const cache = await caches.open(CACHE); | |
| 148 | try { await cache.add(new Request(OFFLINE_URL, { cache: 'reload' })); } catch (_) {} | |
| 149 | self.skipWaiting(); | |
| 150 | })()); | |
| 151 | }); | |
| 152 | ||
| 153 | self.addEventListener('activate', (event) => { | |
| 154 | event.waitUntil((async () => { | |
| 155 | // Drop any cache that isn't our current one. | |
| 156 | const keys = await caches.keys(); | |
| 157 | await Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))); | |
| 158 | await self.clients.claim(); | |
| 159 | })()); | |
| 160 | }); | |
| 161 | ||
| 162 | self.addEventListener('push', (event) => { | |
| 163 | let data = { title: 'Gluecron', body: '', url: '/', tag: 'gluecron', icon: '/icon.svg' }; | |
| 164 | if (event.data) { | |
| 165 | try { data = Object.assign(data, event.data.json()); } | |
| 166 | catch (_) { try { data.body = event.data.text(); } catch (_) {} } | |
| 167 | } | |
| 168 | event.waitUntil(self.registration.showNotification(data.title, { | |
| 169 | body: data.body, | |
| 170 | icon: data.icon, | |
| 171 | tag: data.tag, | |
| 172 | data: { url: data.url }, | |
| 173 | })); | |
| 174 | }); | |
| 175 | ||
| 176 | self.addEventListener('notificationclick', (event) => { | |
| 177 | event.notification.close(); | |
| 178 | const target = (event.notification.data && event.notification.data.url) || '/'; | |
| 179 | event.waitUntil((async () => { | |
| 180 | const all = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); | |
| 181 | for (const c of all) { | |
| 182 | try { | |
| 183 | const u = new URL(c.url); | |
| 184 | if (u.pathname === target || c.url === target) { | |
| 185 | await c.focus(); | |
| 186 | return; | |
| 187 | } | |
| 188 | } catch (_) {} | |
| 189 | } | |
| 190 | await self.clients.openWindow(target); | |
| 191 | })()); | |
| 192 | }); | |
| 193 | ||
| 194 | self.addEventListener('fetch', (event) => { | |
| 195 | const req = event.request; | |
| 196 | if (req.method !== 'GET') return; | |
| 197 | const accept = req.headers.get('accept') || ''; | |
| 198 | // Only intervene on top-level HTML navigations. Everything else (CSS, | |
| 199 | // images, API, /api/*, /.git/*, login/logout) passes straight through. | |
| 200 | if (req.mode !== 'navigate' && !accept.includes('text/html')) return; | |
| 201 | event.respondWith((async () => { | |
| 202 | try { | |
| 203 | return await fetch(req); | |
| 204 | } catch (_) { | |
| 205 | const cache = await caches.open(CACHE); | |
| 206 | const cached = await cache.match(OFFLINE_URL); | |
| 207 | if (cached) return cached; | |
| 208 | return new Response('Offline', { status: 503, headers: { 'content-type': 'text/plain' } }); | |
| 209 | } | |
| 210 | })()); | |
| 211 | }); | |
| 212 | `; | |
| 213 | ||
| 214 | pwa.get("/sw-push.js", (c) => { | |
| 215 | c.header("content-type", "application/javascript"); | |
| 216 | // Same caching policy as /sw.js so updates propagate immediately. | |
| 217 | c.header("cache-control", "no-cache, no-store, must-revalidate"); | |
| 218 | c.header("pragma", "no-cache"); | |
| 219 | c.header("service-worker-allowed", "/"); | |
| 220 | return c.body(PUSH_SERVICE_WORKER_SRC); | |
| 221 | }); | |
| 222 | ||
| 223 | /** Offline fallback — minimal, theme-consistent. */ | |
| 224 | export const OFFLINE_HTML = `<!doctype html> | |
| 225 | <html lang="en" data-theme="dark"> | |
| 226 | <head> | |
| 227 | <meta charset="utf-8" /> | |
| 228 | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| 229 | <title>Offline — gluecron</title> | |
| 230 | <style> | |
| 231 | :root { --bg:#0d1117; --fg:#c9d1d9; --muted:#8b949e; --accent:#58a6ff; --border:#30363d; } | |
| 232 | html, body { margin:0; padding:0; background:var(--bg); color:var(--fg); | |
| 233 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; } | |
| 234 | main { max-width: 480px; margin: 12vh auto 0; padding: 24px; text-align:center; } | |
| 235 | h1 { font-size: 22px; margin: 0 0 12px; } | |
| 236 | p { color: var(--muted); line-height: 1.5; } | |
| 237 | a.btn { | |
| 238 | display:inline-block; margin-top:18px; padding:10px 18px; | |
| 239 | background:transparent; border:1px solid var(--border); border-radius:6px; | |
| 240 | color:var(--accent); text-decoration:none; | |
| 241 | } | |
| 242 | .pulse { | |
| 243 | width:10px; height:10px; border-radius:50%; | |
| 244 | background:#f85149; display:inline-block; margin-right:8px; | |
| 245 | box-shadow:0 0 12px rgba(248,81,73,0.6); | |
| 246 | } | |
| 247 | </style> | |
| 248 | </head> | |
| 249 | <body> | |
| 250 | <main> | |
| 251 | <h1><span class="pulse"></span>You're offline</h1> | |
| 252 | <p>We couldn't reach gluecron. Your last-known dashboard is still in cache — reconnect to refresh it.</p> | |
| 253 | <a class="btn" href="/dashboard">Retry</a> | |
| 254 | </main> | |
| 255 | </body> | |
| 256 | </html> | |
| 257 | `; | |
| 258 | ||
| 259 | pwa.get("/offline.html", (c) => { | |
| 260 | c.header("content-type", "text/html; charset=utf-8"); | |
| 261 | c.header("cache-control", "public, max-age=300"); | |
| 262 | return c.body(OFFLINE_HTML); | |
| 263 | }); | |
| 264 | ||
| 265 | // --- API: VAPID public key -------------------------------------------------- | |
| 266 | pwa.get("/pwa/vapid-public-key", async (c) => { | |
| 267 | try { | |
| 268 | const key = await getVapidPublicKey(); | |
| 269 | return c.json({ key }); | |
| 270 | } catch (err) { | |
| 271 | console.error("[pwa] vapid public key failed:", err); | |
| 272 | return c.json({ error: "vapid_unavailable" }, 500); | |
| 273 | } | |
| 274 | }); | |
| 275 | ||
| 276 | // --- API: subscribe --------------------------------------------------------- | |
| 277 | pwa.post("/pwa/subscribe", requireAuth, async (c) => { | |
| 278 | const user = c.get("user")!; | |
| 279 | let body: any; | |
| 280 | try { | |
| 281 | body = await c.req.json(); | |
| 282 | } catch { | |
| 283 | return c.json({ error: "invalid_json" }, 400); | |
| 284 | } | |
| 285 | const endpoint = typeof body?.endpoint === "string" ? body.endpoint : ""; | |
| 286 | const p256dh = | |
| 287 | typeof body?.keys?.p256dh === "string" ? body.keys.p256dh : ""; | |
| 288 | const auth = typeof body?.keys?.auth === "string" ? body.keys.auth : ""; | |
| 289 | if (!endpoint || !p256dh || !auth) { | |
| 290 | return c.json({ error: "invalid_subscription" }, 400); | |
| 291 | } | |
| 292 | const ua = c.req.header("user-agent") ?? null; | |
| 293 | try { | |
| 294 | await subscribeUser( | |
| 295 | user.id, | |
| 296 | { endpoint, keys: { p256dh, auth } }, | |
| 297 | ua ?? undefined | |
| 298 | ); | |
| 299 | } catch (_) { | |
| 300 | return c.json({ error: "subscribe_failed" }, 500); | |
| 301 | } | |
| 302 | return c.json({ ok: true }, 201); | |
| 303 | }); | |
| 304 | ||
| 305 | // --- API: unsubscribe ------------------------------------------------------- | |
| 306 | pwa.post("/pwa/unsubscribe", requireAuth, async (c) => { | |
| 307 | const user = c.get("user")!; | |
| 308 | let body: any; | |
| 309 | try { | |
| 310 | body = await c.req.json(); | |
| 311 | } catch { | |
| 312 | return c.json({ error: "invalid_json" }, 400); | |
| 313 | } | |
| 314 | const endpoint = typeof body?.endpoint === "string" ? body.endpoint : ""; | |
| 315 | if (!endpoint) return c.json({ error: "missing_endpoint" }, 400); | |
| 316 | await unsubscribeUser(user.id, endpoint); | |
| 317 | return c.body(null, 204); | |
| 318 | }); | |
| 319 | ||
| 320 | // --- API: send a test push to the calling user ------------------------------ | |
| 321 | pwa.post("/pwa/test", requireAuth, async (c) => { | |
| 322 | const user = c.get("user")!; | |
| 323 | const result = await sendPushToUser(user.id, { | |
| 324 | title: "Gluecron test notification", | |
| 325 | body: "If you can read this, push delivery is working on this device.", | |
| 326 | url: "/notifications", | |
| 327 | tag: "gluecron-test", | |
| 328 | }); | |
| 329 | return c.json(result); | |
| 330 | }); | |
| 331 | ||
| eae38d1 | 332 | export default pwa; |