@sweidos/eidos 0.1.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.
- package/README.md +422 -0
- package/dist/eidos-sw.js +229 -0
- package/dist/eidos.cjs.js +880 -0
- package/dist/eidos.cjs.js.map +1 -0
- package/dist/eidos.es.js +880 -0
- package/dist/eidos.es.js.map +1 -0
- package/dist/index.d.ts +166 -0
- package/package.json +62 -0
package/README.md
ADDED
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
# Eidos
|
|
2
|
+
|
|
3
|
+
> Describe intent. The runtime figures out how.
|
|
4
|
+
|
|
5
|
+
Eidos is a small, opinionated abstraction layer for building offline-first web applications. Instead of configuring Service Workers, Cache API strategies, and IndexedDB queues directly, you declare **what you want** and the runtime generates the required behaviour.
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { resource, action } from '@sweidos/eidos'
|
|
9
|
+
|
|
10
|
+
// "I want this resource to work offline."
|
|
11
|
+
const products = resource('/api/products', {
|
|
12
|
+
offline: true,
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
// "I never want to lose this action."
|
|
16
|
+
const createOrder = action(orderApi.create, {
|
|
17
|
+
reliability: 'neverLose',
|
|
18
|
+
})
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
That's it. No service worker file to write. No cache strategy to configure. No retry logic to implement.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## The Problem
|
|
26
|
+
|
|
27
|
+
Building offline-capable web apps today requires a working knowledge of:
|
|
28
|
+
|
|
29
|
+
- Service Worker registration and lifecycle management
|
|
30
|
+
- Cache API and caching strategies (cache-first, network-first, SWR)
|
|
31
|
+
- Fetch event interception and URL routing
|
|
32
|
+
- IndexedDB schema design for persistent action queues
|
|
33
|
+
- Background Sync API and exponential retry logic
|
|
34
|
+
- Cache versioning and stale entry cleanup
|
|
35
|
+
|
|
36
|
+
This is a large surface area, separate from your application logic, that every team re-implements from scratch.
|
|
37
|
+
|
|
38
|
+
## The Vision
|
|
39
|
+
|
|
40
|
+
Developers should describe **what they want**, not **how the browser should implement it**.
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
// Before Eidos
|
|
44
|
+
// workbox-config.js
|
|
45
|
+
registerRoute(
|
|
46
|
+
({ url }) => url.pathname === '/api/products',
|
|
47
|
+
new StaleWhileRevalidate({
|
|
48
|
+
cacheName: 'api-cache',
|
|
49
|
+
plugins: [new ExpirationPlugin({ maxEntries: 60 })],
|
|
50
|
+
}),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
// service-worker.js
|
|
54
|
+
self.addEventListener('sync', (event) => {
|
|
55
|
+
if (event.tag === 'create-order') {
|
|
56
|
+
event.waitUntil(replayOrders())
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
// After Eidos
|
|
61
|
+
resource('/api/products', { offline: true })
|
|
62
|
+
action(createOrder, { reliability: 'neverLose' })
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## Quick Start
|
|
68
|
+
|
|
69
|
+
### Install
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
npm install eidos
|
|
73
|
+
# or
|
|
74
|
+
pnpm add eidos
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Add the service worker
|
|
78
|
+
|
|
79
|
+
Copy `eidos-sw.js` to your project's `public/` directory:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
cp node_modules/eidos/dist/eidos-sw.js public/eidos-sw.js
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
> **Vite users** — you can also add a plugin to do this automatically. See [setup guide](#vite-plugin).
|
|
86
|
+
|
|
87
|
+
### Wrap your app
|
|
88
|
+
|
|
89
|
+
```tsx
|
|
90
|
+
import { EidosProvider } from '@sweidos/eidos'
|
|
91
|
+
|
|
92
|
+
createRoot(document.getElementById('root')!).render(
|
|
93
|
+
<EidosProvider swPath="/eidos-sw.js">
|
|
94
|
+
<App />
|
|
95
|
+
</EidosProvider>
|
|
96
|
+
)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Declare resources and actions
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
// src/lib/eidos.ts — module scope, so replay survives page reload
|
|
103
|
+
import { resource, action } from '@sweidos/eidos'
|
|
104
|
+
|
|
105
|
+
export const products = resource('/api/products', {
|
|
106
|
+
offline: true,
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
export const createOrder = action(
|
|
110
|
+
async (payload: OrderPayload) => {
|
|
111
|
+
const res = await fetch('/api/orders', {
|
|
112
|
+
method: 'POST',
|
|
113
|
+
body: JSON.stringify(payload),
|
|
114
|
+
})
|
|
115
|
+
return res.json()
|
|
116
|
+
},
|
|
117
|
+
{ reliability: 'neverLose' },
|
|
118
|
+
)
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### Use in components
|
|
122
|
+
|
|
123
|
+
```tsx
|
|
124
|
+
// With TanStack Query
|
|
125
|
+
const { data } = useQuery(products.query())
|
|
126
|
+
|
|
127
|
+
// Or plain
|
|
128
|
+
const data = await products.json()
|
|
129
|
+
|
|
130
|
+
// Actions work identically online and offline
|
|
131
|
+
const result = await createOrder({ productId: 1, qty: 2 })
|
|
132
|
+
|
|
133
|
+
if ('queued' in result) {
|
|
134
|
+
console.log(result.message) // "createOrder queued — will execute when online"
|
|
135
|
+
}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
---
|
|
139
|
+
|
|
140
|
+
## API Reference
|
|
141
|
+
|
|
142
|
+
### `resource(url, config)`
|
|
143
|
+
|
|
144
|
+
Registers a URL as an offline-capable resource. Returns a handle for fetching and cache management.
|
|
145
|
+
|
|
146
|
+
```ts
|
|
147
|
+
const products = resource('/api/products', {
|
|
148
|
+
offline: true, // required: enables SW interception
|
|
149
|
+
strategy?: 'cache-first' | 'stale-while-revalidate' | 'network-first',
|
|
150
|
+
cacheName?: string, // custom cache bucket
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
// Handle methods
|
|
154
|
+
products.fetch() // → Promise<Response>
|
|
155
|
+
products.json<T>() // → Promise<T>
|
|
156
|
+
products.query() // → { queryKey, queryFn } for TanStack Query
|
|
157
|
+
products.prefetch() // → Promise<void>
|
|
158
|
+
products.invalidate() // → Promise<void> — clears SW cache entry
|
|
159
|
+
|
|
160
|
+
// Handle properties
|
|
161
|
+
products.url // '/api/products'
|
|
162
|
+
products.strategy // generated GeneratedStrategy object
|
|
163
|
+
products.config // the config you passed in
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
**Strategy selection:**
|
|
167
|
+
|
|
168
|
+
| Intent | Generated Strategy | Reasoning |
|
|
169
|
+
|---|---|---|
|
|
170
|
+
| `offline: true` | `StaleWhileRevalidate` | Best balance of speed and freshness for resilient resources |
|
|
171
|
+
| `offline: true, strategy: 'cache-first'` | `CacheFirst` | Maximum speed, data rarely changes |
|
|
172
|
+
| `offline: true, strategy: 'network-first'` | `NetworkFirst` | Freshness critical, cache as fallback only |
|
|
173
|
+
|
|
174
|
+
### `action(fn, config)`
|
|
175
|
+
|
|
176
|
+
Wraps an async function with reliability guarantees. The wrapped function is a drop-in replacement — calling it is identical whether you're online or offline.
|
|
177
|
+
|
|
178
|
+
```ts
|
|
179
|
+
const createOrder = action(
|
|
180
|
+
async (payload: OrderPayload): Promise<Order> => {
|
|
181
|
+
// your existing async function, unchanged
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
reliability: 'neverLose', // persist to IndexedDB if call fails or offline
|
|
185
|
+
maxRetries?: number, // default: 3
|
|
186
|
+
name?: string, // label shown in devtools
|
|
187
|
+
}
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
// Returns TReturn when successful, QueuedResult when queued
|
|
191
|
+
const result = await createOrder(payload)
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
**Reliability modes:**
|
|
195
|
+
|
|
196
|
+
| Mode | Behaviour |
|
|
197
|
+
|---|---|
|
|
198
|
+
| `best-effort` | Call directly. No persistence, no retry. |
|
|
199
|
+
| `neverLose` | Persist args to IndexedDB before executing. Replay on reconnect. |
|
|
200
|
+
|
|
201
|
+
### `replayQueue()`
|
|
202
|
+
|
|
203
|
+
Manually trigger queue replay. Called automatically on the `online` event when `autoReplay: true` (the default).
|
|
204
|
+
|
|
205
|
+
```ts
|
|
206
|
+
import { replayQueue } from '@sweidos/eidos'
|
|
207
|
+
|
|
208
|
+
window.addEventListener('online', replayQueue)
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
### `EidosProvider`
|
|
212
|
+
|
|
213
|
+
Root provider that registers the SW and initialises the runtime.
|
|
214
|
+
|
|
215
|
+
```tsx
|
|
216
|
+
<EidosProvider
|
|
217
|
+
swPath="/eidos-sw.js" // default
|
|
218
|
+
autoReplay={true} // replay queue on reconnect, default: true
|
|
219
|
+
>
|
|
220
|
+
<App />
|
|
221
|
+
</EidosProvider>
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
### React Hooks
|
|
225
|
+
|
|
226
|
+
```ts
|
|
227
|
+
import { useEidosStatus, useEidosResource, useEidosQueue } from '@sweidos/eidos'
|
|
228
|
+
|
|
229
|
+
// Online + SW status — cheap, safe in headers
|
|
230
|
+
const { isOnline, swStatus } = useEidosStatus()
|
|
231
|
+
|
|
232
|
+
// Live state for a single resource
|
|
233
|
+
const entry = useEidosResource('/api/products')
|
|
234
|
+
// → { status, cacheHits, cachedAt, strategy, ... }
|
|
235
|
+
|
|
236
|
+
// The full action queue
|
|
237
|
+
const queue = useEidosQueue()
|
|
238
|
+
|
|
239
|
+
// Full store (use sparingly)
|
|
240
|
+
const state = useEidos()
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
### `setOfflineSimulation(enabled)`
|
|
244
|
+
|
|
245
|
+
Toggle offline simulation from devtools or tests. Sends a message to the SW to serve only cached responses.
|
|
246
|
+
|
|
247
|
+
```ts
|
|
248
|
+
import { setOfflineSimulation } from '@sweidos/eidos'
|
|
249
|
+
|
|
250
|
+
setOfflineSimulation(true) // force offline
|
|
251
|
+
setOfflineSimulation(false) // restore normal
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
---
|
|
255
|
+
|
|
256
|
+
## Architecture
|
|
257
|
+
|
|
258
|
+
```
|
|
259
|
+
┌─────────────────────────────────────────────┐
|
|
260
|
+
│ Application Layer │
|
|
261
|
+
│ resource() · action() · EidosProvider │ ← you write this
|
|
262
|
+
└────────────────┬────────────────────────────┘
|
|
263
|
+
│ postMessage(EIDOS_REGISTER_RESOURCE)
|
|
264
|
+
┌────────────────▼────────────────────────────┐
|
|
265
|
+
│ Runtime Layer (packages/core) │
|
|
266
|
+
│ Strategy derivation · Zustand store │ ← eidos npm package
|
|
267
|
+
│ SW bridge · IDB queue │
|
|
268
|
+
└────────────────┬────────────────────────────┘
|
|
269
|
+
│ fetch intercept
|
|
270
|
+
┌────────────────▼────────────────────────────┐
|
|
271
|
+
│ Worker Layer (eidos-sw.js) │
|
|
272
|
+
│ CacheFirst · StaleWhileRevalidate │ ← generated SW
|
|
273
|
+
│ NetworkFirst · Offline simulation │
|
|
274
|
+
└────────────────┬────────────────────────────┘
|
|
275
|
+
│ Cache API · IndexedDB
|
|
276
|
+
┌────────────────▼────────────────────────────┐
|
|
277
|
+
│ Storage Layer │
|
|
278
|
+
│ Cache Storage · IndexedDB (action queue) │ ← browser APIs
|
|
279
|
+
└─────────────────────────────────────────────┘
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
### Service Worker protocol
|
|
283
|
+
|
|
284
|
+
The runtime communicates with `eidos-sw.js` via `postMessage`. Messages sent from the app:
|
|
285
|
+
|
|
286
|
+
| Message | Purpose |
|
|
287
|
+
|---|---|
|
|
288
|
+
| `EIDOS_REGISTER_RESOURCE` | Add a fetch-intercept rule |
|
|
289
|
+
| `EIDOS_UNREGISTER_RESOURCE` | Remove a rule |
|
|
290
|
+
| `EIDOS_CLEAR_CACHE` | Evict cache entries |
|
|
291
|
+
| `EIDOS_SIMULATE_OFFLINE` | Toggle offline simulation |
|
|
292
|
+
| `EIDOS_PING` | Health check |
|
|
293
|
+
|
|
294
|
+
Messages received from the SW:
|
|
295
|
+
|
|
296
|
+
| Message | Purpose |
|
|
297
|
+
|---|---|
|
|
298
|
+
| `EIDOS_CACHE_HIT` | A cached response was served |
|
|
299
|
+
| `EIDOS_CACHE_UPDATED` | Cache entry was refreshed from network |
|
|
300
|
+
| `EIDOS_NETWORK_ERROR` | Network request failed |
|
|
301
|
+
| `EIDOS_CACHE_CLEARED` | Cache was cleared |
|
|
302
|
+
|
|
303
|
+
---
|
|
304
|
+
|
|
305
|
+
## Repository Structure
|
|
306
|
+
|
|
307
|
+
```
|
|
308
|
+
eidos/
|
|
309
|
+
├── packages/
|
|
310
|
+
│ ├── core/ eidos npm package
|
|
311
|
+
│ │ └── src/
|
|
312
|
+
│ │ ├── types.ts
|
|
313
|
+
│ │ ├── resource.ts resource() implementation
|
|
314
|
+
│ │ ├── action.ts action() + queue replay
|
|
315
|
+
│ │ ├── runtime.ts init + SW registration
|
|
316
|
+
│ │ ├── store.ts Zustand store
|
|
317
|
+
│ │ ├── sw-bridge.ts postMessage channel
|
|
318
|
+
│ │ ├── idb.ts IndexedDB wrapper
|
|
319
|
+
│ │ └── react/ EidosProvider + hooks
|
|
320
|
+
│ └── worker/ SW typed source
|
|
321
|
+
│ └── src/sw.ts → compiles to eidos-sw.js
|
|
322
|
+
├── apps/
|
|
323
|
+
│ └── playground/ interactive demo dashboard
|
|
324
|
+
│ └── public/
|
|
325
|
+
│ └── eidos-sw.js compiled service worker
|
|
326
|
+
└── examples/ (planned)
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
---
|
|
330
|
+
|
|
331
|
+
## Dev Dashboard
|
|
332
|
+
|
|
333
|
+
The playground at `apps/playground` is a full interactive dashboard that demonstrates every feature:
|
|
334
|
+
|
|
335
|
+
```bash
|
|
336
|
+
pnpm dev # → http://localhost:3000
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
It includes:
|
|
340
|
+
|
|
341
|
+
- **Overview** — live status + interactive products/orders demos
|
|
342
|
+
- **Resources** — every registered resource with cache stats and strategy detail
|
|
343
|
+
- **Action Queue** — live queue with per-item status and replay controls
|
|
344
|
+
- **Intent Inspector** — step-by-step trace from intent declaration to SW rule
|
|
345
|
+
- **How It Works** — architecture diagrams and lifecycle walkthroughs
|
|
346
|
+
|
|
347
|
+
---
|
|
348
|
+
|
|
349
|
+
## Vite Plugin
|
|
350
|
+
|
|
351
|
+
To automatically copy `eidos-sw.js` into `public/` during dev and build, add this to your `vite.config.ts`:
|
|
352
|
+
|
|
353
|
+
```ts
|
|
354
|
+
import { copyFileSync } from 'fs'
|
|
355
|
+
import { resolve } from 'path'
|
|
356
|
+
|
|
357
|
+
function eidosPlugin() {
|
|
358
|
+
return {
|
|
359
|
+
name: 'eidos-sw',
|
|
360
|
+
buildStart() {
|
|
361
|
+
copyFileSync(
|
|
362
|
+
resolve('./node_modules/eidos/dist/eidos-sw.js'),
|
|
363
|
+
resolve('./public/eidos-sw.js'),
|
|
364
|
+
)
|
|
365
|
+
},
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
---
|
|
371
|
+
|
|
372
|
+
## Known Limitations
|
|
373
|
+
|
|
374
|
+
These are real limitations in v0.1. They are documented so you know exactly what you're getting.
|
|
375
|
+
|
|
376
|
+
| Limitation | Detail |
|
|
377
|
+
|---|---|
|
|
378
|
+
| GET-only caching | The SW only intercepts `GET` requests. `POST`/`PUT`/`DELETE` are never cached. |
|
|
379
|
+
| Pathname matching | Resources match by pathname only. Cross-origin URLs require the full URL to be registered. |
|
|
380
|
+
| Module-scope actions | `action()` must be called at module scope for replay to work after a page reload. |
|
|
381
|
+
| No TTL | Cached resources do not expire automatically. Call `resource.invalidate()` to clear. |
|
|
382
|
+
| Single SW | `EidosProvider` assumes `/eidos-sw.js`. Multiple SW registrations in one app are unsupported. |
|
|
383
|
+
|
|
384
|
+
---
|
|
385
|
+
|
|
386
|
+
## Roadmap
|
|
387
|
+
|
|
388
|
+
- [ ] URL pattern matching (wildcards, regex)
|
|
389
|
+
- [ ] Cache TTL / expiration
|
|
390
|
+
- [ ] Cross-origin resource support
|
|
391
|
+
- [ ] Background Sync integration (native browser API)
|
|
392
|
+
- [ ] Vite plugin (first-class, published separately)
|
|
393
|
+
- [ ] React Native / Expo adapter
|
|
394
|
+
- [ ] TanStack Query integration package
|
|
395
|
+
|
|
396
|
+
---
|
|
397
|
+
|
|
398
|
+
## Contributing
|
|
399
|
+
|
|
400
|
+
```bash
|
|
401
|
+
# Install
|
|
402
|
+
pnpm install
|
|
403
|
+
|
|
404
|
+
# Run the playground
|
|
405
|
+
pnpm dev
|
|
406
|
+
|
|
407
|
+
# Type-check everything
|
|
408
|
+
pnpm type-check
|
|
409
|
+
|
|
410
|
+
# Build the core package
|
|
411
|
+
pnpm build:core
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
The project uses pnpm workspaces. TypeScript strict mode is enabled everywhere.
|
|
415
|
+
|
|
416
|
+
The naming (`Eidos`) is a placeholder. All references are easy to find/replace — the package name, SW filename, and message prefix are the only places the name appears.
|
|
417
|
+
|
|
418
|
+
---
|
|
419
|
+
|
|
420
|
+
## License
|
|
421
|
+
|
|
422
|
+
MIT © Aditya Raj
|
package/dist/eidos-sw.js
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// eidos-sw.js — Eidos Service Worker v0.1.0
|
|
2
|
+
// Two responsibilities:
|
|
3
|
+
// 1. App shell caching — dashboard works offline after first visit
|
|
4
|
+
// 2. API resource caching — strategies declared via resource()
|
|
5
|
+
|
|
6
|
+
const CACHE_VERSION = 'v1'
|
|
7
|
+
const SHELL_CACHE = `eidos-shell-${CACHE_VERSION}`
|
|
8
|
+
const API_CACHE = `eidos-resources-${CACHE_VERSION}`
|
|
9
|
+
|
|
10
|
+
const runtimeConfig = {
|
|
11
|
+
resources: new Map(), // pathname → { strategy, cacheName }
|
|
12
|
+
simulateOffline: false,
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// ── Install ───────────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
self.addEventListener('install', (event) => {
|
|
18
|
+
event.waitUntil(self.skipWaiting())
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
// ── Activate ──────────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
self.addEventListener('activate', (event) => {
|
|
24
|
+
event.waitUntil(
|
|
25
|
+
Promise.all([
|
|
26
|
+
self.clients.claim(),
|
|
27
|
+
caches.keys().then((keys) =>
|
|
28
|
+
Promise.all(
|
|
29
|
+
keys
|
|
30
|
+
.filter((k) => k.startsWith('eidos') && k !== SHELL_CACHE && k !== API_CACHE)
|
|
31
|
+
.map((k) => caches.delete(k))
|
|
32
|
+
)
|
|
33
|
+
),
|
|
34
|
+
])
|
|
35
|
+
)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
// ── Message channel ───────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
self.addEventListener('message', (event) => {
|
|
41
|
+
const data = event.data
|
|
42
|
+
if (!data?.type) return
|
|
43
|
+
|
|
44
|
+
switch (data.type) {
|
|
45
|
+
case 'EIDOS_REGISTER_RESOURCE':
|
|
46
|
+
runtimeConfig.resources.set(data.url, {
|
|
47
|
+
strategy: data.strategy,
|
|
48
|
+
cacheName: data.cacheName ?? API_CACHE,
|
|
49
|
+
})
|
|
50
|
+
event.source?.postMessage({ type: 'EIDOS_RESOURCE_REGISTERED', url: data.url })
|
|
51
|
+
break
|
|
52
|
+
|
|
53
|
+
case 'EIDOS_UNREGISTER_RESOURCE':
|
|
54
|
+
runtimeConfig.resources.delete(data.url)
|
|
55
|
+
break
|
|
56
|
+
|
|
57
|
+
case 'EIDOS_SIMULATE_OFFLINE':
|
|
58
|
+
runtimeConfig.simulateOffline = data.enabled
|
|
59
|
+
break
|
|
60
|
+
|
|
61
|
+
case 'EIDOS_CLEAR_CACHE': {
|
|
62
|
+
caches.open(API_CACHE).then(async (cache) => {
|
|
63
|
+
if (data.url) {
|
|
64
|
+
const keys = await cache.keys()
|
|
65
|
+
await Promise.all(
|
|
66
|
+
keys.filter((r) => new URL(r.url).pathname === data.url).map((r) => cache.delete(r))
|
|
67
|
+
)
|
|
68
|
+
} else {
|
|
69
|
+
const keys = await cache.keys()
|
|
70
|
+
await Promise.all(keys.map((k) => cache.delete(k)))
|
|
71
|
+
}
|
|
72
|
+
notifyClients({ type: 'EIDOS_CACHE_CLEARED', url: data.url })
|
|
73
|
+
})
|
|
74
|
+
break
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
case 'EIDOS_PING':
|
|
78
|
+
event.source?.postMessage({ type: 'EIDOS_PONG' })
|
|
79
|
+
break
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
// ── Fetch interception ────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
self.addEventListener('fetch', (event) => {
|
|
86
|
+
if (event.request.method !== 'GET') return
|
|
87
|
+
|
|
88
|
+
const url = new URL(event.request.url)
|
|
89
|
+
const pathname = url.pathname
|
|
90
|
+
|
|
91
|
+
// 1. Registered API resources
|
|
92
|
+
const reg = runtimeConfig.resources.get(pathname)
|
|
93
|
+
if (reg) {
|
|
94
|
+
event.respondWith(handleApiResource(event.request, pathname, reg))
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// 2. App shell — same-origin non-API assets (HTML, JS, CSS, fonts, images)
|
|
99
|
+
// Stale-while-revalidate: serve from cache instantly, refresh in background.
|
|
100
|
+
if (url.origin === self.location.origin && !pathname.startsWith('/api/')) {
|
|
101
|
+
event.respondWith(appShell(event.request))
|
|
102
|
+
}
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
// ── App shell strategy ────────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
async function appShell(request) {
|
|
108
|
+
const cache = await caches.open(SHELL_CACHE)
|
|
109
|
+
const cached = await cache.match(request)
|
|
110
|
+
|
|
111
|
+
const refresh = fetch(request)
|
|
112
|
+
.then((resp) => {
|
|
113
|
+
if (resp.ok) cache.put(request, resp.clone())
|
|
114
|
+
return resp
|
|
115
|
+
})
|
|
116
|
+
.catch(() => null)
|
|
117
|
+
|
|
118
|
+
if (cached) {
|
|
119
|
+
refresh // update in background
|
|
120
|
+
return cached
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const fresh = await refresh
|
|
124
|
+
return fresh ?? new Response(
|
|
125
|
+
'<html><body><h2>Offline</h2><p>Visit this page while online first.</p></body></html>',
|
|
126
|
+
{ status: 503, headers: { 'Content-Type': 'text/html' } }
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── API caching strategies ────────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
async function handleApiResource(request, pathname, reg) {
|
|
133
|
+
if (runtimeConfig.simulateOffline) return serveOffline(request, pathname, reg.cacheName)
|
|
134
|
+
|
|
135
|
+
switch (reg.strategy) {
|
|
136
|
+
case 'cache-first': return cacheFirst(request, pathname, reg.cacheName)
|
|
137
|
+
case 'stale-while-revalidate': return staleWhileRevalidate(request, pathname, reg.cacheName)
|
|
138
|
+
case 'network-first': return networkFirst(request, pathname, reg.cacheName)
|
|
139
|
+
default: return fetch(request)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function cacheFirst(request, pathname, cacheName) {
|
|
144
|
+
const cache = await caches.open(cacheName)
|
|
145
|
+
const cached = await cache.match(request)
|
|
146
|
+
if (cached) {
|
|
147
|
+
notifyClients({ type: 'EIDOS_CACHE_HIT', url: pathname, strategy: 'cache-first' })
|
|
148
|
+
return cached
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
const response = await fetch(request)
|
|
152
|
+
if (response.ok) {
|
|
153
|
+
await cache.put(request, response.clone())
|
|
154
|
+
notifyClients({ type: 'EIDOS_CACHE_UPDATED', url: pathname, strategy: 'cache-first' })
|
|
155
|
+
}
|
|
156
|
+
return response
|
|
157
|
+
} catch {
|
|
158
|
+
notifyClients({ type: 'EIDOS_NETWORK_ERROR', url: pathname })
|
|
159
|
+
return offlineErrorResponse(pathname)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function staleWhileRevalidate(request, pathname, cacheName) {
|
|
164
|
+
const cache = await caches.open(cacheName)
|
|
165
|
+
const cached = await cache.match(request)
|
|
166
|
+
|
|
167
|
+
const revalidate = fetch(request)
|
|
168
|
+
.then(async (resp) => {
|
|
169
|
+
if (resp.ok) {
|
|
170
|
+
await cache.put(request, resp.clone())
|
|
171
|
+
notifyClients({ type: 'EIDOS_CACHE_UPDATED', url: pathname, strategy: 'stale-while-revalidate' })
|
|
172
|
+
}
|
|
173
|
+
return resp
|
|
174
|
+
})
|
|
175
|
+
.catch(() => {
|
|
176
|
+
notifyClients({ type: 'EIDOS_NETWORK_ERROR', url: pathname })
|
|
177
|
+
return null
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
if (cached) {
|
|
181
|
+
notifyClients({ type: 'EIDOS_CACHE_HIT', url: pathname, strategy: 'stale-while-revalidate' })
|
|
182
|
+
return cached
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const fresh = await revalidate
|
|
186
|
+
return fresh ?? offlineErrorResponse(pathname)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function networkFirst(request, pathname, cacheName) {
|
|
190
|
+
const cache = await caches.open(cacheName)
|
|
191
|
+
try {
|
|
192
|
+
const response = await fetch(request)
|
|
193
|
+
if (response.ok) {
|
|
194
|
+
await cache.put(request, response.clone())
|
|
195
|
+
notifyClients({ type: 'EIDOS_CACHE_UPDATED', url: pathname, strategy: 'network-first' })
|
|
196
|
+
}
|
|
197
|
+
return response
|
|
198
|
+
} catch {
|
|
199
|
+
const cached = await cache.match(request)
|
|
200
|
+
if (cached) {
|
|
201
|
+
notifyClients({ type: 'EIDOS_CACHE_HIT', url: pathname, strategy: 'network-first' })
|
|
202
|
+
return cached
|
|
203
|
+
}
|
|
204
|
+
notifyClients({ type: 'EIDOS_NETWORK_ERROR', url: pathname })
|
|
205
|
+
return offlineErrorResponse(pathname)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function serveOffline(request, pathname, cacheName) {
|
|
210
|
+
const cache = await caches.open(cacheName)
|
|
211
|
+
const cached = await cache.match(request)
|
|
212
|
+
if (cached) {
|
|
213
|
+
notifyClients({ type: 'EIDOS_CACHE_HIT', url: pathname, strategy: 'offline-simulation', simulated: true })
|
|
214
|
+
return cached
|
|
215
|
+
}
|
|
216
|
+
return offlineErrorResponse(pathname)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function offlineErrorResponse(pathname) {
|
|
220
|
+
return new Response(
|
|
221
|
+
JSON.stringify({ error: 'offline', message: `No cached response for ${pathname}`, eidos: true }),
|
|
222
|
+
{ status: 503, headers: { 'Content-Type': 'application/json', 'X-Eidos-Offline': 'true' } }
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function notifyClients(message) {
|
|
227
|
+
const clients = await self.clients.matchAll({ includeUncontrolled: true })
|
|
228
|
+
clients.forEach((c) => c.postMessage(message))
|
|
229
|
+
}
|