@fairfox/polly 0.1.4 → 0.1.5

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 (2) hide show
  1. package/README.md +560 -184
  2. package/package.json +1 -1
package/README.md CHANGED
@@ -1,317 +1,693 @@
1
1
  # @fairfox/polly
2
2
 
3
- **Build Chrome extensions with reactive state and zero boilerplate.**
3
+ **Multi-execution-context framework with reactive state and cross-context messaging.**
4
4
 
5
- Stop fighting Chrome's extension APIs. Write extensions like modern web apps.
5
+ Build Chrome extensions, PWAs, and worker-based applications with automatic state synchronization, reactive UI updates, and type-safe messaging.
6
6
 
7
- ```typescript
8
- // Define state once
9
- export const counter = $sharedState('counter', 0)
7
+ ## Why Polly?
10
8
 
11
- // Use everywhere - automatically syncs!
12
- counter.value++ // Updates popup, options, background, everywhere
13
- ```
9
+ Modern applications run code in multiple isolated execution contexts:
14
10
 
15
- ## Why?
11
+ - **Chrome extensions**: Background service workers, popups, content scripts, options pages
12
+ - **PWAs**: Main thread, service workers, web workers
13
+ - **Node/Bun/Deno apps**: Main process, worker threads
16
14
 
17
- Chrome extension development is painful:
15
+ Managing state and communication between these contexts is painful:
18
16
 
19
- - ❌ State scattered across contexts (popup, background, content scripts)
20
- - ❌ Manual `chrome.storage` calls everywhere
21
- - ❌ Complex message passing with `chrome.runtime.sendMessage`
17
+ - ❌ State scattered across contexts with manual synchronization
18
+ - ❌ Complex message passing with serialization concerns
22
19
  - ❌ No reactivity - manually update UI when state changes
23
- - ❌ Hard to test - mock 50+ Chrome APIs
24
-
25
- This framework fixes all of that:
20
+ - ❌ Difficult to test - must mock platform APIs
21
+ - ❌ Hard to reason about concurrent state updates
26
22
 
27
- - ✅ **Reactive state** - UI updates automatically
28
- - ✅ **Auto-syncing** - State syncs across all contexts instantly
29
- - ✅ **Persistence** - State survives restarts (automatic)
30
- - ✅ **Type-safe messaging** - Send messages between contexts easily
31
- - ✅ **Built for testing** - DOM-based E2E tests with Playwright
23
+ **Polly solves this:**
32
24
 
33
- ## Quick Start
25
+ - **Reactive state** - UI updates automatically with Preact Signals
26
+ - ✅ **Auto-syncing** - State syncs across all contexts instantly with conflict resolution
27
+ - ✅ **Persistence** - Optional automatic persistence to chrome.storage or localStorage
28
+ - ✅ **Type-safe messaging** - Request/response pattern with full TypeScript support
29
+ - ✅ **Built for testing** - DOM-based E2E tests without mocking
30
+ - ✅ **Distributed consistency** - Lamport clocks prevent race conditions
34
31
 
35
- ### Install
32
+ ## Installation
36
33
 
37
34
  ```bash
38
- # Using Bun (recommended)
39
- bun add @fairfox/polly
35
+ bun add @fairfox/polly preact @preact/signals
36
+ ```
37
+
38
+ ## Getting Started
39
+
40
+ ### Example: PWA with Backend API
40
41
 
41
- # Using npm
42
- npm install @fairfox/polly
42
+ Let's build a PWA that connects to a backend API, with a service worker handling requests and the main thread rendering UI. Polly makes this trivial.
43
43
 
44
- # Using pnpm
45
- pnpm add @fairfox/polly
44
+ #### Step 1: Define Your Message Types
46
45
 
47
- # Using yarn
48
- yarn add @fairfox/polly
46
+ Create typed messages for communication between your UI and service worker:
47
+
48
+ ```typescript
49
+ // src/shared/messages.ts
50
+ import type { ExtensionMessage } from '@fairfox/polly/types'
51
+
52
+ // Define your custom messages
53
+ type CustomMessages =
54
+ | { type: 'API_FETCH_USER'; userId: string }
55
+ | { type: 'API_UPDATE_USER'; userId: string; data: UserData }
56
+ | { type: 'API_DELETE_USER'; userId: string }
57
+ | { type: 'CACHE_CLEAR' }
58
+
59
+ // Combine with framework messages
60
+ export type AppMessages = ExtensionMessage | CustomMessages
61
+
62
+ export interface UserData {
63
+ name: string
64
+ email: string
65
+ avatar: string
66
+ }
49
67
  ```
50
68
 
51
- ### Create Extension
69
+ #### Step 2: Define Shared State
52
70
 
53
- **1. Define shared state** (automatically syncs everywhere):
71
+ Create reactive state that automatically syncs across all contexts:
54
72
 
55
73
  ```typescript
56
74
  // src/shared/state.ts
57
- import { $sharedState } from '@fairfox/polly/state'
75
+ import { $sharedState, $syncedState, $state } from '@fairfox/polly/state'
76
+
77
+ // Synced + persisted (survives reload)
78
+ export const currentUser = $sharedState<UserData | null>('user', null)
79
+ export const settings = $sharedState('settings', {
80
+ theme: 'dark' as 'light' | 'dark',
81
+ notifications: true
82
+ })
58
83
 
59
- export const counter = $sharedState('counter', 0)
60
- export const settings = $sharedState('settings', { theme: 'dark' })
84
+ // Synced but not persisted (temporary)
85
+ export const onlineStatus = $syncedState('online', true)
86
+ export const activeRequests = $syncedState('requests', 0)
87
+
88
+ // Local only (component state)
89
+ export const isLoading = $state(false)
61
90
  ```
62
91
 
63
- **2. Use in popup UI** (reactive - updates automatically):
92
+ **Why three types of state?**
93
+
94
+ - `$sharedState` - Use for user data, settings - anything that should persist
95
+ - `$syncedState` - Use for ephemeral shared state like connection status
96
+ - `$state` - Use for local UI state like loading spinners
97
+
98
+ #### Step 3: Create Backend Service (Service Worker)
99
+
100
+ Handle API requests and manage data in your service worker:
64
101
 
65
102
  ```typescript
66
- // src/popup/index.tsx
103
+ // src/background/index.ts
104
+ import { createBackground } from '@fairfox/polly/background'
105
+ import type { AppMessages } from '../shared/messages'
106
+ import { currentUser } from '../shared/state'
107
+
108
+ const bus = createBackground<AppMessages>()
109
+
110
+ // API base URL (configurable)
111
+ const API_URL = 'https://api.example.com'
112
+
113
+ // Handle user fetch requests
114
+ bus.on('API_FETCH_USER', async (payload) => {
115
+ try {
116
+ const response = await fetch(`${API_URL}/users/${payload.userId}`)
117
+ const data = await response.json()
118
+
119
+ // Update shared state - automatically syncs to UI!
120
+ currentUser.value = data
121
+
122
+ return { success: true, data }
123
+ } catch (error) {
124
+ return { success: false, error: error.message }
125
+ }
126
+ })
127
+
128
+ // Handle user updates
129
+ bus.on('API_UPDATE_USER', async (payload) => {
130
+ try {
131
+ const response = await fetch(`${API_URL}/users/${payload.userId}`, {
132
+ method: 'PUT',
133
+ headers: { 'Content-Type': 'application/json' },
134
+ body: JSON.stringify(payload.data)
135
+ })
136
+ const data = await response.json()
137
+
138
+ currentUser.value = data
139
+
140
+ return { success: true, data }
141
+ } catch (error) {
142
+ return { success: false, error: error.message }
143
+ }
144
+ })
145
+
146
+ // Handle cache clearing
147
+ bus.on('CACHE_CLEAR', async () => {
148
+ currentUser.value = null
149
+ return { success: true }
150
+ })
151
+
152
+ console.log('Service worker ready!')
153
+ ```
154
+
155
+ **Key insight:** You update state directly in the service worker (`currentUser.value = data`), and it automatically appears in your UI. No manual message sending required!
156
+
157
+ #### Step 4: Build Your UI
158
+
159
+ Create a reactive UI that updates automatically when state changes:
160
+
161
+ ```typescript
162
+ // src/ui/App.tsx
67
163
  import { render } from 'preact'
68
- import { counter } from '../shared/state'
164
+ import { getMessageBus } from '@fairfox/polly/message-bus'
165
+ import { currentUser, settings } from '../shared/state'
166
+ import type { AppMessages } from '../shared/messages'
167
+
168
+ const bus = getMessageBus<AppMessages>('popup')
169
+
170
+ function App() {
171
+ const handleFetchUser = async () => {
172
+ const result = await bus.send({
173
+ type: 'API_FETCH_USER',
174
+ userId: '123'
175
+ })
176
+
177
+ if (!result.success) {
178
+ alert(`Error: ${result.error}`)
179
+ }
180
+ }
181
+
182
+ const handleUpdateUser = async () => {
183
+ await bus.send({
184
+ type: 'API_UPDATE_USER',
185
+ userId: '123',
186
+ data: {
187
+ name: 'Jane Doe',
188
+ email: 'jane@example.com',
189
+ avatar: 'https://...'
190
+ }
191
+ })
192
+ }
69
193
 
70
- function Popup() {
71
194
  return (
72
- <div>
73
- <p>Count: {counter.value}</p>
74
- <button onClick={() => counter.value++}>Increment</button>
195
+ <div className={`app theme-${settings.value.theme}`}>
196
+ <h1>User Profile</h1>
197
+
198
+ {/* Reactive - updates automatically! */}
199
+ {currentUser.value ? (
200
+ <div>
201
+ <img src={currentUser.value.avatar} alt="Avatar" />
202
+ <h2>{currentUser.value.name}</h2>
203
+ <p>{currentUser.value.email}</p>
204
+ <button onClick={handleUpdateUser}>Update Profile</button>
205
+ </div>
206
+ ) : (
207
+ <button onClick={handleFetchUser}>Load User</button>
208
+ )}
209
+
210
+ <label>
211
+ <input
212
+ type="checkbox"
213
+ checked={settings.value.notifications}
214
+ onChange={(e) => {
215
+ // Direct state update - syncs everywhere!
216
+ settings.value = {
217
+ ...settings.value,
218
+ notifications: e.currentTarget.checked
219
+ }
220
+ }}
221
+ />
222
+ Enable Notifications
223
+ </label>
75
224
  </div>
76
225
  )
77
226
  }
78
227
 
79
- render(<Popup />, document.getElementById('root')!)
228
+ render(<App />, document.getElementById('root')!)
80
229
  ```
81
230
 
82
- **3. Setup background** (handles routing):
231
+ **Key insight:** The UI automatically re-renders when `currentUser` or `settings` change, even if those changes come from the service worker or another tab!
232
+
233
+ #### Step 5: Build Your Application
234
+
235
+ ```bash
236
+ # Create a polly.config.ts (optional)
237
+ export default {
238
+ srcDir: 'src',
239
+ distDir: 'dist',
240
+ manifest: 'manifest.json'
241
+ }
242
+
243
+ # Build
244
+ polly build
245
+
246
+ # Build for production (minified)
247
+ polly build --prod
248
+
249
+ # Watch mode
250
+ polly dev
251
+ ```
252
+
253
+ ### The Polly Development Flow
254
+
255
+ Here's how to get the most out of Polly:
256
+
257
+ #### 1. Start with State Design
258
+
259
+ Think about what data needs to be:
260
+ - **Shared across contexts** → Use `$sharedState` or `$syncedState`
261
+ - **Persisted** → Use `$sharedState` or `$persistedState`
262
+ - **Local to a component** → Use `$state`
83
263
 
84
264
  ```typescript
85
- // src/background/index.ts
86
- import { createBackground } from '@fairfox/polly/background'
265
+ // Good state design
266
+ export const userSession = $sharedState('session', null) // Persist login
267
+ export const wsConnection = $syncedState('ws', null) // Don't persist socket
268
+ export const formData = $state({}) // Local form state
269
+ ```
270
+
271
+ #### 2. Define Messages as a Contract
272
+
273
+ Your message types are the contract between contexts. Define them explicitly:
87
274
 
88
- const bus = createBackground()
275
+ ```typescript
276
+ type CustomMessages =
277
+ | { type: 'ACTION_NAME'; /* inputs */ }
278
+ | { type: 'QUERY_NAME'; /* params */ }
89
279
  ```
90
280
 
91
- > **⚠️ Important:** Always use `createBackground()` in background scripts, not `getMessageBus('background')`.
92
- > The framework protects against misconfiguration with singleton enforcement and automatic double-execution detection.
93
- > See [Background Setup Guide](./docs/BACKGROUND_SETUP.md) for details.
281
+ Think of messages like API endpoints - they define the interface between your service worker and UI.
94
282
 
95
- **4. Build and load**:
283
+ #### 3. Handle Business Logic in Background
96
284
 
97
- ```bash
98
- polly build
285
+ The background/service worker is your "backend". Handle:
286
+ - API calls
287
+ - Data processing
288
+ - Chrome API interactions (tabs, storage, etc.)
289
+ - State updates
290
+
291
+ ```typescript
292
+ bus.on('SOME_ACTION', async (payload) => {
293
+ // 1. Do work
294
+ const result = await doSomething(payload)
295
+
296
+ // 2. Update state (auto-syncs to UI)
297
+ myState.value = result
298
+
299
+ // 3. Return response
300
+ return { success: true, result }
301
+ })
99
302
  ```
100
303
 
101
- Load `dist/` folder in Chrome → **Done!** 🎉
304
+ #### 4. Keep UI Simple
102
305
 
103
- ## Features
306
+ Your UI just:
307
+ - Displays state
308
+ - Sends messages
309
+ - Updates local UI state
104
310
 
105
- ### 🔄 Automatic State Sync
311
+ The UI should be "dumb" - all business logic lives in the background.
106
312
 
107
313
  ```typescript
108
- // Change state anywhere
109
- counter.value = 5
110
-
111
- // Instantly appears EVERYWHERE:
112
- // - Popup ✓
113
- // - Options page
114
- // - Background ✓
115
- // - All tabs ✓
314
+ function Component() {
315
+ // Just render state and send messages!
316
+ return (
317
+ <div>
318
+ <p>{myState.value}</p>
319
+ <button onClick={() => bus.send({ type: 'DO_THING' })}>
320
+ Click Me
321
+ </button>
322
+ </div>
323
+ )
324
+ }
116
325
  ```
117
326
 
118
- ### 💾 Automatic Persistence
327
+ #### 5. Test with Real Browser APIs
328
+
329
+ Polly works with real Chrome/browser APIs, so you can test without mocks:
119
330
 
120
331
  ```typescript
121
- // State automatically saves to chrome.storage
122
- const theme = $sharedState('theme', 'dark')
332
+ // tests/app.test.ts
333
+ import { test, expect } from '@playwright/test'
334
+
335
+ test('user profile updates', async ({ page, extensionId }) => {
336
+ await page.goto(`chrome-extension://${extensionId}/popup.html`)
123
337
 
124
- // Survives:
125
- // - Extension reload ✓
126
- // - Browser restart
127
- // - Chrome crash ✓
338
+ await page.click('[data-testid="fetch-user"]')
339
+
340
+ // State automatically synced - just check the DOM!
341
+ await expect(page.locator('[data-testid="user-name"]'))
342
+ .toHaveText('Jane Doe')
343
+ })
128
344
  ```
129
345
 
130
- ### ⚡️ Three Types of State
346
+ ## Core Concepts
347
+
348
+ ### State Primitives
349
+
350
+ Polly provides four state primitives, each for different use cases:
131
351
 
132
352
  ```typescript
133
- // Syncs + persists (most common)
353
+ // Syncs across contexts + persists to storage (most common)
134
354
  const settings = $sharedState('settings', { theme: 'dark' })
135
355
 
136
- // Syncs, no persist (temporary shared state)
356
+ // Syncs across contexts, no persistence (temporary shared state)
137
357
  const activeTab = $syncedState('activeTab', null)
138
358
 
139
- // Local only (like regular React state)
359
+ // Persists to storage, no sync (local persistent state)
360
+ const lastOpened = $persistedState('lastOpened', Date.now())
361
+
362
+ // Local only, no sync, no persistence (like regular Preact signals)
140
363
  const loading = $state(false)
141
364
  ```
142
365
 
143
- ### 📡 Easy Message Passing
366
+ **When to use each:**
367
+
368
+ - **$sharedState**: User preferences, authentication state, application data
369
+ - **$syncedState**: WebSocket connections, temporary flags, live collaboration state
370
+ - **$persistedState**: Component-specific settings, form drafts
371
+ - **$state**: Loading indicators, modal visibility, form validation errors
372
+
373
+ ### Message Patterns
374
+
375
+ #### Request/Response Pattern
144
376
 
145
377
  ```typescript
146
- // Background
378
+ // Background: Handle requests
147
379
  bus.on('GET_DATA', async (payload) => {
148
380
  const data = await fetchData(payload.id)
149
381
  return { success: true, data }
150
382
  })
151
383
 
152
- // Popup
384
+ // UI: Send requests
153
385
  const result = await bus.send({ type: 'GET_DATA', id: 123 })
154
- console.log(result.data)
386
+ if (result.success) {
387
+ console.log(result.data)
388
+ }
155
389
  ```
156
390
 
157
- ### 🧪 Built for Testing
391
+ #### Broadcast Pattern
158
392
 
159
393
  ```typescript
160
- // E2E tests with Playwright
161
- test('counter increments', async ({ page }) => {
162
- await page.click('[data-testid="increment"]')
163
- const count = await page.locator('[data-testid="count"]').textContent()
164
- expect(count).toBe('1')
394
+ // Send to all contexts
395
+ bus.broadcast({ type: 'NOTIFICATION', message: 'Hello everyone!' })
396
+
397
+ // All contexts receive it
398
+ bus.on('NOTIFICATION', (payload) => {
399
+ showToast(payload.message)
165
400
  })
166
401
  ```
167
402
 
168
- State automatically syncs during tests - no mocks needed!
403
+ #### Fire and Forget
169
404
 
170
- ## Examples
405
+ ```typescript
406
+ // Don't await the response
407
+ bus.send({ type: 'LOG_EVENT', event: 'click' })
408
+ ```
171
409
 
172
- - [**Minimal**](./examples/minimal/) - Dead simple counter (30 lines)
173
- - [**Full Featured**](./tests/framework-validation/test-extension/) - Shows all features
174
- - More coming soon...
410
+ ### Chrome Extension Specific
175
411
 
176
- ## Architecture
412
+ If you're building a Chrome extension:
413
+
414
+ ```typescript
415
+ // Background script must use createBackground()
416
+ import { createBackground } from '@fairfox/polly/background'
417
+ const bus = createBackground<YourMessages>()
177
418
 
419
+ // Other contexts use getMessageBus()
420
+ import { getMessageBus } from '@fairfox/polly/message-bus'
421
+ const bus = getMessageBus<YourMessages>('popup')
178
422
  ```
179
- ┌─────────────────────────────────────────┐
180
- │ Your Extension │
181
- ├─────────────────────────────────────────┤
182
- │ Popup Options Content Script │
183
- │ ↓ ↓ ↓ │
184
- │ ┌─────────────────────────────────┐ │
185
- │ │ Framework State Layer │ │
186
- │ │ (Auto-sync, Lamport clocks) │ │
187
- │ └─────────────────────────────────┘ │
188
- │ ↓ │
189
- │ ┌─────────────────────────────────┐ │
190
- │ │ Message Router (Background) │ │
191
- │ └─────────────────────────────────┘ │
192
- │ ↓ │
193
- │ ┌─────────────────────────────────┐ │
194
- │ │ Chrome Extension APIs │ │
195
- │ │ (storage, runtime, tabs) │ │
196
- │ └─────────────────────────────────┘ │
197
- └─────────────────────────────────────────┘
423
+
424
+ **Important:** The background script creates a `MessageRouter` automatically. This routes messages between all contexts. Always use `createBackground()` in background scripts to ensure proper setup.
425
+
426
+ ## CLI Tools
427
+
428
+ Polly includes CLI tools for development:
429
+
430
+ ```bash
431
+ # Build your application
432
+ polly build [--prod]
433
+
434
+ # Type checking
435
+ polly typecheck
436
+
437
+ # Linting
438
+ polly lint [--fix]
439
+
440
+ # Formatting
441
+ polly format
442
+
443
+ # Run all checks
444
+ polly check
445
+
446
+ # Generate architecture diagrams
447
+ polly visualize [--export] [--serve]
448
+
449
+ # Formal verification (if configured)
450
+ polly verify [--setup]
198
451
  ```
199
452
 
200
- ## API Reference
453
+ ## Architecture Visualization
201
454
 
202
- ### State Primitives
455
+ Polly can analyze your codebase and generate architecture diagrams:
456
+
457
+ ```bash
458
+ polly visualize
459
+ ```
460
+
461
+ This creates a Structurizr DSL file documenting:
462
+ - Execution contexts (background, popup, etc.)
463
+ - Message flows between contexts
464
+ - External integrations (APIs, libraries)
465
+ - Chrome API usage
466
+
467
+ View the diagrams using Structurizr Lite:
468
+
469
+ ```bash
470
+ docker run -it --rm -p 8080:8080 \
471
+ -v $(pwd)/docs:/usr/local/structurizr \
472
+ structurizr/lite
473
+ ```
474
+
475
+ ## Real-World Patterns
476
+
477
+ ### API Client Pattern
203
478
 
204
479
  ```typescript
205
- // Syncs across contexts + persists to storage
206
- $sharedState<T>(key: string, initialValue: T): Signal<T>
480
+ // src/background/api-client.ts
481
+ export class APIClient {
482
+ constructor(private baseURL: string) {}
483
+
484
+ async get<T>(path: string): Promise<T> {
485
+ const response = await fetch(`${this.baseURL}${path}`)
486
+ return response.json()
487
+ }
488
+
489
+ async post<T>(path: string, data: unknown): Promise<T> {
490
+ const response = await fetch(`${this.baseURL}${path}`, {
491
+ method: 'POST',
492
+ headers: { 'Content-Type': 'application/json' },
493
+ body: JSON.stringify(data)
494
+ })
495
+ return response.json()
496
+ }
497
+ }
207
498
 
208
- // Syncs across contexts (no persistence)
209
- $syncedState<T>(key: string, initialValue: T): Signal<T>
499
+ // src/background/index.ts
500
+ const api = new APIClient('https://api.example.com')
501
+
502
+ bus.on('API_REQUEST', async (payload) => {
503
+ const data = await api.get(payload.endpoint)
504
+ return { success: true, data }
505
+ })
506
+ ```
210
507
 
211
- // Persists to storage (no sync)
212
- $persistedState<T>(key: string, initialValue: T): Signal<T>
508
+ ### Offline Support Pattern
213
509
 
214
- // Local only (like Preact signal)
215
- $state<T>(initialValue: T): Signal<T>
510
+ ```typescript
511
+ // Cache API responses
512
+ const cache = $sharedState<Record<string, unknown>>('cache', {})
513
+
514
+ bus.on('API_FETCH', async (payload) => {
515
+ // Check cache first
516
+ if (cache.value[payload.url]) {
517
+ return { success: true, data: cache.value[payload.url], cached: true }
518
+ }
519
+
520
+ try {
521
+ const response = await fetch(payload.url)
522
+ const data = await response.json()
523
+
524
+ // Update cache
525
+ cache.value = { ...cache.value, [payload.url]: data }
526
+
527
+ return { success: true, data, cached: false }
528
+ } catch (error) {
529
+ // Fallback to cache if offline
530
+ if (cache.value[payload.url]) {
531
+ return { success: true, data: cache.value[payload.url], cached: true }
532
+ }
533
+ return { success: false, error: error.message }
534
+ }
535
+ })
216
536
  ```
217
537
 
218
- ### Message Bus
538
+ ### Authentication Pattern
219
539
 
220
540
  ```typescript
221
- // Send message to background
222
- await bus.send({ type: 'MY_MESSAGE', data: 'foo' })
541
+ // State
542
+ const authToken = $sharedState<string | null>('authToken', null)
543
+ const currentUser = $sharedState<User | null>('currentUser', null)
223
544
 
224
- // Broadcast to all contexts
225
- bus.broadcast({ type: 'NOTIFICATION', text: 'Hello!' })
545
+ // Background
546
+ bus.on('AUTH_LOGIN', async (payload) => {
547
+ const response = await fetch('https://api.example.com/auth/login', {
548
+ method: 'POST',
549
+ body: JSON.stringify(payload)
550
+ })
551
+ const { token, user } = await response.json()
226
552
 
227
- // Handle messages
228
- bus.on('MY_MESSAGE', async (payload) => {
553
+ // Update state - syncs to all contexts
554
+ authToken.value = token
555
+ currentUser.value = user
556
+
557
+ return { success: true }
558
+ })
559
+
560
+ bus.on('AUTH_LOGOUT', async () => {
561
+ authToken.value = null
562
+ currentUser.value = null
229
563
  return { success: true }
230
564
  })
565
+
566
+ // UI
567
+ function LoginButton() {
568
+ const handleLogin = async () => {
569
+ await bus.send({
570
+ type: 'AUTH_LOGIN',
571
+ username: 'user',
572
+ password: 'pass'
573
+ })
574
+ }
575
+
576
+ return currentUser.value ? (
577
+ <div>Welcome, {currentUser.value.name}</div>
578
+ ) : (
579
+ <button onClick={handleLogin}>Login</button>
580
+ )
581
+ }
231
582
  ```
232
583
 
233
- ### Adapters
584
+ ## Examples
585
+
586
+ Check out the [examples](https://github.com/AlexJeffcott/polly/tree/main/packages/examples) directory:
587
+
588
+ - **minimal** - Dead simple counter (best starting point)
589
+ - **full-featured** - Complete example with all features
590
+ - **todo-list** - Real-world CRUD application
234
591
 
235
- All Chrome APIs available via `bus.adapters`:
592
+ ## API Reference
593
+
594
+ ### State
236
595
 
237
596
  ```typescript
238
- // Storage
239
- await bus.adapters.storage.set({ key: 'value' })
240
- const data = await bus.adapters.storage.get('key')
597
+ import { $sharedState, $syncedState, $persistedState, $state } from '@fairfox/polly/state'
598
+
599
+ // Syncs + persists
600
+ const signal = $sharedState<T>(key: string, initialValue: T)
241
601
 
242
- // Tabs
243
- const tabs = await bus.adapters.tabs.query({ active: true })
602
+ // Syncs, no persist
603
+ const signal = $syncedState<T>(key: string, initialValue: T)
244
604
 
245
- // Runtime
246
- const id = bus.adapters.runtime.id
605
+ // Persists, no sync
606
+ const signal = $persistedState<T>(key: string, initialValue: T)
607
+
608
+ // Local only
609
+ const signal = $state<T>(initialValue: T)
610
+
611
+ // All return Preact Signal<T>
612
+ signal.value // Get value
613
+ signal.value = 42 // Set value
247
614
  ```
248
615
 
249
- ## How It Works
616
+ ### Message Bus
617
+
618
+ ```typescript
619
+ import { getMessageBus } from '@fairfox/polly/message-bus'
620
+ import { createBackground } from '@fairfox/polly/background'
250
621
 
251
- **State Synchronization:**
252
- - Uses **Lamport clocks** for distributed consistency
253
- - Broadcasts changes via `chrome.runtime` ports
254
- - Conflict-free (CRDT-style convergence)
622
+ // In background script
623
+ const bus = createBackground<YourMessages>()
255
624
 
256
- **Reactivity:**
257
- - Built on [Preact Signals](https://preactjs.com/guide/v10/signals/)
258
- - Automatic UI updates (no manual re-renders)
259
- - Works with any framework (Preact, React, Vue, etc.)
625
+ // In other contexts
626
+ const bus = getMessageBus<YourMessages>('popup')
260
627
 
261
- **Message Routing:**
262
- - Background acts as message hub
263
- - Popup/Options/Content scripts connect via ports
264
- - Type-safe request/response pattern
628
+ // Send message
629
+ const response = await bus.send({ type: 'MY_MESSAGE', data: 'foo' })
265
630
 
266
- ## Testing
631
+ // Broadcast to all contexts
632
+ bus.broadcast({ type: 'NOTIFICATION', text: 'Hi!' })
633
+
634
+ // Handle messages
635
+ bus.on('MY_MESSAGE', async (payload) => {
636
+ return { success: true }
637
+ })
638
+ ```
267
639
 
268
- Run the full test suite:
640
+ ### Types
269
641
 
270
- ```bash
271
- bun test # Unit tests
272
- bun run test:framework # E2E tests (Playwright)
642
+ ```typescript
643
+ import type { ExtensionMessage } from '@fairfox/polly/types'
644
+
645
+ // Define custom messages
646
+ type CustomMessages =
647
+ | { type: 'ACTION_ONE'; data: string }
648
+ | { type: 'ACTION_TWO'; id: number }
649
+
650
+ // Combine with framework messages
651
+ type AllMessages = ExtensionMessage | CustomMessages
273
652
  ```
274
653
 
275
- All 16 E2E tests validate real Chrome extension behavior:
276
- - ✅ State sync (popup ↔ options ↔ background)
277
- - ✅ Persistence (survives reload)
278
- - ✅ Reactivity (UI updates automatically)
279
- - ✅ Message passing (request/response)
280
- - ✅ Chrome APIs (storage, tabs, runtime)
654
+ ## How It Works
281
655
 
282
- ## Requirements
656
+ ### State Synchronization
283
657
 
284
- - **Bun** 1.0+ (for building)
285
- - **Chrome** 88+ (Manifest V3)
286
- - **TypeScript** 5.0+ (recommended)
658
+ Polly uses **Lamport clocks** for distributed state consistency:
287
659
 
288
- ## Contributing
660
+ 1. Each state update gets a logical timestamp
661
+ 2. Updates are broadcast to all contexts
662
+ 3. Contexts apply updates in causal order
663
+ 4. Conflicts are resolved deterministically
289
664
 
290
- Contributions welcome! See [CONTRIBUTING.md](./CONTRIBUTING.md)
665
+ This prevents race conditions when multiple contexts update state concurrently.
291
666
 
292
- ## Development
667
+ ### Message Routing
293
668
 
294
- ### TypeScript Configuration
669
+ The background context acts as a message hub:
295
670
 
296
- This project uses separate TypeScript configs for source code and tests:
671
+ 1. Background starts a `MessageRouter`
672
+ 2. Other contexts connect via `chrome.runtime.Port`
673
+ 3. Messages are routed through the background
674
+ 4. Responses are returned to the sender
297
675
 
298
- - **`tsconfig.json`** - Main config for source code (`src/`)
299
- - Strict mode enabled
300
- - Excludes `tests/` directory
676
+ This enables request/response patterns and broadcast messaging.
301
677
 
302
- - **`tsconfig.test.json`** - Config for test files (`tests/`)
303
- - Extends main config
304
- - Relaxes some strict rules for testing
305
- - Includes path mappings: `@/*` → `./src/*`
678
+ ### Reactivity
306
679
 
307
- #### For Neovim/LSP Users
680
+ Built on [Preact Signals](https://preactjs.com/guide/v10/signals/):
308
681
 
309
- If your LSP shows errors about missing modules (like `@/shared/types/messages`), restart your LSP:
310
- ```vim
311
- :LspRestart
312
- ```
682
+ - Automatic UI updates when state changes
683
+ - Fine-grained reactivity (only affected components re-render)
684
+ - Works with Preact, React, Vue, Solid, etc.
313
685
 
314
- Your LSP will automatically use the correct config based on which file you're editing.
686
+ ## Requirements
687
+
688
+ - **Bun** 1.3+ (for building)
689
+ - **TypeScript** 5.0+ (recommended)
690
+ - **Chrome** 88+ (for Chrome extensions)
315
691
 
316
692
  ## License
317
693
 
@@ -319,4 +695,4 @@ MIT © 2024
319
695
 
320
696
  ---
321
697
 
322
- **[View Examples](https://github.com/AlexJeffcott/polly/tree/main/packages/examples)** · **[Read Docs](https://github.com/AlexJeffcott/polly/tree/main/packages/polly/docs)** · **[Report Issue](https://github.com/AlexJeffcott/polly/issues)**
698
+ **[Examples](https://github.com/AlexJeffcott/polly/tree/main/packages/examples)** · **[GitHub](https://github.com/AlexJeffcott/polly)** · **[Issues](https://github.com/AlexJeffcott/polly/issues)**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fairfox/polly",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Multi-execution-context framework with reactive state and cross-context messaging for Chrome extensions, PWAs, and worker-based applications",