Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
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.

pwa.tsBlame421 lines · 3 contributors
eae38d1Claude1/**
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
17import { Hono } from "hono";
534f04aClaude18import type { AuthEnv } from "../middleware/auth";
19import { requireAuth } from "../middleware/auth";
20import {
21 getVapidPublicKey,
22 sendPushToUser,
23 subscribeUser,
24 unsubscribeUser,
25} from "../lib/push";
eae38d1Claude26
534f04aClaude27const pwa = new Hono<AuthEnv>();
eae38d1Claude28
29export 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
49pwa.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
55const 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
63pwa.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/**
e1c7aa6Claude70 * 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.
eae38d1Claude81 */
e1c7aa6Claude82export const SERVICE_WORKER_SRC = `// gluecron service worker — v4 (self-nuke)
83self.addEventListener('install', () => self.skipWaiting());
eae38d1Claude84self.addEventListener('activate', (e) => {
e1c7aa6Claude85 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 })());
eae38d1Claude95});
e1c7aa6Claude96// No fetch handler — every request goes straight to the network.
eae38d1Claude97`;
98
b1be050CC LABS App99/**
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
f85b88aTest User120 * 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.
b1be050CC LABS App128 */
129
130// One-shot warning latch — exported only for tests to reset between cases.
131let _missingShaWarned = false;
132export function _resetSwShaWarningForTests(): void {
133 _missingShaWarned = false;
134}
135
136export 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 }
f85b88aTest User145 // 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";
b1be050CC LABS App150}
151
152export 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)
158const SW_VERSION = "${safe}";
159const CACHE_PREFIX = "gluecron-";
160const CURRENT_CACHE = CACHE_PREFIX + SW_VERSION;
161
162self.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
169self.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
eae38d1Claude187pwa.get("/sw.js", (c) => {
188 c.header("content-type", "application/javascript");
b1be050CC LABS App189 // 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");
e1c7aa6Claude192 c.header("pragma", "no-cache");
b1be050CC LABS App193 // Service-Worker-Allowed required for root-scope SW served from root.
eae38d1Claude194 c.header("service-worker-allowed", "/");
b1be050CC LABS App195 const version = buildSwVersion();
196 return c.body(buildVersionedServiceWorker(version));
eae38d1Claude197});
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 */
203export const PWA_REGISTER_SNIPPET = `
204if ('serviceWorker' in navigator) {
205 window.addEventListener('load', function() {
206 navigator.serviceWorker.register('/sw.js').catch(function() {});
207 });
208}
209`.trim();
210
534f04aClaude211// ---------------------------------------------------------------------------
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/**
220 * Push + offline service worker. Strictly additive to the v4 self-nuke SW.
221 * Handles three things:
222 * 1. `push` event → display a notification (title/body/url/tag).
223 * 2. `notificationclick` → focus an existing tab on `url` or open a new one.
224 * 3. `fetch` event → serve `/offline.html` as the fallback when the
225 * network fails on an HTML navigation. Non-HTML fetches passthrough.
226 *
227 * The cache name is unique so we don't collide with anything `/sw.js`
228 * historically touched.
229 */
230export const PUSH_SERVICE_WORKER_SRC = `// gluecron push + offline service worker (Block M2)
231const CACHE = 'gluecron-offline-v1';
232const OFFLINE_URL = '/offline.html';
233
234self.addEventListener('install', (event) => {
235 event.waitUntil((async () => {
236 const cache = await caches.open(CACHE);
237 try { await cache.add(new Request(OFFLINE_URL, { cache: 'reload' })); } catch (_) {}
238 self.skipWaiting();
239 })());
240});
241
242self.addEventListener('activate', (event) => {
243 event.waitUntil((async () => {
244 // Drop any cache that isn't our current one.
245 const keys = await caches.keys();
246 await Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)));
247 await self.clients.claim();
248 })());
249});
250
251self.addEventListener('push', (event) => {
252 let data = { title: 'Gluecron', body: '', url: '/', tag: 'gluecron', icon: '/icon.svg' };
253 if (event.data) {
254 try { data = Object.assign(data, event.data.json()); }
255 catch (_) { try { data.body = event.data.text(); } catch (_) {} }
256 }
257 event.waitUntil(self.registration.showNotification(data.title, {
258 body: data.body,
259 icon: data.icon,
260 tag: data.tag,
261 data: { url: data.url },
262 }));
263});
264
265self.addEventListener('notificationclick', (event) => {
266 event.notification.close();
267 const target = (event.notification.data && event.notification.data.url) || '/';
268 event.waitUntil((async () => {
269 const all = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
270 for (const c of all) {
271 try {
272 const u = new URL(c.url);
273 if (u.pathname === target || c.url === target) {
274 await c.focus();
275 return;
276 }
277 } catch (_) {}
278 }
279 await self.clients.openWindow(target);
280 })());
281});
282
283self.addEventListener('fetch', (event) => {
284 const req = event.request;
285 if (req.method !== 'GET') return;
286 const accept = req.headers.get('accept') || '';
287 // Only intervene on top-level HTML navigations. Everything else (CSS,
288 // images, API, /api/*, /.git/*, login/logout) passes straight through.
289 if (req.mode !== 'navigate' && !accept.includes('text/html')) return;
290 event.respondWith((async () => {
291 try {
292 return await fetch(req);
293 } catch (_) {
294 const cache = await caches.open(CACHE);
295 const cached = await cache.match(OFFLINE_URL);
296 if (cached) return cached;
297 return new Response('Offline', { status: 503, headers: { 'content-type': 'text/plain' } });
298 }
299 })());
300});
301`;
302
303pwa.get("/sw-push.js", (c) => {
304 c.header("content-type", "application/javascript");
305 // Same caching policy as /sw.js so updates propagate immediately.
306 c.header("cache-control", "no-cache, no-store, must-revalidate");
307 c.header("pragma", "no-cache");
308 c.header("service-worker-allowed", "/");
309 return c.body(PUSH_SERVICE_WORKER_SRC);
310});
311
312/** Offline fallback — minimal, theme-consistent. */
313export const OFFLINE_HTML = `<!doctype html>
314<html lang="en" data-theme="dark">
315<head>
316<meta charset="utf-8" />
317<meta name="viewport" content="width=device-width, initial-scale=1.0" />
318<title>Offline — gluecron</title>
319<style>
320 :root { --bg:#0d1117; --fg:#c9d1d9; --muted:#8b949e; --accent:#58a6ff; --border:#30363d; }
321 html, body { margin:0; padding:0; background:var(--bg); color:var(--fg);
322 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
323 main { max-width: 480px; margin: 12vh auto 0; padding: 24px; text-align:center; }
324 h1 { font-size: 22px; margin: 0 0 12px; }
325 p { color: var(--muted); line-height: 1.5; }
326 a.btn {
327 display:inline-block; margin-top:18px; padding:10px 18px;
328 background:transparent; border:1px solid var(--border); border-radius:6px;
329 color:var(--accent); text-decoration:none;
330 }
331 .pulse {
332 width:10px; height:10px; border-radius:50%;
333 background:#f85149; display:inline-block; margin-right:8px;
334 box-shadow:0 0 12px rgba(248,81,73,0.6);
335 }
336</style>
337</head>
338<body>
339<main>
340 <h1><span class="pulse"></span>You're offline</h1>
341 <p>We couldn't reach gluecron. Your last-known dashboard is still in cache &mdash; reconnect to refresh it.</p>
342 <a class="btn" href="/dashboard">Retry</a>
343</main>
344</body>
345</html>
346`;
347
348pwa.get("/offline.html", (c) => {
349 c.header("content-type", "text/html; charset=utf-8");
350 c.header("cache-control", "public, max-age=300");
351 return c.body(OFFLINE_HTML);
352});
353
354// --- API: VAPID public key --------------------------------------------------
355pwa.get("/pwa/vapid-public-key", async (c) => {
356 try {
357 const key = await getVapidPublicKey();
358 return c.json({ key });
359 } catch (err) {
360 console.error("[pwa] vapid public key failed:", err);
361 return c.json({ error: "vapid_unavailable" }, 500);
362 }
363});
364
365// --- API: subscribe ---------------------------------------------------------
366pwa.post("/pwa/subscribe", requireAuth, async (c) => {
367 const user = c.get("user")!;
368 let body: any;
369 try {
370 body = await c.req.json();
371 } catch {
372 return c.json({ error: "invalid_json" }, 400);
373 }
374 const endpoint = typeof body?.endpoint === "string" ? body.endpoint : "";
375 const p256dh =
376 typeof body?.keys?.p256dh === "string" ? body.keys.p256dh : "";
377 const auth = typeof body?.keys?.auth === "string" ? body.keys.auth : "";
378 if (!endpoint || !p256dh || !auth) {
379 return c.json({ error: "invalid_subscription" }, 400);
380 }
381 const ua = c.req.header("user-agent") ?? null;
382 try {
383 await subscribeUser(
384 user.id,
385 { endpoint, keys: { p256dh, auth } },
386 ua ?? undefined
387 );
388 } catch (_) {
389 return c.json({ error: "subscribe_failed" }, 500);
390 }
391 return c.json({ ok: true }, 201);
392});
393
394// --- API: unsubscribe -------------------------------------------------------
395pwa.post("/pwa/unsubscribe", requireAuth, async (c) => {
396 const user = c.get("user")!;
397 let body: any;
398 try {
399 body = await c.req.json();
400 } catch {
401 return c.json({ error: "invalid_json" }, 400);
402 }
403 const endpoint = typeof body?.endpoint === "string" ? body.endpoint : "";
404 if (!endpoint) return c.json({ error: "missing_endpoint" }, 400);
405 await unsubscribeUser(user.id, endpoint);
406 return c.body(null, 204);
407});
408
409// --- API: send a test push to the calling user ------------------------------
410pwa.post("/pwa/test", requireAuth, async (c) => {
411 const user = c.get("user")!;
412 const result = await sendPushToUser(user.id, {
413 title: "Gluecron test notification",
414 body: "If you can read this, push delivery is working on this device.",
415 url: "/notifications",
416 tag: "gluecron-test",
417 });
418 return c.json(result);
419});
420
eae38d1Claude421export default pwa;