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

pwa.tsBlame414 lines · 2 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
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.
126let _missingShaWarned = false;
127export function _resetSwShaWarningForTests(): void {
128 _missingShaWarned = false;
129}
130
131export 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
145export 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)
151const SW_VERSION = "${safe}";
152const CACHE_PREFIX = "gluecron-";
153const CURRENT_CACHE = CACHE_PREFIX + SW_VERSION;
154
155self.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
162self.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
eae38d1Claude180pwa.get("/sw.js", (c) => {
181 c.header("content-type", "application/javascript");
b1be050CC LABS App182 // 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");
e1c7aa6Claude185 c.header("pragma", "no-cache");
b1be050CC LABS App186 // Service-Worker-Allowed required for root-scope SW served from root.
eae38d1Claude187 c.header("service-worker-allowed", "/");
b1be050CC LABS App188 const version = buildSwVersion();
189 return c.body(buildVersionedServiceWorker(version));
eae38d1Claude190});
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 */
196export const PWA_REGISTER_SNIPPET = `
197if ('serviceWorker' in navigator) {
198 window.addEventListener('load', function() {
199 navigator.serviceWorker.register('/sw.js').catch(function() {});
200 });
201}
202`.trim();
203
534f04aClaude204// ---------------------------------------------------------------------------
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 */
223export const PUSH_SERVICE_WORKER_SRC = `// gluecron push + offline service worker (Block M2)
224const CACHE = 'gluecron-offline-v1';
225const OFFLINE_URL = '/offline.html';
226
227self.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
235self.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
244self.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
258self.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
276self.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
296pwa.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. */
306export 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 &mdash; reconnect to refresh it.</p>
335 <a class="btn" href="/dashboard">Retry</a>
336</main>
337</body>
338</html>
339`;
340
341pwa.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 --------------------------------------------------
348pwa.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 ---------------------------------------------------------
359pwa.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 -------------------------------------------------------
388pwa.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 ------------------------------
403pwa.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
eae38d1Claude414export default pwa;