@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 CHANGED
@@ -1,1038 +1,215 @@
1
1
  # @fairfox/polly
2
2
 
3
- **Runtime-agnostic framework for reactive state and cross-context messaging.**
3
+ Reactive state for multi-context apps, with formal verification.
4
4
 
5
- Build applications that run anywhere — Chrome extensions, PWAs, CLI tools, server processes, or edge workers — with automatic state synchronization, reactive updates, and type-safe messaging. The core has zero browser dependencies; all platform APIs are abstracted behind adapters.
5
+ ## The pitch
6
6
 
7
- ## Why Polly?
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, $syncedState, $state } from '@fairfox/polly/state'
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
- // Local only (component state)
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 '@fairfox/polly/background'
173
- import type { AppMessages } from '../shared/messages'
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
- return { success: true, data }
191
- } catch (error) {
192
- return { success: false, error: error.message }
193
- }
194
- })
21
+ const bus = createBackground();
195
22
 
196
- // Handle user updates
197
- bus.on('API_UPDATE_USER', async (payload) => {
198
- try {
199
- const response = await fetch(`${API_URL}/users/${payload.userId}`, {
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/ui/App.tsx
231
- import { render } from 'preact'
232
- import { getMessageBus } from '@fairfox/polly/message-bus'
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 className={`app theme-${settings.value.theme}`}>
264
- <h1>User Profile</h1>
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('root')!)
43
+ render(<App />, document.getElementById("root")!);
297
44
  ```
298
45
 
299
- **Key insight:** The UI automatically re-renders when `currentUser` or `settings` change, even if those changes come from the service worker or another tab!
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
- ```bash
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
- # Watch mode
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
- Here's how to get the most out of Polly:
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
- #### 1. Start with State Design
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
- // Good state design
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
- #### 2. Define Messages as a Contract
340
-
341
- Your message types are the contract between contexts. Define them explicitly:
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
- Think of messages like API endpoints - they define the interface between your service worker and UI.
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
- bus.on('SOME_ACTION', async (payload) => {
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
- #### 4. Keep UI Simple
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
- Your UI just:
375
- - Displays state
376
- - Sends messages
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
- #### 5. Test with Real Browser APIs
89
+ ## Verification that plugs in
396
90
 
397
- Polly works with real Chrome/browser APIs, so you can test without mocks:
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
- ```typescript
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
- Polly provides first-class support for building full-stack web applications with Elysia and Bun, treating your SPA as a distributed system.
95
+ ### Step 1: Add contracts to handlers
417
96
 
418
- **Why?** Modern SPAs are distributed systems facing classic distributed computing problems: network unreliability, eventual consistency, offline behavior, cache invalidation, and the CAP theorem. The Elysia integration makes these concerns explicit and verifiable.
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
- // server/index.ts
424
- import { Elysia, t } from 'elysia'
425
- import { polly } from '@fairfox/polly/elysia'
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
- // Define client-side effects (what happens after server operations)
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
- // Define authorization rules
471
- authorization: {
472
- 'POST /todos': ({ state }) => state.client.user.value !== null,
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
- // Configure offline behavior
478
- offline: {
479
- 'POST /todos': {
480
- queue: true, // Queue when offline
481
- optimistic: (body) => ({
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
- // Enable TLA+ generation for verification
490
- tlaGeneration: true,
491
- }))
116
+ ensures(todos.value.length > 0, "Todos must not be empty after add");
492
117
 
493
- // Write normal Elysia routes (no Polly annotations!)
494
- .post('/todos', async ({ body, pollyState }) => {
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
- #### Client: Use Eden with Polly Wrapper
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
- // Create type-safe API client (types inferred from server!)
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
- // client/components/TodoList.tsx
527
- import { useSignal } from '@preact/signals'
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
- return (
544
- <div>
545
- {/* Connection status */}
546
- <div>Status: {api.$polly.state.isOnline.value ? '🟢 Online' : '🔴 Offline'}</div>
547
-
548
- {/* Queued requests indicator */}
549
- {api.$polly.state.queuedRequests.value.length > 0 && (
550
- <div>{api.$polly.state.queuedRequests.value.length} requests queued</div>
551
- )}
552
-
553
- {/* Todo list (automatically updates from state) */}
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
- #### Key Benefits
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
- #### Broadcast Pattern
148
+ Generating TLA+ specification...
149
+ Running TLC model checker...
646
150
 
647
- ```typescript
648
- // Send to all contexts
649
- bus.broadcast({ type: 'NOTIFICATION', message: 'Hello everyone!' })
151
+ Model checking complete.
152
+ States explored: 1,247
153
+ Distinct states: 312
154
+ No errors found.
650
155
 
651
- // All contexts receive it
652
- bus.on('NOTIFICATION', (payload) => {
653
- showToast(payload.message)
654
- })
156
+ All properties verified.
655
157
  ```
656
158
 
657
- #### Fire and Forget
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
- ### Chrome Extension Specific
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
- If you're building a Chrome extension:
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
- # Build your application
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
- ## Architecture Visualization
708
-
709
- Polly can analyze your codebase and generate architecture diagrams:
169
+ Scaffold a project:
710
170
 
711
171
  ```bash
712
- polly visualize
172
+ polly init my-app --type=extension # or: pwa, websocket, generic
713
173
  ```
714
174
 
715
- This creates a Structurizr DSL file documenting:
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
- docker run -it --rm -p 8080:8080 \
725
- -v $(pwd)/docs:/usr/local/structurizr \
726
- structurizr/lite
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
- Check out the [examples](https://github.com/AlexJeffcott/polly/tree/main/examples) directory:
841
-
842
- - **minimal** - Dead simple counter (best starting point)
843
- - **todo-list** - CRUD app with formal verification and `requires()`/`ensures()`
844
- - **full-featured** - Complete Chrome extension with all features
845
- - **elysia-todo-app** - Full-stack web app with Elysia + Bun
846
- - **webrtc-p2p-chat** - Peer-to-peer chat with WebRTC data channels
847
- - **team-task-manager** - Collaborative task management with role constraints
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
- ### Headless Core Pattern (CLI, Server, etc.)
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
- ### Types
973
-
974
- ```typescript
975
- import type { ExtensionMessage } from '@fairfox/polly/types'
976
-
977
- // Define custom messages
978
- type CustomMessages =
979
- | { type: 'ACTION_ONE'; data: string }
980
- | { type: 'ACTION_TWO'; id: number }
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
- ## How It Works
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 © 2024
211
+ MIT
1035
212
 
1036
213
  ---
1037
214
 
1038
- **[Examples](https://github.com/AlexJeffcott/polly/tree/main/examples)** · **[GitHub](https://github.com/AlexJeffcott/polly)** · **[Issues](https://github.com/AlexJeffcott/polly/issues)**
215
+ [GitHub](https://github.com/AlexJeffcott/polly) &middot; [Issues](https://github.com/AlexJeffcott/polly/issues) &middot; [Examples](https://github.com/AlexJeffcott/polly/tree/main/examples)