@unbrained/pm-web 2026.7.11 → 2026.7.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +26 -0
  3. package/dist/app.d.ts +1 -0
  4. package/dist/app.js +38 -2
  5. package/dist/app.js.map +1 -1
  6. package/dist/auth.d.ts +2 -1
  7. package/dist/auth.js +10 -0
  8. package/dist/auth.js.map +1 -1
  9. package/dist/db.js +17 -1
  10. package/dist/db.js.map +1 -1
  11. package/dist/ical.js.map +1 -1
  12. package/dist/index.d.ts +1 -22
  13. package/dist/index.js +2 -3
  14. package/dist/index.js.map +1 -1
  15. package/dist/oidc.d.ts +89 -0
  16. package/dist/oidc.js +305 -0
  17. package/dist/oidc.js.map +1 -0
  18. package/dist/routes/auth.js +3 -15
  19. package/dist/routes/auth.js.map +1 -1
  20. package/dist/routes/github.js +3 -3
  21. package/dist/routes/github.js.map +1 -1
  22. package/dist/routes/oidc.d.ts +4 -0
  23. package/dist/routes/oidc.js +147 -0
  24. package/dist/routes/oidc.js.map +1 -0
  25. package/dist/routes/pm.js +121 -107
  26. package/dist/routes/pm.js.map +1 -1
  27. package/dist/routes/projects.js +9 -2
  28. package/dist/routes/projects.js.map +1 -1
  29. package/dist/server.js +7 -2
  30. package/dist/server.js.map +1 -1
  31. package/dist/services/pm-runner.d.ts +11 -3
  32. package/dist/services/pm-runner.js +167 -54
  33. package/dist/services/pm-runner.js.map +1 -1
  34. package/dist/services/realtime-bus.d.ts +1 -0
  35. package/dist/services/realtime-bus.js +148 -0
  36. package/dist/services/realtime-bus.js.map +1 -0
  37. package/dist/services/sse.d.ts +3 -1
  38. package/dist/services/sse.js +83 -48
  39. package/dist/services/sse.js.map +1 -1
  40. package/manifest.json +1 -1
  41. package/package.json +11 -11
  42. package/public/cookie-consent.js +54 -39
  43. package/public/cookie-settings.html +7 -7
  44. package/public/index.html +2 -0
  45. package/public/legal-notice.html +10 -12
  46. package/public/privacy-policy.html +14 -24
  47. package/public/src/app.js +4 -4
  48. package/public/src/app.js.map +1 -1
  49. package/public/src/app.ts +4 -4
  50. package/public/src/components/toast.js.map +1 -1
  51. package/public/src/cookie-consent.ts +68 -0
  52. package/public/src/sw.ts +443 -0
  53. package/public/src/views/auth.js +22 -0
  54. package/public/src/views/auth.js.map +1 -1
  55. package/public/src/views/auth.ts +20 -0
  56. package/public/src/views/graph.js.map +1 -1
  57. package/public/styles.css +2 -0
  58. package/public/sw.js +320 -256
  59. package/public/terms.html +7 -9
  60. package/public/tsconfig.json +1 -1
  61. package/public/tsconfig.scripts.json +18 -0
  62. package/public/tsconfig.sw.json +18 -0
  63. package/sql/schema.sql +16 -0
package/public/sw.js CHANGED
@@ -1,58 +1,74 @@
1
+ "use strict";
1
2
  // ═══════════════════════════════════════════════════════════════
2
3
  // SERVICE WORKER — pm-web PWA
3
4
  // Cache versioning: auto-bust based on build timestamp
4
5
  // Offline fallback page, mutation queue via IndexedDB
6
+ //
7
+ // This is the TypeScript source for /sw.js. It is compiled with the
8
+ // `WebWorker` lib (see public/tsconfig.sw.json) so the ServiceWorker
9
+ // global scope (`ServiceWorkerGlobalScope`, `caches`, `clients`,
10
+ // `skipWaiting`, IndexedDB, fetch) is fully typed. The emitted
11
+ // `public/sw.js` is plain JavaScript served at the same URL.
5
12
  // ═══════════════════════════════════════════════════════════════
6
-
13
+ // `self` is the ServiceWorkerGlobalScope inside a service worker. The
14
+ // `WebWorker` lib types the ambient `self` as the generic WorkerGlobalScope,
15
+ // so narrow it once at the top via a `unknown` cast (no `any`).
16
+ const sw = self;
17
+ // `__BUILD_TIME__` is an optional build-time substitution placeholder.
18
+ // No substitution is performed by the default build, so the literal is
19
+ // retained verbatim and the cache name falls back to a runtime stamp.
20
+ // Kept as a widened `string` so the placeholder comparison stays a
21
+ // runtime check and the emitted output is deterministic.
7
22
  const BUILD_TIMESTAMP = '__BUILD_TIME__';
8
23
  const CACHE_NAME = 'pm-web-' + (BUILD_TIMESTAMP !== '__BUILD_TIME__' ? BUILD_TIMESTAMP : Date.now().toString(36));
9
24
  const MUTATION_DB = 'pm-web-offline';
10
25
  const MUTATION_STORE = 'mutations';
11
-
12
26
  const STATIC_ASSETS = [
13
- '/',
14
- '/styles.css',
15
- '/manifest.json',
16
- '/icons/icon-192.png',
17
- '/icons/icon-512.png',
18
- '/src/api.js',
19
- '/src/app.js',
20
- '/src/components/modals.js',
21
- '/src/components/toast.js',
22
- '/src/constants.js',
23
- '/src/state.js',
24
- '/src/types.js',
25
- '/src/utils.js',
26
- '/src/views/activity.js',
27
- '/src/views/admin.js',
28
- '/src/views/auth.js',
29
- '/src/views/calendar.js',
30
- '/src/views/comments-audit.js',
31
- '/src/views/config.js',
32
- '/src/views/context.js',
33
- '/src/views/create.js',
34
- '/src/views/dedupe.js',
35
- '/src/views/export.js',
36
- '/src/views/github.js',
37
- '/src/views/graph-canvas.js',
38
- '/src/views/graph.js',
39
- '/src/views/groups.js',
40
- '/src/views/guide.js',
41
- '/src/views/health.js',
42
- '/src/views/items.js',
43
- '/src/views/normalize.js',
44
- '/src/views/projects.js',
45
- '/src/views/router.js',
46
- '/src/views/search.js',
47
- '/src/views/settings.js',
48
- '/src/views/shared.js',
49
- '/src/views/sharing.js',
50
- '/src/views/stats.js',
51
- '/src/views/templates.js',
52
- '/src/views/validate.js',
53
- '/src/views/plan.js',
27
+ '/',
28
+ '/styles.css',
29
+ '/manifest.json',
30
+ '/icons/icon-192.png',
31
+ '/icons/icon-512.png',
32
+ '/src/api.js',
33
+ '/src/app.js',
34
+ '/src/components/modals.js',
35
+ '/src/components/toast.js',
36
+ '/src/constants.js',
37
+ '/src/filters.js',
38
+ '/src/state.js',
39
+ '/src/theme.js',
40
+ '/src/types.js',
41
+ '/src/utils.js',
42
+ '/src/views/activity.js',
43
+ '/src/views/admin.js',
44
+ '/src/views/auth.js',
45
+ '/src/views/calendar.js',
46
+ '/src/views/comments-audit.js',
47
+ '/src/views/config.js',
48
+ '/src/views/context.js',
49
+ '/src/views/create.js',
50
+ '/src/views/dedupe.js',
51
+ '/src/views/export.js',
52
+ '/src/views/github.js',
53
+ '/src/views/graph-canvas.js',
54
+ '/src/views/graph.js',
55
+ '/src/views/groups.js',
56
+ '/src/views/guide.js',
57
+ '/src/views/health.js',
58
+ '/src/views/items.js',
59
+ '/src/views/normalize.js',
60
+ '/src/views/plan.js',
61
+ '/src/views/plan-execution.js',
62
+ '/src/views/projects.js',
63
+ '/src/views/router.js',
64
+ '/src/views/search.js',
65
+ '/src/views/settings.js',
66
+ '/src/views/shared.js',
67
+ '/src/views/sharing.js',
68
+ '/src/views/stats.js',
69
+ '/src/views/templates.js',
70
+ '/src/views/validate.js',
54
71
  ];
55
-
56
72
  // ── Offline fallback page ──
57
73
  const OFFLINE_HTML = `<!DOCTYPE html>
58
74
  <html lang="en">
@@ -79,240 +95,288 @@ const OFFLINE_HTML = `<!DOCTYPE html>
79
95
  </div>
80
96
  </body>
81
97
  </html>`;
82
-
83
- // ── IndexedDB Mutation Queue ──
84
- function openMutationDB(): Promise<IDBDatabase> {
85
- return new Promise((resolve, reject) => {
86
- const request = indexedDB.open(MUTATION_DB, 1);
87
- request.onupgradeneeded = () => {
88
- const db = request.result;
89
- if (!db.objectStoreNames.contains(MUTATION_STORE)) {
90
- const store = db.createObjectStore(MUTATION_STORE, { keyPath: 'id', autoIncrement: true });
91
- store.createIndex('timestamp', 'timestamp', { unique: false });
92
- }
93
- };
94
- request.onsuccess = () => resolve(request.result);
95
- request.onerror = () => reject(request.error);
96
- });
97
- }
98
-
99
- async function queueMutation(method: string, path: string, body: unknown): Promise<void> {
100
- try {
101
- const db = await openMutationDB();
102
- const tx = db.transaction(MUTATION_STORE, 'readwrite');
103
- const store = tx.objectStore(MUTATION_STORE);
104
- store.add({
105
- method,
106
- path,
107
- body: body !== undefined ? JSON.stringify(body) : null,
108
- timestamp: Date.now(),
98
+ function openMutationDB() {
99
+ return new Promise((resolve, reject) => {
100
+ const request = indexedDB.open(MUTATION_DB, 1);
101
+ request.onupgradeneeded = () => {
102
+ const db = request.result;
103
+ if (!db.objectStoreNames.contains(MUTATION_STORE)) {
104
+ const store = db.createObjectStore(MUTATION_STORE, { keyPath: 'id', autoIncrement: true });
105
+ store.createIndex('timestamp', 'timestamp', { unique: false });
106
+ }
107
+ };
108
+ request.onsuccess = () => resolve(request.result);
109
+ request.onerror = () => reject(request.error);
109
110
  });
110
- } catch (e) {
111
- // If IndexedDB fails, mutations are lost — graceful degradation
112
- console.warn('Failed to queue mutation for offline:', e);
113
- }
114
111
  }
115
-
116
- async function getQueuedMutations(): Promise<Array<{ id: number; method: string; path: string; body: string | null; timestamp: number }>> {
117
- try {
118
- const db = await openMutationDB();
119
- const tx = db.transaction(MUTATION_STORE, 'readonly');
120
- const store = tx.objectStore(MUTATION_STORE);
112
+ /**
113
+ * Resolve when an IDB transaction has durably committed; reject on abort or
114
+ * error so callers can never mistake a half-applied (or never-applied) write
115
+ * for a persisted mutation. The transaction auto-commits once all queued
116
+ * requests settle, so awaiting this is sufficient to know the write is durable.
117
+ */
118
+ function transactionDone(tx) {
121
119
  return new Promise((resolve, reject) => {
122
- const request = store.getAll();
123
- request.onsuccess = () => resolve(request.result);
124
- request.onerror = () => reject(request.error);
120
+ tx.oncomplete = () => resolve();
121
+ tx.onabort = () => reject(tx.error ?? new Error('IDB transaction aborted'));
122
+ tx.onerror = () => reject(tx.error ?? new Error('IDB transaction error'));
125
123
  });
126
- } catch {
127
- return [];
128
- }
129
124
  }
130
-
131
- async function clearMutation(id: number): Promise<void> {
132
- try {
133
- const db = await openMutationDB();
134
- const tx = db.transaction(MUTATION_STORE, 'readwrite');
135
- tx.objectStore(MUTATION_STORE).delete(id);
136
- } catch { /* ignore */ }
125
+ /**
126
+ * Queue a mutation for later replay. Returns `true` only when the mutation has
127
+ * been durably persisted to IndexedDB; `false` when persistence failed so the
128
+ * caller can respond with an explicit error instead of claiming it was queued.
129
+ */
130
+ async function queueMutation(method, path, body) {
131
+ try {
132
+ const db = await openMutationDB();
133
+ const tx = db.transaction(MUTATION_STORE, 'readwrite');
134
+ const store = tx.objectStore(MUTATION_STORE);
135
+ const record = {
136
+ method,
137
+ path,
138
+ body: body !== undefined ? JSON.stringify(body) : null,
139
+ timestamp: Date.now(),
140
+ };
141
+ store.add(record);
142
+ // Await the transaction commit (not just the request dispatch) so the
143
+ // promise only resolves after the mutation is durably persisted. A request
144
+ // error triggers a transaction abort, surfaced via `transactionDone`.
145
+ await transactionDone(tx);
146
+ return true;
147
+ }
148
+ catch (e) {
149
+ // Persistence failed — do NOT claim the mutation was queued.
150
+ console.warn('Failed to queue mutation for offline:', e);
151
+ return false;
152
+ }
137
153
  }
138
-
139
- async function flushMutationQueue(): Promise<void> {
140
- const mutations = await getQueuedMutations();
141
- if (mutations.length === 0) return;
142
-
143
- for (const mut of mutations) {
154
+ async function getQueuedMutations() {
144
155
  try {
145
- const opts: RequestInit = {
146
- method: mut.method,
147
- headers: { 'Content-Type': 'application/json' },
148
- credentials: 'include',
149
- };
150
- if (mut.body !== null) opts.body = mut.body;
151
- const res = await fetch('/api' + mut.path, opts);
152
- if (res.ok) {
153
- await clearMutation(mut.id);
154
- } else {
155
- console.warn('Offline mutation failed:', mut.method, mut.path, res.status);
156
- // Stop processing on first failure — try again later
157
- break;
158
- }
159
- } catch {
160
- // Network failed again stop processing
161
- break;
156
+ const db = await openMutationDB();
157
+ const tx = db.transaction(MUTATION_STORE, 'readonly');
158
+ const store = tx.objectStore(MUTATION_STORE);
159
+ return await new Promise((resolve, reject) => {
160
+ const request = store.getAll();
161
+ request.onsuccess = () => resolve(request.result);
162
+ request.onerror = () => reject(request.error);
163
+ });
164
+ }
165
+ catch {
166
+ return [];
167
+ }
168
+ }
169
+ /**
170
+ * Remove a replayed mutation from the queue. Returns `true` only when the
171
+ * deletion has committed, so the caller can stop replay on a persistence
172
+ * failure instead of silently leaving duplicate entries to be retried.
173
+ */
174
+ async function clearMutation(id) {
175
+ try {
176
+ const db = await openMutationDB();
177
+ const tx = db.transaction(MUTATION_STORE, 'readwrite');
178
+ tx.objectStore(MUTATION_STORE).delete(id);
179
+ await transactionDone(tx);
180
+ return true;
181
+ }
182
+ catch (e) {
183
+ console.warn('Failed to clear queued mutation:', e);
184
+ return false;
185
+ }
186
+ }
187
+ async function flushMutationQueue() {
188
+ const mutations = await getQueuedMutations();
189
+ if (mutations.length === 0)
190
+ return;
191
+ for (const mut of mutations) {
192
+ try {
193
+ const opts = {
194
+ method: mut.method,
195
+ headers: { 'Content-Type': 'application/json' },
196
+ credentials: 'include',
197
+ };
198
+ if (mut.body !== null)
199
+ opts.body = mut.body;
200
+ const res = await fetch('/api' + mut.path, opts);
201
+ if (res.ok) {
202
+ const cleared = await clearMutation(mut.id);
203
+ if (!cleared) {
204
+ // Could not remove the replayed mutation from the queue — stop to
205
+ // avoid duplicate replays on the next flush; it will retry later.
206
+ break;
207
+ }
208
+ }
209
+ else {
210
+ console.warn('Offline mutation failed:', mut.method, mut.path, res.status);
211
+ // Stop processing on first failure — try again later
212
+ break;
213
+ }
214
+ }
215
+ catch {
216
+ // Network failed again — stop processing
217
+ break;
218
+ }
219
+ }
220
+ // Notify clients about replayed mutations
221
+ const remaining = await getQueuedMutations();
222
+ const clients = await sw.clients.matchAll();
223
+ if (remaining.length === 0 && mutations.length > 0) {
224
+ clients.forEach((client) => {
225
+ client.postMessage({ type: 'MUTATIONS_REPLAYED', count: mutations.length });
226
+ });
227
+ }
228
+ else if (remaining.length > 0) {
229
+ clients.forEach((client) => {
230
+ client.postMessage({
231
+ type: 'MUTATIONS_PARTIAL',
232
+ replayed: mutations.length - remaining.length,
233
+ remaining: remaining.length,
234
+ });
235
+ });
162
236
  }
163
- }
164
-
165
- // Notify clients about replayed mutations
166
- const remaining = await getQueuedMutations();
167
- if (remaining.length === 0 && mutations.length > 0) {
168
- const clients = await self.clients.matchAll();
169
- clients.forEach(client => {
170
- client.postMessage({ type: 'MUTATIONS_REPLAYED', count: mutations.length });
171
- });
172
- } else if (remaining.length > 0) {
173
- const clients = await self.clients.matchAll();
174
- clients.forEach(client => {
175
- client.postMessage({ type: 'MUTATIONS_PARTIAL', replayed: mutations.length - remaining.length, remaining: remaining.length });
176
- });
177
- }
178
237
  }
179
-
180
238
  // ── Install ──
181
- self.addEventListener('install', (event) => {
182
- event.waitUntil(
183
- caches.open(CACHE_NAME).then((cache) =>
184
- Promise.all(STATIC_ASSETS.map((asset) => cache.add(asset).catch(() => null)))
185
- )
186
- );
187
- self.skipWaiting();
239
+ sw.addEventListener('install', (event) => {
240
+ event.waitUntil(caches.open(CACHE_NAME).then((cache) => Promise.all(STATIC_ASSETS.map((asset) => cache.add(asset).catch(() => null)))));
241
+ sw.skipWaiting();
188
242
  });
189
-
190
243
  // ── Activate ──
191
- self.addEventListener('activate', (event) => {
192
- event.waitUntil(
193
- caches.keys().then((keys) =>
194
- Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)))
195
- )
196
- );
197
- self.clients.claim();
244
+ sw.addEventListener('activate', (event) => {
245
+ event.waitUntil(caches.keys().then((keys) => Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)))));
246
+ sw.clients.claim();
198
247
  });
199
-
200
248
  // ── Fetch strategy ──
201
- self.addEventListener('fetch', (event) => {
202
- const url = new URL(event.request.url);
203
-
204
- // API calls: try network, queue mutations if offline
205
- if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/healthz')) {
206
- // Queue write operations (POST, PUT, PATCH, DELETE) when offline
207
- if (event.request.method !== 'GET' && event.request.method !== 'HEAD') {
208
- event.respondWith(
209
- fetch(event.request).catch(async () => {
210
- // Network failed — queue the mutation for later
211
- let body: unknown = undefined;
212
- try {
213
- body = await event.request.clone().json();
214
- } catch { /* no body */ }
215
- await queueMutation(event.request.method, url.pathname.replace('/api', ''), body);
216
- return new Response(JSON.stringify({ queued: true, message: 'Request queued for when you are back online' }), {
217
- status: 202,
249
+ sw.addEventListener('fetch', (event) => {
250
+ const url = new URL(event.request.url);
251
+ // API calls: try network, queue mutations if offline
252
+ if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/healthz')) {
253
+ // Queue write operations (POST, PUT, PATCH, DELETE) when offline
254
+ if (event.request.method !== 'GET' && event.request.method !== 'HEAD') {
255
+ event.respondWith(fetch(event.request).catch(async () => {
256
+ // Network failed — queue the mutation for later.
257
+ let body = undefined;
258
+ try {
259
+ body = await event.request.clone().json();
260
+ }
261
+ catch { /* no body */ }
262
+ const queued = await queueMutation(event.request.method, url.pathname.replace('/api', ''), body);
263
+ if (queued) {
264
+ return new Response(JSON.stringify({ queued: true, message: 'Request queued for when you are back online' }), { status: 202, headers: { 'Content-Type': 'application/json' } });
265
+ }
266
+ // Persistence failed — do not claim the mutation was queued.
267
+ return new Response(JSON.stringify({ error: 'Offline and unable to queue mutation', queued: false }), { status: 503, headers: { 'Content-Type': 'application/json' } });
268
+ }));
269
+ return;
270
+ }
271
+ // GET/HEAD API calls: network-only, return offline JSON error
272
+ event.respondWith(fetch(event.request)
273
+ .catch(() => new Response(JSON.stringify({ error: 'Offline — check your connection', queued: 0 }), {
274
+ status: 503,
218
275
  headers: { 'Content-Type': 'application/json' },
219
- });
220
- })
221
- );
222
- return;
276
+ })));
277
+ return;
223
278
  }
224
-
225
- // GET/HEAD API calls: network-only, return offline JSON error
226
- event.respondWith(
227
- fetch(event.request)
228
- .catch(() => new Response(JSON.stringify({ error: 'Offline — check your connection', queued: 0 }), {
229
- status: 503,
230
- headers: { 'Content-Type': 'application/json' },
231
- }))
232
- );
233
- return;
234
- }
235
-
236
- // Navigation (SPA shell): network-first, cache fallback, offline page fallback
237
- if (event.request.mode === 'navigate') {
238
- event.respondWith(
239
- fetch(event.request)
240
- .then((res) => {
241
- if (res.ok) {
242
- const clone = res.clone();
243
- caches.open(CACHE_NAME).then((c) => c.put('/', clone));
244
- }
245
- return res;
246
- })
247
- .catch(async () => {
248
- // Try cached shell first
249
- const cached = await caches.match('/');
250
- if (cached) return cached;
251
- // Return offline fallback page
252
- return new Response(OFFLINE_HTML, {
253
- status: 503,
254
- headers: { 'Content-Type': 'text/html; charset=utf-8' },
255
- });
256
- })
257
- );
258
- return;
259
- }
260
-
261
- // Static assets: stale-while-revalidate
262
- if (
263
- url.pathname.endsWith('.css') ||
264
- url.pathname.endsWith('.js') ||
265
- url.pathname.endsWith('.json') ||
266
- url.pathname.endsWith('.png') ||
267
- url.pathname.endsWith('.svg') ||
268
- url.pathname.endsWith('.ico') ||
269
- url.pathname.endsWith('.woff2') ||
270
- url.hostname.includes('fonts.googleapis.com') ||
271
- url.hostname.includes('fonts.gstatic.com')
272
- ) {
273
- event.respondWith(
274
- caches.open(CACHE_NAME).then((cache) =>
275
- cache.match(event.request).then((cached) => {
276
- const fetched = fetch(event.request).then((res) => {
277
- if (res.ok) cache.put(event.request, res.clone());
279
+ // Navigation (SPA shell): network-first, cache fallback, offline page fallback
280
+ if (event.request.mode === 'navigate') {
281
+ event.respondWith(fetch(event.request)
282
+ .then((res) => {
283
+ if (res.ok) {
284
+ const clone = res.clone();
285
+ caches.open(CACHE_NAME).then((c) => c.put('/', clone));
286
+ }
278
287
  return res;
279
- }).catch(() => cached);
280
- return cached || fetched;
281
288
  })
282
- )
283
- );
284
- return;
285
- }
286
-
287
- // Default: network, fallback to cache
288
- event.respondWith(
289
- fetch(event.request)
290
- .catch(() => caches.match(event.request))
291
- );
289
+ .catch(async () => {
290
+ // Try cached shell first
291
+ const cached = await caches.match('/');
292
+ if (cached)
293
+ return cached;
294
+ // Return offline fallback page
295
+ return new Response(OFFLINE_HTML, {
296
+ status: 503,
297
+ headers: { 'Content-Type': 'text/html; charset=utf-8' },
298
+ });
299
+ }));
300
+ return;
301
+ }
302
+ // Static assets: stale-while-revalidate
303
+ if (url.pathname.endsWith('.css') ||
304
+ url.pathname.endsWith('.js') ||
305
+ url.pathname.endsWith('.json') ||
306
+ url.pathname.endsWith('.png') ||
307
+ url.pathname.endsWith('.svg') ||
308
+ url.pathname.endsWith('.ico') ||
309
+ url.pathname.endsWith('.woff2') ||
310
+ url.hostname.includes('fonts.googleapis.com') ||
311
+ url.hostname.includes('fonts.gstatic.com')) {
312
+ event.respondWith((async () => {
313
+ const cache = await caches.open(CACHE_NAME);
314
+ const cached = await cache.match(event.request);
315
+ // Kick off revalidation in the background. The network promise resolves
316
+ // to `undefined` on failure (no unsafe cast — the type is explicit).
317
+ const network = fetch(event.request)
318
+ .then((res) => {
319
+ if (res.ok)
320
+ void cache.put(event.request, res.clone());
321
+ return res;
322
+ })
323
+ .catch(() => undefined);
324
+ // Stale-while-revalidate: serve cached immediately when present.
325
+ if (cached) {
326
+ void network;
327
+ return cached;
328
+ }
329
+ // No cached entry — must wait for the network.
330
+ const res = await network;
331
+ if (res)
332
+ return res;
333
+ return new Response('Unavailable offline', {
334
+ status: 503,
335
+ headers: { 'Content-Type': 'text/plain; charset=utf-8' },
336
+ });
337
+ })());
338
+ return;
339
+ }
340
+ // Default: network, fallback to cache, then explicit 503.
341
+ event.respondWith((async () => {
342
+ try {
343
+ return await fetch(event.request);
344
+ }
345
+ catch {
346
+ const cached = await caches.match(event.request);
347
+ if (cached)
348
+ return cached;
349
+ return new Response('Unavailable offline', {
350
+ status: 503,
351
+ headers: { 'Content-Type': 'text/plain; charset=utf-8' },
352
+ });
353
+ }
354
+ })());
292
355
  });
293
-
294
356
  // ── Messages ──
295
- self.addEventListener('message', (event) => {
296
- if (event.data && event.data.type === 'SKIP_WAITING') {
297
- self.skipWaiting();
298
- }
299
- if (event.data && event.data.type === 'CACHE_URLS') {
300
- const urls = event.data.urls || [];
301
- caches.open(CACHE_NAME).then((cache) => cache.addAll(urls).catch(() => {}));
302
- }
303
- if (event.data && event.data.type === 'FLUSH_QUEUE') {
304
- flushMutationQueue();
305
- }
357
+ sw.addEventListener('message', (event) => {
358
+ const data = event.data;
359
+ if (data && data.type === 'SKIP_WAITING') {
360
+ sw.skipWaiting();
361
+ }
362
+ if (data && data.type === 'CACHE_URLS') {
363
+ const urls = data.urls ?? [];
364
+ caches.open(CACHE_NAME).then((cache) => cache.addAll(urls).catch(() => { }));
365
+ }
366
+ if (data && data.type === 'FLUSH_QUEUE') {
367
+ void flushMutationQueue();
368
+ }
306
369
  });
307
-
308
370
  // ── Background sync ──
309
- self.addEventListener('sync', (event) => {
310
- if (event.tag === 'pm-sync') {
311
- event.waitUntil(flushMutationQueue());
312
- }
371
+ // The `WebWorker` lib has no `SyncEvent`, so receive the generic Event and
372
+ // narrow to our minimal SyncEvent interface (no `any`).
373
+ sw.addEventListener('sync', (event) => {
374
+ const syncEvent = event;
375
+ if (syncEvent.tag === 'pm-sync') {
376
+ syncEvent.waitUntil(flushMutationQueue());
377
+ }
313
378
  });
314
-
315
379
  // ── Online event: flush queue when connectivity returns ──
316
- self.addEventListener('online', () => {
317
- flushMutationQueue();
380
+ sw.addEventListener('online', () => {
381
+ void flushMutationQueue();
318
382
  });