@sweidos/eidos 2.1.0 → 2.3.0

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 (56) hide show
  1. package/README.md +290 -22
  2. package/dist/action.d.ts +22 -0
  3. package/dist/action.js +47 -47
  4. package/dist/action.js.map +1 -1
  5. package/dist/async-storage-adapter.d.ts +25 -0
  6. package/dist/debug.d.ts +46 -0
  7. package/dist/debug.js +43 -0
  8. package/dist/debug.js.map +1 -0
  9. package/dist/devtools.js +350 -21
  10. package/dist/eidos-sw.js +60 -19
  11. package/dist/eidos.cjs +5 -5
  12. package/dist/eidos.cjs.map +1 -1
  13. package/dist/idb.d.ts +10 -0
  14. package/dist/index.d.ts +20 -586
  15. package/dist/index.js +47 -41
  16. package/dist/internal/url-base64.d.ts +2 -0
  17. package/dist/query.d.ts +1 -2
  18. package/dist/queue-storage.d.ts +12 -0
  19. package/dist/queue-sync.d.ts +32 -0
  20. package/dist/react/Provider.d.ts +16 -0
  21. package/dist/react/ProviderRN.d.ts +0 -1
  22. package/dist/react/hooks.d.ts +51 -0
  23. package/dist/react/hooks.js +30 -27
  24. package/dist/react/hooks.js.map +1 -1
  25. package/dist/replay.d.ts +15 -0
  26. package/dist/resource.d.ts +32 -0
  27. package/dist/resource.js +80 -78
  28. package/dist/resource.js.map +1 -1
  29. package/dist/runtime-rn.d.ts +0 -1
  30. package/dist/runtime.d.ts +39 -0
  31. package/dist/runtime.js +32 -24
  32. package/dist/runtime.js.map +1 -1
  33. package/dist/store-slices.d.ts +26 -0
  34. package/dist/store-slices.js +31 -20
  35. package/dist/store-slices.js.map +1 -1
  36. package/dist/store.d.ts +15 -0
  37. package/dist/store.js +22 -19
  38. package/dist/store.js.map +1 -1
  39. package/dist/stores.d.ts +64 -0
  40. package/dist/stores.js +31 -22
  41. package/dist/stores.js.map +1 -1
  42. package/dist/sveltekit.d.ts +0 -1
  43. package/dist/sw-bridge.d.ts +24 -0
  44. package/dist/sw-bridge.js +69 -54
  45. package/dist/sw-bridge.js.map +1 -1
  46. package/dist/testing.cjs +3 -2
  47. package/dist/testing.d.ts +1 -2
  48. package/dist/testing.js +3 -2
  49. package/dist/types.d.ts +305 -0
  50. package/dist/types.js +19 -8
  51. package/dist/types.js.map +1 -1
  52. package/dist/version.d.ts +1 -0
  53. package/dist/version.js +1 -1
  54. package/dist/version.js.map +1 -1
  55. package/dist/vite.d.ts +0 -1
  56. package/package.json +9 -7
package/dist/devtools.js CHANGED
@@ -1,6 +1,31 @@
1
1
  "use client";
2
- import { useCallback, useState, useSyncExternalStore } from "react";
2
+ import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
3
3
  import { jsx, jsxs } from "react/jsx-runtime";
4
+ //#region src/types.ts
5
+ function emptyReliabilityStats() {
6
+ return {
7
+ queued: 0,
8
+ succeeded: 0,
9
+ failed: 0,
10
+ retried: 0,
11
+ conflicted: 0,
12
+ cancelled: 0
13
+ };
14
+ }
15
+ /** Single pass over the queue — avoids separate .filter() calls per status. */
16
+ function countQueueByStatus(queue) {
17
+ let pending = 0, failed = 0, replaying = 0;
18
+ for (const q of queue) if (q.status === "pending") pending++;
19
+ else if (q.status === "failed") failed++;
20
+ else if (q.status === "replaying") replaying++;
21
+ return {
22
+ pending,
23
+ failed,
24
+ replaying,
25
+ total: queue.length
26
+ };
27
+ }
28
+ //#endregion
4
29
  //#region src/store-slices.ts
5
30
  function createResourceActions(set) {
6
31
  return {
@@ -39,6 +64,15 @@ function createQueueActions(set) {
39
64
  hydrateQueue: (items) => set(() => ({ queue: items }))
40
65
  };
41
66
  }
67
+ function createReliabilityActions(set) {
68
+ return {
69
+ recordReliabilityEvent: (event) => set((s) => ({ reliability: {
70
+ ...s.reliability,
71
+ [event]: s.reliability[event] + 1
72
+ } })),
73
+ resetReliabilityStats: () => set(() => ({ reliability: emptyReliabilityStats() }))
74
+ };
75
+ }
42
76
  //#endregion
43
77
  //#region src/store.ts
44
78
  var _state;
@@ -59,13 +93,15 @@ _state = {
59
93
  swError: void 0,
60
94
  resources: {},
61
95
  queue: [],
96
+ reliability: emptyReliabilityStats(),
62
97
  setOnline: (isOnline) => _set(() => ({ isOnline })),
63
98
  setSwStatus: (swStatus, swError) => _set(() => ({
64
99
  swStatus,
65
100
  swError
66
101
  })),
67
102
  ...createResourceActions(_set),
68
- ...createQueueActions(_set)
103
+ ...createQueueActions(_set),
104
+ ...createReliabilityActions(_set)
69
105
  };
70
106
  function _getState() {
71
107
  return _state;
@@ -89,21 +125,6 @@ var useEidosStore = {
89
125
  }
90
126
  };
91
127
  //#endregion
92
- //#region src/types.ts
93
- /** Single pass over the queue — avoids separate .filter() calls per status. */
94
- function countQueueByStatus(queue) {
95
- let pending = 0, failed = 0, replaying = 0;
96
- for (const q of queue) if (q.status === "pending") pending++;
97
- else if (q.status === "failed") failed++;
98
- else if (q.status === "replaying") replaying++;
99
- return {
100
- pending,
101
- failed,
102
- replaying,
103
- total: queue.length
104
- };
105
- }
106
- //#endregion
107
128
  //#region src/react/hooks.ts
108
129
  function useStore(selector) {
109
130
  const fn = selector ?? ((s) => s);
@@ -146,10 +167,29 @@ function useEidosQueueStats() {
146
167
  total: +t
147
168
  };
148
169
  }
170
+ /**
171
+ * Calls `callback` once each time the action queue drains from non-empty → 0.
172
+ * Stable callback reference not required — always calls the latest version.
173
+ * Use for "all offline actions synced!" toasts.
174
+ *
175
+ * @example
176
+ * useEidosOnDrain(() => toast.success('All offline actions synced!'))
177
+ */
178
+ /**
179
+ * Cumulative, session-scoped `neverLose` queue outcome counters — opt-in
180
+ * reliability telemetry for dashboards/devtools. Re-renders only when a
181
+ * counter changes.
182
+ */
183
+ function useEidosReliabilityStats() {
184
+ return useStore((s) => s.reliability);
185
+ }
149
186
  //#endregion
150
187
  //#region src/sw-bridge.ts
151
188
  var _registration = null;
152
189
  var _pendingMessages = [];
190
+ function getSwRegistration() {
191
+ return _registration;
192
+ }
153
193
  function sendToWorker(message) {
154
194
  const sw = _registration?.active;
155
195
  if (sw) sw.postMessage(message);
@@ -162,6 +202,14 @@ function setOfflineSimulation(enabled) {
162
202
  });
163
203
  useEidosStore.getState().setOnline(!enabled);
164
204
  }
205
+ /**
206
+ * Tells the waiting service worker to activate immediately, then reloads the page.
207
+ * Only relevant when `skipWaiting: false` — call this after the user confirms
208
+ * a "reload to update" toast shown via `onUpdateAvailable`.
209
+ */
210
+ function triggerSwUpdate() {
211
+ _registration?.waiting?.postMessage({ type: "EIDOS_SKIP_WAITING" });
212
+ }
165
213
  //#endregion
166
214
  //#region src/idb.ts
167
215
  var DB_NAME = "eidos";
@@ -403,6 +451,7 @@ async function _markSucceeded(item, store) {
403
451
  status: "succeeded",
404
452
  completedAt
405
453
  });
454
+ store.recordReliabilityEvent("succeeded");
406
455
  broadcastQueueSync({
407
456
  type: "update",
408
457
  id: item.id,
@@ -453,6 +502,7 @@ async function _resolveConflict(item, store, err) {
453
502
  }
454
503
  if (resolution === "skip") {
455
504
  store.removeQueueItem(item.id);
505
+ store.recordReliabilityEvent("conflicted");
456
506
  broadcastQueueSync({
457
507
  type: "remove",
458
508
  id: item.id
@@ -480,6 +530,7 @@ async function _scheduleRetryOrFail(item, store, err) {
480
530
  retryCount
481
531
  };
482
532
  store.updateQueueItem(item.id, update);
533
+ store.recordReliabilityEvent("failed");
483
534
  broadcastQueueSync({
484
535
  type: "update",
485
536
  id: item.id,
@@ -499,6 +550,7 @@ async function _scheduleRetryOrFail(item, store, err) {
499
550
  nextRetryAt: Date.now() + backoffMs(retryCount)
500
551
  };
501
552
  store.updateQueueItem(item.id, update);
553
+ store.recordReliabilityEvent("retried");
502
554
  broadcastQueueSync({
503
555
  type: "update",
504
556
  id: item.id,
@@ -529,6 +581,7 @@ async function _replayItem(item, store) {
529
581
  } catch (err) {
530
582
  if (isAbortError(err)) {
531
583
  store.removeQueueItem(item.id);
584
+ store.recordReliabilityEvent("cancelled");
532
585
  broadcastQueueSync({
533
586
  type: "remove",
534
587
  id: item.id
@@ -709,7 +762,10 @@ var ICONS = {
709
762
  arrowDown: "M12 5v14M19 12l-7 7-7-7",
710
763
  clock: "M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20zM12 6v6l4 2",
711
764
  x: "M18 6 6 18M6 6l12 12",
712
- rotateCcw: "M3 12a9 9 0 1 0 2.6-6.4M3 12V5m0 7h7"
765
+ rotateCcw: "M3 12a9 9 0 1 0 2.6-6.4M3 12V5m0 7h7",
766
+ activity: "M22 12h-4l-3 9L9 3l-3 9H2",
767
+ shield: "M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z",
768
+ refreshCw: "M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8M21 3v5h-5M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16M3 21v-5h5"
713
769
  };
714
770
  function positionStyle(p) {
715
771
  const base = {
@@ -746,6 +802,7 @@ function EidosDevtools({ position = "bottom-right", defaultOpen = false }) {
746
802
  const { pending, failed, replaying } = useEidosQueueStats();
747
803
  const resources = useEidosResources();
748
804
  const resourceList = Object.values(resources);
805
+ const reliability = useEidosReliabilityStats();
749
806
  const badgeCount = pending + failed + replaying;
750
807
  const toggleOffline = useCallback(() => {
751
808
  const next = !simOffline;
@@ -949,7 +1006,12 @@ function EidosDevtools({ position = "bottom-right", defaultOpen = false }) {
949
1006
  flexShrink: 0,
950
1007
  background: C.surface
951
1008
  },
952
- children: ["queue", "cache"].map((t) => /* @__PURE__ */ jsx("button", {
1009
+ children: [
1010
+ "queue",
1011
+ "cache",
1012
+ "reliability",
1013
+ "sw"
1014
+ ].map((t) => /* @__PURE__ */ jsx("button", {
953
1015
  role: "tab",
954
1016
  "aria-selected": tab === t,
955
1017
  onClick: () => setTab(t),
@@ -970,7 +1032,7 @@ function EidosDevtools({ position = "bottom-right", defaultOpen = false }) {
970
1032
  letterSpacing: "0.05em",
971
1033
  transition: "color 0.15s, border-color 0.15s"
972
1034
  },
973
- children: t === "queue" ? `Queue (${queue.length})` : `Cache (${resourceList.length})`
1035
+ children: t === "queue" ? `Queue (${queue.length})` : t === "cache" ? `Cache (${resourceList.length})` : t === "reliability" ? "Reliability" : "SW"
974
1036
  }, t))
975
1037
  }),
976
1038
  /* @__PURE__ */ jsx("div", {
@@ -983,7 +1045,7 @@ function EidosDevtools({ position = "bottom-right", defaultOpen = false }) {
983
1045
  queue,
984
1046
  onReplay: handleReplay,
985
1047
  onClear: handleClear
986
- }) : /* @__PURE__ */ jsx(CacheTab, { resources: resourceList })
1048
+ }) : tab === "cache" ? /* @__PURE__ */ jsx(CacheTab, { resources: resourceList }) : tab === "reliability" ? /* @__PURE__ */ jsx(ReliabilityTab, { stats: reliability }) : /* @__PURE__ */ jsx(SwTab, { resources: resourceList })
987
1049
  })
988
1050
  ]
989
1051
  }), toggleBtn]
@@ -1235,5 +1297,272 @@ function CacheTab({ resources }) {
1235
1297
  ]
1236
1298
  }, res.url)) });
1237
1299
  }
1300
+ function ReliabilityTab({ stats }) {
1301
+ const rows = [
1302
+ {
1303
+ label: "Queued",
1304
+ key: "queued",
1305
+ color: C.blue
1306
+ },
1307
+ {
1308
+ label: "Succeeded",
1309
+ key: "succeeded",
1310
+ color: C.green
1311
+ },
1312
+ {
1313
+ label: "Retried",
1314
+ key: "retried",
1315
+ color: C.yellow
1316
+ },
1317
+ {
1318
+ label: "Failed",
1319
+ key: "failed",
1320
+ color: C.red
1321
+ },
1322
+ {
1323
+ label: "Conflicted",
1324
+ key: "conflicted",
1325
+ color: C.purple
1326
+ },
1327
+ {
1328
+ label: "Cancelled",
1329
+ key: "cancelled",
1330
+ color: C.muted
1331
+ }
1332
+ ];
1333
+ const total = stats.queued;
1334
+ const successRate = total > 0 ? Math.round(stats.succeeded / total * 100) : null;
1335
+ return /* @__PURE__ */ jsxs("div", { children: [
1336
+ /* @__PURE__ */ jsxs("div", {
1337
+ style: {
1338
+ display: "flex",
1339
+ alignItems: "center",
1340
+ gap: 8,
1341
+ padding: "8px 12px",
1342
+ borderBottom: `1px solid ${C.border}`
1343
+ },
1344
+ children: [/* @__PURE__ */ jsx("span", {
1345
+ style: {
1346
+ color: C.muted,
1347
+ display: "inline-flex"
1348
+ },
1349
+ children: /* @__PURE__ */ jsx(Icon, {
1350
+ path: ICONS.activity,
1351
+ size: 12
1352
+ })
1353
+ }), /* @__PURE__ */ jsx("span", {
1354
+ style: {
1355
+ color: C.muted,
1356
+ fontSize: 10
1357
+ },
1358
+ children: successRate === null ? "No queued actions yet this session" : `${successRate}% succeeded`
1359
+ })]
1360
+ }),
1361
+ rows.map(({ label, key, color }) => /* @__PURE__ */ jsxs("div", {
1362
+ style: {
1363
+ display: "flex",
1364
+ alignItems: "center",
1365
+ justifyContent: "space-between",
1366
+ padding: "7px 12px",
1367
+ borderBottom: `1px solid ${C.border}`
1368
+ },
1369
+ children: [/* @__PURE__ */ jsx("span", {
1370
+ style: { color: C.text },
1371
+ children: label
1372
+ }), /* @__PURE__ */ jsx("span", {
1373
+ style: pill(color),
1374
+ children: stats[key]
1375
+ })]
1376
+ }, key)),
1377
+ /* @__PURE__ */ jsxs("div", {
1378
+ style: {
1379
+ padding: "8px 12px",
1380
+ color: C.muted,
1381
+ fontSize: 10
1382
+ },
1383
+ children: [
1384
+ "Session-only counters — reset on reload. Wire up",
1385
+ " ",
1386
+ /* @__PURE__ */ jsx("code", {
1387
+ style: { color: C.cyan },
1388
+ children: "onReliabilityReport"
1389
+ }),
1390
+ " in",
1391
+ " ",
1392
+ /* @__PURE__ */ jsx("code", {
1393
+ style: { color: C.cyan },
1394
+ children: "initEidos()"
1395
+ }),
1396
+ " to forward these to analytics."
1397
+ ]
1398
+ })
1399
+ ] });
1400
+ }
1401
+ function readSwSnapshot() {
1402
+ const reg = getSwRegistration();
1403
+ if (!reg) return {
1404
+ activeUrl: null,
1405
+ hasWaiting: false,
1406
+ hasInstalling: false
1407
+ };
1408
+ return {
1409
+ activeUrl: reg.active?.scriptURL ?? null,
1410
+ hasWaiting: reg.waiting !== null,
1411
+ hasInstalling: reg.installing !== null
1412
+ };
1413
+ }
1414
+ function SwTab({ resources }) {
1415
+ const [snap, setSnap] = useState(readSwSnapshot);
1416
+ useEffect(() => {
1417
+ const id = setInterval(() => setSnap(readSwSnapshot()), 1e3);
1418
+ return () => clearInterval(id);
1419
+ }, []);
1420
+ const buckets = resources.reduce((acc, res) => {
1421
+ const name = res.strategy.cacheName;
1422
+ acc[name] = (acc[name] ?? 0) + 1;
1423
+ return acc;
1424
+ }, {});
1425
+ const bucketList = Object.entries(buckets);
1426
+ const swState = snap.hasInstalling ? "installing" : snap.hasWaiting ? "waiting" : snap.activeUrl ? "active" : "none";
1427
+ const stateColor = swState === "active" ? C.green : swState === "waiting" || swState === "installing" ? C.yellow : C.muted;
1428
+ const shortUrl = snap.activeUrl ? snap.activeUrl.replace(/^https?:\/\/[^/]+/, "") || snap.activeUrl : null;
1429
+ return /* @__PURE__ */ jsxs("div", { children: [
1430
+ /* @__PURE__ */ jsxs("div", {
1431
+ style: {
1432
+ display: "flex",
1433
+ alignItems: "center",
1434
+ gap: 8,
1435
+ padding: "8px 12px",
1436
+ borderBottom: `1px solid ${C.border}`
1437
+ },
1438
+ children: [
1439
+ /* @__PURE__ */ jsx("span", {
1440
+ style: {
1441
+ color: C.muted,
1442
+ display: "inline-flex"
1443
+ },
1444
+ children: /* @__PURE__ */ jsx(Icon, {
1445
+ path: ICONS.shield,
1446
+ size: 12
1447
+ })
1448
+ }),
1449
+ /* @__PURE__ */ jsx("span", {
1450
+ style: pill(stateColor),
1451
+ children: swState
1452
+ }),
1453
+ shortUrl !== null ? /* @__PURE__ */ jsx("span", {
1454
+ title: snap.activeUrl ?? void 0,
1455
+ style: {
1456
+ flex: 1,
1457
+ color: C.muted,
1458
+ fontSize: 10,
1459
+ overflow: "hidden",
1460
+ textOverflow: "ellipsis",
1461
+ whiteSpace: "nowrap"
1462
+ },
1463
+ children: shortUrl
1464
+ }) : /* @__PURE__ */ jsx("span", {
1465
+ style: {
1466
+ flex: 1,
1467
+ color: C.muted,
1468
+ fontSize: 10
1469
+ },
1470
+ children: "No SW registered"
1471
+ }),
1472
+ /* @__PURE__ */ jsx("button", {
1473
+ onClick: () => setSnap(readSwSnapshot()),
1474
+ title: "Refresh SW state",
1475
+ "aria-label": "Refresh SW state",
1476
+ ...withFocusRing(),
1477
+ style: {
1478
+ ...btn("ghost"),
1479
+ padding: "2px 6px",
1480
+ minHeight: 20
1481
+ },
1482
+ children: /* @__PURE__ */ jsx(Icon, {
1483
+ path: ICONS.refreshCw,
1484
+ size: 10
1485
+ })
1486
+ })
1487
+ ]
1488
+ }),
1489
+ snap.hasWaiting ? /* @__PURE__ */ jsxs("div", {
1490
+ style: {
1491
+ display: "flex",
1492
+ alignItems: "center",
1493
+ gap: 8,
1494
+ padding: "8px 12px",
1495
+ borderBottom: `1px solid ${C.border}`,
1496
+ background: `${C.yellow}11`
1497
+ },
1498
+ children: [/* @__PURE__ */ jsx("span", {
1499
+ style: {
1500
+ color: C.yellow,
1501
+ fontSize: 10,
1502
+ flex: 1
1503
+ },
1504
+ children: "Update ready — new SW is waiting to activate."
1505
+ }), /* @__PURE__ */ jsx("button", {
1506
+ onClick: triggerSwUpdate,
1507
+ title: "Activate the waiting SW now",
1508
+ ...withFocusRing(),
1509
+ style: {
1510
+ ...btn("primary"),
1511
+ minHeight: 22,
1512
+ fontSize: 10
1513
+ },
1514
+ children: "Force update"
1515
+ })]
1516
+ }) : null,
1517
+ /* @__PURE__ */ jsxs("div", {
1518
+ style: {
1519
+ padding: "6px 12px",
1520
+ color: C.muted,
1521
+ fontSize: 10,
1522
+ borderBottom: `1px solid ${C.border}`
1523
+ },
1524
+ children: [
1525
+ "Cache buckets (",
1526
+ bucketList.length,
1527
+ ")"
1528
+ ]
1529
+ }),
1530
+ bucketList.length === 0 ? /* @__PURE__ */ jsx("div", {
1531
+ style: {
1532
+ padding: "16px 12px",
1533
+ textAlign: "center",
1534
+ color: C.muted,
1535
+ fontSize: 10
1536
+ },
1537
+ children: "No resources registered"
1538
+ }) : bucketList.map(([name, count]) => /* @__PURE__ */ jsxs("div", {
1539
+ style: {
1540
+ display: "flex",
1541
+ alignItems: "center",
1542
+ justifyContent: "space-between",
1543
+ padding: "7px 12px",
1544
+ borderBottom: `1px solid ${C.border}`
1545
+ },
1546
+ children: [/* @__PURE__ */ jsx("span", {
1547
+ style: {
1548
+ color: C.text,
1549
+ fontSize: 10,
1550
+ overflow: "hidden",
1551
+ textOverflow: "ellipsis",
1552
+ whiteSpace: "nowrap",
1553
+ flex: 1
1554
+ },
1555
+ children: name
1556
+ }), /* @__PURE__ */ jsxs("span", {
1557
+ style: pill(C.blue),
1558
+ children: [
1559
+ count,
1560
+ " resource",
1561
+ count !== 1 ? "s" : ""
1562
+ ]
1563
+ })]
1564
+ }, name))
1565
+ ] });
1566
+ }
1238
1567
  //#endregion
1239
1568
  export { EidosDevtools };
package/dist/eidos-sw.js CHANGED
@@ -1,4 +1,4 @@
1
- //#region ../core/src/internal/url-base64.ts
1
+ //#region src/internal/url-base64.ts
2
2
  /** Decodes a base64url string (e.g. a VAPID public key) into raw bytes. */
3
3
  function urlBase64ToUint8Array(base64Url) {
4
4
  const base64 = (base64Url + "=".repeat((4 - base64Url.length % 4) % 4)).replace(/-/g, "+").replace(/_/g, "/");
@@ -13,9 +13,7 @@ var runtimeConfig = {
13
13
  resources: /* @__PURE__ */ new Map(),
14
14
  simulateOffline: false
15
15
  };
16
- self.addEventListener("install", (event) => {
17
- event.waitUntil(self.skipWaiting());
18
- });
16
+ self.addEventListener("install", () => {});
19
17
  self.addEventListener("activate", (event) => {
20
18
  event.waitUntil(Promise.all([self.clients.claim(), caches.keys().then((keys) => Promise.all(keys.filter((k) => k.startsWith(CACHE_PREFIX) && !k.endsWith(CACHE_VERSION)).map((k) => caches.delete(k))))]));
21
19
  });
@@ -29,7 +27,9 @@ self.addEventListener("message", (event) => {
29
27
  runtimeConfig.resources.set(url, {
30
28
  strategy: data.strategy,
31
29
  cacheName: data.cacheName ?? `${CACHE_PREFIX}-resources-${CACHE_VERSION}`,
32
- ...patternSrc !== void 0 && { pattern: new RegExp(patternSrc) }
30
+ ...patternSrc !== void 0 && { pattern: new RegExp(patternSrc) },
31
+ ...data.maxAge !== void 0 && { maxAge: data.maxAge },
32
+ ...data.maxEntries !== void 0 && { maxEntries: data.maxEntries }
33
33
  });
34
34
  event.source?.postMessage({
35
35
  type: "EIDOS_RESOURCE_REGISTERED",
@@ -65,6 +65,9 @@ self.addEventListener("message", (event) => {
65
65
  });
66
66
  break;
67
67
  }
68
+ case "EIDOS_SKIP_WAITING":
69
+ self.skipWaiting();
70
+ break;
68
71
  case "EIDOS_PING":
69
72
  event.source?.postMessage({ type: "EIDOS_PONG" });
70
73
  break;
@@ -88,7 +91,7 @@ self.addEventListener("fetch", (event) => {
88
91
  }
89
92
  if (!reg) return;
90
93
  if (reg.strategy === "stale-while-revalidate" && !runtimeConfig.simulateOffline) {
91
- event.respondWith(staleWhileRevalidate(event, event.request, pathname, reg.cacheName));
94
+ event.respondWith(staleWhileRevalidate(event, event.request, pathname, reg));
92
95
  return;
93
96
  }
94
97
  event.respondWith(handleFetch(event.request, pathname, reg));
@@ -96,16 +99,45 @@ self.addEventListener("fetch", (event) => {
96
99
  async function handleFetch(request, pathname, reg) {
97
100
  if (runtimeConfig.simulateOffline) return serveOffline(request, pathname, reg.cacheName);
98
101
  switch (reg.strategy) {
99
- case "cache-first": return cacheFirst(request, pathname, reg.cacheName);
100
- case "stale-while-revalidate": return staleWhileRevalidate(null, request, pathname, reg.cacheName);
101
- case "network-first": return networkFirst(request, pathname, reg.cacheName);
102
+ case "cache-first": return cacheFirst(request, pathname, reg);
103
+ case "stale-while-revalidate": return staleWhileRevalidate(null, request, pathname, reg);
104
+ case "network-first": return networkFirst(request, pathname, reg);
102
105
  default: return fetch(request);
103
106
  }
104
107
  }
105
- async function cacheFirst(request, pathname, cacheName) {
108
+ var CACHED_AT_HEADER = "X-Eidos-Cached-At";
109
+ /**
110
+ * Puts a response into cache with a `X-Eidos-Cached-At` timestamp header so
111
+ * the SW can enforce `maxAge` on subsequent cache hits.
112
+ * Caller must pass a clone of the response — `response.body` is consumed here.
113
+ */
114
+ async function putCached(cache, request, response) {
115
+ const headers = new Headers(response.headers);
116
+ headers.set(CACHED_AT_HEADER, String(Date.now()));
117
+ await cache.put(request, new Response(response.body, {
118
+ status: response.status,
119
+ statusText: response.statusText,
120
+ headers
121
+ }));
122
+ }
123
+ /** Returns true if the cached response has exceeded `maxAge`. */
124
+ function isExpired(cached, maxAge) {
125
+ if (maxAge === void 0) return false;
126
+ const cachedAt = Number(cached.headers.get(CACHED_AT_HEADER) ?? "0");
127
+ return cachedAt > 0 && Date.now() - cachedAt > maxAge;
128
+ }
129
+ /** Evicts the oldest (first-inserted) entries when the cache exceeds `maxEntries`. */
130
+ async function evictIfNeeded(cache, maxEntries) {
131
+ if (maxEntries === void 0) return;
132
+ const keys = await cache.keys();
133
+ const overflow = keys.length - maxEntries;
134
+ if (overflow > 0) await Promise.all(keys.slice(0, overflow).map((k) => cache.delete(k)));
135
+ }
136
+ async function cacheFirst(request, pathname, reg) {
137
+ const { cacheName, maxAge, maxEntries } = reg;
106
138
  const cache = await caches.open(cacheName);
107
139
  const cached = await cache.match(request);
108
- if (cached) {
140
+ if (cached && !isExpired(cached, maxAge)) {
109
141
  notifyClients({
110
142
  type: "EIDOS_CACHE_HIT",
111
143
  url: pathname,
@@ -113,10 +145,12 @@ async function cacheFirst(request, pathname, cacheName) {
113
145
  });
114
146
  return cached;
115
147
  }
148
+ if (cached) await cache.delete(request);
116
149
  try {
117
150
  const response = await fetch(request);
118
151
  if (response.ok) {
119
- await cache.put(request, response.clone());
152
+ await putCached(cache, request, response.clone());
153
+ await evictIfNeeded(cache, maxEntries);
120
154
  notifyClients({
121
155
  type: "EIDOS_CACHE_UPDATED",
122
156
  url: pathname,
@@ -132,12 +166,17 @@ async function cacheFirst(request, pathname, cacheName) {
132
166
  return offlineErrorResponse(pathname);
133
167
  }
134
168
  }
135
- async function staleWhileRevalidate(event, request, pathname, cacheName) {
169
+ async function staleWhileRevalidate(event, request, pathname, reg) {
170
+ const { cacheName, maxAge, maxEntries } = reg;
136
171
  const cache = await caches.open(cacheName);
137
172
  const cached = await cache.match(request);
173
+ const expired = cached ? isExpired(cached, maxAge) : false;
174
+ if (expired) await cache.delete(request);
175
+ const effectiveCached = expired ? null : cached;
138
176
  const revalidatePromise = fetch(request).then(async (response) => {
139
177
  if (response.ok) {
140
- await cache.put(request, response.clone());
178
+ await putCached(cache, request, response.clone());
179
+ await evictIfNeeded(cache, maxEntries);
141
180
  notifyClients({
142
181
  type: "EIDOS_CACHE_UPDATED",
143
182
  url: pathname,
@@ -152,23 +191,25 @@ async function staleWhileRevalidate(event, request, pathname, cacheName) {
152
191
  strategy: "stale-while-revalidate"
153
192
  });
154
193
  });
155
- if (cached) {
194
+ if (effectiveCached) {
156
195
  event?.waitUntil(revalidatePromise);
157
196
  notifyClients({
158
197
  type: "EIDOS_CACHE_HIT",
159
198
  url: pathname,
160
199
  strategy: "stale-while-revalidate"
161
200
  });
162
- return cached;
201
+ return effectiveCached;
163
202
  }
164
203
  return await revalidatePromise ?? offlineErrorResponse(pathname);
165
204
  }
166
- async function networkFirst(request, pathname, cacheName) {
205
+ async function networkFirst(request, pathname, reg) {
206
+ const { cacheName, maxAge, maxEntries } = reg;
167
207
  const cache = await caches.open(cacheName);
168
208
  try {
169
209
  const response = await fetch(request, { signal: AbortSignal.timeout(3e3) });
170
210
  if (response.ok) {
171
- await cache.put(request, response.clone());
211
+ await putCached(cache, request, response.clone());
212
+ await evictIfNeeded(cache, maxEntries);
172
213
  notifyClients({
173
214
  type: "EIDOS_CACHE_UPDATED",
174
215
  url: pathname,
@@ -178,7 +219,7 @@ async function networkFirst(request, pathname, cacheName) {
178
219
  return response;
179
220
  } catch {
180
221
  const cached = await cache.match(request);
181
- if (cached) {
222
+ if (cached && !isExpired(cached, maxAge)) {
182
223
  notifyClients({
183
224
  type: "EIDOS_CACHE_HIT",
184
225
  url: pathname,