@fairfox/polly 0.19.0 → 0.20.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 +125 -948
- package/dist/cli/polly.js +25 -4
- package/dist/cli/polly.js.map +3 -3
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.js +46 -3
- package/dist/src/index.js.map +6 -5
- package/dist/src/shared/lib/resource.d.ts +54 -0
- package/dist/src/shared/lib/resource.js +583 -0
- package/dist/src/shared/lib/resource.js.map +13 -0
- package/dist/src/shared/lib/state.d.ts +1 -0
- package/dist/src/shared/lib/state.js +2 -1
- package/dist/src/shared/lib/state.js.map +3 -3
- package/dist/src/shared/state/app-state.js.map +2 -2
- package/dist/tools/init/templates/pwa/package.json.template +1 -2
- package/dist/tools/verify/specs/docker-compose.yml +1 -1
- package/dist/tools/verify/src/cli.js +176 -9
- package/dist/tools/verify/src/cli.js.map +6 -6
- package/dist/tools/visualize/src/cli.js +139 -4
- package/dist/tools/visualize/src/cli.js.map +3 -3
- package/package.json +5 -5
- package/dist/src/elysia/tla-generator.d.ts +0 -16
- package/dist/tools/verify/specs/verification.config.ts +0 -64
package/README.md
CHANGED
|
@@ -1,1038 +1,215 @@
|
|
|
1
1
|
# @fairfox/polly
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Reactive state for multi-context apps, with formal verification.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## The pitch
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
Modern applications run code in multiple isolated execution contexts:
|
|
10
|
-
|
|
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
|
|
14
|
-
|
|
15
|
-
Managing state and communication between these contexts is painful:
|
|
16
|
-
|
|
17
|
-
- ❌ State scattered across contexts with manual synchronization
|
|
18
|
-
- ❌ Complex message passing with serialization concerns
|
|
19
|
-
- ❌ No reactivity - manually update UI when state changes
|
|
20
|
-
- ❌ Difficult to test - must mock platform APIs
|
|
21
|
-
- ❌ Hard to reason about concurrent state updates
|
|
22
|
-
|
|
23
|
-
**Polly solves this:**
|
|
24
|
-
|
|
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, IndexedDB, or custom storage
|
|
28
|
-
- ✅ **Type-safe messaging** - Request/response pattern with full TypeScript support
|
|
29
|
-
- ✅ **Built for testing** - Full mock adapters, no browser required
|
|
30
|
-
- ✅ **Distributed consistency** - Lamport clocks prevent race conditions
|
|
31
|
-
- ✅ **Runtime-agnostic** - Core has zero browser dependencies, runs anywhere
|
|
32
|
-
|
|
33
|
-
## Architecture: Adapters All the Way Down
|
|
34
|
-
|
|
35
|
-
Polly's core is completely decoupled from platform APIs through an adapter pattern:
|
|
36
|
-
|
|
37
|
-
```
|
|
38
|
-
┌─────────────────────────────────────────────────────────────┐
|
|
39
|
-
│ Application Core │
|
|
40
|
-
│ (State + Handlers + Business Logic) │
|
|
41
|
-
│ │
|
|
42
|
-
│ - Zero browser dependencies │
|
|
43
|
-
│ - Same code runs everywhere │
|
|
44
|
-
│ - Fully testable without mocking platform APIs │
|
|
45
|
-
└─────────────────────────────────────────────────────────────┘
|
|
46
|
-
│
|
|
47
|
-
Adapter Interfaces
|
|
48
|
-
│
|
|
49
|
-
┌───────────────────┼───────────────────┐
|
|
50
|
-
↓ ↓ ↓
|
|
51
|
-
┌───────────┐ ┌───────────┐ ┌───────────┐
|
|
52
|
-
│ Browser │ │ Extension │ │ Node │
|
|
53
|
-
│ │ │ │ │ Bun/Deno │
|
|
54
|
-
│ IndexedDB │ │ chrome. │ │ │
|
|
55
|
-
│ Broadcast │ │ storage │ │ File/ │
|
|
56
|
-
│ Channel │ │ runtime │ │ SQLite │
|
|
57
|
-
└───────────┘ └───────────┘ └───────────┘
|
|
58
|
-
```
|
|
59
|
-
|
|
60
|
-
### Adapter Interfaces
|
|
61
|
-
|
|
62
|
-
| Interface | Purpose | Implementations |
|
|
63
|
-
|-----------|---------|-----------------|
|
|
64
|
-
| `StorageAdapter` | Data persistence | `ChromeStorageAdapter`, `IndexedDBAdapter`, `MemoryStorageAdapter` |
|
|
65
|
-
| `SyncAdapter` | Cross-context sync | `ChromeRuntimeSyncAdapter`, `BroadcastChannelSyncAdapter`, `NoOpSyncAdapter` |
|
|
66
|
-
| `FetchAdapter` | HTTP requests | `BrowserFetchAdapter` (or native `fetch` in Node 18+/Bun/Deno) |
|
|
67
|
-
| `RuntimeAdapter` | Extension messaging | `ChromeRuntimeAdapter` |
|
|
68
|
-
|
|
69
|
-
### Running in Different Environments
|
|
70
|
-
|
|
71
|
-
**Browser/PWA:**
|
|
72
|
-
```typescript
|
|
73
|
-
import { createWebAdapters } from '@fairfox/polly/adapters'
|
|
74
|
-
const adapters = createWebAdapters() // Uses IndexedDB + BroadcastChannel
|
|
75
|
-
```
|
|
76
|
-
|
|
77
|
-
**Chrome Extension:**
|
|
78
|
-
```typescript
|
|
79
|
-
import { createChromeAdapters } from '@fairfox/polly/adapters'
|
|
80
|
-
const adapters = createChromeAdapters() // Uses chrome.storage + chrome.runtime
|
|
81
|
-
```
|
|
82
|
-
|
|
83
|
-
**Node/Bun/Deno CLI:**
|
|
84
|
-
```typescript
|
|
85
|
-
import { createNodeAdapters } from '@fairfox/polly/adapters'
|
|
86
|
-
const adapters = createNodeAdapters({
|
|
87
|
-
storage: new FileStorageAdapter('./data.json'), // or SQLite, Redis, etc.
|
|
88
|
-
sync: new NoOpSyncAdapter(), // or IPC, Redis pub/sub, etc.
|
|
89
|
-
})
|
|
90
|
-
```
|
|
91
|
-
|
|
92
|
-
**Testing:**
|
|
93
|
-
```typescript
|
|
94
|
-
import { createMockAdapters } from '@fairfox/polly/test'
|
|
95
|
-
const mocks = createMockAdapters() // In-memory, fully controllable
|
|
96
|
-
```
|
|
97
|
-
|
|
98
|
-
The adapter factory auto-detects your environment, so most of the time you don't need to configure anything.
|
|
99
|
-
|
|
100
|
-
## Installation
|
|
101
|
-
|
|
102
|
-
```bash
|
|
103
|
-
bun add @fairfox/polly preact @preact/signals
|
|
104
|
-
```
|
|
105
|
-
|
|
106
|
-
## Getting Started
|
|
107
|
-
|
|
108
|
-
### Example: PWA with Backend API
|
|
109
|
-
|
|
110
|
-
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.
|
|
111
|
-
|
|
112
|
-
#### Step 1: Define Your Message Types
|
|
113
|
-
|
|
114
|
-
Create typed messages for communication between your UI and service worker:
|
|
115
|
-
|
|
116
|
-
```typescript
|
|
117
|
-
// src/shared/messages.ts
|
|
118
|
-
import type { ExtensionMessage } from '@fairfox/polly/types'
|
|
119
|
-
|
|
120
|
-
// Define your custom messages
|
|
121
|
-
type CustomMessages =
|
|
122
|
-
| { type: 'API_FETCH_USER'; userId: string }
|
|
123
|
-
| { type: 'API_UPDATE_USER'; userId: string; data: UserData }
|
|
124
|
-
| { type: 'API_DELETE_USER'; userId: string }
|
|
125
|
-
| { type: 'CACHE_CLEAR' }
|
|
126
|
-
|
|
127
|
-
// Combine with framework messages
|
|
128
|
-
export type AppMessages = ExtensionMessage | CustomMessages
|
|
129
|
-
|
|
130
|
-
export interface UserData {
|
|
131
|
-
name: string
|
|
132
|
-
email: string
|
|
133
|
-
avatar: string
|
|
134
|
-
}
|
|
135
|
-
```
|
|
136
|
-
|
|
137
|
-
#### Step 2: Define Shared State
|
|
138
|
-
|
|
139
|
-
Create reactive state that automatically syncs across all contexts:
|
|
7
|
+
Define state once. Read it anywhere. Polly keeps them in sync.
|
|
140
8
|
|
|
141
9
|
```typescript
|
|
142
10
|
// src/shared/state.ts
|
|
143
|
-
import { $sharedState
|
|
144
|
-
|
|
145
|
-
// Synced + persisted (survives reload)
|
|
146
|
-
export const currentUser = $sharedState<UserData | null>('user', null)
|
|
147
|
-
export const settings = $sharedState('settings', {
|
|
148
|
-
theme: 'dark' as 'light' | 'dark',
|
|
149
|
-
notifications: true
|
|
150
|
-
})
|
|
151
|
-
|
|
152
|
-
// Synced but not persisted (temporary)
|
|
153
|
-
export const onlineStatus = $syncedState('online', true)
|
|
154
|
-
export const activeRequests = $syncedState('requests', 0)
|
|
11
|
+
import { $sharedState } from "@fairfox/polly/state";
|
|
155
12
|
|
|
156
|
-
|
|
157
|
-
export const isLoading = $state(false)
|
|
13
|
+
export const counter = $sharedState("counter", 0);
|
|
158
14
|
```
|
|
159
15
|
|
|
160
|
-
**Why three types of state?**
|
|
161
|
-
|
|
162
|
-
- `$sharedState` - Use for user data, settings - anything that should persist
|
|
163
|
-
- `$syncedState` - Use for ephemeral shared state like connection status
|
|
164
|
-
- `$state` - Use for local UI state like loading spinners
|
|
165
|
-
|
|
166
|
-
#### Step 3: Create Backend Service (Service Worker)
|
|
167
|
-
|
|
168
|
-
Handle API requests and manage data in your service worker:
|
|
169
|
-
|
|
170
16
|
```typescript
|
|
171
|
-
// src/background/index.ts
|
|
172
|
-
import { createBackground } from
|
|
173
|
-
import
|
|
174
|
-
import { currentUser } from '../shared/state'
|
|
175
|
-
|
|
176
|
-
const bus = createBackground<AppMessages>()
|
|
177
|
-
|
|
178
|
-
// API base URL (configurable)
|
|
179
|
-
const API_URL = 'https://api.example.com'
|
|
180
|
-
|
|
181
|
-
// Handle user fetch requests
|
|
182
|
-
bus.on('API_FETCH_USER', async (payload) => {
|
|
183
|
-
try {
|
|
184
|
-
const response = await fetch(`${API_URL}/users/${payload.userId}`)
|
|
185
|
-
const data = await response.json()
|
|
186
|
-
|
|
187
|
-
// Update shared state - automatically syncs to UI!
|
|
188
|
-
currentUser.value = data
|
|
17
|
+
// src/background/index.ts — service worker, extension background, Node process
|
|
18
|
+
import { createBackground } from "@fairfox/polly/background";
|
|
19
|
+
import { counter } from "../shared/state";
|
|
189
20
|
|
|
190
|
-
|
|
191
|
-
} catch (error) {
|
|
192
|
-
return { success: false, error: error.message }
|
|
193
|
-
}
|
|
194
|
-
})
|
|
21
|
+
const bus = createBackground();
|
|
195
22
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
method: 'PUT',
|
|
201
|
-
headers: { 'Content-Type': 'application/json' },
|
|
202
|
-
body: JSON.stringify(payload.data)
|
|
203
|
-
})
|
|
204
|
-
const data = await response.json()
|
|
205
|
-
|
|
206
|
-
currentUser.value = data
|
|
207
|
-
|
|
208
|
-
return { success: true, data }
|
|
209
|
-
} catch (error) {
|
|
210
|
-
return { success: false, error: error.message }
|
|
211
|
-
}
|
|
212
|
-
})
|
|
213
|
-
|
|
214
|
-
// Handle cache clearing
|
|
215
|
-
bus.on('CACHE_CLEAR', async () => {
|
|
216
|
-
currentUser.value = null
|
|
217
|
-
return { success: true }
|
|
218
|
-
})
|
|
219
|
-
|
|
220
|
-
console.log('Service worker ready!')
|
|
23
|
+
bus.on("INCREMENT", () => {
|
|
24
|
+
counter.value++;
|
|
25
|
+
return { count: counter.value };
|
|
26
|
+
});
|
|
221
27
|
```
|
|
222
28
|
|
|
223
|
-
**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!
|
|
224
|
-
|
|
225
|
-
#### Step 4: Build Your UI
|
|
226
|
-
|
|
227
|
-
Create a reactive UI that updates automatically when state changes:
|
|
228
|
-
|
|
229
29
|
```typescript
|
|
230
|
-
// src/
|
|
231
|
-
import { render } from
|
|
232
|
-
import {
|
|
233
|
-
import { currentUser, settings } from '../shared/state'
|
|
234
|
-
import type { AppMessages } from '../shared/messages'
|
|
235
|
-
|
|
236
|
-
const bus = getMessageBus<AppMessages>('popup')
|
|
30
|
+
// src/popup/index.tsx — browser popup, any Preact/React UI
|
|
31
|
+
import { render } from "preact";
|
|
32
|
+
import { counter } from "../shared/state";
|
|
237
33
|
|
|
238
34
|
function App() {
|
|
239
|
-
const handleFetchUser = async () => {
|
|
240
|
-
const result = await bus.send({
|
|
241
|
-
type: 'API_FETCH_USER',
|
|
242
|
-
userId: '123'
|
|
243
|
-
})
|
|
244
|
-
|
|
245
|
-
if (!result.success) {
|
|
246
|
-
alert(`Error: ${result.error}`)
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
const handleUpdateUser = async () => {
|
|
251
|
-
await bus.send({
|
|
252
|
-
type: 'API_UPDATE_USER',
|
|
253
|
-
userId: '123',
|
|
254
|
-
data: {
|
|
255
|
-
name: 'Jane Doe',
|
|
256
|
-
email: 'jane@example.com',
|
|
257
|
-
avatar: 'https://...'
|
|
258
|
-
}
|
|
259
|
-
})
|
|
260
|
-
}
|
|
261
|
-
|
|
262
35
|
return (
|
|
263
|
-
<div
|
|
264
|
-
<
|
|
265
|
-
|
|
266
|
-
{/* Reactive - updates automatically! */}
|
|
267
|
-
{currentUser.value ? (
|
|
268
|
-
<div>
|
|
269
|
-
<img src={currentUser.value.avatar} alt="Avatar" />
|
|
270
|
-
<h2>{currentUser.value.name}</h2>
|
|
271
|
-
<p>{currentUser.value.email}</p>
|
|
272
|
-
<button onClick={handleUpdateUser}>Update Profile</button>
|
|
273
|
-
</div>
|
|
274
|
-
) : (
|
|
275
|
-
<button onClick={handleFetchUser}>Load User</button>
|
|
276
|
-
)}
|
|
277
|
-
|
|
278
|
-
<label>
|
|
279
|
-
<input
|
|
280
|
-
type="checkbox"
|
|
281
|
-
checked={settings.value.notifications}
|
|
282
|
-
onChange={(e) => {
|
|
283
|
-
// Direct state update - syncs everywhere!
|
|
284
|
-
settings.value = {
|
|
285
|
-
...settings.value,
|
|
286
|
-
notifications: e.currentTarget.checked
|
|
287
|
-
}
|
|
288
|
-
}}
|
|
289
|
-
/>
|
|
290
|
-
Enable Notifications
|
|
291
|
-
</label>
|
|
36
|
+
<div>
|
|
37
|
+
<p>Count: {counter.value}</p>
|
|
38
|
+
<button onClick={() => counter.value++}>+</button>
|
|
292
39
|
</div>
|
|
293
|
-
)
|
|
40
|
+
);
|
|
294
41
|
}
|
|
295
42
|
|
|
296
|
-
render(<App />, document.getElementById(
|
|
43
|
+
render(<App />, document.getElementById("root")!);
|
|
297
44
|
```
|
|
298
45
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
#### Step 5: Build Your Application
|
|
46
|
+
The background writes `counter`. The popup reads it. Both stay in sync — no message passing, no subscriptions to manage. State is a [Preact Signal](https://preactjs.com/guide/v10/signals/), so the UI re-renders automatically when the value changes.
|
|
302
47
|
|
|
303
|
-
|
|
304
|
-
# Create a polly.config.ts (optional)
|
|
305
|
-
export default {
|
|
306
|
-
srcDir: 'src',
|
|
307
|
-
distDir: 'dist',
|
|
308
|
-
manifest: 'manifest.json'
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
# Build
|
|
312
|
-
polly build
|
|
313
|
-
|
|
314
|
-
# Build for production (minified)
|
|
315
|
-
polly build --prod
|
|
48
|
+
## State that syncs everywhere
|
|
316
49
|
|
|
317
|
-
|
|
318
|
-
polly dev
|
|
319
|
-
```
|
|
320
|
-
|
|
321
|
-
### The Polly Development Flow
|
|
50
|
+
Modern apps run code in multiple isolated contexts: service workers, content scripts, server processes. Keeping state consistent across them means writing sync logic for every piece of data. Polly replaces that with four primitives:
|
|
322
51
|
|
|
323
|
-
|
|
52
|
+
| Primitive | Syncs | Persists | Use for |
|
|
53
|
+
|-----------|:-----:|:--------:|---------|
|
|
54
|
+
| `$sharedState` | yes | yes | User data, settings, auth — anything that should survive a restart and stay consistent |
|
|
55
|
+
| `$syncedState` | yes | no | Ephemeral shared state: connection status, live collaboration flags |
|
|
56
|
+
| `$persistedState` | no | yes | Per-context settings, form drafts |
|
|
57
|
+
| `$state` | no | no | Local UI state: loading spinners, modal visibility |
|
|
324
58
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
Think about what data needs to be:
|
|
328
|
-
- **Shared across contexts** → Use `$sharedState` or `$syncedState`
|
|
329
|
-
- **Persisted** → Use `$sharedState` or `$persistedState`
|
|
330
|
-
- **Local to a component** → Use `$state`
|
|
59
|
+
All four return Preact Signals. Read with `.value`, write with `.value =`.
|
|
331
60
|
|
|
332
61
|
```typescript
|
|
333
|
-
|
|
334
|
-
export const userSession = $sharedState('session', null) // Persist login
|
|
335
|
-
export const wsConnection = $syncedState('ws', null) // Don't persist socket
|
|
336
|
-
export const formData = $state({}) // Local form state
|
|
337
|
-
```
|
|
62
|
+
import { $sharedState, $syncedState, $persistedState, $state } from "@fairfox/polly/state";
|
|
338
63
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
```typescript
|
|
344
|
-
type CustomMessages =
|
|
345
|
-
| { type: 'ACTION_NAME'; /* inputs */ }
|
|
346
|
-
| { type: 'QUERY_NAME'; /* params */ }
|
|
64
|
+
const user = $sharedState("user", { name: "Guest", loggedIn: false });
|
|
65
|
+
const wsConnected = $syncedState("ws", false);
|
|
66
|
+
const draft = $persistedState("draft", "");
|
|
67
|
+
const loading = $state(false);
|
|
347
68
|
```
|
|
348
69
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
#### 3. Handle Business Logic in Background
|
|
352
|
-
|
|
353
|
-
The background/service worker is your "backend". Handle:
|
|
354
|
-
- API calls
|
|
355
|
-
- Data processing
|
|
356
|
-
- Chrome API interactions (tabs, storage, etc.)
|
|
357
|
-
- State updates
|
|
70
|
+
For async data, `$resource` fetches and re-fetches automatically when its dependencies change:
|
|
358
71
|
|
|
359
72
|
```typescript
|
|
360
|
-
|
|
361
|
-
// 1. Do work
|
|
362
|
-
const result = await doSomething(payload)
|
|
363
|
-
|
|
364
|
-
// 2. Update state (auto-syncs to UI)
|
|
365
|
-
myState.value = result
|
|
366
|
-
|
|
367
|
-
// 3. Return response
|
|
368
|
-
return { success: true, result }
|
|
369
|
-
})
|
|
370
|
-
```
|
|
73
|
+
import { $resource } from "@fairfox/polly/resource";
|
|
371
74
|
|
|
372
|
-
|
|
75
|
+
const todos = $resource("todos", {
|
|
76
|
+
source: () => ({ userId: user.value.id }),
|
|
77
|
+
fetcher: async ({ userId }) => {
|
|
78
|
+
const res = await fetch(`/api/todos?userId=${userId}`);
|
|
79
|
+
return res.json();
|
|
80
|
+
},
|
|
81
|
+
initialValue: [],
|
|
82
|
+
});
|
|
373
83
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
- Updates local UI state
|
|
378
|
-
|
|
379
|
-
The UI should be "dumb" - all business logic lives in the background.
|
|
380
|
-
|
|
381
|
-
```typescript
|
|
382
|
-
function Component() {
|
|
383
|
-
// Just render state and send messages!
|
|
384
|
-
return (
|
|
385
|
-
<div>
|
|
386
|
-
<p>{myState.value}</p>
|
|
387
|
-
<button onClick={() => bus.send({ type: 'DO_THING' })}>
|
|
388
|
-
Click Me
|
|
389
|
-
</button>
|
|
390
|
-
</div>
|
|
391
|
-
)
|
|
392
|
-
}
|
|
84
|
+
todos.data; // Signal<Todo[]>
|
|
85
|
+
todos.status; // Signal<"idle" | "loading" | "success" | "error">
|
|
86
|
+
todos.refetch();
|
|
393
87
|
```
|
|
394
88
|
|
|
395
|
-
|
|
89
|
+
## Verification that plugs in
|
|
396
90
|
|
|
397
|
-
|
|
91
|
+
A popup and a background script both write to the same state. A content script reads it mid-update. Tests miss these bugs because they depend on timing.
|
|
398
92
|
|
|
399
|
-
|
|
400
|
-
// tests/app.test.ts
|
|
401
|
-
import { test, expect } from '@playwright/test'
|
|
402
|
-
|
|
403
|
-
test('user profile updates', async ({ page, extensionId }) => {
|
|
404
|
-
await page.goto(`chrome-extension://${extensionId}/popup.html`)
|
|
405
|
-
|
|
406
|
-
await page.click('[data-testid="fetch-user"]')
|
|
407
|
-
|
|
408
|
-
// State automatically synced - just check the DOM!
|
|
409
|
-
await expect(page.locator('[data-testid="user-name"]'))
|
|
410
|
-
.toHaveText('Jane Doe')
|
|
411
|
-
})
|
|
412
|
-
```
|
|
413
|
-
|
|
414
|
-
### Full-Stack SPAs with Elysia (Bun)
|
|
93
|
+
Polly generates [TLA+](https://lamport.azuretext.org/tla/tla.html) specifications from your existing state and handlers, then model-checks them with TLC. You don't learn a new language. You annotate what you already wrote.
|
|
415
94
|
|
|
416
|
-
|
|
95
|
+
### Step 1: Add contracts to handlers
|
|
417
96
|
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
#### Server: Add Polly Middleware
|
|
97
|
+
`requires()` and `ensures()` are runtime no-ops. `polly verify` reads them statically.
|
|
421
98
|
|
|
422
99
|
```typescript
|
|
423
|
-
|
|
424
|
-
import {
|
|
425
|
-
import {
|
|
426
|
-
import { $syncedState, $serverState } from '@fairfox/polly'
|
|
427
|
-
|
|
428
|
-
const app = new Elysia()
|
|
429
|
-
.use(polly({
|
|
430
|
-
// Define shared state
|
|
431
|
-
state: {
|
|
432
|
-
client: {
|
|
433
|
-
todos: $syncedState('todos', []),
|
|
434
|
-
user: $syncedState('user', null),
|
|
435
|
-
},
|
|
436
|
-
server: {
|
|
437
|
-
db: $serverState('db', database),
|
|
438
|
-
},
|
|
439
|
-
},
|
|
100
|
+
import { createBackground } from "@fairfox/polly/background";
|
|
101
|
+
import { requires, ensures } from "@fairfox/polly/verify";
|
|
102
|
+
import { user, todos } from "./state";
|
|
440
103
|
|
|
441
|
-
|
|
442
|
-
effects: {
|
|
443
|
-
'POST /todos': {
|
|
444
|
-
client: ({ result, state }) => {
|
|
445
|
-
// Update client state with new todo
|
|
446
|
-
state.client.todos.value = [...state.client.todos.value, result]
|
|
447
|
-
},
|
|
448
|
-
broadcast: true, // Notify all connected clients
|
|
449
|
-
},
|
|
450
|
-
'PATCH /todos/:id': {
|
|
451
|
-
client: ({ result, state }) => {
|
|
452
|
-
// Update specific todo in client state
|
|
453
|
-
state.client.todos.value = state.client.todos.value.map(t =>
|
|
454
|
-
t.id === result.id ? result : t
|
|
455
|
-
)
|
|
456
|
-
},
|
|
457
|
-
broadcast: true,
|
|
458
|
-
},
|
|
459
|
-
'DELETE /todos/:id': {
|
|
460
|
-
client: ({ params, state }) => {
|
|
461
|
-
// Remove todo from client state
|
|
462
|
-
state.client.todos.value = state.client.todos.value.filter(
|
|
463
|
-
t => t.id !== Number(params.id)
|
|
464
|
-
)
|
|
465
|
-
},
|
|
466
|
-
broadcast: true,
|
|
467
|
-
},
|
|
468
|
-
},
|
|
104
|
+
const bus = createBackground();
|
|
469
105
|
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
'PATCH /todos/:id': ({ state }) => state.client.user.value !== null,
|
|
474
|
-
'DELETE /todos/:id': ({ state }) => state.client.user.value !== null,
|
|
475
|
-
},
|
|
106
|
+
bus.on("TODO_ADD", (payload: { text: string }) => {
|
|
107
|
+
requires(user.value.loggedIn === true, "Must be logged in");
|
|
108
|
+
requires(payload.text !== "", "Text must not be empty");
|
|
476
109
|
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
id: -Date.now(), // Temporary ID
|
|
483
|
-
text: body.text,
|
|
484
|
-
completed: false,
|
|
485
|
-
}),
|
|
486
|
-
},
|
|
487
|
-
},
|
|
110
|
+
todos.value = [...todos.value, {
|
|
111
|
+
id: Date.now().toString(),
|
|
112
|
+
text: payload.text,
|
|
113
|
+
completed: false,
|
|
114
|
+
}];
|
|
488
115
|
|
|
489
|
-
|
|
490
|
-
tlaGeneration: true,
|
|
491
|
-
}))
|
|
116
|
+
ensures(todos.value.length > 0, "Todos must not be empty after add");
|
|
492
117
|
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
const todo = await pollyState.server.db.value.todos.create(body)
|
|
496
|
-
return todo
|
|
497
|
-
}, {
|
|
498
|
-
body: t.Object({ text: t.String() })
|
|
499
|
-
})
|
|
500
|
-
|
|
501
|
-
.listen(3000)
|
|
118
|
+
return { success: true };
|
|
119
|
+
});
|
|
502
120
|
```
|
|
503
121
|
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
```typescript
|
|
507
|
-
// client/api.ts
|
|
508
|
-
import { createPollyClient } from '@fairfox/polly/client'
|
|
509
|
-
import { $syncedState } from '@fairfox/polly'
|
|
510
|
-
import type { app } from '../server' // Import server type!
|
|
511
|
-
|
|
512
|
-
// Define client state
|
|
513
|
-
export const clientState = {
|
|
514
|
-
todos: $syncedState('todos', []),
|
|
515
|
-
user: $syncedState('user', null),
|
|
516
|
-
}
|
|
122
|
+
### Step 2: Define state bounds
|
|
517
123
|
|
|
518
|
-
|
|
519
|
-
export const api = createPollyClient<typeof app>('http://localhost:3000', {
|
|
520
|
-
state: clientState,
|
|
521
|
-
websocket: true, // Enable real-time updates
|
|
522
|
-
})
|
|
523
|
-
```
|
|
124
|
+
A verification config tells TLC what values to explore:
|
|
524
125
|
|
|
525
126
|
```typescript
|
|
526
|
-
//
|
|
527
|
-
import {
|
|
528
|
-
import { api, clientState } from '../api'
|
|
529
|
-
|
|
530
|
-
export function TodoList() {
|
|
531
|
-
const newTodo = useSignal('')
|
|
532
|
-
|
|
533
|
-
async function handleAdd() {
|
|
534
|
-
// Automatically handles:
|
|
535
|
-
// - Optimistic update if offline
|
|
536
|
-
// - Queue for retry
|
|
537
|
-
// - Execute client effect on success
|
|
538
|
-
// - Broadcast to other clients
|
|
539
|
-
await api.todos.post({ text: newTodo.value })
|
|
540
|
-
newTodo.value = ''
|
|
541
|
-
}
|
|
127
|
+
// specs/verification.config.ts
|
|
128
|
+
import { defineVerification } from "@fairfox/polly/verify";
|
|
542
129
|
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
<ul>
|
|
555
|
-
{clientState.todos.value.map(todo => (
|
|
556
|
-
<li key={todo.id}>
|
|
557
|
-
<input
|
|
558
|
-
type="checkbox"
|
|
559
|
-
checked={todo.completed}
|
|
560
|
-
onChange={() => api.todos[todo.id].patch({ completed: !todo.completed })}
|
|
561
|
-
/>
|
|
562
|
-
<span>{todo.text}</span>
|
|
563
|
-
<button onClick={() => api.todos[todo.id].delete()}>Delete</button>
|
|
564
|
-
</li>
|
|
565
|
-
))}
|
|
566
|
-
</ul>
|
|
567
|
-
|
|
568
|
-
{/* Add new todo */}
|
|
569
|
-
<input
|
|
570
|
-
value={newTodo.value}
|
|
571
|
-
onInput={(e) => newTodo.value = e.currentTarget.value}
|
|
572
|
-
placeholder="What needs to be done?"
|
|
573
|
-
/>
|
|
574
|
-
<button onClick={handleAdd}>Add</button>
|
|
575
|
-
</div>
|
|
576
|
-
)
|
|
577
|
-
}
|
|
130
|
+
export const verificationConfig = defineVerification({
|
|
131
|
+
state: {
|
|
132
|
+
"user.loggedIn": { type: "boolean" },
|
|
133
|
+
"user.role": { type: "enum", values: ["guest", "user", "admin"] },
|
|
134
|
+
todos: { maxLength: 1 },
|
|
135
|
+
},
|
|
136
|
+
messages: {
|
|
137
|
+
maxInFlight: 2,
|
|
138
|
+
maxTabs: 1,
|
|
139
|
+
},
|
|
140
|
+
});
|
|
578
141
|
```
|
|
579
142
|
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
1. **Zero Type Duplication** - Eden infers client types from Elysia routes automatically
|
|
583
|
-
2. **Distributed Systems Semantics** - Explicit offline, authorization, and effects configuration
|
|
584
|
-
3. **Production-Ready** - Middleware is pass-through in production (minimal overhead)
|
|
585
|
-
4. **Real-Time Updates** - WebSocket broadcast keeps all clients in sync
|
|
586
|
-
5. **Formal Verification** - Generate TLA+ specs from middleware config to verify distributed properties
|
|
143
|
+
### Step 3: Run it
|
|
587
144
|
|
|
588
|
-
#### Production vs Development
|
|
589
|
-
|
|
590
|
-
**Development Mode:**
|
|
591
|
-
- Middleware adds metadata to responses for hot-reload and debugging
|
|
592
|
-
- Client effects serialized from server for live updates
|
|
593
|
-
- TLA+ generation enabled for verification
|
|
594
|
-
|
|
595
|
-
**Production Mode:**
|
|
596
|
-
- Middleware is minimal (authorization + broadcast only)
|
|
597
|
-
- Client effects are bundled at build time
|
|
598
|
-
- Zero serialization overhead
|
|
599
|
-
|
|
600
|
-
## Core Concepts
|
|
601
|
-
|
|
602
|
-
### State Primitives
|
|
603
|
-
|
|
604
|
-
Polly provides four state primitives, each for different use cases:
|
|
605
|
-
|
|
606
|
-
```typescript
|
|
607
|
-
// Syncs across contexts + persists to storage (most common)
|
|
608
|
-
const settings = $sharedState('settings', { theme: 'dark' })
|
|
609
|
-
|
|
610
|
-
// Syncs across contexts, no persistence (temporary shared state)
|
|
611
|
-
const activeTab = $syncedState('activeTab', null)
|
|
612
|
-
|
|
613
|
-
// Persists to storage, no sync (local persistent state)
|
|
614
|
-
const lastOpened = $persistedState('lastOpened', Date.now())
|
|
615
|
-
|
|
616
|
-
// Local only, no sync, no persistence (like regular Preact signals)
|
|
617
|
-
const loading = $state(false)
|
|
618
|
-
```
|
|
619
|
-
|
|
620
|
-
**When to use each:**
|
|
621
|
-
|
|
622
|
-
- **$sharedState**: User preferences, authentication state, application data
|
|
623
|
-
- **$syncedState**: WebSocket connections, temporary flags, live collaboration state
|
|
624
|
-
- **$persistedState**: Component-specific settings, form drafts
|
|
625
|
-
- **$state**: Loading indicators, modal visibility, form validation errors
|
|
626
|
-
|
|
627
|
-
### Message Patterns
|
|
628
|
-
|
|
629
|
-
#### Request/Response Pattern
|
|
630
|
-
|
|
631
|
-
```typescript
|
|
632
|
-
// Background: Handle requests
|
|
633
|
-
bus.on('GET_DATA', async (payload) => {
|
|
634
|
-
const data = await fetchData(payload.id)
|
|
635
|
-
return { success: true, data }
|
|
636
|
-
})
|
|
637
|
-
|
|
638
|
-
// UI: Send requests
|
|
639
|
-
const result = await bus.send({ type: 'GET_DATA', id: 123 })
|
|
640
|
-
if (result.success) {
|
|
641
|
-
console.log(result.data)
|
|
642
|
-
}
|
|
643
145
|
```
|
|
146
|
+
$ polly verify
|
|
644
147
|
|
|
645
|
-
|
|
148
|
+
Generating TLA+ specification...
|
|
149
|
+
Running TLC model checker...
|
|
646
150
|
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
151
|
+
Model checking complete.
|
|
152
|
+
States explored: 1,247
|
|
153
|
+
Distinct states: 312
|
|
154
|
+
No errors found.
|
|
650
155
|
|
|
651
|
-
|
|
652
|
-
bus.on('NOTIFICATION', (payload) => {
|
|
653
|
-
showToast(payload.message)
|
|
654
|
-
})
|
|
156
|
+
All properties verified.
|
|
655
157
|
```
|
|
656
158
|
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
```typescript
|
|
660
|
-
// Don't await the response
|
|
661
|
-
bus.send({ type: 'LOG_EVENT', event: 'click' })
|
|
662
|
-
```
|
|
159
|
+
If a `requires()` can be violated — say, a logout races with a todo add — TLC finds the exact sequence of steps that triggers it.
|
|
663
160
|
|
|
664
|
-
|
|
161
|
+
For larger apps, [subsystem-scoped verification](https://github.com/AlexJeffcott/polly/tree/main/examples/todo-list) splits the state space so checking stays fast.
|
|
665
162
|
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
```typescript
|
|
669
|
-
// Background script must use createBackground()
|
|
670
|
-
import { createBackground } from '@fairfox/polly/background'
|
|
671
|
-
const bus = createBackground<YourMessages>()
|
|
672
|
-
|
|
673
|
-
// Other contexts use getMessageBus()
|
|
674
|
-
import { getMessageBus } from '@fairfox/polly/message-bus'
|
|
675
|
-
const bus = getMessageBus<YourMessages>('popup')
|
|
676
|
-
```
|
|
677
|
-
|
|
678
|
-
**Important:** The background script creates a `MessageRouter` automatically. This routes messages between all contexts. Always use `createBackground()` in background scripts to ensure proper setup.
|
|
679
|
-
|
|
680
|
-
## CLI Tools
|
|
681
|
-
|
|
682
|
-
Polly includes CLI tools for development:
|
|
163
|
+
## Quick start
|
|
683
164
|
|
|
684
165
|
```bash
|
|
685
|
-
|
|
686
|
-
polly build [--prod]
|
|
687
|
-
|
|
688
|
-
# Type checking
|
|
689
|
-
polly typecheck
|
|
690
|
-
|
|
691
|
-
# Linting
|
|
692
|
-
polly lint [--fix]
|
|
693
|
-
|
|
694
|
-
# Formatting
|
|
695
|
-
polly format
|
|
696
|
-
|
|
697
|
-
# Run all checks
|
|
698
|
-
polly check
|
|
699
|
-
|
|
700
|
-
# Generate architecture diagrams
|
|
701
|
-
polly visualize [--export] [--serve]
|
|
702
|
-
|
|
703
|
-
# Formal verification (if configured)
|
|
704
|
-
polly verify [--setup]
|
|
166
|
+
bun add @fairfox/polly preact @preact/signals
|
|
705
167
|
```
|
|
706
168
|
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
Polly can analyze your codebase and generate architecture diagrams:
|
|
169
|
+
Scaffold a project:
|
|
710
170
|
|
|
711
171
|
```bash
|
|
712
|
-
polly
|
|
172
|
+
polly init my-app --type=extension # or: pwa, websocket, generic
|
|
713
173
|
```
|
|
714
174
|
|
|
715
|
-
|
|
716
|
-
- Execution contexts (background, popup, etc.)
|
|
717
|
-
- Message flows between contexts
|
|
718
|
-
- External integrations (APIs, libraries)
|
|
719
|
-
- Chrome API usage
|
|
720
|
-
|
|
721
|
-
View the diagrams using Structurizr Lite:
|
|
175
|
+
Or start from one of the examples:
|
|
722
176
|
|
|
723
177
|
```bash
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
```
|
|
728
|
-
|
|
729
|
-
## Real-World Patterns
|
|
730
|
-
|
|
731
|
-
### API Client Pattern
|
|
732
|
-
|
|
733
|
-
```typescript
|
|
734
|
-
// src/background/api-client.ts
|
|
735
|
-
export class APIClient {
|
|
736
|
-
constructor(private baseURL: string) {}
|
|
737
|
-
|
|
738
|
-
async get<T>(path: string): Promise<T> {
|
|
739
|
-
const response = await fetch(`${this.baseURL}${path}`)
|
|
740
|
-
return response.json()
|
|
741
|
-
}
|
|
742
|
-
|
|
743
|
-
async post<T>(path: string, data: unknown): Promise<T> {
|
|
744
|
-
const response = await fetch(`${this.baseURL}${path}`, {
|
|
745
|
-
method: 'POST',
|
|
746
|
-
headers: { 'Content-Type': 'application/json' },
|
|
747
|
-
body: JSON.stringify(data)
|
|
748
|
-
})
|
|
749
|
-
return response.json()
|
|
750
|
-
}
|
|
751
|
-
}
|
|
752
|
-
|
|
753
|
-
// src/background/index.ts
|
|
754
|
-
const api = new APIClient('https://api.example.com')
|
|
755
|
-
|
|
756
|
-
bus.on('API_REQUEST', async (payload) => {
|
|
757
|
-
const data = await api.get(payload.endpoint)
|
|
758
|
-
return { success: true, data }
|
|
759
|
-
})
|
|
760
|
-
```
|
|
761
|
-
|
|
762
|
-
### Offline Support Pattern
|
|
763
|
-
|
|
764
|
-
```typescript
|
|
765
|
-
// Cache API responses
|
|
766
|
-
const cache = $sharedState<Record<string, unknown>>('cache', {})
|
|
767
|
-
|
|
768
|
-
bus.on('API_FETCH', async (payload) => {
|
|
769
|
-
// Check cache first
|
|
770
|
-
if (cache.value[payload.url]) {
|
|
771
|
-
return { success: true, data: cache.value[payload.url], cached: true }
|
|
772
|
-
}
|
|
773
|
-
|
|
774
|
-
try {
|
|
775
|
-
const response = await fetch(payload.url)
|
|
776
|
-
const data = await response.json()
|
|
777
|
-
|
|
778
|
-
// Update cache
|
|
779
|
-
cache.value = { ...cache.value, [payload.url]: data }
|
|
780
|
-
|
|
781
|
-
return { success: true, data, cached: false }
|
|
782
|
-
} catch (error) {
|
|
783
|
-
// Fallback to cache if offline
|
|
784
|
-
if (cache.value[payload.url]) {
|
|
785
|
-
return { success: true, data: cache.value[payload.url], cached: true }
|
|
786
|
-
}
|
|
787
|
-
return { success: false, error: error.message }
|
|
788
|
-
}
|
|
789
|
-
})
|
|
790
|
-
```
|
|
791
|
-
|
|
792
|
-
### Authentication Pattern
|
|
793
|
-
|
|
794
|
-
```typescript
|
|
795
|
-
// State
|
|
796
|
-
const authToken = $sharedState<string | null>('authToken', null)
|
|
797
|
-
const currentUser = $sharedState<User | null>('currentUser', null)
|
|
798
|
-
|
|
799
|
-
// Background
|
|
800
|
-
bus.on('AUTH_LOGIN', async (payload) => {
|
|
801
|
-
const response = await fetch('https://api.example.com/auth/login', {
|
|
802
|
-
method: 'POST',
|
|
803
|
-
body: JSON.stringify(payload)
|
|
804
|
-
})
|
|
805
|
-
const { token, user } = await response.json()
|
|
806
|
-
|
|
807
|
-
// Update state - syncs to all contexts
|
|
808
|
-
authToken.value = token
|
|
809
|
-
currentUser.value = user
|
|
810
|
-
|
|
811
|
-
return { success: true }
|
|
812
|
-
})
|
|
813
|
-
|
|
814
|
-
bus.on('AUTH_LOGOUT', async () => {
|
|
815
|
-
authToken.value = null
|
|
816
|
-
currentUser.value = null
|
|
817
|
-
return { success: true }
|
|
818
|
-
})
|
|
819
|
-
|
|
820
|
-
// UI
|
|
821
|
-
function LoginButton() {
|
|
822
|
-
const handleLogin = async () => {
|
|
823
|
-
await bus.send({
|
|
824
|
-
type: 'AUTH_LOGIN',
|
|
825
|
-
username: 'user',
|
|
826
|
-
password: 'pass'
|
|
827
|
-
})
|
|
828
|
-
}
|
|
829
|
-
|
|
830
|
-
return currentUser.value ? (
|
|
831
|
-
<div>Welcome, {currentUser.value.name}</div>
|
|
832
|
-
) : (
|
|
833
|
-
<button onClick={handleLogin}>Login</button>
|
|
834
|
-
)
|
|
835
|
-
}
|
|
178
|
+
git clone https://github.com/AlexJeffcott/polly.git
|
|
179
|
+
cd polly/examples/minimal
|
|
180
|
+
bun install && bun run dev
|
|
836
181
|
```
|
|
837
182
|
|
|
838
183
|
## Examples
|
|
839
184
|
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
185
|
+
| Example | What it demonstrates |
|
|
186
|
+
|---------|---------------------|
|
|
187
|
+
| [minimal](examples/minimal) | Counter with `$sharedState` — the simplest possible Polly app |
|
|
188
|
+
| [todo-list](examples/todo-list) | CRUD with `requires()`/`ensures()`, subsystem-scoped verification |
|
|
189
|
+
| [full-featured](examples/full-featured) | Production Chrome extension with all framework features |
|
|
190
|
+
| [elysia-todo-app](examples/elysia-todo-app) | Full-stack web app with Elysia + Bun, offline-first |
|
|
191
|
+
| [webrtc-p2p-chat](examples/webrtc-p2p-chat) | Peer-to-peer chat over WebRTC data channels |
|
|
192
|
+
| [team-task-manager](examples/team-task-manager) | Collaborative task management with end-to-end encryption |
|
|
848
193
|
|
|
849
|
-
|
|
194
|
+
## CLI
|
|
850
195
|
|
|
851
|
-
Polly excels at the "stores ARE the application" pattern — a headless core that can be rendered by any interface:
|
|
852
|
-
|
|
853
|
-
```typescript
|
|
854
|
-
// core/state.ts - Your application state (runs anywhere)
|
|
855
|
-
import { $state, $syncedState } from '@fairfox/polly/state'
|
|
856
|
-
|
|
857
|
-
export const todos = $syncedState('todos', [])
|
|
858
|
-
export const filter = $state<'all' | 'active' | 'completed'>('all')
|
|
859
|
-
|
|
860
|
-
// core/actions.ts - Your business logic (runs anywhere)
|
|
861
|
-
export const addTodo = (text: string) => {
|
|
862
|
-
todos.value = [...todos.value, { id: Date.now(), text, completed: false }]
|
|
863
|
-
}
|
|
864
|
-
|
|
865
|
-
export const toggleTodo = (id: number) => {
|
|
866
|
-
todos.value = todos.value.map(t =>
|
|
867
|
-
t.id === id ? { ...t, completed: !t.completed } : t
|
|
868
|
-
)
|
|
869
|
-
}
|
|
870
|
-
|
|
871
|
-
export const filteredTodos = () => {
|
|
872
|
-
switch (filter.value) {
|
|
873
|
-
case 'active': return todos.value.filter(t => !t.completed)
|
|
874
|
-
case 'completed': return todos.value.filter(t => t.completed)
|
|
875
|
-
default: return todos.value
|
|
876
|
-
}
|
|
877
|
-
}
|
|
878
|
-
```
|
|
879
|
-
|
|
880
|
-
```typescript
|
|
881
|
-
// renderers/cli.ts - CLI renderer (Bun/Node)
|
|
882
|
-
import { effect } from '@preact/signals'
|
|
883
|
-
import { todos, addTodo, toggleTodo, filteredTodos } from '../core'
|
|
884
|
-
|
|
885
|
-
// React to state changes
|
|
886
|
-
effect(() => {
|
|
887
|
-
console.clear()
|
|
888
|
-
console.log('=== Todo List ===')
|
|
889
|
-
filteredTodos().forEach((t, i) => {
|
|
890
|
-
console.log(`${i + 1}. [${t.completed ? 'x' : ' '}] ${t.text}`)
|
|
891
|
-
})
|
|
892
|
-
})
|
|
893
|
-
|
|
894
|
-
// CLI commands
|
|
895
|
-
process.stdin.on('data', (data) => {
|
|
896
|
-
const input = data.toString().trim()
|
|
897
|
-
if (input.startsWith('add ')) addTodo(input.slice(4))
|
|
898
|
-
if (input.startsWith('toggle ')) toggleTodo(Number(input.slice(7)))
|
|
899
|
-
})
|
|
900
|
-
```
|
|
901
|
-
|
|
902
|
-
```typescript
|
|
903
|
-
// renderers/web.tsx - Web renderer (Preact)
|
|
904
|
-
import { render } from 'preact'
|
|
905
|
-
import { todos, addTodo, toggleTodo, filteredTodos } from '../core'
|
|
906
|
-
|
|
907
|
-
function App() {
|
|
908
|
-
return (
|
|
909
|
-
<ul>
|
|
910
|
-
{filteredTodos().map(t => (
|
|
911
|
-
<li key={t.id} onClick={() => toggleTodo(t.id)}>
|
|
912
|
-
{t.completed ? '✓' : '○'} {t.text}
|
|
913
|
-
</li>
|
|
914
|
-
))}
|
|
915
|
-
</ul>
|
|
916
|
-
)
|
|
917
|
-
}
|
|
918
|
-
|
|
919
|
-
render(<App />, document.getElementById('root')!)
|
|
920
|
-
```
|
|
921
|
-
|
|
922
|
-
Same state, same logic, different renderers. The core is fully testable without any DOM or browser APIs.
|
|
923
|
-
|
|
924
|
-
## API Reference
|
|
925
|
-
|
|
926
|
-
### State
|
|
927
|
-
|
|
928
|
-
```typescript
|
|
929
|
-
import { $sharedState, $syncedState, $persistedState, $state } from '@fairfox/polly/state'
|
|
930
|
-
|
|
931
|
-
// Syncs + persists
|
|
932
|
-
const signal = $sharedState<T>(key: string, initialValue: T)
|
|
933
|
-
|
|
934
|
-
// Syncs, no persist
|
|
935
|
-
const signal = $syncedState<T>(key: string, initialValue: T)
|
|
936
|
-
|
|
937
|
-
// Persists, no sync
|
|
938
|
-
const signal = $persistedState<T>(key: string, initialValue: T)
|
|
939
|
-
|
|
940
|
-
// Local only
|
|
941
|
-
const signal = $state<T>(initialValue: T)
|
|
942
|
-
|
|
943
|
-
// All return Preact Signal<T>
|
|
944
|
-
signal.value // Get value
|
|
945
|
-
signal.value = 42 // Set value
|
|
946
|
-
```
|
|
947
|
-
|
|
948
|
-
### Message Bus
|
|
949
|
-
|
|
950
|
-
```typescript
|
|
951
|
-
import { getMessageBus } from '@fairfox/polly/message-bus'
|
|
952
|
-
import { createBackground } from '@fairfox/polly/background'
|
|
953
|
-
|
|
954
|
-
// In background script
|
|
955
|
-
const bus = createBackground<YourMessages>()
|
|
956
|
-
|
|
957
|
-
// In other contexts
|
|
958
|
-
const bus = getMessageBus<YourMessages>('popup')
|
|
959
|
-
|
|
960
|
-
// Send message
|
|
961
|
-
const response = await bus.send({ type: 'MY_MESSAGE', data: 'foo' })
|
|
962
|
-
|
|
963
|
-
// Broadcast to all contexts
|
|
964
|
-
bus.broadcast({ type: 'NOTIFICATION', text: 'Hi!' })
|
|
965
|
-
|
|
966
|
-
// Handle messages
|
|
967
|
-
bus.on('MY_MESSAGE', async (payload) => {
|
|
968
|
-
return { success: true }
|
|
969
|
-
})
|
|
970
196
|
```
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
// Combine with framework messages
|
|
983
|
-
type AllMessages = ExtensionMessage | CustomMessages
|
|
197
|
+
polly init [name] Scaffold a new project
|
|
198
|
+
polly build [--prod] Build for development or production
|
|
199
|
+
polly dev Build with watch mode
|
|
200
|
+
polly check Run all checks (typecheck, lint, test, build)
|
|
201
|
+
polly typecheck Type-check your code
|
|
202
|
+
polly lint [--fix] Lint (and optionally auto-fix)
|
|
203
|
+
polly format Format your code
|
|
204
|
+
polly test Run tests
|
|
205
|
+
polly verify Run formal verification
|
|
206
|
+
polly visualize Generate architecture diagrams (Structurizr DSL)
|
|
984
207
|
```
|
|
985
208
|
|
|
986
|
-
##
|
|
987
|
-
|
|
988
|
-
### State Synchronization
|
|
989
|
-
|
|
990
|
-
Polly uses **Lamport clocks** for distributed state consistency:
|
|
991
|
-
|
|
992
|
-
1. Each state update gets a logical timestamp
|
|
993
|
-
2. Updates are broadcast to all contexts
|
|
994
|
-
3. Contexts apply updates in causal order
|
|
995
|
-
4. Conflicts are resolved deterministically
|
|
996
|
-
|
|
997
|
-
This prevents race conditions when multiple contexts update state concurrently.
|
|
998
|
-
|
|
999
|
-
### Message Routing
|
|
1000
|
-
|
|
1001
|
-
The background context acts as a message hub:
|
|
1002
|
-
|
|
1003
|
-
1. Background starts a `MessageRouter`
|
|
1004
|
-
2. Other contexts connect via `chrome.runtime.Port`
|
|
1005
|
-
3. Messages are routed through the background
|
|
1006
|
-
4. Responses are returned to the sender
|
|
1007
|
-
|
|
1008
|
-
This enables request/response patterns and broadcast messaging.
|
|
1009
|
-
|
|
1010
|
-
### Reactivity
|
|
1011
|
-
|
|
1012
|
-
Built on [Preact Signals](https://preactjs.com/guide/v10/signals/):
|
|
1013
|
-
|
|
1014
|
-
- Automatic UI updates when state changes
|
|
1015
|
-
- Fine-grained reactivity (only affected components re-render)
|
|
1016
|
-
- Works with Preact, React, Vue, Solid, etc.
|
|
1017
|
-
|
|
1018
|
-
## Requirements
|
|
1019
|
-
|
|
1020
|
-
**For building:**
|
|
1021
|
-
- **Bun** 1.3+ or **Node** 18+
|
|
1022
|
-
- **TypeScript** 5.0+ (recommended)
|
|
1023
|
-
|
|
1024
|
-
**Runtime environments:**
|
|
1025
|
-
- **Browser**: Chrome 88+, Firefox 89+, Safari 15+, Edge 88+
|
|
1026
|
-
- **Chrome extensions**: Manifest V3
|
|
1027
|
-
- **Node.js**: 18+ (for native `fetch`)
|
|
1028
|
-
- **Bun**: 1.0+
|
|
1029
|
-
- **Deno**: 1.28+
|
|
1030
|
-
- **Edge workers**: Cloudflare Workers, Vercel Edge, etc.
|
|
1031
|
-
|
|
1032
|
-
## License
|
|
209
|
+
## Licence
|
|
1033
210
|
|
|
1034
|
-
MIT
|
|
211
|
+
MIT
|
|
1035
212
|
|
|
1036
213
|
---
|
|
1037
214
|
|
|
1038
|
-
|
|
215
|
+
[GitHub](https://github.com/AlexJeffcott/polly) · [Issues](https://github.com/AlexJeffcott/polly/issues) · [Examples](https://github.com/AlexJeffcott/polly/tree/main/examples)
|