@sweidos/eidos 1.0.31 → 1.0.33
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 +146 -848
- package/dist/action.js +11 -9
- package/dist/action.js.map +1 -1
- package/dist/devtools.js +29 -36
- 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 +1 -1
- package/dist/resource.js +29 -22
- package/dist/resource.js.map +1 -1
- package/dist/runtime.js.map +1 -1
- package/dist/store.js +9 -8
- 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 +21 -4
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
|
|
27
|
+
## The problem
|
|
37
28
|
|
|
38
|
-
Every
|
|
39
|
-
|
|
40
|
-
## The Solution
|
|
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,924 +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
|
-
```
|
|
214
|
-
|
|
215
|
-
---
|
|
216
|
-
|
|
217
|
-
### `action(fn, config)`
|
|
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:**
|
|
241
|
-
|
|
242
|
-
| Mode | Behaviour |
|
|
243
|
-
|------|-----------|
|
|
244
|
-
| `best-effort` | Execute directly. No persistence, no retry. |
|
|
245
|
-
| `neverLose` | Persist args to IndexedDB before executing. Replay on reconnect with exponential backoff. |
|
|
246
|
-
|
|
247
|
-
**Exponential backoff:** `neverLose` actions that fail are retried with `2s × 2^retryCount` delay (capped at 5 min, ±20% jitter). Items not yet due are skipped on each replay pass.
|
|
248
|
-
|
|
249
|
-
**Conflict resolution:** when a 4xx HTTP response occurs during replay, `onConflict` is called with the thrown error and the original args. Return `'skip'` to silently remove the item from the queue without calling `onRollback`, or `'retry'` to continue normal retry/backoff behaviour.
|
|
250
|
-
|
|
251
|
-
A 4xx is detected when the thrown value is a `Response` with `status` in [400, 499], or any object with a `.status` property in that range.
|
|
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
|
-
```
|
|
301
|
-
|
|
302
|
-
---
|
|
303
|
-
|
|
304
|
-
### `clearQueue()`
|
|
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
|
-
```
|
|
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. |
|
|
328
131
|
|
|
329
132
|
---
|
|
330
133
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
```ts
|
|
334
|
-
import { useEidosStatus, useEidosResource, useEidosQueue, useEidosQueueStats, useEidosAction, useEidosOnDrain } from '@sweidos/eidos'
|
|
335
|
-
|
|
336
|
-
// Online status + SW lifecycle — cheap subscription, safe in headers
|
|
337
|
-
const { isOnline, swStatus, swError } = useEidosStatus()
|
|
338
|
-
|
|
339
|
-
// Live cache state for a single resource URL
|
|
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'
|
|
134
|
+
## Framework support
|
|
354
135
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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` |
|
|
361
146
|
|
|
362
147
|
---
|
|
363
148
|
|
|
364
|
-
|
|
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>
|
|
149
|
+
## Core API
|
|
382
150
|
|
|
383
|
-
|
|
384
|
-
<p>Pending: {$eidosQueueStats.pending}</p>
|
|
385
|
-
<p>Cache hits: {$eidosResource('/api/products')?.cacheHits ?? 0}</p>
|
|
151
|
+
Full reference at **[sweidos.vercel.app/overview](https://sweidos.vercel.app/overview)**.
|
|
386
152
|
|
|
387
|
-
|
|
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
|
-
}
|
|
484
|
-
```
|
|
485
|
-
|
|
486
|
-
---
|
|
487
|
-
|
|
488
|
-
## Performance
|
|
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
|
-
└─────────────────────────────────────────────┘
|
|
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
|
|
537
212
|
```
|
|
538
213
|
|
|
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
214
|
---
|
|
562
215
|
|
|
563
|
-
##
|
|
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
234
|
---
|
|
615
235
|
|
|
616
|
-
##
|
|
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
|
-
---
|
|
683
|
-
|
|
684
|
-
## Testing Utilities
|
|
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
244
|
|
|
697
|
-
|
|
245
|
+
beforeEach(() => resetEidos())
|
|
698
246
|
|
|
699
|
-
```ts
|
|
700
|
-
beforeEach(async () => {
|
|
701
|
-
await resetEidos()
|
|
702
|
-
// ✓ queue cleared, resources cleared, online restored, _initialized reset
|
|
703
|
-
})
|
|
704
|
-
```
|
|
705
|
-
|
|
706
|
-
### Testing offline queuing
|
|
707
|
-
|
|
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
|
-
## OpenAPI
|
|
760
|
-
|
|
761
|
-
`eidos-gen` is a standalone CLI that reads an OpenAPI 3.x spec (JSON or YAML) and generates a fully-typed Eidos declarations file — `resource()` for every GET endpoint, `action()` for every POST / PUT / PATCH / DELETE.
|
|
263
|
+
## OpenAPI codegen
|
|
762
264
|
|
|
763
265
|
```bash
|
|
764
266
|
npx eidos-gen openapi.json
|
|
765
|
-
# → writes eidos.generated.ts
|
|
766
|
-
```
|
|
767
|
-
|
|
768
|
-
**Example output** (from a Store API spec):
|
|
769
|
-
|
|
770
|
-
```ts
|
|
771
|
-
// Generated by eidos-gen — edit function bodies freely, re-run to refresh declarations.
|
|
772
|
-
import { resource, action } from '@sweidos/eidos'
|
|
773
|
-
|
|
774
|
-
export interface Product { id: string; name: string; price: number; inStock?: boolean }
|
|
775
|
-
export interface CreateProductRequest { name: string; price: number }
|
|
776
|
-
|
|
777
|
-
// Resources (GET)
|
|
778
|
-
export const listProducts = resource('/api/products', { offline: true })
|
|
779
|
-
export const getProduct = resource('/api/products/:id', { offline: true })
|
|
780
|
-
|
|
781
|
-
// Actions (POST / PUT / PATCH / DELETE)
|
|
782
|
-
export const createProduct = action(
|
|
783
|
-
async (payload: CreateProductRequest): Promise<Product> => {
|
|
784
|
-
const res = await fetch('/api/products', { method: 'POST', ... })
|
|
785
|
-
return res.json()
|
|
786
|
-
},
|
|
787
|
-
{ reliability: 'neverLose', name: 'createProduct' },
|
|
788
|
-
)
|
|
789
|
-
export const deleteProduct = action(
|
|
790
|
-
async (payload: { id: string }): Promise<void> => {
|
|
791
|
-
const res = await fetch(`/api/products/${payload.id}`, { method: 'DELETE' })
|
|
792
|
-
...
|
|
793
|
-
},
|
|
794
|
-
{ reliability: 'neverLose', name: 'deleteProduct' },
|
|
795
|
-
)
|
|
267
|
+
# → writes eidos.generated.ts with typed resource() + action() declarations
|
|
796
268
|
```
|
|
797
269
|
|
|
798
|
-
`
|
|
799
|
-
- **Path params** — `{id}` → `:id` on resources; `{ id: string } & RequestBody` on actions with template-literal URL interpolation
|
|
800
|
-
- **Type generation** — interfaces from `components/schemas` (objects, enums, unions, arrays)
|
|
801
|
-
- **`$ref` resolution** — schema references inline as type names
|
|
802
|
-
- **Response types** — `200`/`201`/`202` response body type used as the action return type
|
|
803
|
-
- **DELETE with no body** — omits `Content-Type` / `body`, handles 204 no-content
|
|
804
|
-
|
|
805
|
-
**Options:**
|
|
806
|
-
|
|
807
|
-
```bash
|
|
808
|
-
npx eidos-gen <spec> # JSON or YAML
|
|
809
|
-
npx eidos-gen <spec> --out src/lib/eidos.ts
|
|
810
|
-
npx eidos-gen <spec> --no-offline # set offline:false on resources
|
|
811
|
-
npx eidos-gen <spec> --eidos ./my-fork # custom import path
|
|
812
|
-
```
|
|
270
|
+
Handles path params, `$ref` resolution, request/response types, DELETE body omission.
|
|
813
271
|
|
|
814
272
|
---
|
|
815
273
|
|
|
816
|
-
##
|
|
817
|
-
|
|
818
|
-
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.
|
|
819
|
-
|
|
820
|
-
### Next.js App Router (`@sweidos/eidos/nextjs`)
|
|
821
|
-
|
|
822
|
-
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.
|
|
823
|
-
|
|
824
|
-
```tsx
|
|
825
|
-
// app/providers.tsx ← no 'use client' needed here
|
|
826
|
-
import { EidosProvider, useEidosStatus } from '@sweidos/eidos/nextjs'
|
|
827
|
-
|
|
828
|
-
export function Providers({ children }: { children: React.ReactNode }) {
|
|
829
|
-
return <EidosProvider swPath="/eidos-sw.js">{children}</EidosProvider>
|
|
830
|
-
}
|
|
831
|
-
```
|
|
832
|
-
|
|
833
|
-
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.
|
|
834
|
-
|
|
835
|
-
### SvelteKit (`@sweidos/eidos/sveltekit`)
|
|
836
|
-
|
|
837
|
-
Use `initEidosSvelteKit()` inside `onMount` in your root `+layout.svelte`. The helper returns an `onMount`-compatible callback that defers init to the browser, keeping SSR clean.
|
|
838
|
-
|
|
839
|
-
```svelte
|
|
840
|
-
<!-- src/routes/+layout.svelte -->
|
|
841
|
-
<script>
|
|
842
|
-
import { onMount } from 'svelte'
|
|
843
|
-
import { initEidosSvelteKit } from '@sweidos/eidos/sveltekit'
|
|
844
|
-
|
|
845
|
-
onMount(initEidosSvelteKit({ swPath: '/eidos-sw.js', autoReplay: true }))
|
|
846
|
-
</script>
|
|
847
|
-
|
|
848
|
-
<slot />
|
|
849
|
-
```
|
|
850
|
-
|
|
851
|
-
Use the framework-agnostic stores (`eidosQueue`, `eidosStatus`, etc.) from the main `@sweidos/eidos` import in your Svelte components — they work with Svelte's `$` auto-subscribe prefix out of the box.
|
|
852
|
-
|
|
853
|
-
### React Native (`@sweidos/eidos/react-native`)
|
|
854
|
-
|
|
855
|
-
The React Native subpath swaps the browser-specific backends (IndexedDB, Service Worker, Cache API) for a pluggable `AsyncStorage`-backed queue while keeping the same `action()` / `resource()` API surface.
|
|
856
|
-
|
|
857
|
-
**Setup**
|
|
858
|
-
|
|
859
|
-
```bash
|
|
860
|
-
# peer deps
|
|
861
|
-
npm install @react-native-async-storage/async-storage @react-native-community/netinfo
|
|
862
|
-
```
|
|
863
|
-
|
|
864
|
-
```ts
|
|
865
|
-
// index.js — before rendering anything
|
|
866
|
-
import AsyncStorage from '@react-native-async-storage/async-storage'
|
|
867
|
-
import { initEidosRN } from '@sweidos/eidos/react-native'
|
|
868
|
-
|
|
869
|
-
await initEidosRN({ storage: AsyncStorage })
|
|
870
|
-
```
|
|
274
|
+
## Devtools
|
|
871
275
|
|
|
872
276
|
```tsx
|
|
873
|
-
|
|
874
|
-
import { useNetInfo } from '@react-native-community/netinfo'
|
|
875
|
-
import { EidosProviderRN } from '@sweidos/eidos/react-native'
|
|
876
|
-
|
|
877
|
-
export function App() {
|
|
878
|
-
const { isConnected } = useNetInfo()
|
|
879
|
-
return (
|
|
880
|
-
<EidosProviderRN isConnected={isConnected ?? true}>
|
|
881
|
-
<Navigation />
|
|
882
|
-
</EidosProviderRN>
|
|
883
|
-
)
|
|
884
|
-
}
|
|
885
|
-
```
|
|
886
|
-
|
|
887
|
-
```ts
|
|
888
|
-
// Declare actions exactly as you would in a web app
|
|
889
|
-
import { action } from '@sweidos/eidos'
|
|
890
|
-
|
|
891
|
-
export const createOrder = action(
|
|
892
|
-
async (payload: CreateOrderInput) => {
|
|
893
|
-
const res = await fetch('/api/orders', { method: 'POST', body: JSON.stringify(payload) })
|
|
894
|
-
if (!res.ok) throw res
|
|
895
|
-
return res.json()
|
|
896
|
-
},
|
|
897
|
-
{ reliability: 'neverLose', name: 'createOrder' },
|
|
898
|
-
)
|
|
899
|
-
```
|
|
900
|
-
|
|
901
|
-
Actions queued while offline are persisted to AsyncStorage and replayed automatically when the device reconnects.
|
|
902
|
-
|
|
903
|
-
**Custom storage**
|
|
904
|
-
|
|
905
|
-
`AsyncStorageLike` accepts any key-value store that implements `getItem` / `setItem` / `removeItem` — you can use MMKV or SQLite instead of AsyncStorage:
|
|
277
|
+
import { EidosDevtools } from '@sweidos/eidos/devtools'
|
|
906
278
|
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
import { AsyncStorageQueueStorage, setQueueStorage } from '@sweidos/eidos/react-native'
|
|
910
|
-
|
|
911
|
-
const mmkv = new MMKV()
|
|
912
|
-
setQueueStorage(new AsyncStorageQueueStorage({
|
|
913
|
-
getItem: async (key) => mmkv.getString(key) ?? null,
|
|
914
|
-
setItem: async (key, value) => mmkv.set(key, value),
|
|
915
|
-
removeItem: async (key) => mmkv.delete(key),
|
|
916
|
-
}))
|
|
279
|
+
// Drop anywhere — bottom-right floating panel, no CSS import
|
|
280
|
+
{process.env.NODE_ENV === 'development' && <EidosDevtools />}
|
|
917
281
|
```
|
|
918
282
|
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
| Feature | Web | React Native |
|
|
922
|
-
|---------|-----|--------------|
|
|
923
|
-
| `action()` queue + replay | ✅ IndexedDB | ✅ AsyncStorage |
|
|
924
|
-
| Offline-aware (auto-queue) | ✅ | ✅ |
|
|
925
|
-
| `resource()` in-memory caching | ✅ | ✅ (in-memory only — no SW) |
|
|
926
|
-
| `resource()` offline persistence | ✅ Cache API + SW | ❌ (fetch from API when online) |
|
|
927
|
-
| `useEidos`, `useEidosQueue` hooks | ✅ | ✅ |
|
|
928
|
-
| Background Sync | ✅ | ❌ (App must be foregrounded) |
|
|
283
|
+
Panel shows: live queue state · cache entries · SW status · offline simulation toggle.
|
|
929
284
|
|
|
930
285
|
---
|
|
931
286
|
|
|
932
|
-
##
|
|
933
|
-
|
|
934
|
-
`@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.
|
|
935
|
-
|
|
936
|
-
```tsx
|
|
937
|
-
import { EidosDevtools } from '@sweidos/eidos/devtools'
|
|
938
|
-
|
|
939
|
-
// Add anywhere in your component tree (bottom-right by default)
|
|
940
|
-
export default function App() {
|
|
941
|
-
return (
|
|
942
|
-
<>
|
|
943
|
-
<YourApp />
|
|
944
|
-
{process.env.NODE_ENV === 'development' && <EidosDevtools />}
|
|
945
|
-
</>
|
|
946
|
-
)
|
|
947
|
-
}
|
|
948
|
-
```
|
|
287
|
+
## SSR adapters
|
|
949
288
|
|
|
950
|
-
**
|
|
289
|
+
**Next.js** — import from `@sweidos/eidos/nextjs`. Pre-marked `'use client'`, works in App Router layouts without a wrapper.
|
|
951
290
|
|
|
952
|
-
|
|
953
|
-
|------|------|---------|-------------|
|
|
954
|
-
| `position` | `'bottom-right' \| 'bottom-left' \| 'top-right' \| 'top-left'` | `'bottom-right'` | Corner to anchor the panel |
|
|
955
|
-
| `defaultOpen` | `boolean` | `false` | Start expanded |
|
|
291
|
+
**SvelteKit** — `initEidosSvelteKit()` inside `onMount`. Framework-agnostic stores (`$eidosQueue`, `$eidosStatus`) work with Svelte's `$` auto-subscribe.
|
|
956
292
|
|
|
957
|
-
**
|
|
958
|
-
- **Status bar** — online/offline indicator, SW registration status, offline simulation toggle (`setOfflineSimulation`)
|
|
959
|
-
- **Queue tab** — all queue items with status badges (`pending` / `replaying` / `succeeded` / `failed`), priority, retry count, plus Replay and Clear buttons
|
|
960
|
-
- **Cache tab** — all registered resources with cache status, strategy name, hit/miss counts, and last cached timestamp
|
|
961
|
-
|
|
962
|
-
The component is self-contained with inline styles — no CSS import needed, no style conflicts.
|
|
293
|
+
**React Native** — `@sweidos/eidos/react-native` with AsyncStorage-backed queue. Same `action()` API surface, no Service Worker dependency.
|
|
963
294
|
|
|
964
295
|
---
|
|
965
296
|
|
|
966
|
-
## Known
|
|
297
|
+
## Known limitations
|
|
967
298
|
|
|
968
299
|
| Limitation | Detail |
|
|
969
300
|
|------------|--------|
|
|
970
|
-
| GET-only caching | SW intercepts `GET` only.
|
|
971
|
-
|
|
|
972
|
-
|
|
|
973
|
-
|
|
|
974
|
-
| 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`. |
|
|
975
|
-
|
|
976
|
-
---
|
|
977
|
-
|
|
978
|
-
## Roadmap
|
|
979
|
-
|
|
980
|
-
- [x] Cache TTL / `maxAge` expiration
|
|
981
|
-
- [x] Exponential backoff with jitter for queue replay
|
|
982
|
-
- [x] Per-resource `cacheName` override
|
|
983
|
-
- [x] `resource.unregister()` for cleanup
|
|
984
|
-
- [x] URL pattern matching (`*`, `**`, `:param`)
|
|
985
|
-
- [x] Cross-origin resource support
|
|
986
|
-
- [x] Background Sync API integration
|
|
987
|
-
- [x] Vite plugin (`@sweidos/eidos/vite` subpath — ships in the main package)
|
|
988
|
-
- [x] Vue / Svelte bindings (framework-agnostic reactive stores)
|
|
989
|
-
- [x] TanStack Query integration (`@sweidos/eidos/query` subpath — `useEidosQuery`, `useEidosMutation`, `withEidosQueryClient`)
|
|
990
|
-
|
|
991
|
-
**Core reliability**
|
|
992
|
-
- [x] Optimistic updates — `onOptimistic` / `onRollback` callbacks on `action()` for instant UI feedback before server confirms
|
|
993
|
-
- [x] Conflict resolution hook — `onConflict` callback when replaying a queued action returns 4xx; decide per-item: retry or skip
|
|
994
|
-
- [x] Queue prioritization — `priority: 'high' | 'normal' | 'low'` on `action()`; high-priority items replay first
|
|
995
|
-
|
|
996
|
-
**DX / Tooling**
|
|
997
|
-
- [x] Devtools panel component — drop-in `<EidosDevtools />` showing cache entries, queue state, replay status, and offline toggle
|
|
998
|
-
- [x] Testing utilities (`@sweidos/eidos/testing`) — `mockOffline()`, `mockOnline()`, `drainQueue()`, `waitForQueueDrain()`, `getCachedEntry(url)`, `clearEidosCache()`, `resetEidos()`, `getEidosState()` for Vitest / Playwright
|
|
999
|
-
- [x] SvelteKit / Next.js adapters — SSR-aware init helpers that skip SW registration server-side
|
|
1000
|
-
|
|
1001
|
-
**Performance**
|
|
1002
|
-
- [x] Request deduplication — multiple simultaneous `resource.fetch()` calls share one in-flight network request; each caller gets an independent cloned `Response`
|
|
1003
|
-
- [x] Cache warming — `warmCache(handles[])` bulk-prefetches a list of resources on init (e.g. on login)
|
|
1004
|
-
|
|
1005
|
-
**Ecosystem**
|
|
1006
|
-
- [x] React Native support — `@sweidos/eidos/react-native`; AsyncStorage-backed queue, same `action()` API surface; `EidosProviderRN` syncs NetInfo connectivity into the replay loop
|
|
1007
|
-
- [x] 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. |
|
|
1008
305
|
|
|
1009
306
|
---
|
|
1010
307
|
|
|
@@ -1013,14 +310,15 @@ The component is self-contained with inline styles — no CSS import needed, no
|
|
|
1013
310
|
```bash
|
|
1014
311
|
pnpm install # install all workspace deps
|
|
1015
312
|
pnpm dev # run playground at localhost:3000
|
|
1016
|
-
pnpm type-check # typecheck all packages
|
|
1017
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
|
|
1018
316
|
```
|
|
1019
317
|
|
|
1020
|
-
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.
|
|
1021
319
|
|
|
1022
320
|
---
|
|
1023
321
|
|
|
1024
322
|
## License
|
|
1025
323
|
|
|
1026
|
-
MIT © Aditya Raj
|
|
324
|
+
MIT © [Aditya Raj](https://github.com/iamadi11)
|