crann 1.0.48 → 2.0.1

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 +357 -604
  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,496 @@ 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)
21
-
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.
38
-
39
- **1. Define the state in your Service Worker:**
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
40
33
 
41
34
  ```typescript
42
- // service-worker.ts
43
- import { create } from "crann";
44
-
45
- const crann = create({
46
- isBorderEnabled: { default: false }, // Single shared state item
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
+ },
47
54
  });
48
-
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.
52
55
  ```
53
56
 
54
- **2. Control the state from your Popup:**
57
+ ### 2. Initialize the Store (Service Worker)
55
58
 
56
59
  ```typescript
57
- // popup.ts
58
- import { connect } from "crann";
59
-
60
- const { set, get } = connect(); // Connect to the Crann hub
61
-
62
- const toggleButton = document.getElementById("toggleBorder");
60
+ // service-worker.ts
61
+ import { createStore } from "crann";
62
+ import { config } from "./config";
63
63
 
64
- // Set initial button state
65
- const currentState = get();
66
- toggleButton.textContent = currentState.isBorderEnabled
67
- ? "Disable Border"
68
- : "Enable Border";
64
+ const store = createStore(config);
69
65
 
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";
66
+ store.subscribe((state, changes) => {
67
+ console.log("State changed:", changes);
76
68
  });
77
69
  ```
78
70
 
79
- **3. React to the state in your Content Script:**
71
+ ### 3. Connect from Any Context
80
72
 
81
73
  ```typescript
82
- // content-script.ts
83
- import { connect } from "crann";
84
-
85
- const { subscribe } = connect(); // Connect to the Crann hub
86
-
87
- console.log("Content script connected to Crann.");
88
-
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
- : "";
103
- ```
104
-
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.
106
-
107
- ## Getting Started: Core Usage
108
-
109
- ### Step 1: Create the State Hub (Service Worker)
110
-
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:
112
-
113
- ```typescript
114
- // service-worker.ts
115
- import { create, Partition, Persistence } from "crann";
116
-
117
- const crann = create({
118
- // Basic state with default value
119
- active: { default: false },
120
-
121
- // State that's unique to each context
122
- name: {
123
- default: "",
124
- partition: Partition.Instance,
125
- },
126
-
127
- // State that persists between sessions
128
- timesUsed: {
129
- default: 0,
130
- persistence: Persistence.Local,
131
- },
132
-
133
- // State that resets when the browser closes
134
- sessionStart: {
135
- default: new Date(),
136
- persistence: Persistence.Session,
137
- },
138
- });
139
-
140
- // Get shared state (no instance state)
141
- const { active, timesUsed } = crann.get();
74
+ // popup.ts or content-script.ts
75
+ import { connectStore } from "crann";
76
+ import { config } from "./config";
142
77
 
143
- // Optionally: Get state for a specific instance (includes instance state)
144
- const { active, timesUsed, name } = crann.get("instanceKey");
78
+ const agent = connectStore(config);
145
79
 
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);
80
+ agent.onReady(() => {
81
+ console.log("Connected! Current state:", agent.getState());
82
+
83
+ // Update state
84
+ agent.setState({ isEnabled: true });
85
+
86
+ // Call actions
87
+ agent.actions.increment(5);
152
88
  });
153
89
  ```
154
90
 
155
- ### Step 2: Connect from Other Contexts
156
-
157
- Other parts of your extension connect to the state hub. They automatically get access to both shared and their own partitioned state:
91
+ ### 4. Use with React
158
92
 
159
93
  ```typescript
160
- // popup.ts or content-script.ts
161
- import { connect } from "crann";
94
+ // hooks.ts
95
+ import { createCrannHooks } from "crann/react";
96
+ import { config } from "./config";
162
97
 
163
- const { get, set, subscribe } = connect();
98
+ export const { useCrannState, useCrannActions, useCrannReady } = createCrannHooks(config);
164
99
 
165
- // Get all state (shared + this context's partition)
166
- const { active, name, timesUsed } = get();
100
+ // Counter.tsx
101
+ function Counter() {
102
+ const count = useCrannState(s => s.count);
103
+ const { increment } = useCrannActions();
104
+ const isReady = useCrannReady();
167
105
 
168
- // Set state
169
- set({ name: "My Context's Name" });
106
+ if (!isReady) return <div>Loading...</div>;
170
107
 
171
- // Subscribe to specific state changes
172
- subscribe(
173
- (changes) => {
174
- console.log("Times used changed:", changes.timesUsed);
175
- },
176
- ["timesUsed"]
177
- );
108
+ return (
109
+ <button onClick={() => increment(1)}>
110
+ Count: {count}
111
+ </button>
112
+ );
113
+ }
178
114
  ```
179
115
 
180
- ## Advanced Features
181
-
182
- ### Handling Complex Types
116
+ ## Configuration
183
117
 
184
- Sometimes the default value alone isn't enough for TypeScript to infer the full type. Use type assertions to specify the complete type:
118
+ The `createConfig` function defines your store schema:
185
119
 
186
120
  ```typescript
187
- import { create } from "crann";
188
-
189
- // Example 1: Custom object type with null default
190
- type CustomType = { name: string; age: number };
191
-
192
- // Example 2: Specific string literal union
193
- type ConnectionStatus = "idle" | "connecting" | "connected" | "error";
194
-
195
- const crann = create({
196
- person: {
197
- default: null as null | CustomType,
121
+ import { createConfig, Scope, Persist } from "crann";
122
+
123
+ const config = createConfig({
124
+ // Required: unique identifier for this store
125
+ name: "myStore",
126
+
127
+ // Optional: version number for migrations (default: 1)
128
+ version: 1,
129
+
130
+ // State definitions
131
+ count: { default: 0 },
132
+
133
+ // With persistence
134
+ theme: {
135
+ default: "light" as "light" | "dark",
136
+ persist: Persist.Local, // Persist.Local | Persist.Session | Persist.None
198
137
  },
199
- connectionStatus: {
200
- default: "idle" as ConnectionStatus,
138
+
139
+ // Agent-scoped state (each tab/frame gets its own copy)
140
+ selectedElement: {
141
+ default: null as HTMLElement | null,
142
+ scope: Scope.Agent, // Scope.Shared (default) | Scope.Agent
201
143
  },
202
- userStatus: {
203
- default: "active" as "active" | "inactive",
204
- persistence: Persistence.Local,
144
+
145
+ // Actions (RPC handlers)
146
+ actions: {
147
+ doSomething: {
148
+ handler: async (ctx, arg1: string, arg2: number) => {
149
+ // ctx.state - current state
150
+ // ctx.setState - update state
151
+ // ctx.agentId - calling agent's ID
152
+ return { result: "value" };
153
+ },
154
+ validate: (arg1, arg2) => {
155
+ if (!arg1) throw new Error("arg1 required");
156
+ },
157
+ },
205
158
  },
206
159
  });
207
-
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
160
  ```
213
161
 
214
- ### Understanding Partitioned State
162
+ ## Store API (Service Worker)
215
163
 
216
- Partitioned state (`Partition.Instance`) is useful when you want each context to have its own version of a state variable. For example:
217
-
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
221
-
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.
223
-
224
- ### State Persistence Options
225
-
226
- Crann offers two levels of persistence:
227
-
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
164
+ The Store runs in the service worker and manages all state:
230
165
 
231
166
  ```typescript
232
- const crann = create({
233
- // Will be remembered between browser sessions
234
- userPreferences: {
235
- default: { theme: "light" },
236
- persistence: Persistence.Local,
237
- },
167
+ import { createStore } from "crann";
238
168
 
239
- // Will reset when browser closes
240
- currentSession: {
241
- default: { startTime: new Date() },
242
- persistence: Persistence.Session,
243
- },
169
+ const store = createStore(config, {
170
+ debug: true, // Enable debug logging
244
171
  });
245
- ```
246
172
 
247
- Remember: Persisted state is always shared state (not partitioned).
173
+ // Get current state
174
+ const state = store.getState();
248
175
 
249
- ### Advanced API Functions
176
+ // Update state
177
+ await store.setState({ count: 5 });
250
178
 
251
- The `create` function returns an object with the following methods:
179
+ // Get agent-scoped state for a specific agent
180
+ const agentState = store.getAgentState(agentId);
252
181
 
253
- ```typescript
254
- const crann = create({
255
- // ... state config ...
182
+ // Subscribe to all state changes
183
+ const unsubscribe = store.subscribe((state, changes, agentInfo) => {
184
+ console.log("Changed:", changes);
256
185
  });
257
186
 
258
- // Get state
259
- const state = crann.get(); // Get all state
260
- const instanceState = crann.get("instanceKey"); // Get state for specific instance
261
-
262
- // Set state
263
- await crann.set({ key: "value" }); // Set service state
264
- await crann.set({ key: "value" }, "instanceKey"); // Set instance state
265
-
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
187
+ // Listen for agent connections
188
+ store.onAgentConnect((agent) => {
189
+ console.log(`Agent ${agent.id} connected from tab ${agent.tabId}`);
271
190
  });
272
191
 
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
192
+ store.onAgentDisconnect((agent) => {
193
+ console.log(`Agent ${agent.id} disconnected`);
277
194
  });
278
195
 
279
- // Find an instance by location
280
- const instanceId = crann.findInstance({
281
- context: "content-script",
282
- tabId: 123,
283
- frameId: 0,
284
- });
196
+ // Get all connected agents
197
+ const agents = store.getAgents();
198
+ const contentScripts = store.getAgents({ context: "contentscript" });
285
199
 
286
- // Query agents by location
287
- const agents = crann.queryAgents({
288
- context: "content-script",
289
- });
200
+ // Clear all state to defaults
201
+ await store.clear();
290
202
 
291
- // Clear all state
292
- await crann.clear();
203
+ // Destroy the store (cleanup)
204
+ store.destroy();
205
+ // Or clear persisted data on destroy:
206
+ store.destroy({ clearPersisted: true });
293
207
  ```
294
208
 
295
- ### Remote Procedure Calls (RPC Actions)
209
+ ## Agent API
296
210
 
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.
211
+ Agents connect to the store from content scripts, popups, and other contexts:
298
212
 
299
- #### Defining Actions in the Service Worker
213
+ ```typescript
214
+ import { connectStore } from "crann";
300
215
 
301
- Actions are defined in your config alongside regular state items. The key difference is that actions have a `handler` property:
216
+ const agent = connectStore(config, {
217
+ debug: true,
218
+ });
302
219
 
303
- ```typescript
304
- // service-worker.ts
305
- import { create } from "crann";
306
- import { BrowserLocation } from "porter-source";
220
+ // Wait for connection to be ready
221
+ agent.onReady(() => {
222
+ console.log("Connected!");
223
+ });
307
224
 
308
- const crann = create({
309
- // Regular state
310
- counter: {
311
- default: 0,
312
- persist: "local",
313
- },
225
+ // Or use the promise
226
+ await agent.ready();
314
227
 
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
- },
228
+ // Get current state
229
+ const state = agent.getState();
332
230
 
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
- }
350
- },
351
- },
231
+ // Update state
232
+ await agent.setState({ count: 10 });
352
233
 
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() };
361
- },
362
- },
234
+ // Subscribe to changes
235
+ const unsubscribe = agent.subscribe((changes, state) => {
236
+ console.log("State changed:", changes);
363
237
  });
364
- ```
365
238
 
366
- #### Understanding Action Handler Parameters
239
+ // Call actions (RPC)
240
+ const result = await agent.actions.doSomething("arg1", 42);
367
241
 
368
- Action handlers receive four parameters that are automatically provided by Crann:
242
+ // Get agent info
243
+ const info = agent.getInfo();
244
+ // { id, tabId, frameId, context }
369
245
 
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()`
246
+ // Handle disconnect/reconnect
247
+ agent.onDisconnect(() => console.log("Disconnected"));
248
+ agent.onReconnect(() => console.log("Reconnected"));
374
249
 
375
- ```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
- },
397
- }
250
+ // Clean up
251
+ agent.disconnect();
398
252
  ```
399
253
 
400
- #### Using Actions in Service Worker
254
+ ## React Integration
401
255
 
402
- Actions can be called from any context that connects to Crann:
256
+ Import from `crann/react` for React hooks:
403
257
 
404
258
  ```typescript
405
- // content-script.ts
406
- import { connect } from "crann";
259
+ import { createCrannHooks } from "crann/react";
407
260
  import { config } from "./config";
408
261
 
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
- });
262
+ // Create hooks bound to your config
263
+ export const {
264
+ useCrannState,
265
+ useCrannActions,
266
+ useCrannReady,
267
+ useAgent,
268
+ CrannProvider,
269
+ } = createCrannHooks(config);
445
270
  ```
446
271
 
447
- #### Using Actions in Popup/Options Pages
272
+ ### useCrannState
448
273
 
449
- The same pattern works in popup and options pages:
274
+ Two patterns for reading state:
450
275
 
451
276
  ```typescript
452
- // popup.ts
453
- import { connect } from "crann";
454
- import { config } from "./config";
277
+ // Selector pattern - returns selected value
278
+ const count = useCrannState(s => s.count);
279
+ const theme = useCrannState(s => s.settings.theme);
455
280
 
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
- });
484
- });
281
+ // Key pattern - returns [value, setValue] tuple
282
+ const [count, setCount] = useCrannState("count");
283
+ setCount(10); // Updates state
485
284
  ```
486
285
 
487
- #### Using Actions in React Components
286
+ ### useCrannActions
488
287
 
489
- Crann's React integration also supports RPC actions through the `useCrannState` hook:
288
+ Returns typed actions with stable references (won't cause re-renders):
490
289
 
491
- ```tsx
492
- // MyReactComponent.tsx
493
- import React, { useState } from "react";
494
- import { createCrannStateHook } from "crann";
495
- import { config } from "./config";
290
+ ```typescript
291
+ const { increment, fetchData } = useCrannActions();
496
292
 
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
- };
293
+ // Actions are async
294
+ await increment(5);
295
+ const result = await fetchData("https://api.example.com");
296
+ ```
537
297
 
538
- return (
539
- <div>
540
- <h2>Counter: {counter}</h2>
298
+ ### useCrannReady
541
299
 
542
- <button onClick={handleIncrement} disabled={isLoading}>
543
- {isLoading ? "Incrementing..." : "Increment Counter"}
544
- </button>
300
+ Check connection status:
545
301
 
546
- <button onClick={fetchCurrentTime} disabled={isLoading}>
547
- {isLoading ? "Fetching..." : "Get Current Time"}
548
- </button>
302
+ ```typescript
303
+ const isReady = useCrannReady();
549
304
 
550
- {currentTime && <p>Current time: {currentTime}</p>}
551
- {error && <p className="error">Error: {error}</p>}
552
- </div>
553
- );
305
+ if (!isReady) {
306
+ return <LoadingSpinner />;
554
307
  }
555
-
556
- export default CounterComponent;
557
308
  ```
558
309
 
559
- #### Key Benefits of RPC Actions
310
+ ### CrannProvider (Optional)
560
311
 
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
568
-
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.
312
+ For testing or dependency injection:
572
313
 
573
314
  ```typescript
574
- // In your React component
575
- import { useCrann } from "crann";
315
+ // In tests
316
+ const mockAgent = createMockAgent();
576
317
 
577
- function MyReactComponent() {
578
- // The hook returns the same interface as connect()
579
- const { get, set, subscribe } = useCrann();
318
+ render(
319
+ <CrannProvider agent={mockAgent}>
320
+ <MyComponent />
321
+ </CrannProvider>
322
+ );
323
+ ```
580
324
 
581
- // Get the current state
582
- const { isEnabled, count } = get();
325
+ ## RPC Actions
583
326
 
584
- // Set state (triggers re-render)
585
- const toggleEnabled = () => {
586
- set({ isEnabled: !isEnabled });
587
- };
327
+ Actions execute in the service worker but can be called from any context:
588
328
 
589
- // Subscribe to specific state changes
590
- subscribe(
591
- (changes) => {
592
- console.log("Count changed:", changes.count);
329
+ ```typescript
330
+ // In config
331
+ const config = createConfig({
332
+ name: "myStore",
333
+ count: { default: 0 },
334
+
335
+ actions: {
336
+ increment: {
337
+ handler: async (ctx, amount: number = 1) => {
338
+ const newCount = ctx.state.count + amount;
339
+ // Option 1: Return state updates
340
+ return { count: newCount };
341
+
342
+ // Option 2: Use ctx.setState
343
+ // await ctx.setState({ count: newCount });
344
+ // return { success: true };
345
+ },
593
346
  },
594
- ["count"]
595
- );
347
+
348
+ fetchUser: {
349
+ handler: async (ctx, userId: string) => {
350
+ // Runs in service worker - can make network requests
351
+ const response = await fetch(`/api/users/${userId}`);
352
+ const user = await response.json();
353
+ return { user };
354
+ },
355
+ validate: (userId) => {
356
+ if (!userId) throw new Error("userId required");
357
+ },
358
+ },
359
+ },
360
+ });
596
361
 
597
- return (
598
- <div>
599
- <button onClick={toggleEnabled}>
600
- {isEnabled ? "Disable" : "Enable"}
601
- </button>
602
- <p>Count: {count}</p>
603
- </div>
604
- );
605
- }
606
- ```
362
+ // From any context (popup, content script, etc.)
363
+ const agent = connectStore(config);
364
+ await agent.ready();
607
365
 
608
- The `useCrann` hook provides the same functionality as `connect()`, but with React-specific optimizations:
366
+ const result = await agent.actions.increment(5);
367
+ console.log(result.count); // 5
609
368
 
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
369
+ const { user } = await agent.actions.fetchUser("123");
370
+ console.log(user.name);
371
+ ```
613
372
 
614
- #### Using with TypeScript
373
+ ### ActionContext
615
374
 
616
- For better type safety, you can create a custom hook that includes your state types:
375
+ Action handlers receive a context object:
617
376
 
618
377
  ```typescript
619
- // types.ts
620
- interface MyState {
621
- isEnabled: boolean;
622
- count: number;
623
- user: {
624
- name: string;
625
- age: number;
626
- } | null;
378
+ interface ActionContext<TState> {
379
+ state: TState; // Current state snapshot
380
+ setState: (partial: Partial<TState>) => Promise<void>; // Update state
381
+ agentId: string; // Calling agent's ID
382
+ agentLocation: BrowserLocation; // Tab/frame info
627
383
  }
384
+ ```
628
385
 
629
- // hooks.ts
630
- import { useCrann } from "crann";
631
- import type { MyState } from "./types";
386
+ ## State Persistence
632
387
 
633
- export function useMyCrann() {
634
- return useCrann<MyState>();
635
- }
388
+ Control how state is persisted:
389
+
390
+ ```typescript
391
+ import { createConfig, Persist } from "crann";
392
+
393
+ const config = createConfig({
394
+ name: "myStore",
395
+
396
+ // No persistence (default) - resets on service worker restart
397
+ volatile: { default: null },
398
+
399
+ // Local storage - persists across browser sessions
400
+ preferences: {
401
+ default: { theme: "light" },
402
+ persist: Persist.Local,
403
+ },
404
+
405
+ // Session storage - persists until browser closes
406
+ sessionData: {
407
+ default: {},
408
+ persist: Persist.Session,
409
+ },
410
+ });
411
+ ```
636
412
 
637
- // MyComponent.tsx
638
- import { useMyCrann } from "./hooks";
413
+ ### Storage Keys
639
414
 
640
- function MyComponent() {
641
- const { get, set } = useMyCrann();
415
+ Crann uses structured storage keys: `crann:{name}:v{version}:{key}`
642
416
 
643
- // TypeScript now knows the shape of your state
644
- const { user } = get();
417
+ This prevents collisions and enables clean migrations.
645
418
 
646
- const updateUser = () => {
647
- set({
648
- user: {
649
- name: "Alice",
650
- age: 30,
651
- },
652
- });
653
- };
419
+ ## Migration from v1
654
420
 
655
- return (
656
- <div>
657
- {user && <p>Hello, {user.name}!</p>}
658
- <button onClick={updateUser}>Update User</button>
659
- </div>
660
- );
661
- }
662
- ```
421
+ ### Key Changes
422
+
423
+ | v1 | v2 |
424
+ |----|----|
425
+ | `create()` | `createStore()` |
426
+ | `connect()` | `connectStore()` |
427
+ | `Partition.Instance` | `Scope.Agent` |
428
+ | `Partition.Service` | `Scope.Shared` |
429
+ | `crann.set()` | `store.setState()` |
430
+ | `crann.get()` | `store.getState()` |
431
+ | `callAction("name", arg)` | `agent.actions.name(arg)` |
432
+ | Config object literal | `createConfig()` |
663
433
 
664
- #### Performance Considerations
434
+ ### Migration Steps
665
435
 
666
- The `useCrann` hook is optimized for React usage:
436
+ 1. **Update config to use `createConfig()`:**
667
437
 
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
438
+ ```typescript
439
+ // Before (v1)
440
+ const crann = create({
441
+ count: { default: 0 },
442
+ });
443
+
444
+ // After (v2)
445
+ const config = createConfig({
446
+ name: "myStore", // Required in v2
447
+ count: { default: 0 },
448
+ });
672
449
 
673
- For best performance:
450
+ const store = createStore(config);
451
+ ```
674
452
 
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
453
+ 2. **Update terminology:**
679
454
 
680
455
  ```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
- );
456
+ // Before (v1)
457
+ partition: Partition.Instance
697
458
 
698
- // Only subscribe to the keys we care about
699
- subscribe(
700
- (changes) => {
701
- console.log("Filter changed:", changes.filter);
702
- },
703
- ["filter"]
704
- );
459
+ // After (v2)
460
+ scope: Scope.Agent
461
+ ```
705
462
 
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
- }
463
+ 3. **Update React hooks:**
464
+
465
+ ```typescript
466
+ // Before (v1)
467
+ import { useCrann } from "crann";
468
+ const { get, set, callAction } = useCrann();
469
+
470
+ // After (v2)
471
+ import { createCrannHooks } from "crann/react";
472
+ const { useCrannState, useCrannActions } = createCrannHooks(config);
720
473
  ```
721
474
 
722
- ## What Was The Problem?
475
+ 4. **Update action calls:**
723
476
 
724
- Browser extensions often have multiple components:
477
+ ```typescript
478
+ // Before (v1)
479
+ await callAction("increment", 5);
725
480
 
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.
481
+ // After (v2)
482
+ await agent.actions.increment(5);
483
+ ```
730
484
 
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.
485
+ ## Why Crann?
732
486
 
733
- ## Why Is This Better: How Crann Simplifies Synchronization
487
+ Browser extensions have multiple isolated contexts that need to share state:
734
488
 
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.
489
+ - **Service Worker** - Background logic and events
490
+ - **Content Scripts** - Injected into web pages
491
+ - **Popup** - Extension icon click UI
492
+ - **Side Panels, DevTools** - Other specialized contexts
736
493
 
737
- **Visualizing the Problem: Manual Message Passing vs. Crann's Centralized State**
494
+ Traditionally, this requires complex `chrome.runtime.sendMessage` / `onMessage` patterns. Crann eliminates this boilerplate by providing a central state hub that all contexts can connect to.
738
495
 
739
- ![with_messages](img/with_messages.png)
740
- _Traditional message passing requires complex, bidirectional communication between all parts._
496
+ ![Traditional vs Crann Architecture](img/with_crann.png)
741
497
 
742
- ![with_crann](img/with_crann.png)
743
- _Crann's centralized state management simplifies the architecture by eliminating the need for manual message passing._
498
+ ---
744
499
 
745
- This dramatically simplifies your architecture:
500
+ **License:** ISC
746
501
 
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.
502
+ **Repository:** [github.com/moclei/crann](https://github.com/moclei/crann)