@unbrained/pm-web 2026.7.11 → 2026.7.17-1

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