@sweidos/eidos 1.0.31 → 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 CHANGED
@@ -1,53 +1,43 @@
1
1
  # Eidos
2
2
 
3
- [![npm](https://img.shields.io/npm/v/@sweidos/eidos)](https://www.npmjs.com/package/@sweidos/eidos)
4
- [![CI](https://github.com/iamadi11/eidos/actions/workflows/deploy.yml/badge.svg)](https://github.com/iamadi11/eidos/actions/workflows/deploy.yml)
5
- [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
3
+ [![npm version](https://img.shields.io/npm/v/@sweidos/eidos?color=22C55E&label=npm)](https://www.npmjs.com/package/@sweidos/eidos)
4
+ [![npm downloads](https://img.shields.io/npm/dm/@sweidos/eidos?color=22C55E)](https://www.npmjs.com/package/@sweidos/eidos)
5
+ [![bundle size](https://img.shields.io/bundlephobia/minzip/@sweidos/eidos?label=minzip&color=22C55E)](https://bundlephobia.com/package/@sweidos/eidos)
6
+ [![TypeScript](https://img.shields.io/badge/TypeScript-strict-22C55E)](https://www.typescriptlang.org/)
7
+ [![CI](https://github.com/iamadi11/eidos/actions/workflows/deploy.yml/badge.svg)](https://github.com/iamadi11/eidos/actions)
8
+ [![License: MIT](https://img.shields.io/badge/License-MIT-22C55E.svg)](LICENSE)
6
9
 
7
- > Describe intent. The runtime figures out how.
10
+ > **Describe intent. The runtime figures out how.**
8
11
 
9
- Eidos is a small, opinionated abstraction layer for building offline-first web apps. Instead of configuring Service Workers, Cache API strategies, and IndexedDB queues by hand, you declare **what you want** and the runtime handles the rest.
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://playground-iamadi11s-projects.vercel.app)**
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 Problem
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 team re-implements this surface area from scratch.
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 + service-worker.js (40+ lines)
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 Start
49
+ ## Quick start
60
50
 
61
51
  ### 1. Install
62
52
 
63
53
  ```bash
64
54
  npm install @sweidos/eidos
65
- # or
66
- pnpm add @sweidos/eidos
55
+ # pnpm add @sweidos/eidos
56
+ # yarn add @sweidos/eidos
67
57
  ```
68
58
 
69
- ### 2. Add the service worker
59
+ ### 2. Register the Vite plugin (auto-copies the service worker)
70
60
 
71
- ```bash
72
- cp node_modules/@sweidos/eidos/dist/eidos-sw.js public/eidos-sw.js
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 users** — use the [first-class Vite plugin](#vite-plugin) to automate this.
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
- // TanStack Queryfirst-class hooks
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 componentsworks the same online and offline
154
105
  const result = await createOrder({ productId: 1, qty: 2 })
155
106
 
156
107
  if ('queued' in result) {
157
- // Persisted to IndexedDB — will replay automatically on reconnect
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
- ## API Reference
165
-
166
- ### `resource(url, config)`
167
-
168
- Registers a URL as an offline-capable resource. Returns a `ResourceHandle`.
169
-
170
- ```ts
171
- const handle = resource('/api/products', {
172
- offline: true, // required enables SW interception
173
- strategy?: 'cache-first' | 'stale-while-revalidate' | 'network-first',
174
- cacheName?: string, // custom Cache Storage bucket (default: 'eidos-resources-v1')
175
- maxAge?: number, // TTL in msexpired entries are re-fetched from network
176
- })
177
-
178
- // URL patterns SW intercepts all matching requests automatically
179
- resource('/api/products/*', { offline: true }) // single segment: /api/products/123
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
- ### React Hooks
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
- // 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
- ```
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
- ### 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>
149
+ ## Core API
382
150
 
383
- <p>Online: {$eidosStatus.isOnline}</p>
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
- {#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
- import { eidosStatus, eidosResource } from '@sweidos/eidos'
417
-
418
- const unsub = eidosStatus.subscribe(({ isOnline }) => {
419
- document.title = isOnline ? 'App' : 'App (offline)'
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
- // Read current value once without subscribing
423
- const hits = eidosResource('/api/products').getState()?.cacheHits ?? 0
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
- | Store | Type | Emits when |
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
- ### `warmCache(handles[])`
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
- Bulk-prefetch an array of resource handles concurrently — warms the cache for all of them in one call. Useful on login or app init when you know which resources the user will need offline.
178
+ URL patterns work on any handle: `/api/products/*`, `/api/users/:id`, `**`
440
179
 
441
- Pattern handles (containing `*`, `**`, or `:param`) are counted as failed — they match multiple URLs so there is no single URL to prefetch.
180
+ ### `action(fn, config)`
442
181
 
443
182
  ```ts
444
- import { warmCache } from '@sweidos/eidos'
445
- import type { WarmCacheResult } from '@sweidos/eidos'
446
-
447
- // After login — warm the cache with the user's likely-needed data
448
- const result: WarmCacheResult = await warmCache([products, userProfile, settings])
449
- // { warmed: 3, failed: 0, errors: [] }
450
- //
451
- // warmed handles prefetched successfully
452
- // failed — handles that threw (network error, offline, pattern handle, etc.)
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
- In development, a `console.warn` is printed for each failed handle.
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
- import { setOfflineSimulation } from '@sweidos/eidos'
466
-
467
- setOfflineSimulation(true) // SW serves only cached responses
468
- setOfflineSimulation(false) // restore normal behaviour
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
- import { isBgSyncSupported } from '@sweidos/eidos'
479
-
480
- if (isBgSyncSupported()) {
481
- // browser will fire 'eidos-queue-replay' sync tag when connectivity returns,
482
- // even if the user briefly navigated away from the page
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
- ## 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
- // vite.config.ts
596
- import { eidos } from '@sweidos/eidos/vite'
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
- **Options:**
222
+ // In components
223
+ const { data, isPending } = useEidosQuery<Product[]>(products)
605
224
 
606
- ```ts
607
- eidos({
608
- swDest: 'public/eidos-sw.js', // default — relative to project root
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
- ## 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
- ---
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
- drainQueue, waitForQueueDrain,
692
- getCachedEntry, clearEidosCache,
693
- resetEidos, getEidosState,
240
+ mockOffline, mockOnline, drainQueue,
241
+ waitForQueueDrain, getCachedEntry,
242
+ clearEidosCache, resetEidos, getEidosState,
694
243
  } from '@sweidos/eidos/testing'
695
- ```
696
244
 
697
- ### `resetEidos()` — `beforeEach` cleanup
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 savePost({ title: 'Draft' })
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
- ```ts
721
- it('replays queue on reconnect', async () => {
253
+ it('replays on reconnect', async () => {
722
254
  mockOffline()
723
- await savePost({ title: 'Draft' })
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 Codegen
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
- `eidos-gen` handles:
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
- ## SSR Adapters
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
- // App.tsx
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
- ```ts
908
- import { MMKV } from 'react-native-mmkv'
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
- **What works in RN vs web**
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
- ## Devtools
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
- **Props:**
289
+ **Next.js** — import from `@sweidos/eidos/nextjs`. Pre-marked `'use client'`, works in App Router layouts without a wrapper.
951
290
 
952
- | Prop | Type | Default | Description |
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
- **Panel features:**
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 Limitations
297
+ ## Known limitations
967
298
 
968
299
  | Limitation | Detail |
969
300
  |------------|--------|
970
- | GET-only caching | SW intercepts `GET` only. `POST`/`PUT`/`DELETE` are not cached (but *are* queued via `action()`). |
971
- | Query string ignored | Resources match by pathname (or full URL for cross-origin). `/api/products?page=2` and `/api/products` share the same SW rule but are cached as separate entries. |
972
- | Module-scope actions | `action()` must be called at module scope so functions are registered before a page reload triggers queue replay. |
973
- | Single SW | `EidosProvider` assumes one SW at `/eidos-sw.js`. Multiple registrations are unsupported. |
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)