@sweidos/eidos 1.0.34 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +171 -89
  2. package/dist/action.js +197 -91
  3. package/dist/async-storage-adapter.js +15 -12
  4. package/dist/cli.js +102 -0
  5. package/dist/devtools.js +1009 -551
  6. package/dist/eidos-sw.js +280 -188
  7. package/dist/eidos.cjs +15 -0
  8. package/dist/idb.js +59 -56
  9. package/dist/index.d.ts +135 -18
  10. package/dist/index.js +46 -42
  11. package/dist/nextjs.js +1 -10
  12. package/dist/push.cjs +120 -0
  13. package/dist/push.d.ts +28 -0
  14. package/dist/push.js +113 -0
  15. package/dist/query.cjs +131 -0
  16. package/dist/query.js +121 -41
  17. package/dist/queue-storage.js +5 -4
  18. package/dist/react/Devtools.d.ts +1 -1
  19. package/dist/react/Provider.js +11 -7
  20. package/dist/react/hooks.js +48 -38
  21. package/dist/react-native.js +47 -53
  22. package/dist/replay.js +15 -0
  23. package/dist/resource.js +77 -79
  24. package/dist/runtime.js +39 -28
  25. package/dist/store-slices.js +43 -0
  26. package/dist/store.js +32 -49
  27. package/dist/stores.js +25 -22
  28. package/dist/sveltekit.js +22 -6
  29. package/dist/sw-bridge.js +64 -49
  30. package/dist/testing.cjs +165 -0
  31. package/dist/testing.js +140 -70
  32. package/dist/version.js +4 -3
  33. package/dist/vite.cjs +48 -0
  34. package/dist/vite.js +45 -29
  35. package/package.json +57 -28
  36. package/dist/action.js.map +0 -1
  37. package/dist/async-storage-adapter.js.map +0 -1
  38. package/dist/eidos.cjs.js +0 -14
  39. package/dist/eidos.cjs.js.map +0 -1
  40. package/dist/idb.js.map +0 -1
  41. package/dist/index.js.map +0 -1
  42. package/dist/query.cjs.js +0 -48
  43. package/dist/queue-storage.js.map +0 -1
  44. package/dist/react/Provider.js.map +0 -1
  45. package/dist/react/hooks.js.map +0 -1
  46. package/dist/resource.js.map +0 -1
  47. package/dist/runtime.js.map +0 -1
  48. package/dist/store.js.map +0 -1
  49. package/dist/stores.js.map +0 -1
  50. package/dist/sw-bridge.js.map +0 -1
  51. package/dist/testing.cjs.js +0 -86
  52. package/dist/version.js.map +0 -1
  53. package/dist/vite.cjs.js +0 -31
package/README.md CHANGED
@@ -12,10 +12,10 @@
12
12
  Declare what your app needs offline. Eidos picks the cache strategy, registers the Service Worker, and persists your action queue to IndexedDB — automatically.
13
13
 
14
14
  ```ts
15
- import { resource, action } from '@sweidos/eidos'
15
+ import { resource, action } from '@sweidos/eidos';
16
16
 
17
- const products = resource('/api/products', { offline: true })
18
- const createOrder = action(orderApi.create, { reliability: 'neverLose' })
17
+ const products = resource('/api/products', { offline: true });
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.
@@ -60,12 +60,12 @@ npm install @sweidos/eidos
60
60
 
61
61
  ```ts
62
62
  // vite.config.ts
63
- import { eidos } from '@sweidos/eidos/vite'
64
- import { defineConfig } from 'vite'
63
+ import { eidos } from '@sweidos/eidos/vite';
64
+ import { defineConfig } from 'vite';
65
65
 
66
66
  export default defineConfig({
67
67
  plugins: [eidos()],
68
- })
68
+ });
69
69
  ```
70
70
 
71
71
  > **Without Vite** — copy manually: `cp node_modules/@sweidos/eidos/dist/eidos-sw.js public/`
@@ -74,39 +74,39 @@ export default defineConfig({
74
74
 
75
75
  ```tsx
76
76
  // main.tsx
77
- import { EidosProvider } from '@sweidos/eidos'
78
- import { createRoot } from 'react-dom/client'
79
- import { App } from './App'
77
+ import { EidosProvider } from '@sweidos/eidos';
78
+ import { createRoot } from 'react-dom/client';
79
+ import { App } from './App';
80
80
 
81
81
  createRoot(document.getElementById('root')!).render(
82
82
  <EidosProvider swPath="/eidos-sw.js">
83
83
  <App />
84
- </EidosProvider>
85
- )
84
+ </EidosProvider>,
85
+ );
86
86
  ```
87
87
 
88
88
  ```ts
89
89
  // src/lib/eidos.ts ← module scope required for queue replay after reload
90
- import { resource, action } from '@sweidos/eidos'
90
+ import { resource, action } from '@sweidos/eidos';
91
91
 
92
- export const products = resource('/api/products', { offline: true })
92
+ export const products = resource('/api/products', { offline: true });
93
93
 
94
94
  export const createOrder = action(
95
95
  async (payload: OrderPayload) => {
96
- const res = await fetch('/api/orders', { method: 'POST', body: JSON.stringify(payload) })
97
- return res.json()
96
+ const res = await fetch('/api/orders', { method: 'POST', body: JSON.stringify(payload) });
97
+ return res.json();
98
98
  },
99
99
  { reliability: 'neverLose', name: 'createOrder' },
100
- )
100
+ );
101
101
  ```
102
102
 
103
103
  ```tsx
104
104
  // In components — works the same online and offline
105
- const result = await createOrder({ productId: 1, qty: 2 })
105
+ const result = await createOrder({ productId: 1, qty: 2 });
106
106
 
107
107
  if ('queued' in result) {
108
108
  // Saved to IndexedDB — replays automatically on reconnect
109
- console.log(result.message)
109
+ console.log(result.message);
110
110
  }
111
111
  ```
112
112
 
@@ -114,35 +114,35 @@ if ('queued' in result) {
114
114
 
115
115
  ## What you get
116
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. |
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. |
131
131
 
132
132
  ---
133
133
 
134
134
  ## Framework support
135
135
 
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` |
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` |
146
146
 
147
147
  ---
148
148
 
@@ -169,11 +169,11 @@ products.query() // { queryKey, queryFn } for useQuery
169
169
 
170
170
  **Auto-selected strategy:**
171
171
 
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 |
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 |
177
177
 
178
178
  URL patterns work on any handle: `/api/products/*`, `/api/users/:id`, `**`
179
179
 
@@ -194,11 +194,11 @@ const createOrder = action(async (payload: OrderPayload) => { ... }, {
194
194
  ### React hooks
195
195
 
196
196
  ```ts
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!'))
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!'));
202
202
  ```
203
203
 
204
204
  ### Framework-agnostic stores
@@ -217,45 +217,125 @@ eidosResource('/api/products').getState() // ResourceEntry | undefined
217
217
 
218
218
  ```ts
219
219
  // main.tsx — register once
220
- withEidosQueryClient(queryClient)
220
+ withEidosQueryClient(queryClient);
221
221
 
222
222
  // In components
223
- const { data, isPending } = useEidosQuery<Product[]>(products)
223
+ const { data, isPending } = useEidosQuery<Product[]>(products);
224
224
 
225
225
  const mutation = useEidosMutation(createOrder, {
226
226
  invalidates: [products], // clears cache + invalidates TQ on success
227
227
  onSuccess(data) {
228
- if ('queued' in data) toast('Saved offline')
229
- else toast(`Order #${data.id} created`)
228
+ if ('queued' in data) toast('Saved offline');
229
+ else toast(`Order #${data.id} created`);
230
230
  },
231
- })
231
+ });
232
232
  ```
233
233
 
234
234
  ---
235
235
 
236
+ ## Push Notifications
237
+
238
+ Headless, framework-agnostic Web Push. Tree-shaken via a separate subpath — adds zero bytes unless imported.
239
+
240
+ **1. Generate VAPID keys (one-time):**
241
+
242
+ ```sh
243
+ npx @sweidos/eidos generate-vapid-keys
244
+ ```
245
+
246
+ Detects your framework (Vite/Next/SvelteKit/Nuxt) and writes a correctly-prefixed
247
+ public key + an unprefixed private key to `.env.local`:
248
+
249
+ ```
250
+ VITE_EIDOS_VAPID_PUBLIC_KEY=...
251
+ EIDOS_VAPID_PRIVATE_KEY=...
252
+ ```
253
+
254
+ Give `EIDOS_VAPID_PRIVATE_KEY` (and the public key) to your backend. What the
255
+ backend does with them — language, storage, send timing — is entirely its own
256
+ concern; Eidos never talks to it directly.
257
+
258
+ **2. Register handlers once at app init (any tab, no permission prompt):**
259
+
260
+ ```ts
261
+ import { registerPushHandlers } from '@sweidos/eidos/push';
262
+
263
+ registerPushHandlers({
264
+ onNotificationClick: (data) => router.push(data.url),
265
+ onSubscriptionExpired: (sub) =>
266
+ fetch('/api/push-subscribe', { method: 'POST', body: JSON.stringify(sub) }),
267
+ });
268
+ ```
269
+
270
+ **3. Subscribe from a user gesture (e.g. an "Enable notifications" button):**
271
+
272
+ ```ts
273
+ import { subscribeToPush, isPushSupported, getPushPermissionState } from '@sweidos/eidos/push';
274
+
275
+ async function onEnableClick() {
276
+ const result = await subscribeToPush({
277
+ vapidPublicKey: import.meta.env.VITE_EIDOS_VAPID_PUBLIC_KEY,
278
+ onSubscribe: (sub) =>
279
+ fetch('/api/push-subscribe', { method: 'POST', body: JSON.stringify(sub) }),
280
+ });
281
+
282
+ if (result.status === 'subscribed') toast('Notifications enabled');
283
+ else if (result.status === 'denied') toast('Permission denied');
284
+ }
285
+ ```
286
+
287
+ `isPushSupported()` / `getPushPermissionState()` / `getPushUnsupportedReason()`
288
+ let you hide the button when push is unavailable (e.g. iOS Safari outside an
289
+ installed PWA returns `'ios-not-installed'`).
290
+
291
+ ### Server payload schema
292
+
293
+ The service worker shows whatever your server sends — Eidos never renders UI:
294
+
295
+ ```json
296
+ {
297
+ "title": "Order shipped",
298
+ "body": "Your order #1234 is on its way",
299
+ "icon": "/icon.png",
300
+ "badge": "/badge.png",
301
+ "tag": "order-1234",
302
+ "data": { "url": "/orders/1234" }
303
+ }
304
+ ```
305
+
306
+ Click behavior: if the app is open, `data` is delivered to `onNotificationClick`
307
+ for client-side routing; otherwise the SW opens `data.url` directly.
308
+
309
+ ---
310
+
236
311
  ## Testing
237
312
 
238
313
  ```ts
239
314
  import {
240
- mockOffline, mockOnline, drainQueue,
241
- waitForQueueDrain, getCachedEntry,
242
- clearEidosCache, resetEidos, getEidosState,
243
- } from '@sweidos/eidos/testing'
244
-
245
- beforeEach(() => resetEidos())
315
+ mockOffline,
316
+ mockOnline,
317
+ drainQueue,
318
+ waitForQueueDrain,
319
+ getCachedEntry,
320
+ clearEidosCache,
321
+ resetEidos,
322
+ getEidosState,
323
+ } from '@sweidos/eidos/testing';
324
+
325
+ beforeEach(() => resetEidos());
246
326
 
247
327
  it('queues action while offline', async () => {
248
- mockOffline()
249
- await createOrder({ productId: 1, qty: 2 })
250
- expect(getEidosState().queue).toHaveLength(1)
251
- })
328
+ mockOffline();
329
+ await createOrder({ productId: 1, qty: 2 });
330
+ expect(getEidosState().queue).toHaveLength(1);
331
+ });
252
332
 
253
333
  it('replays on reconnect', async () => {
254
- mockOffline()
255
- await createOrder({ productId: 1, qty: 2 })
256
- const result = await drainQueue()
257
- expect(result.succeeded).toBe(1)
258
- })
334
+ mockOffline();
335
+ await createOrder({ productId: 1, qty: 2 });
336
+ const result = await drainQueue();
337
+ expect(result.succeeded).toBe(1);
338
+ });
259
339
  ```
260
340
 
261
341
  ---
@@ -274,10 +354,12 @@ Handles path params, `$ref` resolution, request/response types, DELETE body omis
274
354
  ## Devtools
275
355
 
276
356
  ```tsx
277
- import { EidosDevtools } from '@sweidos/eidos/devtools'
357
+ import { EidosDevtools } from '@sweidos/eidos/devtools';
278
358
 
279
359
  // Drop anywhere — bottom-right floating panel, no CSS import
280
- {process.env.NODE_ENV === 'development' && <EidosDevtools />}
360
+ {
361
+ process.env.NODE_ENV === 'development' && <EidosDevtools />;
362
+ }
281
363
  ```
282
364
 
283
365
  Panel shows: live queue state · cache entries · SW status · offline simulation toggle.
@@ -296,30 +378,30 @@ Panel shows: live queue state · cache entries · SW status · offline simulatio
296
378
 
297
379
  ## Known limitations
298
380
 
299
- | Limitation | Detail |
300
- |------------|--------|
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. |
381
+ | Limitation | Detail |
382
+ | ---------------------- | ----------------------------------------------------------------------------------------------- |
383
+ | GET-only caching | SW intercepts `GET` only. Mutations go through `action()`. |
384
+ | Module-scope actions | `action()` must be at module scope so functions are registered before a reload triggers replay. |
385
+ | Single SW | Assumes one SW at the configured `swPath`. |
386
+ | React Native resources | In-memory only — no Cache API or SW in RN. Action queue fully persists. |
305
387
 
306
388
  ---
307
389
 
308
390
  ## How it compares
309
391
 
310
- | | **Eidos** | Workbox | RTK Query / TanStack Query |
311
- |---|---|---|---|
312
- | Service worker setup | Generated for you — `resource()`/`action()` declarations drive the SW | Hand-write `routing` + `strategies` config | None — no SW |
313
- | Caching strategy | Auto-derived from intent (`offline: true` → SWR, etc.), inspectable via devtools | Manually chosen per route | Configurable `staleTime`/`gcTime`, no Cache Storage integration |
314
- | Offline writes | `action()` + `reliability: 'neverLose'` → IndexedDB queue, auto-replay, exponential backoff | Background Sync plugin, you wire the queue | No built-in offline mutation queue |
315
- | Framework support | React, Svelte, Vue, Next.js, React Native, vanilla JS | Framework-agnostic (SW only) | Per-library (RTK Query = Redux, TanStack = many) |
316
- | TanStack Query bridge | `@sweidos/eidos/query` — drop-in `useEidosQuery`/`useEidosMutation` | — | Native |
317
- | Bundle size (core, gzip) | ~9 kB | ~3-6 kB (modular) | ~13 kB (TanStack Query core) |
392
+ | | **Eidos** | Workbox | RTK Query / TanStack Query |
393
+ | -------------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------ | --------------------------------------------------------------- |
394
+ | Service worker setup | Generated for you — `resource()`/`action()` declarations drive the SW | Hand-write `routing` + `strategies` config | None — no SW |
395
+ | Caching strategy | Auto-derived from intent (`offline: true` → SWR, etc.), inspectable via devtools | Manually chosen per route | Configurable `staleTime`/`gcTime`, no Cache Storage integration |
396
+ | Offline writes | `action()` + `reliability: 'neverLose'` → IndexedDB queue, auto-replay, exponential backoff | Background Sync plugin, you wire the queue | No built-in offline mutation queue |
397
+ | Framework support | React, Svelte, Vue, Next.js, React Native, vanilla JS | Framework-agnostic (SW only) | Per-library (RTK Query = Redux, TanStack = many) |
398
+ | TanStack Query bridge | `@sweidos/eidos/query` — drop-in `useEidosQuery`/`useEidosMutation` | — | Native |
399
+ | Bundle size (core, brotli) | ~5.4 kB | ~3-6 kB (modular) | ~13 kB (TanStack Query core) |
318
400
 
319
401
  Eidos isn't a replacement for TanStack Query — `@sweidos/eidos/query` is a thin
320
402
  adapter so you keep TQ's cache/devtools while Eidos owns the offline layer
321
403
  (SW caching + IndexedDB write queue). Workbox is a lower-level toolkit Eidos
322
- generates strategies *for*; Eidos picks and configures the strategy from your
404
+ generates strategies _for_; Eidos picks and configures the strategy from your
323
405
  `resource()`/`action()` declarations instead of you writing `workbox-*` config
324
406
  by hand.
325
407