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.tsBlame332 lines · 1 contributor
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
99pwa.get("/sw.js", (c) => {
100 c.header("content-type", "application/javascript");
e1c7aa6Claude101 // 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");
eae38d1Claude105 // 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 */
114export const PWA_REGISTER_SNIPPET = `
115if ('serviceWorker' in navigator) {
116 window.addEventListener('load', function() {
117 navigator.serviceWorker.register('/sw.js').catch(function() {});
118 });
119}
120`.trim();
121
534f04aClaude122// ---------------------------------------------------------------------------
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 */
141export const PUSH_SERVICE_WORKER_SRC = `// gluecron push + offline service worker (Block M2)
142const CACHE = 'gluecron-offline-v1';
143const OFFLINE_URL = '/offline.html';
144
145self.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
153self.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
162self.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
176self.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
194self.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
214pwa.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. */
224export 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 &mdash; reconnect to refresh it.</p>
253 <a class="btn" href="/dashboard">Retry</a>
254</main>
255</body>
256</html>
257`;
258
259pwa.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 --------------------------------------------------
266pwa.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 ---------------------------------------------------------
277pwa.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 -------------------------------------------------------
306pwa.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 ------------------------------
321pwa.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
eae38d1Claude332export default pwa;