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.tsBlame395 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 {
44fe49bClaude153 // 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.
b1be050CC LABS App164 const safe = version.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
44fe49bClaude165 return `// gluecron service worker — self-unregister edition (2026-05-16)
b1be050CC LABS App166const SW_VERSION = "${safe}";
167const CACHE_PREFIX = "gluecron-";
168const CURRENT_CACHE = CACHE_PREFIX + SW_VERSION;
169
170self.addEventListener("install", (e) => {
171 self.skipWaiting();
172});
173
174self.addEventListener("activate", (e) => {
44fe49bClaude175 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 })());
b1be050CC LABS App185});
186
44fe49bClaude187// 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.
b1be050CC LABS App190`;
191}
192
eae38d1Claude193pwa.get("/sw.js", (c) => {
194 c.header("content-type", "application/javascript");
b1be050CC LABS App195 // 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");
e1c7aa6Claude198 c.header("pragma", "no-cache");
b1be050CC LABS App199 // Service-Worker-Allowed required for root-scope SW served from root.
eae38d1Claude200 c.header("service-worker-allowed", "/");
b1be050CC LABS App201 const version = buildSwVersion();
202 return c.body(buildVersionedServiceWorker(version));
eae38d1Claude203});
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 */
209export const PWA_REGISTER_SNIPPET = `
210if ('serviceWorker' in navigator) {
211 window.addEventListener('load', function() {
212 navigator.serviceWorker.register('/sw.js').catch(function() {});
213 });
214}
215`.trim();
216
534f04aClaude217// ---------------------------------------------------------------------------
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/**
904927dClaude226 * Push + offline service worker — SELF-NUKE EDITION (2026-05-16).
534f04aClaude227 *
904927dClaude228 * 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.
534f04aClaude255 */
904927dClaude256export const PUSH_SERVICE_WORKER_SRC = `// gluecron push SW — self-unregister (2026-05-16 AA-loop kill)
257self.addEventListener('install', (e) => {
258 self.skipWaiting();
534f04aClaude259});
904927dClaude260self.addEventListener('activate', (e) => {
261 e.waitUntil((async () => {
534f04aClaude262 try {
904927dClaude263 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 (_) {}
534f04aClaude270 })());
271});
904927dClaude272// 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 /.
534f04aClaude275`;
276
277pwa.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. */
287export const OFFLINE_HTML = `<!doctype html>
81201ccTest User288<html lang="en" data-theme="light">
534f04aClaude289<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 &mdash; reconnect to refresh it.</p>
316 <a class="btn" href="/dashboard">Retry</a>
317</main>
318</body>
319</html>
320`;
321
322pwa.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 --------------------------------------------------
329pwa.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 ---------------------------------------------------------
340pwa.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 -------------------------------------------------------
369pwa.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 ------------------------------
384pwa.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
eae38d1Claude395export default pwa;