@sweidos/eidos 1.0.30 → 1.0.32
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 +149 -717
- package/dist/action.js +58 -47
- package/dist/action.js.map +1 -1
- package/dist/async-storage-adapter.js +42 -0
- package/dist/async-storage-adapter.js.map +1 -0
- package/dist/devtools.js +65 -44
- package/dist/eidos.cjs.js +4 -4
- package/dist/eidos.cjs.js.map +1 -1
- package/dist/idb.js +56 -63
- package/dist/idb.js.map +1 -1
- package/dist/index.d.ts +41 -2
- package/dist/index.js +38 -33
- package/dist/index.js.map +1 -1
- package/dist/queue-storage.js +12 -0
- package/dist/queue-storage.js.map +1 -0
- package/dist/react/ProviderRN.d.ts +23 -0
- package/dist/react-native.d.ts +8 -0
- package/dist/react-native.js +59 -0
- package/dist/resource.js +29 -22
- package/dist/resource.js.map +1 -1
- package/dist/runtime-rn.d.ts +18 -0
- package/dist/store.js +26 -24
- package/dist/store.js.map +1 -1
- package/dist/stores.js +15 -13
- package/dist/stores.js.map +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/package.json +26 -5
package/README.md
CHANGED
|
@@ -1,53 +1,43 @@
|
|
|
1
1
|
# Eidos
|
|
2
2
|
|
|
3
|
-
[](https://www.npmjs.com/package/@sweidos/eidos)
|
|
4
|
-
[](https://www.npmjs.com/package/@sweidos/eidos)
|
|
4
|
+
[](https://www.npmjs.com/package/@sweidos/eidos)
|
|
5
|
+
[](https://bundlephobia.com/package/@sweidos/eidos)
|
|
6
|
+
[](https://www.typescriptlang.org/)
|
|
7
|
+
[](https://github.com/iamadi11/eidos/actions)
|
|
8
|
+
[](LICENSE)
|
|
6
9
|
|
|
7
|
-
> Describe intent. The runtime figures out how
|
|
10
|
+
> **Describe intent. The runtime figures out how.**
|
|
8
11
|
|
|
9
|
-
|
|
12
|
+
Declare what your app needs offline. Eidos picks the cache strategy, registers the Service Worker, and persists your action queue to IndexedDB — automatically.
|
|
10
13
|
|
|
11
14
|
```ts
|
|
12
15
|
import { resource, action } from '@sweidos/eidos'
|
|
13
16
|
|
|
14
|
-
// "I want this resource available offline."
|
|
15
17
|
const products = resource('/api/products', { offline: true })
|
|
16
|
-
|
|
17
|
-
// "I never want to lose this action."
|
|
18
18
|
const createOrder = action(orderApi.create, { reliability: 'neverLose' })
|
|
19
19
|
```
|
|
20
20
|
|
|
21
21
|
No service worker file to write. No cache strategy to configure. No retry logic to implement.
|
|
22
22
|
|
|
23
|
-
**[→ Live playground](https://
|
|
23
|
+
**[→ Documentation](https://sweidos.vercel.app/overview)** · **[→ Live playground](https://sweidos.vercel.app)** · **[→ npm](https://www.npmjs.com/package/@sweidos/eidos)**
|
|
24
24
|
|
|
25
25
|
---
|
|
26
26
|
|
|
27
|
-
## The
|
|
28
|
-
|
|
29
|
-
Building offline-capable apps today requires deep knowledge of:
|
|
30
|
-
|
|
31
|
-
- Service Worker registration and lifecycle management
|
|
32
|
-
- Cache API strategies (cache-first, network-first, stale-while-revalidate)
|
|
33
|
-
- Fetch event interception and URL routing
|
|
34
|
-
- IndexedDB schema design for persistent queues
|
|
35
|
-
- Exponential backoff and retry logic
|
|
36
|
-
- Cache versioning and stale entry cleanup
|
|
37
|
-
|
|
38
|
-
Every team re-implements this surface area from scratch.
|
|
27
|
+
## The problem
|
|
39
28
|
|
|
40
|
-
|
|
29
|
+
Every offline-first app re-implements the same surface area:
|
|
41
30
|
|
|
42
31
|
```ts
|
|
43
|
-
// Before — workbox-config.js +
|
|
32
|
+
// Before — workbox-config.js + sw.js + queue.ts (100+ lines across 3 files)
|
|
44
33
|
registerRoute(
|
|
45
34
|
({ url }) => url.pathname === '/api/products',
|
|
46
35
|
new StaleWhileRevalidate({ cacheName: 'api-cache', plugins: [...] }),
|
|
47
36
|
)
|
|
48
|
-
self.addEventListener('sync', event => {
|
|
37
|
+
self.addEventListener('sync', (event) => {
|
|
49
38
|
if (event.tag === 'create-order') event.waitUntil(replayOrders())
|
|
50
39
|
})
|
|
40
|
+
// + IndexedDB schema, retry logic, backoff math, reconnect listener...
|
|
51
41
|
|
|
52
42
|
// After — eidos (2 lines)
|
|
53
43
|
resource('/api/products', { offline: true })
|
|
@@ -56,29 +46,37 @@ action(createOrder, { reliability: 'neverLose' })
|
|
|
56
46
|
|
|
57
47
|
---
|
|
58
48
|
|
|
59
|
-
## Quick
|
|
49
|
+
## Quick start
|
|
60
50
|
|
|
61
51
|
### 1. Install
|
|
62
52
|
|
|
63
53
|
```bash
|
|
64
54
|
npm install @sweidos/eidos
|
|
65
|
-
#
|
|
66
|
-
|
|
55
|
+
# pnpm add @sweidos/eidos
|
|
56
|
+
# yarn add @sweidos/eidos
|
|
67
57
|
```
|
|
68
58
|
|
|
69
|
-
### 2.
|
|
59
|
+
### 2. Register the Vite plugin (auto-copies the service worker)
|
|
70
60
|
|
|
71
|
-
```
|
|
72
|
-
|
|
61
|
+
```ts
|
|
62
|
+
// vite.config.ts
|
|
63
|
+
import { eidos } from '@sweidos/eidos/vite'
|
|
64
|
+
import { defineConfig } from 'vite'
|
|
65
|
+
|
|
66
|
+
export default defineConfig({
|
|
67
|
+
plugins: [eidos()],
|
|
68
|
+
})
|
|
73
69
|
```
|
|
74
70
|
|
|
75
|
-
> **Vite
|
|
71
|
+
> **Without Vite** — copy manually: `cp node_modules/@sweidos/eidos/dist/eidos-sw.js public/`
|
|
76
72
|
|
|
77
|
-
### 3. Wrap your app
|
|
73
|
+
### 3. Wrap your app and declare resources
|
|
78
74
|
|
|
79
75
|
```tsx
|
|
76
|
+
// main.tsx
|
|
80
77
|
import { EidosProvider } from '@sweidos/eidos'
|
|
81
78
|
import { createRoot } from 'react-dom/client'
|
|
79
|
+
import { App } from './App'
|
|
82
80
|
|
|
83
81
|
createRoot(document.getElementById('root')!).render(
|
|
84
82
|
<EidosProvider swPath="/eidos-sw.js">
|
|
@@ -87,790 +85,223 @@ createRoot(document.getElementById('root')!).render(
|
|
|
87
85
|
)
|
|
88
86
|
```
|
|
89
87
|
|
|
90
|
-
### 4. Declare resources and actions at module scope
|
|
91
|
-
|
|
92
88
|
```ts
|
|
93
|
-
// src/lib/eidos.ts
|
|
94
|
-
// Module scope is required — actions must be registered before page reload
|
|
95
|
-
// for queue replay to work.
|
|
89
|
+
// src/lib/eidos.ts ← module scope required for queue replay after reload
|
|
96
90
|
import { resource, action } from '@sweidos/eidos'
|
|
97
91
|
|
|
98
|
-
export const products = resource('/api/products', {
|
|
99
|
-
offline: true, // → StaleWhileRevalidate auto-selected
|
|
100
|
-
maxAge: 5 * 60 * 1000, // optional: treat cache as stale after 5 min
|
|
101
|
-
})
|
|
92
|
+
export const products = resource('/api/products', { offline: true })
|
|
102
93
|
|
|
103
94
|
export const createOrder = action(
|
|
104
95
|
async (payload: OrderPayload) => {
|
|
105
|
-
const res = await fetch('/api/orders', {
|
|
106
|
-
method: 'POST',
|
|
107
|
-
body: JSON.stringify(payload),
|
|
108
|
-
})
|
|
96
|
+
const res = await fetch('/api/orders', { method: 'POST', body: JSON.stringify(payload) })
|
|
109
97
|
return res.json()
|
|
110
98
|
},
|
|
111
|
-
{
|
|
112
|
-
reliability: 'neverLose',
|
|
113
|
-
name: 'createOrder',
|
|
114
|
-
onOptimistic: (payload) => {
|
|
115
|
-
// Called immediately — update UI before the server responds
|
|
116
|
-
addOptimisticOrder(payload)
|
|
117
|
-
},
|
|
118
|
-
onRollback: (payload) => {
|
|
119
|
-
// Called only if maxRetries exhausted — revert the optimistic change
|
|
120
|
-
removeOptimisticOrder(payload)
|
|
121
|
-
},
|
|
122
|
-
onConflict: (error, [payload]) => {
|
|
123
|
-
// Called during replay when the server returns a 4xx (conflict, gone, etc.)
|
|
124
|
-
// Return 'skip' to silently drop the item, or 'retry' to keep retrying.
|
|
125
|
-
if (error instanceof Response && error.status === 409) {
|
|
126
|
-
removeOptimisticOrder(payload) // revert UI
|
|
127
|
-
return 'skip' // drop from queue — already handled server-side
|
|
128
|
-
}
|
|
129
|
-
return 'retry'
|
|
130
|
-
},
|
|
131
|
-
},
|
|
99
|
+
{ reliability: 'neverLose', name: 'createOrder' },
|
|
132
100
|
)
|
|
133
101
|
```
|
|
134
102
|
|
|
135
|
-
### 5. Use in components
|
|
136
|
-
|
|
137
103
|
```tsx
|
|
138
|
-
//
|
|
139
|
-
import { useEidosQuery, useEidosMutation } from '@sweidos/eidos/query'
|
|
140
|
-
|
|
141
|
-
const { data, isPending } = useEidosQuery<Product[]>(products)
|
|
142
|
-
|
|
143
|
-
const mutation = useEidosMutation(createOrder, {
|
|
144
|
-
invalidates: [products], // clears cache + refetches on success
|
|
145
|
-
})
|
|
146
|
-
|
|
147
|
-
// Or with plain useQuery
|
|
148
|
-
const { data } = useQuery(products.query<Product[]>())
|
|
149
|
-
|
|
150
|
-
// Or plain async
|
|
151
|
-
const data = await products.json<Product[]>()
|
|
152
|
-
|
|
153
|
-
// Actions work identically online and offline
|
|
104
|
+
// In components — works the same online and offline
|
|
154
105
|
const result = await createOrder({ productId: 1, qty: 2 })
|
|
155
106
|
|
|
156
107
|
if ('queued' in result) {
|
|
157
|
-
//
|
|
108
|
+
// Saved to IndexedDB — replays automatically on reconnect
|
|
158
109
|
console.log(result.message)
|
|
159
110
|
}
|
|
160
111
|
```
|
|
161
112
|
|
|
162
113
|
---
|
|
163
114
|
|
|
164
|
-
##
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
resource(
|
|
180
|
-
resource('/api/products/**', { offline: true }) // multi-segment: /api/products/123/reviews
|
|
181
|
-
resource('/api/users/:id', { offline: true }) // named segment: /api/users/abc
|
|
182
|
-
|
|
183
|
-
// Cross-origin resources — pass the full URL (including origin)
|
|
184
|
-
resource('https://api.example.com/products', { offline: true })
|
|
185
|
-
resource('https://cdn.example.com/assets/*', { offline: true }) // patterns work too
|
|
186
|
-
```
|
|
187
|
-
|
|
188
|
-
**Auto-selected strategy:**
|
|
189
|
-
|
|
190
|
-
| Config | Strategy | When to use |
|
|
191
|
-
|--------|----------|-------------|
|
|
192
|
-
| `offline: true` | `StaleWhileRevalidate` | Default — instant response + background refresh |
|
|
193
|
-
| `offline: true, strategy: 'cache-first'` | `CacheFirst` | Static assets, rarely-changing data |
|
|
194
|
-
| `offline: true, strategy: 'network-first'` | `NetworkFirst` | Always-fresh data with offline fallback |
|
|
195
|
-
|
|
196
|
-
**Handle methods:**
|
|
197
|
-
|
|
198
|
-
```ts
|
|
199
|
-
handle.fetch() // Promise<Response> — fetches, respects maxAge
|
|
200
|
-
handle.json<T>() // Promise<T> — fetch() + response.json()
|
|
201
|
-
handle.query<T>() // { queryKey, queryFn } — TanStack Query compatible
|
|
202
|
-
handle.prefetch() // Promise<void> — warm the cache
|
|
203
|
-
handle.invalidate() // Promise<void> — evict cached entries
|
|
204
|
-
handle.unregister() // void — remove from SW registry (required to re-register with different config)
|
|
205
|
-
```
|
|
206
|
-
|
|
207
|
-
**Handle properties:**
|
|
208
|
-
|
|
209
|
-
```ts
|
|
210
|
-
handle.url // '/api/products'
|
|
211
|
-
handle.config // the config you passed in
|
|
212
|
-
handle.strategy // { name, swStrategy, cacheName, reasoning, behavior, equivalentCode }
|
|
213
|
-
```
|
|
115
|
+
## What you get
|
|
116
|
+
|
|
117
|
+
| Feature | Description |
|
|
118
|
+
|---------|-------------|
|
|
119
|
+
| **Auto strategy selection** | `offline: true` → StaleWhileRevalidate. No config needed. Override when you want. |
|
|
120
|
+
| **Persistent action queue** | Failed writes go to IndexedDB and replay with exponential backoff on reconnect. |
|
|
121
|
+
| **Request deduplication** | Concurrent `resource.fetch()` calls share one in-flight request. |
|
|
122
|
+
| **Optimistic updates** | `onOptimistic` / `onRollback` callbacks for instant UI feedback. |
|
|
123
|
+
| **Conflict resolution** | `onConflict` decides per 4xx whether to retry or drop a queued action. |
|
|
124
|
+
| **Queue prioritization** | `priority: 'high' | 'normal' | 'low'` — high items replay before normal. |
|
|
125
|
+
| **Cache warming** | `warmCache(handles[])` bulk-prefetches resources on login/init. |
|
|
126
|
+
| **URL patterns** | `/api/products/*`, `/api/users/:id`, `**` wildcards — SW intercepts all matches. |
|
|
127
|
+
| **Background Sync** | Registers a `sync` tag so queued actions replay even after tab close. |
|
|
128
|
+
| **Devtools panel** | `<EidosDevtools />` — live queue, cache state, offline toggle, no CSS import. |
|
|
129
|
+
| **Testing helpers** | `mockOffline`, `drainQueue`, `resetEidos`, `getCachedEntry` for Vitest/Jest. |
|
|
130
|
+
| **OpenAPI codegen** | `npx eidos-gen openapi.json` generates typed `resource()` + `action()` declarations. |
|
|
214
131
|
|
|
215
132
|
---
|
|
216
133
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
Wraps any async function with reliability guarantees. The wrapped function is a drop-in replacement.
|
|
220
|
-
|
|
221
|
-
```ts
|
|
222
|
-
const createOrder = action(
|
|
223
|
-
async (payload: OrderPayload): Promise<Order> => { /* your fn */ },
|
|
224
|
-
{
|
|
225
|
-
reliability: 'neverLose', // persist to IndexedDB + replay on reconnect
|
|
226
|
-
maxRetries?: number, // default: 3
|
|
227
|
-
name?: string, // label in devtools
|
|
228
|
-
priority?: 'high' | 'normal' | 'low', // replay order (default: 'normal')
|
|
229
|
-
onOptimistic?: (...args) => void, // called immediately — update UI optimistically
|
|
230
|
-
onRollback?: (...args) => void, // called on permanent failure — revert UI
|
|
231
|
-
onConflict?: (error, args) => 'retry' | 'skip', // called on 4xx during replay
|
|
232
|
-
}
|
|
233
|
-
)
|
|
234
|
-
|
|
235
|
-
const result = await createOrder(payload)
|
|
236
|
-
// → Order when successful
|
|
237
|
-
// → { queued: true, id, message } when offline or network fails
|
|
238
|
-
```
|
|
239
|
-
|
|
240
|
-
**Reliability modes:**
|
|
134
|
+
## Framework support
|
|
241
135
|
|
|
242
|
-
|
|
|
243
|
-
|
|
244
|
-
|
|
|
245
|
-
|
|
|
246
|
-
|
|
247
|
-
**
|
|
248
|
-
|
|
249
|
-
**
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
```ts
|
|
254
|
-
onConflict: (error, [payload]) => {
|
|
255
|
-
if (error instanceof Response && error.status === 409) {
|
|
256
|
-
// already created server-side — safe to drop and revert UI
|
|
257
|
-
removeOptimisticOrder(payload)
|
|
258
|
-
return 'skip'
|
|
259
|
-
}
|
|
260
|
-
return 'retry' // keep in queue for everything else
|
|
261
|
-
}
|
|
262
|
-
```
|
|
263
|
-
|
|
264
|
-
**Queue prioritization:** `priority` controls the replay order when multiple queued actions are pending. `'high'` items all complete before `'normal'` items start; `'normal'` all complete before `'low'` items start. Within each tier, items run in parallel. Default: `'normal'`.
|
|
265
|
-
|
|
266
|
-
```ts
|
|
267
|
-
// Critical write — replays before any normal/low actions
|
|
268
|
-
const saveDocument = action(api.saveDocument, {
|
|
269
|
-
reliability: 'neverLose',
|
|
270
|
-
priority: 'high',
|
|
271
|
-
})
|
|
272
|
-
|
|
273
|
-
// Background analytics — replays last, after user-visible writes
|
|
274
|
-
const logEvent = action(api.logEvent, {
|
|
275
|
-
reliability: 'neverLose',
|
|
276
|
-
priority: 'low',
|
|
277
|
-
})
|
|
278
|
-
```
|
|
279
|
-
|
|
280
|
-
---
|
|
281
|
-
|
|
282
|
-
### `replayQueue()`
|
|
283
|
-
|
|
284
|
-
Manually trigger queue replay. Called automatically on reconnect when `autoReplay: true`. Returns a `ReplayResult` summary.
|
|
285
|
-
|
|
286
|
-
```ts
|
|
287
|
-
import { replayQueue } from '@sweidos/eidos'
|
|
288
|
-
import type { ReplayResult } from '@sweidos/eidos'
|
|
289
|
-
|
|
290
|
-
// Manual trigger — e.g. after a user clicks "Retry"
|
|
291
|
-
const result: ReplayResult = await replayQueue()
|
|
292
|
-
// { attempted: 3, succeeded: 2, failed: 0, retrying: 1, skipped: 0, conflicted: 0 }
|
|
293
|
-
//
|
|
294
|
-
// attempted — items where the fn was found and called
|
|
295
|
-
// succeeded — resolved successfully
|
|
296
|
-
// failed — maxRetries exceeded, stays in queue
|
|
297
|
-
// retrying — failed, will retry later (nextRetryAt set)
|
|
298
|
-
// skipped — fn not in registry (module not imported yet)
|
|
299
|
-
// conflicted — 4xx response, onConflict returned 'skip', removed from queue
|
|
300
|
-
```
|
|
136
|
+
| Framework | Import path | Notes |
|
|
137
|
+
|-----------|-------------|-------|
|
|
138
|
+
| **React** | `@sweidos/eidos` | Hooks + `EidosProvider` |
|
|
139
|
+
| **Next.js App Router** | `@sweidos/eidos/nextjs` | Pre-marked `'use client'` — no wrapper needed |
|
|
140
|
+
| **SvelteKit** | `@sweidos/eidos/sveltekit` | `initEidosSvelteKit()` in `onMount`, framework-agnostic stores |
|
|
141
|
+
| **Vue** | `@sweidos/eidos` | Framework-agnostic stores via `eidosStatus.subscribe()` |
|
|
142
|
+
| **React Native** | `@sweidos/eidos/react-native` | AsyncStorage-backed queue, same `action()` API |
|
|
143
|
+
| **Vanilla JS** | `@sweidos/eidos` | `eidosStatus`, `eidosQueue`, `eidosQueueStats` stores |
|
|
144
|
+
| **Vite** | `@sweidos/eidos/vite` | Plugin auto-copies `eidos-sw.js` on every build |
|
|
145
|
+
| **TanStack Query** | `@sweidos/eidos/query` | `useEidosQuery`, `useEidosMutation`, `withEidosQueryClient` |
|
|
301
146
|
|
|
302
147
|
---
|
|
303
148
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
Remove all items from the action queue (IndexedDB + in-memory store). Useful for "clear all failed" UI controls and test teardown.
|
|
307
|
-
|
|
308
|
-
```ts
|
|
309
|
-
import { clearQueue } from '@sweidos/eidos'
|
|
310
|
-
|
|
311
|
-
await clearQueue()
|
|
312
|
-
```
|
|
313
|
-
|
|
314
|
-
---
|
|
315
|
-
|
|
316
|
-
### `EidosProvider`
|
|
317
|
-
|
|
318
|
-
React root component. Registers the SW and initialises the runtime.
|
|
319
|
-
|
|
320
|
-
```tsx
|
|
321
|
-
<EidosProvider
|
|
322
|
-
swPath="/eidos-sw.js" // default
|
|
323
|
-
autoReplay={true} // replay queue on reconnect, default: true
|
|
324
|
-
>
|
|
325
|
-
<App />
|
|
326
|
-
</EidosProvider>
|
|
327
|
-
```
|
|
328
|
-
|
|
329
|
-
---
|
|
330
|
-
|
|
331
|
-
### React Hooks
|
|
332
|
-
|
|
333
|
-
```ts
|
|
334
|
-
import { useEidosStatus, useEidosResource, useEidosQueue, useEidosQueueStats, useEidosAction, useEidosOnDrain } from '@sweidos/eidos'
|
|
149
|
+
## Core API
|
|
335
150
|
|
|
336
|
-
|
|
337
|
-
const { isOnline, swStatus, swError } = useEidosStatus()
|
|
151
|
+
Full reference at **[sweidos.vercel.app/overview](https://sweidos.vercel.app/overview)**.
|
|
338
152
|
|
|
339
|
-
|
|
340
|
-
const entry = useEidosResource('/api/products')
|
|
341
|
-
// entry → { status, cacheHits, cacheMisses, cachedAt, strategy, config, ... }
|
|
342
|
-
|
|
343
|
-
// The full action queue, reactive
|
|
344
|
-
const queue = useEidosQueue()
|
|
345
|
-
|
|
346
|
-
// Queue counts — only re-renders when a count changes, not on every mutation
|
|
347
|
-
const { pending, failed, replaying, total } = useEidosQueueStats()
|
|
348
|
-
|
|
349
|
-
// Live state for a single queue item — only re-renders when that item changes
|
|
350
|
-
const result = await createOrder(payload) // { queued: true, id: 'abc123', ... }
|
|
351
|
-
const item = useEidosAction(result.id)
|
|
352
|
-
// item → ActionQueueItem | undefined
|
|
353
|
-
// item?.status → 'pending' | 'replaying' | 'succeeded' | 'failed'
|
|
354
|
-
|
|
355
|
-
// Fire callback when queue drains to empty — for "all synced!" toasts
|
|
356
|
-
useEidosOnDrain(() => toast.success('All offline actions synced!'))
|
|
357
|
-
|
|
358
|
-
// Full store snapshot — use sparingly, prefer the narrower hooks above
|
|
359
|
-
const state = useEidos()
|
|
360
|
-
```
|
|
361
|
-
|
|
362
|
-
---
|
|
363
|
-
|
|
364
|
-
### Vue / Svelte / Vanilla JS Stores
|
|
365
|
-
|
|
366
|
-
Framework-agnostic reactive stores — no React dependency, zero extra peer deps. These implement the [Svelte store contract](https://svelte.dev/docs/svelte-components#script-4-prefix-stores-with-$-to-access-their-values) (`subscribe(run): unsubscribe`) so they work natively with Svelte's `$` prefix. They also wire up cleanly in Vue composables and plain JS.
|
|
367
|
-
|
|
368
|
-
```ts
|
|
369
|
-
import {
|
|
370
|
-
eidosQueue, eidosStatus, eidosQueueStats,
|
|
371
|
-
eidosResource, eidosAction, eidosStore,
|
|
372
|
-
} from '@sweidos/eidos'
|
|
373
|
-
```
|
|
374
|
-
|
|
375
|
-
**Svelte:**
|
|
376
|
-
|
|
377
|
-
```svelte
|
|
378
|
-
<script>
|
|
379
|
-
import { eidosQueue, eidosStatus, eidosQueueStats, eidosResource } from '@sweidos/eidos'
|
|
380
|
-
// Use $ prefix — Svelte auto-subscribes and unsubscribes
|
|
381
|
-
</script>
|
|
382
|
-
|
|
383
|
-
<p>Online: {$eidosStatus.isOnline}</p>
|
|
384
|
-
<p>Pending: {$eidosQueueStats.pending}</p>
|
|
385
|
-
<p>Cache hits: {$eidosResource('/api/products')?.cacheHits ?? 0}</p>
|
|
386
|
-
|
|
387
|
-
{#each $eidosQueue as item}
|
|
388
|
-
<div>{item.actionName} — {item.status}</div>
|
|
389
|
-
{/each}
|
|
390
|
-
```
|
|
391
|
-
|
|
392
|
-
**Vue (Composition API):**
|
|
393
|
-
|
|
394
|
-
```ts
|
|
395
|
-
import { ref, onUnmounted } from 'vue'
|
|
396
|
-
import { eidosStatus, eidosQueue } from '@sweidos/eidos'
|
|
397
|
-
|
|
398
|
-
export function useEidosStatusVue() {
|
|
399
|
-
const status = ref(eidosStatus.getState())
|
|
400
|
-
const unsub = eidosStatus.subscribe((v) => { status.value = v })
|
|
401
|
-
onUnmounted(unsub)
|
|
402
|
-
return status
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
export function useEidosQueueVue() {
|
|
406
|
-
const queue = ref(eidosQueue.getState())
|
|
407
|
-
const unsub = eidosQueue.subscribe((v) => { queue.value = v })
|
|
408
|
-
onUnmounted(unsub)
|
|
409
|
-
return queue
|
|
410
|
-
}
|
|
411
|
-
```
|
|
412
|
-
|
|
413
|
-
**Vanilla JS:**
|
|
153
|
+
### `resource(url, config)`
|
|
414
154
|
|
|
415
155
|
```ts
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
156
|
+
const products = resource('/api/products', {
|
|
157
|
+
offline: true, // enable SW interception + caching
|
|
158
|
+
strategy?: 'cache-first' | 'stale-while-revalidate' | 'network-first',
|
|
159
|
+
cacheName?: string, // custom cache bucket
|
|
160
|
+
maxAge?: number, // TTL in ms — re-fetch after expiry
|
|
420
161
|
})
|
|
421
162
|
|
|
422
|
-
//
|
|
423
|
-
|
|
163
|
+
await products.fetch() // Promise<Response>
|
|
164
|
+
await products.json<Product[]>() // Promise<T>
|
|
165
|
+
await products.prefetch() // fire-and-forget warm
|
|
166
|
+
await products.invalidate() // clear cache + notify TanStack Query
|
|
167
|
+
products.query() // { queryKey, queryFn } for useQuery
|
|
424
168
|
```
|
|
425
169
|
|
|
426
|
-
|
|
427
|
-
|-------|------|-----------|
|
|
428
|
-
| `eidosQueue` | `ActionQueueItem[]` | Any queue mutation |
|
|
429
|
-
| `eidosStatus` | `{ isOnline, swStatus, swError }` | Online or SW status changes |
|
|
430
|
-
| `eidosQueueStats` | `{ pending, failed, replaying, total }` | Any queue mutation |
|
|
431
|
-
| `eidosResource(url)` | `ResourceEntry \| undefined` | Resource registered or updated |
|
|
432
|
-
| `eidosAction(id)` | `ActionQueueItem \| undefined` | Item status changes or removal |
|
|
433
|
-
| `eidosStore` | `EidosStore` | Any state change |
|
|
434
|
-
|
|
435
|
-
---
|
|
170
|
+
**Auto-selected strategy:**
|
|
436
171
|
|
|
437
|
-
|
|
172
|
+
| Config | Strategy | Use when |
|
|
173
|
+
|--------|----------|----------|
|
|
174
|
+
| `offline: true` | StaleWhileRevalidate | Default — fast + background refresh |
|
|
175
|
+
| `offline: true, strategy: 'cache-first'` | CacheFirst | Static assets, config data |
|
|
176
|
+
| `offline: true, strategy: 'network-first'` | NetworkFirst | Always-fresh with offline fallback |
|
|
438
177
|
|
|
439
|
-
|
|
178
|
+
URL patterns work on any handle: `/api/products/*`, `/api/users/:id`, `**`
|
|
440
179
|
|
|
441
|
-
|
|
180
|
+
### `action(fn, config)`
|
|
442
181
|
|
|
443
182
|
```ts
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
//
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
//
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
// errors — the raw thrown values for failed handles
|
|
183
|
+
const createOrder = action(async (payload: OrderPayload) => { ... }, {
|
|
184
|
+
reliability: 'neverLose', // persist to IDB + replay on reconnect
|
|
185
|
+
name: 'createOrder', // stable name for post-reload replay
|
|
186
|
+
maxRetries?: number, // default: 3
|
|
187
|
+
priority?: 'high' | 'normal' | 'low',
|
|
188
|
+
onOptimistic?: (...args) => void, // instant UI update
|
|
189
|
+
onRollback?: (...args) => void, // revert on permanent failure
|
|
190
|
+
onConflict?: (error, args) => 'retry' | 'skip', // 4xx handler
|
|
191
|
+
})
|
|
454
192
|
```
|
|
455
193
|
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
---
|
|
459
|
-
|
|
460
|
-
### `setOfflineSimulation(enabled)`
|
|
461
|
-
|
|
462
|
-
Toggle offline simulation without physically disconnecting the network.
|
|
194
|
+
### React hooks
|
|
463
195
|
|
|
464
196
|
```ts
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
197
|
+
const { isOnline, swStatus } = useEidosStatus()
|
|
198
|
+
const { pending, failed } = useEidosQueueStats()
|
|
199
|
+
const entry = useEidosResource('/api/products')
|
|
200
|
+
const item = useEidosAction(queuedResult.id)
|
|
201
|
+
useEidosOnDrain(() => toast('All offline actions synced!'))
|
|
469
202
|
```
|
|
470
203
|
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
### `isBgSyncSupported()`
|
|
474
|
-
|
|
475
|
-
Returns `true` when the active SW registration supports the Background Sync API (Chrome 49+, Edge 79+, Safari 16+). Use to conditionally surface sync status in your UI.
|
|
204
|
+
### Framework-agnostic stores
|
|
476
205
|
|
|
477
206
|
```ts
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
}
|
|
207
|
+
// Svelte, Vue, vanilla — no React dependency
|
|
208
|
+
eidosStatus.subscribe(({ isOnline }) => { ... })
|
|
209
|
+
eidosQueue.subscribe((queue) => { ... })
|
|
210
|
+
eidosQueueStats.getState() // { pending, failed, replaying, total }
|
|
211
|
+
eidosResource('/api/products').getState() // ResourceEntry | undefined
|
|
484
212
|
```
|
|
485
213
|
|
|
486
214
|
---
|
|
487
215
|
|
|
488
|
-
##
|
|
489
|
-
|
|
490
|
-
Performance is a first-class concern in Eidos. Every design decision optimises for low overhead.
|
|
491
|
-
|
|
492
|
-
| Metric | Value | How |
|
|
493
|
-
|--------|-------|-----|
|
|
494
|
-
| **Bundle size** | 5.0 kB gzip | Zero runtime dependencies — not even a state library |
|
|
495
|
-
| **Re-renders** | Minimal | `useSyncExternalStore` with per-field selectors; components only re-render when their field changes |
|
|
496
|
-
| **Queue replay** | Parallel | `Promise.allSettled` — N pending actions replay concurrently, not serially |
|
|
497
|
-
| **IDB reads** | Index scan | `replayQueue` queries only `pending`/`failed` items via the status index — no full table scan |
|
|
498
|
-
| **Network timeout** | 3 s | `NetworkFirst` strategy aborts fetch after 3 s and falls back to cache — no hanging requests |
|
|
499
|
-
| **Pre-activation buffer** | Zero drops | Messages sent before the SW is active are buffered and flushed on activation |
|
|
500
|
-
| **Concurrency safety** | Lock-guarded | `_replaying` flag prevents duplicate replay passes from concurrent online events |
|
|
501
|
-
| **Request deduplication** | 1 request / N callers | Concurrent `handle.fetch()` calls for the same URL share one in-flight network request; each caller gets a cloned `Response` |
|
|
502
|
-
|
|
503
|
-
### Bundle comparison
|
|
504
|
-
|
|
505
|
-
| Version | Raw | Gzip | Change |
|
|
506
|
-
|---------|-----|------|--------|
|
|
507
|
-
| 1.0.5 (with zustand) | 35.0 kB | 7.9 kB | — |
|
|
508
|
-
| 1.0.6 (zero deps) | 18.6 kB | 5.0 kB | −47% |
|
|
509
|
-
| **1.0.21** (minified + dedup) | **19.0 kB** | **5.8 kB** | +0.5% raw vs 1.0.6 (dedup code), smaller than zustand baseline |
|
|
510
|
-
|
|
511
|
-
---
|
|
512
|
-
|
|
513
|
-
## Architecture
|
|
514
|
-
|
|
515
|
-
```
|
|
516
|
-
┌─────────────────────────────────────────────┐
|
|
517
|
-
│ Application Layer │
|
|
518
|
-
│ resource() · action() · EidosProvider │ ← you write this
|
|
519
|
-
└────────────────┬────────────────────────────┘
|
|
520
|
-
│ EIDOS_REGISTER_RESOURCE (postMessage)
|
|
521
|
-
┌────────────────▼────────────────────────────┐
|
|
522
|
-
│ Runtime Layer (@sweidos/eidos) │
|
|
523
|
-
│ Strategy derivation · reactive store │
|
|
524
|
-
│ SW bridge · IDB queue · exponential backoff │
|
|
525
|
-
└────────────────┬────────────────────────────┘
|
|
526
|
-
│ fetch intercept
|
|
527
|
-
┌────────────────▼────────────────────────────┐
|
|
528
|
-
│ Worker Layer (eidos-sw.js) │
|
|
529
|
-
│ CacheFirst · StaleWhileRevalidate │
|
|
530
|
-
│ NetworkFirst · Offline simulation │
|
|
531
|
-
└────────────────┬────────────────────────────┘
|
|
532
|
-
│ Cache API · IndexedDB
|
|
533
|
-
┌────────────────▼────────────────────────────┐
|
|
534
|
-
│ Storage Layer │
|
|
535
|
-
│ Cache Storage · IndexedDB (action queue) │
|
|
536
|
-
└─────────────────────────────────────────────┘
|
|
537
|
-
```
|
|
538
|
-
|
|
539
|
-
### SW message protocol
|
|
540
|
-
|
|
541
|
-
**App → SW:**
|
|
542
|
-
|
|
543
|
-
| Message | Purpose |
|
|
544
|
-
|---------|---------|
|
|
545
|
-
| `EIDOS_REGISTER_RESOURCE` | Register a fetch-intercept rule |
|
|
546
|
-
| `EIDOS_UNREGISTER_RESOURCE` | Remove a rule |
|
|
547
|
-
| `EIDOS_CLEAR_CACHE` | Evict cache entries for a URL |
|
|
548
|
-
| `EIDOS_SIMULATE_OFFLINE` | Toggle offline simulation mode |
|
|
549
|
-
| `EIDOS_PING` | Health check |
|
|
550
|
-
|
|
551
|
-
**SW → App:**
|
|
552
|
-
|
|
553
|
-
| Message | Purpose |
|
|
554
|
-
|---------|---------|
|
|
555
|
-
| `EIDOS_CACHE_HIT` | Cached response was served |
|
|
556
|
-
| `EIDOS_CACHE_UPDATED` | Cache entry refreshed from network |
|
|
557
|
-
| `EIDOS_NETWORK_ERROR` | Network request failed |
|
|
558
|
-
| `EIDOS_CACHE_CLEARED` | Cache was cleared |
|
|
559
|
-
| `EIDOS_BACKGROUND_SYNC` | Browser fired `sync` event — runtime calls `replayQueue()` |
|
|
560
|
-
|
|
561
|
-
---
|
|
562
|
-
|
|
563
|
-
## Repository Structure
|
|
564
|
-
|
|
565
|
-
```
|
|
566
|
-
eidos/
|
|
567
|
-
├── api/ Vercel serverless functions (demo endpoints)
|
|
568
|
-
├── packages/
|
|
569
|
-
│ ├── core/ @sweidos/eidos npm package
|
|
570
|
-
│ │ └── src/
|
|
571
|
-
│ │ ├── types.ts
|
|
572
|
-
│ │ ├── resource.ts resource() — caching + handle
|
|
573
|
-
│ │ ├── action.ts action() + exponential backoff queue replay
|
|
574
|
-
│ │ ├── runtime.ts initEidos + SW registration
|
|
575
|
-
│ │ ├── store.ts reactive store (useSyncExternalStore)
|
|
576
|
-
│ │ ├── sw-bridge.ts postMessage channel
|
|
577
|
-
│ │ ├── idb.ts IndexedDB CRUD wrapper
|
|
578
|
-
│ │ └── react/ EidosProvider + hooks
|
|
579
|
-
│ └── worker/ SW typed source
|
|
580
|
-
│ └── src/sw.ts → compiles to eidos-sw.js
|
|
581
|
-
├── apps/
|
|
582
|
-
│ └── playground/ Interactive demo dashboard
|
|
583
|
-
│ └── public/
|
|
584
|
-
│ └── eidos-sw.js compiled service worker
|
|
585
|
-
└── .github/workflows/ CI/CD — deploy + npm release on push to main
|
|
586
|
-
```
|
|
587
|
-
|
|
588
|
-
---
|
|
589
|
-
|
|
590
|
-
## Vite Plugin
|
|
591
|
-
|
|
592
|
-
`@sweidos/eidos` ships a first-class Vite plugin via the `@sweidos/eidos/vite` subpath. It automatically copies `eidos-sw.js` from the installed package into your `public/` directory on every build and dev-server start — keeping the SW in sync with the installed version.
|
|
216
|
+
## TanStack Query
|
|
593
217
|
|
|
594
218
|
```ts
|
|
595
|
-
//
|
|
596
|
-
|
|
597
|
-
import { defineConfig } from 'vite'
|
|
598
|
-
|
|
599
|
-
export default defineConfig({
|
|
600
|
-
plugins: [eidos()],
|
|
601
|
-
})
|
|
602
|
-
```
|
|
219
|
+
// main.tsx — register once
|
|
220
|
+
withEidosQueryClient(queryClient)
|
|
603
221
|
|
|
604
|
-
|
|
222
|
+
// In components
|
|
223
|
+
const { data, isPending } = useEidosQuery<Product[]>(products)
|
|
605
224
|
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
225
|
+
const mutation = useEidosMutation(createOrder, {
|
|
226
|
+
invalidates: [products], // clears cache + invalidates TQ on success
|
|
227
|
+
onSuccess(data) {
|
|
228
|
+
if ('queued' in data) toast('Saved offline')
|
|
229
|
+
else toast(`Order #${data.id} created`)
|
|
230
|
+
},
|
|
609
231
|
})
|
|
610
232
|
```
|
|
611
233
|
|
|
612
|
-
No more manual `cp` step. The plugin runs on `buildStart` (prod builds) and `configureServer` (dev).
|
|
613
|
-
|
|
614
|
-
---
|
|
615
|
-
|
|
616
|
-
## TanStack Query Integration
|
|
617
|
-
|
|
618
|
-
`@sweidos/eidos/query` provides first-class hooks for [TanStack Query v5](https://tanstack.com/query/latest). Requires `@tanstack/react-query` — already optional in Eidos, just install it.
|
|
619
|
-
|
|
620
|
-
### Setup (once)
|
|
621
|
-
|
|
622
|
-
```ts
|
|
623
|
-
// main.tsx
|
|
624
|
-
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
625
|
-
import { withEidosQueryClient } from '@sweidos/eidos/query'
|
|
626
|
-
|
|
627
|
-
const queryClient = new QueryClient()
|
|
628
|
-
withEidosQueryClient(queryClient) // bridges handle.invalidate() → TQ cache
|
|
629
|
-
|
|
630
|
-
root.render(
|
|
631
|
-
<QueryClientProvider client={queryClient}>
|
|
632
|
-
<EidosProvider swPath="/eidos-sw.js">
|
|
633
|
-
<App />
|
|
634
|
-
</EidosProvider>
|
|
635
|
-
</QueryClientProvider>
|
|
636
|
-
)
|
|
637
|
-
```
|
|
638
|
-
|
|
639
|
-
### `useEidosQuery(handle, options?)`
|
|
640
|
-
|
|
641
|
-
Wraps `useQuery` with Eidos-smart defaults:
|
|
642
|
-
- `networkMode: 'always'` — Eidos owns offline; queries run even when `navigator.onLine` is false
|
|
643
|
-
- `retry: false` — Eidos handles retries at the SW/replay layer
|
|
644
|
-
|
|
645
|
-
```tsx
|
|
646
|
-
import { useEidosQuery } from '@sweidos/eidos/query'
|
|
647
|
-
|
|
648
|
-
function ProductList() {
|
|
649
|
-
const { data, isPending, isError } = useEidosQuery<Product[]>(products)
|
|
650
|
-
// ...
|
|
651
|
-
}
|
|
652
|
-
```
|
|
653
|
-
|
|
654
|
-
### `useEidosMutation(handle, options?)`
|
|
655
|
-
|
|
656
|
-
Wraps `useMutation` for a single-argument action handle:
|
|
657
|
-
- `networkMode: 'always'` — action queues offline automatically
|
|
658
|
-
- `invalidates` — clears Eidos cache + invalidates TQ entries on success
|
|
659
|
-
|
|
660
|
-
```tsx
|
|
661
|
-
import { useEidosMutation } from '@sweidos/eidos/query'
|
|
662
|
-
|
|
663
|
-
function OrderForm() {
|
|
664
|
-
const mutation = useEidosMutation(createOrder, {
|
|
665
|
-
invalidates: [products], // refetch product list after order
|
|
666
|
-
onSuccess(data) {
|
|
667
|
-
if ('queued' in data) toast('Saved offline — will sync when back online')
|
|
668
|
-
else toast(`Order #${data.id} created!`)
|
|
669
|
-
},
|
|
670
|
-
})
|
|
671
|
-
|
|
672
|
-
return <button onClick={() => mutation.mutate({ productId: 1, qty: 2 })}>Buy</button>
|
|
673
|
-
}
|
|
674
|
-
```
|
|
675
|
-
|
|
676
|
-
### `withEidosQueryClient(client)`
|
|
677
|
-
|
|
678
|
-
Registers a `QueryClient` with Eidos. After calling this:
|
|
679
|
-
- `handle.invalidate()` also calls `queryClient.invalidateQueries({ queryKey: ['eidos', url] })`
|
|
680
|
-
- Both systems stay in sync automatically, even when cache is cleared outside of mutations
|
|
681
|
-
|
|
682
234
|
---
|
|
683
235
|
|
|
684
|
-
## Testing
|
|
685
|
-
|
|
686
|
-
`@sweidos/eidos/testing` provides first-class helpers for Vitest, Jest, and Playwright. Import only in test files.
|
|
236
|
+
## Testing
|
|
687
237
|
|
|
688
238
|
```ts
|
|
689
239
|
import {
|
|
690
|
-
mockOffline, mockOnline,
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
resetEidos, getEidosState,
|
|
240
|
+
mockOffline, mockOnline, drainQueue,
|
|
241
|
+
waitForQueueDrain, getCachedEntry,
|
|
242
|
+
clearEidosCache, resetEidos, getEidosState,
|
|
694
243
|
} from '@sweidos/eidos/testing'
|
|
695
|
-
```
|
|
696
|
-
|
|
697
|
-
### `resetEidos()` — `beforeEach` cleanup
|
|
698
|
-
|
|
699
|
-
```ts
|
|
700
|
-
beforeEach(async () => {
|
|
701
|
-
await resetEidos()
|
|
702
|
-
// ✓ queue cleared, resources cleared, online restored, _initialized reset
|
|
703
|
-
})
|
|
704
|
-
```
|
|
705
244
|
|
|
706
|
-
|
|
245
|
+
beforeEach(() => resetEidos())
|
|
707
246
|
|
|
708
|
-
```ts
|
|
709
247
|
it('queues action while offline', async () => {
|
|
710
248
|
mockOffline()
|
|
711
|
-
await
|
|
712
|
-
|
|
249
|
+
await createOrder({ productId: 1, qty: 2 })
|
|
713
250
|
expect(getEidosState().queue).toHaveLength(1)
|
|
714
|
-
expect(getEidosState().isOnline).toBe(false)
|
|
715
251
|
})
|
|
716
|
-
```
|
|
717
|
-
|
|
718
|
-
### Testing queue replay
|
|
719
252
|
|
|
720
|
-
|
|
721
|
-
it('replays queue on reconnect', async () => {
|
|
253
|
+
it('replays on reconnect', async () => {
|
|
722
254
|
mockOffline()
|
|
723
|
-
await
|
|
724
|
-
|
|
725
|
-
const result = await drainQueue() // forces online + replays
|
|
255
|
+
await createOrder({ productId: 1, qty: 2 })
|
|
256
|
+
const result = await drainQueue()
|
|
726
257
|
expect(result.succeeded).toBe(1)
|
|
727
258
|
})
|
|
728
259
|
```
|
|
729
260
|
|
|
730
|
-
### Testing cache state
|
|
731
|
-
|
|
732
|
-
```ts
|
|
733
|
-
it('caches the resource after first fetch', async () => {
|
|
734
|
-
const products = resource('/api/products', { offline: true })
|
|
735
|
-
await products.fetch()
|
|
736
|
-
|
|
737
|
-
const cached = await getCachedEntry('/api/products')
|
|
738
|
-
expect(cached).toBeDefined()
|
|
739
|
-
const body = await cached!.json()
|
|
740
|
-
expect(body).toEqual([...])
|
|
741
|
-
})
|
|
742
|
-
```
|
|
743
|
-
|
|
744
|
-
### API summary
|
|
745
|
-
|
|
746
|
-
| Helper | Description |
|
|
747
|
-
|--------|-------------|
|
|
748
|
-
| `mockOffline(opts?)` | Set `isOnline = false`. Pass `{ stubFetch: true }` to also make `fetch()` throw. |
|
|
749
|
-
| `mockOnline()` | Restore `isOnline = true`. Removes fetch stub if present. |
|
|
750
|
-
| `drainQueue()` | Force-replay queue now. Returns `ReplayResult`. |
|
|
751
|
-
| `waitForQueueDrain(opts?)` | Wait until no pending/replaying items. Timeout default 5s. |
|
|
752
|
-
| `getCachedEntry(url, name?)` | Read a `Response` from Cache Storage. Returns `undefined` if missing. |
|
|
753
|
-
| `clearEidosCache(name?)` | Delete an entire cache namespace (default: `eidos-resources-v1`). |
|
|
754
|
-
| `resetEidos()` | Full teardown: queue, resources, SW status, online state, runtime flag. |
|
|
755
|
-
| `getEidosState()` | Plain-object snapshot of store state (no store methods). |
|
|
756
|
-
|
|
757
261
|
---
|
|
758
262
|
|
|
759
|
-
##
|
|
760
|
-
|
|
761
|
-
Eidos is browser-only — Service Workers, Cache API, and IndexedDB are not available in Node.js. The runtime already no-ops safely when `window` is undefined, but two subpath exports make integration with SSR frameworks seamless.
|
|
762
|
-
|
|
763
|
-
### Next.js App Router (`@sweidos/eidos/nextjs`)
|
|
764
|
-
|
|
765
|
-
Imports from this subpath are pre-marked `'use client'`, so you can use `EidosProvider` and all hooks directly in your App Router layout without creating your own wrapper file.
|
|
766
|
-
|
|
767
|
-
```tsx
|
|
768
|
-
// app/providers.tsx ← no 'use client' needed here
|
|
769
|
-
import { EidosProvider, useEidosStatus } from '@sweidos/eidos/nextjs'
|
|
770
|
-
|
|
771
|
-
export function Providers({ children }: { children: React.ReactNode }) {
|
|
772
|
-
return <EidosProvider swPath="/eidos-sw.js">{children}</EidosProvider>
|
|
773
|
-
}
|
|
774
|
-
```
|
|
775
|
-
|
|
776
|
-
The `'use client'` boundary is on the published `dist/nextjs.js` — Next.js recognises it and marks everything imported through that entry as client code.
|
|
263
|
+
## OpenAPI codegen
|
|
777
264
|
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
```svelte
|
|
783
|
-
<!-- src/routes/+layout.svelte -->
|
|
784
|
-
<script>
|
|
785
|
-
import { onMount } from 'svelte'
|
|
786
|
-
import { initEidosSvelteKit } from '@sweidos/eidos/sveltekit'
|
|
787
|
-
|
|
788
|
-
onMount(initEidosSvelteKit({ swPath: '/eidos-sw.js', autoReplay: true }))
|
|
789
|
-
</script>
|
|
790
|
-
|
|
791
|
-
<slot />
|
|
265
|
+
```bash
|
|
266
|
+
npx eidos-gen openapi.json
|
|
267
|
+
# → writes eidos.generated.ts with typed resource() + action() declarations
|
|
792
268
|
```
|
|
793
269
|
|
|
794
|
-
|
|
270
|
+
Handles path params, `$ref` resolution, request/response types, DELETE body omission.
|
|
795
271
|
|
|
796
272
|
---
|
|
797
273
|
|
|
798
274
|
## Devtools
|
|
799
275
|
|
|
800
|
-
`@sweidos/eidos/devtools` exports a floating panel component you can drop into any React app during development. It shows live queue state, cache entries, SW registration status, and lets you toggle offline simulation — all without leaving your app.
|
|
801
|
-
|
|
802
276
|
```tsx
|
|
803
277
|
import { EidosDevtools } from '@sweidos/eidos/devtools'
|
|
804
278
|
|
|
805
|
-
//
|
|
806
|
-
|
|
807
|
-
return (
|
|
808
|
-
<>
|
|
809
|
-
<YourApp />
|
|
810
|
-
{process.env.NODE_ENV === 'development' && <EidosDevtools />}
|
|
811
|
-
</>
|
|
812
|
-
)
|
|
813
|
-
}
|
|
279
|
+
// Drop anywhere — bottom-right floating panel, no CSS import
|
|
280
|
+
{process.env.NODE_ENV === 'development' && <EidosDevtools />}
|
|
814
281
|
```
|
|
815
282
|
|
|
816
|
-
|
|
283
|
+
Panel shows: live queue state · cache entries · SW status · offline simulation toggle.
|
|
817
284
|
|
|
818
|
-
|
|
819
|
-
|------|------|---------|-------------|
|
|
820
|
-
| `position` | `'bottom-right' \| 'bottom-left' \| 'top-right' \| 'top-left'` | `'bottom-right'` | Corner to anchor the panel |
|
|
821
|
-
| `defaultOpen` | `boolean` | `false` | Start expanded |
|
|
285
|
+
---
|
|
822
286
|
|
|
823
|
-
|
|
824
|
-
- **Status bar** — online/offline indicator, SW registration status, offline simulation toggle (`setOfflineSimulation`)
|
|
825
|
-
- **Queue tab** — all queue items with status badges (`pending` / `replaying` / `succeeded` / `failed`), priority, retry count, plus Replay and Clear buttons
|
|
826
|
-
- **Cache tab** — all registered resources with cache status, strategy name, hit/miss counts, and last cached timestamp
|
|
287
|
+
## SSR adapters
|
|
827
288
|
|
|
828
|
-
|
|
289
|
+
**Next.js** — import from `@sweidos/eidos/nextjs`. Pre-marked `'use client'`, works in App Router layouts without a wrapper.
|
|
290
|
+
|
|
291
|
+
**SvelteKit** — `initEidosSvelteKit()` inside `onMount`. Framework-agnostic stores (`$eidosQueue`, `$eidosStatus`) work with Svelte's `$` auto-subscribe.
|
|
292
|
+
|
|
293
|
+
**React Native** — `@sweidos/eidos/react-native` with AsyncStorage-backed queue. Same `action()` API surface, no Service Worker dependency.
|
|
829
294
|
|
|
830
295
|
---
|
|
831
296
|
|
|
832
|
-
## Known
|
|
297
|
+
## Known limitations
|
|
833
298
|
|
|
834
299
|
| Limitation | Detail |
|
|
835
300
|
|------------|--------|
|
|
836
|
-
| GET-only caching | SW intercepts `GET` only.
|
|
837
|
-
|
|
|
838
|
-
|
|
|
839
|
-
|
|
|
840
|
-
| React in main bundle | ~~Fixed in v1.0.22~~ — ESM output uses `preserveModules`; Vue/Svelte/vanilla consumers only pull in the modules they import. React is isolated to `dist/react/hooks.js` and `dist/react/Provider.js`. |
|
|
841
|
-
|
|
842
|
-
---
|
|
843
|
-
|
|
844
|
-
## Roadmap
|
|
845
|
-
|
|
846
|
-
- [x] Cache TTL / `maxAge` expiration
|
|
847
|
-
- [x] Exponential backoff with jitter for queue replay
|
|
848
|
-
- [x] Per-resource `cacheName` override
|
|
849
|
-
- [x] `resource.unregister()` for cleanup
|
|
850
|
-
- [x] URL pattern matching (`*`, `**`, `:param`)
|
|
851
|
-
- [x] Cross-origin resource support
|
|
852
|
-
- [x] Background Sync API integration
|
|
853
|
-
- [x] Vite plugin (`@sweidos/eidos/vite` subpath — ships in the main package)
|
|
854
|
-
- [x] Vue / Svelte bindings (framework-agnostic reactive stores)
|
|
855
|
-
- [x] TanStack Query integration (`@sweidos/eidos/query` subpath — `useEidosQuery`, `useEidosMutation`, `withEidosQueryClient`)
|
|
856
|
-
|
|
857
|
-
**Core reliability**
|
|
858
|
-
- [x] Optimistic updates — `onOptimistic` / `onRollback` callbacks on `action()` for instant UI feedback before server confirms
|
|
859
|
-
- [x] Conflict resolution hook — `onConflict` callback when replaying a queued action returns 4xx; decide per-item: retry or skip
|
|
860
|
-
- [x] Queue prioritization — `priority: 'high' | 'normal' | 'low'` on `action()`; high-priority items replay first
|
|
861
|
-
|
|
862
|
-
**DX / Tooling**
|
|
863
|
-
- [x] Devtools panel component — drop-in `<EidosDevtools />` showing cache entries, queue state, replay status, and offline toggle
|
|
864
|
-
- [x] Testing utilities (`@sweidos/eidos/testing`) — `mockOffline()`, `mockOnline()`, `drainQueue()`, `waitForQueueDrain()`, `getCachedEntry(url)`, `clearEidosCache()`, `resetEidos()`, `getEidosState()` for Vitest / Playwright
|
|
865
|
-
- [x] SvelteKit / Next.js adapters — SSR-aware init helpers that skip SW registration server-side
|
|
866
|
-
|
|
867
|
-
**Performance**
|
|
868
|
-
- [x] Request deduplication — multiple simultaneous `resource.fetch()` calls share one in-flight network request; each caller gets an independent cloned `Response`
|
|
869
|
-
- [x] Cache warming — `warmCache(handles[])` bulk-prefetches a list of resources on init (e.g. on login)
|
|
870
|
-
|
|
871
|
-
**Ecosystem**
|
|
872
|
-
- [ ] React Native support — AsyncStorage + fetch-based backend (no Cache API / SW); same `resource` / `action` API surface
|
|
873
|
-
- [ ] OpenAPI codegen CLI — `npx eidos-gen ./openapi.json` generates typed `resource()` and `action()` declarations
|
|
301
|
+
| GET-only caching | SW intercepts `GET` only. Mutations go through `action()`. |
|
|
302
|
+
| Module-scope actions | `action()` must be at module scope so functions are registered before a reload triggers replay. |
|
|
303
|
+
| Single SW | Assumes one SW at the configured `swPath`. |
|
|
304
|
+
| React Native resources | In-memory only — no Cache API or SW in RN. Action queue fully persists. |
|
|
874
305
|
|
|
875
306
|
---
|
|
876
307
|
|
|
@@ -879,14 +310,15 @@ The component is self-contained with inline styles — no CSS import needed, no
|
|
|
879
310
|
```bash
|
|
880
311
|
pnpm install # install all workspace deps
|
|
881
312
|
pnpm dev # run playground at localhost:3000
|
|
882
|
-
pnpm type-check # typecheck all packages
|
|
883
313
|
pnpm --filter @sweidos/eidos build # build core package
|
|
314
|
+
pnpm --filter @sweidos/eidos test # run unit tests
|
|
315
|
+
pnpm type-check # typecheck all packages
|
|
884
316
|
```
|
|
885
317
|
|
|
886
|
-
The project uses pnpm workspaces. TypeScript strict mode throughout.
|
|
318
|
+
The project uses pnpm workspaces. TypeScript strict mode throughout. Please open an issue before large PRs.
|
|
887
319
|
|
|
888
320
|
---
|
|
889
321
|
|
|
890
322
|
## License
|
|
891
323
|
|
|
892
|
-
MIT © Aditya Raj
|
|
324
|
+
MIT © [Aditya Raj](https://github.com/iamadi11)
|