crann 1.0.49 → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +353 -594
  2. package/dist/cjs/index.js +2 -2
  3. package/dist/cjs/index.js.map +4 -4
  4. package/dist/cjs/react.js +2 -0
  5. package/dist/cjs/react.js.map +7 -0
  6. package/dist/esm/index.js +2 -2
  7. package/dist/esm/index.js.map +4 -4
  8. package/dist/esm/react.js +2 -0
  9. package/dist/esm/react.js.map +7 -0
  10. package/dist/types/__mocks__/uuid.d.ts +10 -0
  11. package/dist/types/__tests__/integration.test.d.ts +7 -0
  12. package/dist/types/agent/Agent.d.ts +77 -0
  13. package/dist/types/agent/__tests__/Agent.test.d.ts +1 -0
  14. package/dist/types/agent/__tests__/setup.d.ts +4 -0
  15. package/dist/types/agent/index.d.ts +7 -0
  16. package/dist/types/agent/types.d.ts +73 -0
  17. package/dist/types/crann.d.ts +1 -2
  18. package/dist/types/errors.d.ts +59 -0
  19. package/dist/types/model/crann.model.d.ts +1 -1
  20. package/dist/types/react/__tests__/hooks.test.d.ts +6 -0
  21. package/dist/types/react/hooks.d.ts +44 -0
  22. package/dist/types/react/index.d.ts +13 -0
  23. package/dist/types/react/types.d.ts +74 -0
  24. package/dist/types/rpc/adapter.d.ts +1 -1
  25. package/dist/types/rpc/types.d.ts +1 -1
  26. package/dist/types/store/ActionExecutor.d.ts +35 -0
  27. package/dist/types/store/AgentRegistry.d.ts +49 -0
  28. package/dist/types/store/Persistence.d.ts +59 -0
  29. package/dist/types/store/StateManager.d.ts +65 -0
  30. package/dist/types/store/Store.d.ts +188 -0
  31. package/dist/types/store/__tests__/ActionExecutor.test.d.ts +1 -0
  32. package/dist/types/store/__tests__/AgentRegistry.test.d.ts +1 -0
  33. package/dist/types/store/__tests__/Persistence.test.d.ts +6 -0
  34. package/dist/types/store/__tests__/StateManager.test.d.ts +1 -0
  35. package/dist/types/store/__tests__/setup.d.ts +4 -0
  36. package/dist/types/store/__tests__/types.test.d.ts +1 -0
  37. package/dist/types/store/index.d.ts +10 -0
  38. package/dist/types/store/types.d.ts +169 -0
  39. package/dist/types/transport/core/PorterAgent.d.ts +46 -0
  40. package/dist/types/transport/core/PorterSource.d.ts +40 -0
  41. package/dist/types/transport/index.d.ts +6 -0
  42. package/dist/types/transport/managers/AgentConnectionManager.d.ts +42 -0
  43. package/dist/types/transport/managers/AgentManager.d.ts +40 -0
  44. package/dist/types/transport/managers/AgentMessageHandler.d.ts +17 -0
  45. package/dist/types/transport/managers/ConnectionManager.d.ts +14 -0
  46. package/dist/types/transport/managers/MessageHandler.d.ts +29 -0
  47. package/dist/types/transport/managers/MessageQueue.d.ts +19 -0
  48. package/dist/types/transport/porter.model.d.ts +71 -0
  49. package/dist/types/transport/porter.utils.d.ts +44 -0
  50. package/dist/types/transport/react/index.d.ts +1 -0
  51. package/dist/types/transport/react/usePorter.d.ts +17 -0
  52. package/dist/types/utils/agent.d.ts +1 -1
  53. package/package.json +28 -2
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- Crann: Effortless State Synchronization for Web Extensions
1
+ # Crann: Effortless State Synchronization for Web Extensions
2
2
 
3
3
  ![crann_logo](img/crann_logo_smaller.png)
4
4
 
@@ -7,743 +7,502 @@ Crann: Effortless State Synchronization for Web Extensions
7
7
  ## Table of Contents
8
8
 
9
9
  - [Core Features](#core-features)
10
- - [Quick Start](#quick-start-a-simple-synchronization-example)
11
- - [Core Usage](#getting-started-core-usage)
12
- - [Advanced Features](#advanced-features)
13
- - [Complex Types](#handling-complex-types)
14
- - [Partitioned State](#understanding-partitioned-state)
15
- - [Persistence](#state-persistence-options)
16
- - [Advanced API](#advanced-api-functions)
17
- - [Remote Procedure Calls (RPC Actions)](#remote-procedure-calls-rpc-actions)
18
- - [React Integration](#react-integration)
19
- - [What Was The Problem?](#what-was-the-problem)
20
- - [Why Is This Better?](#why-is-this-better-how-crann-simplifies-synchronization)
10
+ - [Quick Start](#quick-start)
11
+ - [Configuration](#configuration)
12
+ - [Store API (Service Worker)](#store-api-service-worker)
13
+ - [Agent API (Content Scripts, Popup, etc.)](#agent-api)
14
+ - [React Integration](#react-integration)
15
+ - [RPC Actions](#rpc-actions)
16
+ - [State Persistence](#state-persistence)
17
+ - [Migration from v1](#migration-from-v1)
18
+
19
+ ## Core Features
20
+
21
+ - **Minimal size** (< 5kb gzipped)
22
+ - **Multi-context sync** - Content Scripts, Service Worker, Devtools, Sidepanels, Popup
23
+ - **No message boilerplate** - Eliminates `chrome.runtime.sendMessage` / `onMessage`
24
+ - **Reactive updates** - Subscribe to state changes
25
+ - **Persistence** - Optional local or session storage
26
+ - **Full TypeScript** - Complete type inference from config
27
+ - **React hooks** - First-class React integration via `crann/react`
28
+ - **RPC Actions** - Execute logic in the service worker from any context
29
+
30
+ ## Quick Start
31
+
32
+ ### 1. Define Your Config
21
33
 
22
- ## State Synchronization for Web Extensions
23
-
24
- Crann synchronizes state across all parts of your Web Extension with full TypeScript support, eliminating the need for complex manual message passing. Focus on your extension's features, not the plumbing.
25
-
26
- **Core Features:**
27
-
28
- - Minimal size (< 5kb)
29
- - Syncs state between any context (Content Scripts, Service Worker, Devtools, Sidepanels, Popup, etc.)
30
- - Eliminates manual `chrome.runtime.sendMessage` / `onMessage` boilerplate
31
- - Reactive state updates via subscriptions (`subscribe`)
32
- - Optional state persistence (`Persistence.Local` / `Persistence.Session`)
33
- - Strong TypeScript inference and support for type safety
34
-
35
- ### Quick Start: A Simple Synchronization Example
36
-
37
- Let's see how easy it is. Imagine we want a toggle in the popup to control whether a border is applied to the current web page by a content script.
34
+ ```typescript
35
+ // config.ts
36
+ import { createConfig, Persist } from "crann";
37
+
38
+ export const config = createConfig({
39
+ name: "myExtension", // Required: unique store name
40
+ version: 1, // Optional: for migrations
41
+
42
+ // Define your state
43
+ isEnabled: { default: false },
44
+ count: { default: 0, persist: Persist.Local },
45
+
46
+ // Define actions (RPC)
47
+ actions: {
48
+ increment: {
49
+ handler: async (ctx, amount: number = 1) => {
50
+ return { count: ctx.state.count + amount };
51
+ },
52
+ },
53
+ },
54
+ });
55
+ ```
38
56
 
39
- **1. Define the state in your Service Worker:**
57
+ ### 2. Initialize the Store (Service Worker)
40
58
 
41
59
  ```typescript
42
60
  // service-worker.ts
43
- import { create } from "crann";
61
+ import { createStore } from "crann";
62
+ import { config } from "./config";
44
63
 
45
- const crann = create({
46
- isBorderEnabled: { default: false }, // Single shared state item
47
- });
64
+ const store = createStore(config);
48
65
 
49
- console.log("Crann hub initialized.");
50
- // Keep the service worker alive if needed (e.g., using chrome.runtime.connect)
51
- // Crann itself doesn't automatically keep the SW alive.
66
+ store.subscribe((state, changes) => {
67
+ console.log("State changed:", changes);
68
+ });
52
69
  ```
53
70
 
54
- **2. Control the state from your Popup:**
71
+ ### 3. Connect from Any Context
55
72
 
56
73
  ```typescript
57
- // popup.ts
58
- import { connect } from "crann";
74
+ // popup.ts or content-script.ts
75
+ import { connectStore } from "crann";
76
+ import { config } from "./config";
59
77
 
60
- const { set, get } = connect(); // Connect to the Crann hub
78
+ const agent = connectStore(config);
61
79
 
62
- const toggleButton = document.getElementById("toggleBorder");
80
+ agent.onReady(() => {
81
+ console.log("Connected! Current state:", agent.getState());
63
82
 
64
- // Set initial button state
65
- const currentState = get();
66
- toggleButton.textContent = currentState.isBorderEnabled
67
- ? "Disable Border"
68
- : "Enable Border";
83
+ // Update state
84
+ agent.setState({ isEnabled: true });
69
85
 
70
- // Add click listener to update state
71
- toggleButton.addEventListener("click", () => {
72
- const newState = !get().isBorderEnabled; // Get current state before setting
73
- set({ isBorderEnabled: newState });
74
- // Update button text immediately (or subscribe to changes)
75
- toggleButton.textContent = newState ? "Disable Border" : "Enable Border";
86
+ // Call actions
87
+ agent.actions.increment(5);
76
88
  });
77
89
  ```
78
90
 
79
- **3. React to the state in your Content Script:**
91
+ ### 4. Use with React
80
92
 
81
93
  ```typescript
82
- // content-script.ts
83
- import { connect } from "crann";
94
+ // hooks.ts
95
+ import { createCrannHooks } from "crann/react";
96
+ import { config } from "./config";
84
97
 
85
- const { subscribe } = connect(); // Connect to the Crann hub
98
+ export const { useCrannState, useCrannActions, useCrannReady } =
99
+ createCrannHooks(config);
86
100
 
87
- console.log("Content script connected to Crann.");
101
+ // Counter.tsx
102
+ function Counter() {
103
+ const count = useCrannState((s) => s.count);
104
+ const { increment } = useCrannActions();
105
+ const isReady = useCrannReady();
88
106
 
89
- // Subscribe to changes in 'isBorderEnabled'
90
- subscribe(
91
- (state) => {
92
- console.log("Border state changed:", state.isBorderEnabled);
93
- document.body.style.border = state.isBorderEnabled ? "5px solid green" : "";
94
- },
95
- ["isBorderEnabled"]
96
- ); // Optional: Only trigger for specific key changes
97
-
98
- // Apply initial state
99
- const initialState = connect().get(); // Can call connect() again or store result
100
- document.body.style.border = initialState.isBorderEnabled
101
- ? "5px solid green"
102
- : "";
107
+ if (!isReady) return <div>Loading...</div>;
108
+
109
+ return <button onClick={() => increment(1)}>Count: {count}</button>;
110
+ }
103
111
  ```
104
112
 
105
- **Notice:** We achieved synchronization between the popup and content script _without writing any `chrome.runtime.sendMessage` or `chrome.runtime.onMessage` code!_ Crann handled the communication behind the scenes.
113
+ ## Configuration
106
114
 
107
- ## Getting Started: Core Usage
115
+ The `createConfig` function defines your store schema:
108
116
 
109
- ### Step 1: Create the State Hub (Service Worker)
117
+ ```typescript
118
+ import { createConfig, Scope, Persist } from "crann";
110
119
 
111
- The service worker is where you initialize your shared state. Here's a more detailed example showing how to define different types of state:
120
+ const config = createConfig({
121
+ // Required: unique identifier for this store
122
+ name: "myStore",
112
123
 
113
- ```typescript
114
- // service-worker.ts
115
- import { create, Partition, Persistence } from "crann";
124
+ // Optional: version number for migrations (default: 1)
125
+ version: 1,
116
126
 
117
- const crann = create({
118
- // Basic state with default value
119
- active: { default: false },
127
+ // State definitions
128
+ count: { default: 0 },
120
129
 
121
- // State that's unique to each context
122
- name: {
123
- default: "",
124
- partition: Partition.Instance,
130
+ // With persistence
131
+ theme: {
132
+ default: "light" as "light" | "dark",
133
+ persist: Persist.Local, // Persist.Local | Persist.Session | Persist.None
125
134
  },
126
135
 
127
- // State that persists between sessions
128
- timesUsed: {
129
- default: 0,
130
- persistence: Persistence.Local,
136
+ // Agent-scoped state (each tab/frame gets its own copy)
137
+ selectedElement: {
138
+ default: null as HTMLElement | null,
139
+ scope: Scope.Agent, // Scope.Shared (default) | Scope.Agent
131
140
  },
132
141
 
133
- // State that resets when the browser closes
134
- sessionStart: {
135
- default: new Date(),
136
- persistence: Persistence.Session,
142
+ // Actions (RPC handlers)
143
+ actions: {
144
+ doSomething: {
145
+ handler: async (ctx, arg1: string, arg2: number) => {
146
+ // ctx.state - current state
147
+ // ctx.setState - update state
148
+ // ctx.agentId - calling agent's ID
149
+ return { result: "value" };
150
+ },
151
+ validate: (arg1, arg2) => {
152
+ if (!arg1) throw new Error("arg1 required");
153
+ },
154
+ },
137
155
  },
138
156
  });
157
+ ```
139
158
 
140
- // Get shared state (no instance state)
141
- const { active, timesUsed } = crann.get();
159
+ ## Store API (Service Worker)
142
160
 
143
- // Optionally: Get state for a specific instance (includes instance state)
144
- const { active, timesUsed, name } = crann.get("instanceKey");
161
+ The Store runs in the service worker and manages all state:
145
162
 
146
- // Subscribe to state changes
147
- crann.subscribe((state, changes, key) => {
148
- // state contains all state (shared + relevant partition)
149
- // changes contains only the keys that changed
150
- // key identifies which context made the change (null if from service worker)
151
- console.log("State changed:", changes);
163
+ ```typescript
164
+ import { createStore } from "crann";
165
+
166
+ const store = createStore(config, {
167
+ debug: true, // Enable debug logging
152
168
  });
153
- ```
154
169
 
155
- ### Step 2: Connect from Other Contexts
170
+ // Get current state
171
+ const state = store.getState();
156
172
 
157
- Other parts of your extension connect to the state hub. They automatically get access to both shared and their own partitioned state:
173
+ // Update state
174
+ await store.setState({ count: 5 });
158
175
 
159
- ```typescript
160
- // popup.ts or content-script.ts
161
- import { connect } from "crann";
176
+ // Get agent-scoped state for a specific agent
177
+ const agentState = store.getAgentState(agentId);
162
178
 
163
- const { get, set, subscribe } = connect();
179
+ // Subscribe to all state changes
180
+ const unsubscribe = store.subscribe((state, changes, agentInfo) => {
181
+ console.log("Changed:", changes);
182
+ });
164
183
 
165
- // Get all state (shared + this context's partition)
166
- const { active, name, timesUsed } = get();
184
+ // Listen for agent connections
185
+ store.onAgentConnect((agent) => {
186
+ console.log(`Agent ${agent.id} connected from tab ${agent.tabId}`);
187
+ });
167
188
 
168
- // Set state
169
- set({ name: "My Context's Name" });
189
+ store.onAgentDisconnect((agent) => {
190
+ console.log(`Agent ${agent.id} disconnected`);
191
+ });
170
192
 
171
- // Subscribe to specific state changes
172
- subscribe(
173
- (changes) => {
174
- console.log("Times used changed:", changes.timesUsed);
175
- },
176
- ["timesUsed"]
177
- );
178
- ```
193
+ // Get all connected agents
194
+ const agents = store.getAgents();
195
+ const contentScripts = store.getAgents({ context: "contentscript" });
179
196
 
180
- ## Advanced Features
197
+ // Clear all state to defaults
198
+ await store.clear();
181
199
 
182
- ### Handling Complex Types
200
+ // Destroy the store (cleanup)
201
+ store.destroy();
202
+ // Or clear persisted data on destroy:
203
+ store.destroy({ clearPersisted: true });
204
+ ```
183
205
 
184
- Sometimes the default value alone isn't enough for TypeScript to infer the full type. Use type assertions to specify the complete type:
206
+ ## Agent API
185
207
 
186
- ```typescript
187
- import { create } from "crann";
208
+ Agents connect to the store from content scripts, popups, and other contexts:
188
209
 
189
- // Example 1: Custom object type with null default
190
- type CustomType = { name: string; age: number };
210
+ ```typescript
211
+ import { connectStore } from "crann";
191
212
 
192
- // Example 2: Specific string literal union
193
- type ConnectionStatus = "idle" | "connecting" | "connected" | "error";
213
+ const agent = connectStore(config, {
214
+ debug: true,
215
+ });
194
216
 
195
- const crann = create({
196
- person: {
197
- default: null as null | CustomType,
198
- },
199
- connectionStatus: {
200
- default: "idle" as ConnectionStatus,
201
- },
202
- userStatus: {
203
- default: "active" as "active" | "inactive",
204
- persistence: Persistence.Local,
205
- },
217
+ // Wait for connection to be ready
218
+ agent.onReady(() => {
219
+ console.log("Connected!");
206
220
  });
207
221
 
208
- // Now TypeScript understands the full potential types
209
- const state = crann.get();
210
- // state.person could be null or { name: string; age: number }
211
- // state.connectionStatus could be 'idle', 'connecting', 'connected', or 'error'
212
- ```
222
+ // Or use the promise
223
+ await agent.ready();
213
224
 
214
- ### Understanding Partitioned State
225
+ // Get current state
226
+ const state = agent.getState();
215
227
 
216
- Partitioned state (`Partition.Instance`) is useful when you want each context to have its own version of a state variable. For example:
228
+ // Update state
229
+ await agent.setState({ count: 10 });
217
230
 
218
- - Each content script might need its own `selectedElement` state
219
- - Each popup might need its own `isOpen` state
220
- - Each devtools panel might need its own `activeTab` state
231
+ // Subscribe to changes
232
+ const unsubscribe = agent.subscribe((changes, state) => {
233
+ console.log("State changed:", changes);
234
+ });
235
+
236
+ // Call actions (RPC)
237
+ const result = await agent.actions.doSomething("arg1", 42);
221
238
 
222
- The service worker can access any context's partitioned state using `get('instanceKey')`, but typically you'll let each context manage its own partitioned state.
239
+ // Get agent info
240
+ const info = agent.getInfo();
241
+ // { id, tabId, frameId, context }
223
242
 
224
- ### State Persistence Options
243
+ // Handle disconnect/reconnect
244
+ agent.onDisconnect(() => console.log("Disconnected"));
245
+ agent.onReconnect(() => console.log("Reconnected"));
246
+
247
+ // Clean up
248
+ agent.disconnect();
249
+ ```
225
250
 
226
- Crann offers two levels of persistence:
251
+ ## React Integration
227
252
 
228
- - **Session Storage** (`Persistence.Session`): State persists between page refreshes but resets when the browser closes
229
- - **Local Storage** (`Persistence.Local`): State persists long-term until explicitly cleared
253
+ Import from `crann/react` for React hooks:
230
254
 
231
255
  ```typescript
232
- const crann = create({
233
- // Will be remembered between browser sessions
234
- userPreferences: {
235
- default: { theme: "light" },
236
- persistence: Persistence.Local,
237
- },
256
+ import { createCrannHooks } from "crann/react";
257
+ import { config } from "./config";
238
258
 
239
- // Will reset when browser closes
240
- currentSession: {
241
- default: { startTime: new Date() },
242
- persistence: Persistence.Session,
243
- },
244
- });
259
+ // Create hooks bound to your config
260
+ export const {
261
+ useCrannState,
262
+ useCrannActions,
263
+ useCrannReady,
264
+ useAgent,
265
+ CrannProvider,
266
+ } = createCrannHooks(config);
245
267
  ```
246
268
 
247
- Remember: Persisted state is always shared state (not partitioned).
269
+ ### useCrannState
248
270
 
249
- ### Advanced API Functions
250
-
251
- The `create` function returns an object with the following methods:
271
+ Two patterns for reading state:
252
272
 
253
273
  ```typescript
254
- const crann = create({
255
- // ... state config ...
256
- });
274
+ // Selector pattern - returns selected value
275
+ const count = useCrannState((s) => s.count);
276
+ const theme = useCrannState((s) => s.settings.theme);
257
277
 
258
- // Get state
259
- const state = crann.get(); // Get all state
260
- const instanceState = crann.get("instanceKey"); // Get state for specific instance
278
+ // Key pattern - returns [value, setValue] tuple
279
+ const [count, setCount] = useCrannState("count");
280
+ setCount(10); // Updates state
281
+ ```
261
282
 
262
- // Set state
263
- await crann.set({ key: "value" }); // Set service state
264
- await crann.set({ key: "value" }, "instanceKey"); // Set instance state
283
+ ### useCrannActions
265
284
 
266
- // Subscribe to state changes
267
- crann.subscribe((state, changes, agent) => {
268
- // state: The complete state
269
- // changes: Only the changed values
270
- // agent: Info about which context made the change
271
- });
285
+ Returns typed actions with stable references (won't cause re-renders):
272
286
 
273
- // Subscribe to instance ready events
274
- const unsubscribe = crann.onInstanceReady((instanceId, agent) => {
275
- // Called when a new instance connects
276
- // Returned function can be called to unsubscribe
277
- });
287
+ ```typescript
288
+ const { increment, fetchData } = useCrannActions();
278
289
 
279
- // Find an instance by location
280
- const instanceId = crann.findInstance({
281
- context: "content-script",
282
- tabId: 123,
283
- frameId: 0,
284
- });
290
+ // Actions are async
291
+ await increment(5);
292
+ const result = await fetchData("https://api.example.com");
293
+ ```
285
294
 
286
- // Query agents by location
287
- const agents = crann.queryAgents({
288
- context: "content-script",
289
- });
295
+ ### useCrannReady
290
296
 
291
- // Clear all state
292
- await crann.clear();
293
- ```
297
+ Check connection status:
294
298
 
295
- ### Remote Procedure Calls (RPC Actions)
299
+ ```typescript
300
+ const isReady = useCrannReady();
296
301
 
297
- Crann supports RPC-style actions that execute in the service worker context while being callable from any extension context. This is perfect for operations that need to run in the service worker, like making network requests or accessing extension APIs.
302
+ if (!isReady) {
303
+ return <LoadingSpinner />;
304
+ }
305
+ ```
298
306
 
299
- #### Defining Actions in the Service Worker
307
+ ### CrannProvider (Optional)
300
308
 
301
- Actions are defined in your config alongside regular state items. The key difference is that actions have a `handler` property:
309
+ For testing or dependency injection:
302
310
 
303
311
  ```typescript
304
- // service-worker.ts
305
- import { create } from "crann";
306
- import { BrowserLocation } from "porter-source";
312
+ // In tests
313
+ const mockAgent = createMockAgent();
307
314
 
308
- const crann = create({
309
- // Regular state
310
- counter: {
311
- default: 0,
312
- persist: "local",
313
- },
315
+ render(
316
+ <CrannProvider agent={mockAgent}>
317
+ <MyComponent />
318
+ </CrannProvider>
319
+ );
320
+ ```
314
321
 
315
- // RPC action
316
- increment: {
317
- handler: async (
318
- state: any,
319
- setState: (newState: Partial<any>) => Promise<void>,
320
- target: BrowserLocation,
321
- amount: number
322
- ) => {
323
- // This runs in the service worker
324
- const newCounter = state.counter + amount;
325
- await setState({ counter: newCounter });
326
- return { counter: newCounter };
327
- },
328
- validate: (amount: number) => {
329
- if (amount < 0) throw new Error("Amount must be positive");
330
- },
331
- },
322
+ ## RPC Actions
332
323
 
333
- // Another action example
334
- fetchData: {
335
- handler: async (
336
- state: any,
337
- setState: (newState: Partial<any>) => Promise<void>,
338
- target: BrowserLocation,
339
- url: string
340
- ) => {
341
- // This runs in the service worker where we can make network requests
342
- const response = await fetch(url);
343
- const data = await response.json();
344
- return { data };
345
- },
346
- validate: (url: string) => {
347
- if (!url.startsWith("https://")) {
348
- throw new Error("URL must be HTTPS");
349
- }
324
+ Actions execute in the service worker but can be called from any context:
325
+
326
+ ```typescript
327
+ // In config
328
+ const config = createConfig({
329
+ name: "myStore",
330
+ count: { default: 0 },
331
+
332
+ actions: {
333
+ increment: {
334
+ handler: async (ctx, amount: number = 1) => {
335
+ const newCount = ctx.state.count + amount;
336
+ // Option 1: Return state updates
337
+ return { count: newCount };
338
+
339
+ // Option 2: Use ctx.setState
340
+ // await ctx.setState({ count: newCount });
341
+ // return { success: true };
342
+ },
350
343
  },
351
- },
352
344
 
353
- // Action that returns the current time
354
- getCurrentTime: {
355
- handler: async (
356
- state: any,
357
- setState: (newState: Partial<any>) => Promise<void>,
358
- target: BrowserLocation
359
- ) => {
360
- return { time: new Date().toISOString() };
345
+ fetchUser: {
346
+ handler: async (ctx, userId: string) => {
347
+ // Runs in service worker - can make network requests
348
+ const response = await fetch(`/api/users/${userId}`);
349
+ const user = await response.json();
350
+ return { user };
351
+ },
352
+ validate: (userId) => {
353
+ if (!userId) throw new Error("userId required");
354
+ },
361
355
  },
362
356
  },
363
357
  });
364
- ```
365
358
 
366
- #### Understanding Action Handler Parameters
359
+ // From any context (popup, content script, etc.)
360
+ const agent = connectStore(config);
361
+ await agent.ready();
362
+
363
+ const result = await agent.actions.increment(5);
364
+ console.log(result.count); // 5
365
+
366
+ const { user } = await agent.actions.fetchUser("123");
367
+ console.log(user.name);
368
+ ```
367
369
 
368
- Action handlers receive four parameters that are automatically provided by Crann:
370
+ ### ActionContext
369
371
 
370
- 1. **`state`**: The current state object containing all shared and service state
371
- 2. **`setState`**: A function to update the state from within the action. Use this to persist changes made by your action
372
- 3. **`target`**: A `BrowserLocation` object that identifies which context called the action
373
- 4. **`...args`**: The arguments passed to the action when called via `callAction()`
372
+ Action handlers receive a context object:
374
373
 
375
374
  ```typescript
376
- // Example showing how to use each parameter
377
- incrementWithLogging: {
378
- handler: async (
379
- state: any,
380
- setState: (newState: Partial<any>) => Promise<void>,
381
- target: BrowserLocation,
382
- amount: number
383
- ) => {
384
- // Read from state
385
- const currentCount = state.counter;
386
-
387
- // Log which context called this action
388
- console.log(`Increment called from ${target.context} with amount ${amount}`);
389
-
390
- // Update state
391
- const newCount = currentCount + amount;
392
- await setState({ counter: newCount });
393
-
394
- // Return result (optional)
395
- return { counter: newCount, previousValue: currentCount };
396
- },
375
+ interface ActionContext<TState> {
376
+ state: TState; // Current state snapshot
377
+ setState: (partial: Partial<TState>) => Promise<void>; // Update state
378
+ agentId: string; // Calling agent's ID
379
+ agentLocation: BrowserLocation; // Tab/frame info
397
380
  }
398
381
  ```
399
382
 
400
- #### Using Actions in Service Worker
383
+ ## State Persistence
401
384
 
402
- Actions can be called from any context that connects to Crann:
385
+ Control how state is persisted:
403
386
 
404
387
  ```typescript
405
- // content-script.ts
406
- import { connect } from "crann";
407
- import { config } from "./config";
408
-
409
- const { get, subscribe, onReady, callAction } = connect(config);
410
-
411
- // Wait for connection
412
- onReady((status) => {
413
- if (status.connected) {
414
- console.log("Connected to Crann");
415
-
416
- // Use the increment action
417
- document
418
- .getElementById("incrementButton")
419
- .addEventListener("click", async () => {
420
- try {
421
- const result = await callAction("increment", 1);
422
- console.log("Counter incremented to:", result);
423
- // Counter is updated in state automatically
424
- } catch (error) {
425
- console.error("Failed to increment:", error.message);
426
- }
427
- });
428
-
429
- // Use the fetchData action
430
- document
431
- .getElementById("fetchButton")
432
- .addEventListener("click", async () => {
433
- try {
434
- const result = await callAction(
435
- "fetchData",
436
- "https://api.example.com/data"
437
- );
438
- console.log("Fetched data:", result.data);
439
- } catch (error) {
440
- console.error("Failed to fetch data:", error.message);
441
- }
442
- });
443
- }
444
- });
445
- ```
388
+ import { createConfig, Persist } from "crann";
446
389
 
447
- #### Using Actions in Popup/Options Pages
390
+ const config = createConfig({
391
+ name: "myStore",
448
392
 
449
- The same pattern works in popup and options pages:
393
+ // No persistence (default) - resets on service worker restart
394
+ volatile: { default: null },
450
395
 
451
- ```typescript
452
- // popup.ts
453
- import { connect } from "crann";
454
- import { config } from "./config";
396
+ // Local storage - persists across browser sessions
397
+ preferences: {
398
+ default: { theme: "light" },
399
+ persist: Persist.Local,
400
+ },
455
401
 
456
- const { get, callAction } = connect(config);
457
-
458
- document.addEventListener("DOMContentLoaded", () => {
459
- // Display the current counter
460
- const counterElement = document.getElementById("counter");
461
- counterElement.textContent = get().counter.toString();
462
-
463
- // Add click handler for the increment button
464
- document
465
- .getElementById("incrementButton")
466
- .addEventListener("click", async () => {
467
- try {
468
- const result = await callAction("increment", 1);
469
- counterElement.textContent = result.counter.toString();
470
- } catch (error) {
471
- console.error("Failed to increment:", error.message);
472
- }
473
- });
474
-
475
- // Get and display the current time
476
- document.getElementById("timeButton").addEventListener("click", async () => {
477
- try {
478
- const result = await callAction("getCurrentTime");
479
- document.getElementById("currentTime").textContent = result.time;
480
- } catch (error) {
481
- console.error("Failed to get time:", error.message);
482
- }
483
- });
402
+ // Session storage - persists until browser closes
403
+ sessionData: {
404
+ default: {},
405
+ persist: Persist.Session,
406
+ },
484
407
  });
485
408
  ```
486
409
 
487
- #### Using Actions in React Components
410
+ ### Storage Keys
488
411
 
489
- Crann's React integration also supports RPC actions through the `useCrannState` hook:
412
+ Crann uses structured storage keys: `crann:{name}:v{version}:{key}`
490
413
 
491
- ```tsx
492
- // MyReactComponent.tsx
493
- import React, { useState } from "react";
494
- import { createCrannStateHook } from "crann";
495
- import { config } from "./config";
414
+ This prevents collisions and enables clean migrations.
496
415
 
497
- // Create a custom hook for your config
498
- const useCrannState = createCrannStateHook(config);
499
-
500
- function CounterComponent() {
501
- const { useStateItem, callAction } = useCrannState();
502
- const [counter, setCounter] = useStateItem("counter");
503
- const [isLoading, setIsLoading] = useState(false);
504
- const [error, setError] = useState<string | null>(null);
505
- const [currentTime, setCurrentTime] = useState<string | null>(null);
506
-
507
- const handleIncrement = async () => {
508
- setIsLoading(true);
509
- setError(null);
510
-
511
- try {
512
- // Call the increment action defined in the service worker
513
- const result = await callAction("increment", 1);
514
- // Note: The state will be automatically updated through the subscription,
515
- // but you can also use the result directly if needed
516
- console.log("Counter incremented to:", result.counter);
517
- } catch (err) {
518
- setError(err.message);
519
- } finally {
520
- setIsLoading(false);
521
- }
522
- };
523
-
524
- const fetchCurrentTime = async () => {
525
- setIsLoading(true);
526
- setError(null);
527
-
528
- try {
529
- const result = await callAction("getCurrentTime");
530
- setCurrentTime(result.time);
531
- } catch (err) {
532
- setError(err.message);
533
- } finally {
534
- setIsLoading(false);
535
- }
536
- };
537
-
538
- return (
539
- <div>
540
- <h2>Counter: {counter}</h2>
541
-
542
- <button onClick={handleIncrement} disabled={isLoading}>
543
- {isLoading ? "Incrementing..." : "Increment Counter"}
544
- </button>
545
-
546
- <button onClick={fetchCurrentTime} disabled={isLoading}>
547
- {isLoading ? "Fetching..." : "Get Current Time"}
548
- </button>
549
-
550
- {currentTime && <p>Current time: {currentTime}</p>}
551
- {error && <p className="error">Error: {error}</p>}
552
- </div>
553
- );
554
- }
416
+ ## Migration from v1
555
417
 
556
- export default CounterComponent;
557
- ```
418
+ ### Key Changes
558
419
 
559
- #### Key Benefits of RPC Actions
420
+ | v1 | v2 |
421
+ | ------------------------- | ------------------------- |
422
+ | `create()` | `createStore()` |
423
+ | `connect()` | `connectStore()` |
424
+ | `Partition.Instance` | `Scope.Agent` |
425
+ | `Partition.Service` | `Scope.Shared` |
426
+ | `crann.set()` | `store.setState()` |
427
+ | `crann.get()` | `store.getState()` |
428
+ | `callAction("name", arg)` | `agent.actions.name(arg)` |
429
+ | Config object literal | `createConfig()` |
560
430
 
561
- 1. **Type Safety**: Full TypeScript support for action parameters and return values
562
- 2. **Validation**: Optional validation of action parameters before execution
563
- 3. **Service Worker Context**: Actions run in the service worker where they have access to all extension APIs
564
- 4. **Automatic State Updates**: Actions can return state updates that are automatically synchronized
565
- 5. **Error Handling**: Proper error propagation from service worker to calling context
566
- 6. **Unified API**: Same pattern works across all contexts (content scripts, popups, React components)
567
- 7. **Simplified Architecture**: Centralize complex operations in the service worker
431
+ ### Migration Steps
568
432
 
569
- ### React Integration
570
-
571
- Crann provides a custom React hook for easy integration with React applications. This is particularly useful when you have a React app running in an iframe injected by your content script.
433
+ 1. **Update config to use `createConfig()`:**
572
434
 
573
435
  ```typescript
574
- // In your React component
575
- import { useCrann } from "crann";
576
-
577
- function MyReactComponent() {
578
- // The hook returns the same interface as connect()
579
- const { get, set, subscribe } = useCrann();
580
-
581
- // Get the current state
582
- const { isEnabled, count } = get();
436
+ // Before (v1)
437
+ const crann = create({
438
+ count: { default: 0 },
439
+ });
583
440
 
584
- // Set state (triggers re-render)
585
- const toggleEnabled = () => {
586
- set({ isEnabled: !isEnabled });
587
- };
441
+ // After (v2)
442
+ const config = createConfig({
443
+ name: "myStore", // Required in v2
444
+ count: { default: 0 },
445
+ });
588
446
 
589
- // Subscribe to specific state changes
590
- subscribe(
591
- (changes) => {
592
- console.log("Count changed:", changes.count);
593
- },
594
- ["count"]
595
- );
596
-
597
- return (
598
- <div>
599
- <button onClick={toggleEnabled}>
600
- {isEnabled ? "Disable" : "Enable"}
601
- </button>
602
- <p>Count: {count}</p>
603
- </div>
604
- );
605
- }
447
+ const store = createStore(config);
606
448
  ```
607
449
 
608
- The `useCrann` hook provides the same functionality as `connect()`, but with React-specific optimizations:
450
+ 2. **Update terminology:**
609
451
 
610
- - Automatically re-renders components when subscribed state changes
611
- - Handles cleanup of subscriptions when components unmount
612
- - Provides TypeScript support for your state types
452
+ ```typescript
453
+ // Before (v1)
454
+ partition: Partition.Instance;
613
455
 
614
- #### Using with TypeScript
456
+ // After (v2)
457
+ scope: Scope.Agent;
458
+ ```
615
459
 
616
- For better type safety, you can create a custom hook that includes your state types:
460
+ 3. **Update React hooks:**
617
461
 
618
462
  ```typescript
619
- // types.ts
620
- interface MyState {
621
- isEnabled: boolean;
622
- count: number;
623
- user: {
624
- name: string;
625
- age: number;
626
- } | null;
627
- }
628
-
629
- // hooks.ts
463
+ // Before (v1)
630
464
  import { useCrann } from "crann";
631
- import type { MyState } from "./types";
632
-
633
- export function useMyCrann() {
634
- return useCrann<MyState>();
635
- }
636
-
637
- // MyComponent.tsx
638
- import { useMyCrann } from "./hooks";
465
+ const { get, set, callAction } = useCrann();
639
466
 
640
- function MyComponent() {
641
- const { get, set } = useMyCrann();
642
-
643
- // TypeScript now knows the shape of your state
644
- const { user } = get();
645
-
646
- const updateUser = () => {
647
- set({
648
- user: {
649
- name: "Alice",
650
- age: 30,
651
- },
652
- });
653
- };
654
-
655
- return (
656
- <div>
657
- {user && <p>Hello, {user.name}!</p>}
658
- <button onClick={updateUser}>Update User</button>
659
- </div>
660
- );
661
- }
467
+ // After (v2)
468
+ import { createCrannHooks } from "crann/react";
469
+ const { useCrannState, useCrannActions } = createCrannHooks(config);
662
470
  ```
663
471
 
664
- #### Performance Considerations
665
-
666
- The `useCrann` hook is optimized for React usage:
667
-
668
- - Only re-renders when subscribed state actually changes
669
- - Batches multiple state updates to minimize re-renders
670
- - Automatically cleans up subscriptions on unmount
671
- - Supports selective subscription to specific state keys
672
-
673
- For best performance:
674
-
675
- 1. Subscribe only to the state keys your component needs
676
- 2. Use the second parameter of `subscribe` to specify which keys to listen for
677
- 3. Consider using `useMemo` for derived state
678
- 4. Use `useCallback` for event handlers that update state
472
+ 4. **Update action calls:**
679
473
 
680
474
  ```typescript
681
- function OptimizedComponent() {
682
- const { get, set, subscribe } = useCrann();
683
- const { items, filter } = get();
684
-
685
- // Only re-render when items or filter changes
686
- const filteredItems = useMemo(() => {
687
- return items.filter((item) => item.includes(filter));
688
- }, [items, filter]);
689
-
690
- // Memoize the handler
691
- const handleFilterChange = useCallback(
692
- (newFilter: string) => {
693
- set({ filter: newFilter });
694
- },
695
- [set]
696
- );
475
+ // Before (v1)
476
+ await callAction("increment", 5);
697
477
 
698
- // Only subscribe to the keys we care about
699
- subscribe(
700
- (changes) => {
701
- console.log("Filter changed:", changes.filter);
702
- },
703
- ["filter"]
704
- );
705
-
706
- return (
707
- <div>
708
- <input
709
- value={filter}
710
- onChange={(e) => handleFilterChange(e.target.value)}
711
- />
712
- <ul>
713
- {filteredItems.map((item) => (
714
- <li key={item}>{item}</li>
715
- ))}
716
- </ul>
717
- </div>
718
- );
719
- }
478
+ // After (v2)
479
+ await agent.actions.increment(5);
720
480
  ```
721
481
 
722
- ## What Was The Problem?
723
-
724
- Browser extensions often have multiple components:
482
+ ## Why Crann?
725
483
 
726
- - **Service Worker:** A background script handling core logic and events.
727
- - **Content Scripts:** JavaScript injected directly into web pages.
728
- - **Popup:** A small window shown when clicking the extension icon.
729
- - **Side Panels, DevTools Pages:** Other specialized UI or inspection contexts.
484
+ Browser extensions have multiple isolated contexts (content scripts, popup, devtools, sidepanel) that need to share state. The traditional approach using `sendMessage`/`onMessage` forces a painful pattern:
730
485
 
731
- These components run in isolated environments. Sharing data or coordinating actions between them traditionally requires manually sending messages back and forth using APIs like `chrome.runtime.sendMessage` and setting up listeners (`chrome.runtime.onMessage`). This can quickly lead to complex, hard-to-debug "spaghetti code" as your extension grows.
486
+ [![Message Router Problem](https://mermaid.ink/img/pako:eNp9k2tvmzAUhv-KZSlikyjCEBLgwyJK2FapbSJwVbVhihi4BDXYyJi2WZT_PodcmtvmT4f3PK998DlewpRlBLqw01kWtBAuWCpiRkqiuEDJEv6qrFadTkxf5uw9nSVcgNswpkCuuvmd86SaAS8nVNSTGLYB8BkV5EPUMfy1AdfLj9CkTUggSnlRCYCO0sZp2jhIj0eTMaua6kAa4smQvAnG5vVWJTSL6Ulp0aMsKyL8rUgJeGT8lfCjsrKCk1QUjAJ8_amGowcchNJ5R-o6yQkIWSNOnJ_RwWE32P8pbfV7IdLZl7LONbGoyNcj47-PbS8CSX-a1AQoPwI8jbCHA-XM7xt7KvoPZe6pMLj1nqZ4NMXe9QWwuwe_B_IXpkMPexcwa489REE49Xx8M7q_wPUkp2naeeJ-t8FgMDjKtr07aaIcGXB19W3bjZ1mnGnj0Zk0xGfSJm7lTZ-gCkvCy6TI5PAv11AM28GPoSvD9ejHMKYrySWNYNGCptAVvCEq5KzJZ9B9Sea1_GqqLBFkWCRyCsq9WiX0mbFyZ5Gf0F3CD-gamqMjByHLNsyurfdtU4UL6CK9r3Wdfh_pes80bKuPVir80-6ga47joC6ykWEaXcswbRXmfF339iwub41wnzVUQLdnWSokWSEYv9u87PaBr_4CI3YYvA?type=png)](https://mermaid.live/edit#pako:eNp9k2tvmzAUhv-KZSlikyjCEBLgwyJK2FapbSJwVbVhihi4BDXYyJi2WZT_PodcmtvmT4f3PK998DlewpRlBLqw01kWtBAuWCpiRkqiuEDJEv6qrFadTkxf5uw9nSVcgNswpkCuuvmd86SaAS8nVNSTGLYB8BkV5EPUMfy1AdfLj9CkTUggSnlRCYCO0sZp2jhIj0eTMaua6kAa4smQvAnG5vVWJTSL6Ulp0aMsKyL8rUgJeGT8lfCjsrKCk1QUjAJ8_amGowcchNJ5R-o6yQkIWSNOnJ_RwWE32P8pbfV7IdLZl7LONbGoyNcj47-PbS8CSX-a1AQoPwI8jbCHA-XM7xt7KvoPZe6pMLj1nqZ4NMXe9QWwuwe_B_IXpkMPexcwa489REE49Xx8M7q_wPUkp2naeeJ-t8FgMDjKtr07aaIcGXB19W3bjZ1mnGnj0Zk0xGfSJm7lTZ-gCkvCy6TI5PAv11AM28GPoSvD9ejHMKYrySWNYNGCptAVvCEq5KzJZ9B9Sea1_GqqLBFkWCRyCsq9WiX0mbFyZ5Gf0F3CD-gamqMjByHLNsyurfdtU4UL6CK9r3Wdfh_pes80bKuPVir80-6ga47joC6ykWEaXcswbRXmfF339iwub41wnzVUQLdnWSokWSEYv9u87PaBr_4CI3YYvA)
732
487
 
733
- ## Why Is This Better: How Crann Simplifies Synchronization
488
+ **The problem with `sendMessage` / `onMessage`:**
734
489
 
735
- Crann acts as a central state management hub, typically initialized in your service worker. It provides a single source of truth for your shared data. Other contexts connect to this hub, allowing them to easily read state, update it, and subscribe to changes.
490
+ - Agents can't message each other directly—everything routes through the service worker
491
+ - Your service worker becomes a message router with growing `switch/case` statements
492
+ - Every new feature means more message types, more handlers, more coupling
493
+ - Manual async handling (`return true` in Chrome, different in Firefox)
494
+ - Hand-rolled TypeScript types that may or may not stay in sync
736
495
 
737
- **Visualizing the Problem: Manual Message Passing vs. Crann's Centralized State**
496
+ **With Crann:**
738
497
 
739
- ![with_messages](img/with_messages.png)
740
- _Traditional message passing requires complex, bidirectional communication between all parts._
498
+ - Define your state and actions in one place
499
+ - Agents sync automatically through the central store
500
+ - Full TypeScript inference—no manual type definitions
501
+ - No message routing, no relay logic, no `return true`
502
+ - Focus on your features, not the plumbing
741
503
 
742
- ![with_crann](img/with_crann.png)
743
- _Crann's centralized state management simplifies the architecture by eliminating the need for manual message passing._
504
+ ---
744
505
 
745
- This dramatically simplifies your architecture:
506
+ **License:** ISC
746
507
 
747
- - **No more manual messaging:** Crann handles the communication internally.
748
- - **Single source of truth:** State is managed centrally.
749
- - **Reactivity:** Components automatically react to state changes they care about.
508
+ **Repository:** [github.com/moclei/crann](https://github.com/moclei/crann)