@sweidos/eidos 0.1.0

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