@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.
- package/README.md +560 -184
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,317 +1,693 @@
|
|
|
1
1
|
# @fairfox/polly
|
|
2
2
|
|
|
3
|
-
**
|
|
3
|
+
**Multi-execution-context framework with reactive state and cross-context messaging.**
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Build Chrome extensions, PWAs, and worker-based applications with automatic state synchronization, reactive UI updates, and type-safe messaging.
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
// Define state once
|
|
9
|
-
export const counter = $sharedState('counter', 0)
|
|
7
|
+
## Why Polly?
|
|
10
8
|
|
|
11
|
-
|
|
12
|
-
counter.value++ // Updates popup, options, background, everywhere
|
|
13
|
-
```
|
|
9
|
+
Modern applications run code in multiple isolated execution contexts:
|
|
14
10
|
|
|
15
|
-
|
|
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
|
-
|
|
15
|
+
Managing state and communication between these contexts is painful:
|
|
18
16
|
|
|
19
|
-
- ❌ State scattered across contexts
|
|
20
|
-
- ❌
|
|
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
|
-
- ❌
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
32
|
+
## Installation
|
|
36
33
|
|
|
37
34
|
```bash
|
|
38
|
-
|
|
39
|
-
|
|
35
|
+
bun add @fairfox/polly preact @preact/signals
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Getting Started
|
|
39
|
+
|
|
40
|
+
### Example: PWA with Backend API
|
|
40
41
|
|
|
41
|
-
|
|
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
|
-
|
|
45
|
-
pnpm add @fairfox/polly
|
|
44
|
+
#### Step 1: Define Your Message Types
|
|
46
45
|
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
69
|
+
#### Step 2: Define Shared State
|
|
52
70
|
|
|
53
|
-
|
|
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
|
-
|
|
60
|
-
export const
|
|
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
|
-
**
|
|
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/
|
|
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 {
|
|
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
|
-
<
|
|
74
|
-
|
|
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(<
|
|
228
|
+
render(<App />, document.getElementById('root')!)
|
|
80
229
|
```
|
|
81
230
|
|
|
82
|
-
**
|
|
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
|
-
//
|
|
86
|
-
|
|
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
|
-
|
|
275
|
+
```typescript
|
|
276
|
+
type CustomMessages =
|
|
277
|
+
| { type: 'ACTION_NAME'; /* inputs */ }
|
|
278
|
+
| { type: 'QUERY_NAME'; /* params */ }
|
|
89
279
|
```
|
|
90
280
|
|
|
91
|
-
|
|
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
|
-
|
|
283
|
+
#### 3. Handle Business Logic in Background
|
|
96
284
|
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
304
|
+
#### 4. Keep UI Simple
|
|
102
305
|
|
|
103
|
-
|
|
306
|
+
Your UI just:
|
|
307
|
+
- Displays state
|
|
308
|
+
- Sends messages
|
|
309
|
+
- Updates local UI state
|
|
104
310
|
|
|
105
|
-
|
|
311
|
+
The UI should be "dumb" - all business logic lives in the background.
|
|
106
312
|
|
|
107
313
|
```typescript
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
122
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
// -
|
|
127
|
-
|
|
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
|
-
|
|
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
|
|
356
|
+
// Syncs across contexts, no persistence (temporary shared state)
|
|
137
357
|
const activeTab = $syncedState('activeTab', null)
|
|
138
358
|
|
|
139
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
384
|
+
// UI: Send requests
|
|
153
385
|
const result = await bus.send({ type: 'GET_DATA', id: 123 })
|
|
154
|
-
|
|
386
|
+
if (result.success) {
|
|
387
|
+
console.log(result.data)
|
|
388
|
+
}
|
|
155
389
|
```
|
|
156
390
|
|
|
157
|
-
|
|
391
|
+
#### Broadcast Pattern
|
|
158
392
|
|
|
159
393
|
```typescript
|
|
160
|
-
//
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
403
|
+
#### Fire and Forget
|
|
169
404
|
|
|
170
|
-
|
|
405
|
+
```typescript
|
|
406
|
+
// Don't await the response
|
|
407
|
+
bus.send({ type: 'LOG_EVENT', event: 'click' })
|
|
408
|
+
```
|
|
171
409
|
|
|
172
|
-
|
|
173
|
-
- [**Full Featured**](./tests/framework-validation/test-extension/) - Shows all features
|
|
174
|
-
- More coming soon...
|
|
410
|
+
### Chrome Extension Specific
|
|
175
411
|
|
|
176
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
##
|
|
453
|
+
## Architecture Visualization
|
|
201
454
|
|
|
202
|
-
|
|
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
|
-
//
|
|
206
|
-
|
|
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
|
-
//
|
|
209
|
-
|
|
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
|
-
|
|
212
|
-
$persistedState<T>(key: string, initialValue: T): Signal<T>
|
|
508
|
+
### Offline Support Pattern
|
|
213
509
|
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
###
|
|
538
|
+
### Authentication Pattern
|
|
219
539
|
|
|
220
540
|
```typescript
|
|
221
|
-
//
|
|
222
|
-
|
|
541
|
+
// State
|
|
542
|
+
const authToken = $sharedState<string | null>('authToken', null)
|
|
543
|
+
const currentUser = $sharedState<User | null>('currentUser', null)
|
|
223
544
|
|
|
224
|
-
//
|
|
225
|
-
bus.
|
|
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
|
-
//
|
|
228
|
-
|
|
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
|
-
|
|
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
|
-
|
|
592
|
+
## API Reference
|
|
593
|
+
|
|
594
|
+
### State
|
|
236
595
|
|
|
237
596
|
```typescript
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
//
|
|
243
|
-
const
|
|
602
|
+
// Syncs, no persist
|
|
603
|
+
const signal = $syncedState<T>(key: string, initialValue: T)
|
|
244
604
|
|
|
245
|
-
//
|
|
246
|
-
const
|
|
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
|
-
|
|
616
|
+
### Message Bus
|
|
617
|
+
|
|
618
|
+
```typescript
|
|
619
|
+
import { getMessageBus } from '@fairfox/polly/message-bus'
|
|
620
|
+
import { createBackground } from '@fairfox/polly/background'
|
|
250
621
|
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
257
|
-
|
|
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
|
-
|
|
262
|
-
|
|
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
|
-
|
|
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
|
-
|
|
640
|
+
### Types
|
|
269
641
|
|
|
270
|
-
```
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
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
|
-
|
|
656
|
+
### State Synchronization
|
|
283
657
|
|
|
284
|
-
|
|
285
|
-
- **Chrome** 88+ (Manifest V3)
|
|
286
|
-
- **TypeScript** 5.0+ (recommended)
|
|
658
|
+
Polly uses **Lamport clocks** for distributed state consistency:
|
|
287
659
|
|
|
288
|
-
|
|
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
|
-
|
|
665
|
+
This prevents race conditions when multiple contexts update state concurrently.
|
|
291
666
|
|
|
292
|
-
|
|
667
|
+
### Message Routing
|
|
293
668
|
|
|
294
|
-
|
|
669
|
+
The background context acts as a message hub:
|
|
295
670
|
|
|
296
|
-
|
|
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
|
-
|
|
299
|
-
- Strict mode enabled
|
|
300
|
-
- Excludes `tests/` directory
|
|
676
|
+
This enables request/response patterns and broadcast messaging.
|
|
301
677
|
|
|
302
|
-
|
|
303
|
-
- Extends main config
|
|
304
|
-
- Relaxes some strict rules for testing
|
|
305
|
-
- Includes path mappings: `@/*` → `./src/*`
|
|
678
|
+
### Reactivity
|
|
306
679
|
|
|
307
|
-
|
|
680
|
+
Built on [Preact Signals](https://preactjs.com/guide/v10/signals/):
|
|
308
681
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
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
|
-
**[
|
|
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.
|
|
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",
|