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.
- package/README.md +357 -604
- package/dist/cjs/index.js +2 -2
- package/dist/cjs/index.js.map +4 -4
- package/dist/cjs/react.js +2 -0
- package/dist/cjs/react.js.map +7 -0
- package/dist/esm/index.js +2 -2
- package/dist/esm/index.js.map +4 -4
- package/dist/esm/react.js +2 -0
- package/dist/esm/react.js.map +7 -0
- package/dist/types/__mocks__/uuid.d.ts +10 -0
- package/dist/types/__tests__/integration.test.d.ts +7 -0
- package/dist/types/agent/Agent.d.ts +77 -0
- package/dist/types/agent/__tests__/Agent.test.d.ts +1 -0
- package/dist/types/agent/__tests__/setup.d.ts +4 -0
- package/dist/types/agent/index.d.ts +7 -0
- package/dist/types/agent/types.d.ts +73 -0
- package/dist/types/crann.d.ts +1 -2
- package/dist/types/errors.d.ts +59 -0
- package/dist/types/model/crann.model.d.ts +1 -1
- package/dist/types/react/__tests__/hooks.test.d.ts +6 -0
- package/dist/types/react/hooks.d.ts +44 -0
- package/dist/types/react/index.d.ts +13 -0
- package/dist/types/react/types.d.ts +74 -0
- package/dist/types/rpc/adapter.d.ts +1 -1
- package/dist/types/rpc/types.d.ts +1 -1
- package/dist/types/store/ActionExecutor.d.ts +35 -0
- package/dist/types/store/AgentRegistry.d.ts +49 -0
- package/dist/types/store/Persistence.d.ts +59 -0
- package/dist/types/store/StateManager.d.ts +65 -0
- package/dist/types/store/Store.d.ts +188 -0
- package/dist/types/store/__tests__/ActionExecutor.test.d.ts +1 -0
- package/dist/types/store/__tests__/AgentRegistry.test.d.ts +1 -0
- package/dist/types/store/__tests__/Persistence.test.d.ts +6 -0
- package/dist/types/store/__tests__/StateManager.test.d.ts +1 -0
- package/dist/types/store/__tests__/setup.d.ts +4 -0
- package/dist/types/store/__tests__/types.test.d.ts +1 -0
- package/dist/types/store/index.d.ts +10 -0
- package/dist/types/store/types.d.ts +169 -0
- package/dist/types/transport/core/PorterAgent.d.ts +46 -0
- package/dist/types/transport/core/PorterSource.d.ts +40 -0
- package/dist/types/transport/index.d.ts +6 -0
- package/dist/types/transport/managers/AgentConnectionManager.d.ts +42 -0
- package/dist/types/transport/managers/AgentManager.d.ts +40 -0
- package/dist/types/transport/managers/AgentMessageHandler.d.ts +17 -0
- package/dist/types/transport/managers/ConnectionManager.d.ts +14 -0
- package/dist/types/transport/managers/MessageHandler.d.ts +29 -0
- package/dist/types/transport/managers/MessageQueue.d.ts +19 -0
- package/dist/types/transport/porter.model.d.ts +71 -0
- package/dist/types/transport/porter.utils.d.ts +44 -0
- package/dist/types/transport/react/index.d.ts +1 -0
- package/dist/types/transport/react/usePorter.d.ts +17 -0
- package/dist/types/utils/agent.d.ts +1 -1
- 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
|

|
|
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
|
|
11
|
-
- [
|
|
12
|
-
- [
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
**
|
|
27
|
-
|
|
28
|
-
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
//
|
|
43
|
-
import {
|
|
44
|
-
|
|
45
|
-
const
|
|
46
|
-
|
|
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
|
-
|
|
57
|
+
### 2. Initialize the Store (Service Worker)
|
|
55
58
|
|
|
56
59
|
```typescript
|
|
57
|
-
//
|
|
58
|
-
import {
|
|
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
|
-
|
|
65
|
-
const currentState = get();
|
|
66
|
-
toggleButton.textContent = currentState.isBorderEnabled
|
|
67
|
-
? "Disable Border"
|
|
68
|
-
: "Enable Border";
|
|
64
|
+
const store = createStore(config);
|
|
69
65
|
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
71
|
+
### 3. Connect from Any Context
|
|
80
72
|
|
|
81
73
|
```typescript
|
|
82
|
-
// content-script.ts
|
|
83
|
-
import {
|
|
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
|
-
|
|
144
|
-
const { active, timesUsed, name } = crann.get("instanceKey");
|
|
78
|
+
const agent = connectStore(config);
|
|
145
79
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
//
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
###
|
|
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
|
-
//
|
|
161
|
-
import {
|
|
94
|
+
// hooks.ts
|
|
95
|
+
import { createCrannHooks } from "crann/react";
|
|
96
|
+
import { config } from "./config";
|
|
162
97
|
|
|
163
|
-
const {
|
|
98
|
+
export const { useCrannState, useCrannActions, useCrannReady } = createCrannHooks(config);
|
|
164
99
|
|
|
165
|
-
//
|
|
166
|
-
|
|
100
|
+
// Counter.tsx
|
|
101
|
+
function Counter() {
|
|
102
|
+
const count = useCrannState(s => s.count);
|
|
103
|
+
const { increment } = useCrannActions();
|
|
104
|
+
const isReady = useCrannReady();
|
|
167
105
|
|
|
168
|
-
|
|
169
|
-
set({ name: "My Context's Name" });
|
|
106
|
+
if (!isReady) return <div>Loading...</div>;
|
|
170
107
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
);
|
|
108
|
+
return (
|
|
109
|
+
<button onClick={() => increment(1)}>
|
|
110
|
+
Count: {count}
|
|
111
|
+
</button>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
178
114
|
```
|
|
179
115
|
|
|
180
|
-
##
|
|
181
|
-
|
|
182
|
-
### Handling Complex Types
|
|
116
|
+
## Configuration
|
|
183
117
|
|
|
184
|
-
|
|
118
|
+
The `createConfig` function defines your store schema:
|
|
185
119
|
|
|
186
120
|
```typescript
|
|
187
|
-
import {
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
200
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
162
|
+
## Store API (Service Worker)
|
|
215
163
|
|
|
216
|
-
|
|
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
|
-
|
|
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
|
-
|
|
240
|
-
|
|
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
|
-
|
|
173
|
+
// Get current state
|
|
174
|
+
const state = store.getState();
|
|
248
175
|
|
|
249
|
-
|
|
176
|
+
// Update state
|
|
177
|
+
await store.setState({ count: 5 });
|
|
250
178
|
|
|
251
|
-
|
|
179
|
+
// Get agent-scoped state for a specific agent
|
|
180
|
+
const agentState = store.getAgentState(agentId);
|
|
252
181
|
|
|
253
|
-
|
|
254
|
-
const
|
|
255
|
-
|
|
182
|
+
// Subscribe to all state changes
|
|
183
|
+
const unsubscribe = store.subscribe((state, changes, agentInfo) => {
|
|
184
|
+
console.log("Changed:", changes);
|
|
256
185
|
});
|
|
257
186
|
|
|
258
|
-
//
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
274
|
-
|
|
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
|
-
//
|
|
280
|
-
const
|
|
281
|
-
|
|
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
|
-
//
|
|
287
|
-
|
|
288
|
-
context: "content-script",
|
|
289
|
-
});
|
|
200
|
+
// Clear all state to defaults
|
|
201
|
+
await store.clear();
|
|
290
202
|
|
|
291
|
-
//
|
|
292
|
-
|
|
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
|
-
|
|
209
|
+
## Agent API
|
|
296
210
|
|
|
297
|
-
|
|
211
|
+
Agents connect to the store from content scripts, popups, and other contexts:
|
|
298
212
|
|
|
299
|
-
|
|
213
|
+
```typescript
|
|
214
|
+
import { connectStore } from "crann";
|
|
300
215
|
|
|
301
|
-
|
|
216
|
+
const agent = connectStore(config, {
|
|
217
|
+
debug: true,
|
|
218
|
+
});
|
|
302
219
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
220
|
+
// Wait for connection to be ready
|
|
221
|
+
agent.onReady(() => {
|
|
222
|
+
console.log("Connected!");
|
|
223
|
+
});
|
|
307
224
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
counter: {
|
|
311
|
-
default: 0,
|
|
312
|
-
persist: "local",
|
|
313
|
-
},
|
|
225
|
+
// Or use the promise
|
|
226
|
+
await agent.ready();
|
|
314
227
|
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
|
|
334
|
-
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
|
|
239
|
+
// Call actions (RPC)
|
|
240
|
+
const result = await agent.actions.doSomething("arg1", 42);
|
|
367
241
|
|
|
368
|
-
|
|
242
|
+
// Get agent info
|
|
243
|
+
const info = agent.getInfo();
|
|
244
|
+
// { id, tabId, frameId, context }
|
|
369
245
|
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
|
|
376
|
-
|
|
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
|
-
|
|
254
|
+
## React Integration
|
|
401
255
|
|
|
402
|
-
|
|
256
|
+
Import from `crann/react` for React hooks:
|
|
403
257
|
|
|
404
258
|
```typescript
|
|
405
|
-
|
|
406
|
-
import { connect } from "crann";
|
|
259
|
+
import { createCrannHooks } from "crann/react";
|
|
407
260
|
import { config } from "./config";
|
|
408
261
|
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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
|
-
|
|
272
|
+
### useCrannState
|
|
448
273
|
|
|
449
|
-
|
|
274
|
+
Two patterns for reading state:
|
|
450
275
|
|
|
451
276
|
```typescript
|
|
452
|
-
//
|
|
453
|
-
|
|
454
|
-
|
|
277
|
+
// Selector pattern - returns selected value
|
|
278
|
+
const count = useCrannState(s => s.count);
|
|
279
|
+
const theme = useCrannState(s => s.settings.theme);
|
|
455
280
|
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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
|
-
|
|
286
|
+
### useCrannActions
|
|
488
287
|
|
|
489
|
-
|
|
288
|
+
Returns typed actions with stable references (won't cause re-renders):
|
|
490
289
|
|
|
491
|
-
```
|
|
492
|
-
|
|
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
|
-
//
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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
|
-
|
|
539
|
-
<div>
|
|
540
|
-
<h2>Counter: {counter}</h2>
|
|
298
|
+
### useCrannReady
|
|
541
299
|
|
|
542
|
-
|
|
543
|
-
{isLoading ? "Incrementing..." : "Increment Counter"}
|
|
544
|
-
</button>
|
|
300
|
+
Check connection status:
|
|
545
301
|
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
</button>
|
|
302
|
+
```typescript
|
|
303
|
+
const isReady = useCrannReady();
|
|
549
304
|
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
</div>
|
|
553
|
-
);
|
|
305
|
+
if (!isReady) {
|
|
306
|
+
return <LoadingSpinner />;
|
|
554
307
|
}
|
|
555
|
-
|
|
556
|
-
export default CounterComponent;
|
|
557
308
|
```
|
|
558
309
|
|
|
559
|
-
|
|
310
|
+
### CrannProvider (Optional)
|
|
560
311
|
|
|
561
|
-
|
|
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
|
|
575
|
-
|
|
315
|
+
// In tests
|
|
316
|
+
const mockAgent = createMockAgent();
|
|
576
317
|
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
318
|
+
render(
|
|
319
|
+
<CrannProvider agent={mockAgent}>
|
|
320
|
+
<MyComponent />
|
|
321
|
+
</CrannProvider>
|
|
322
|
+
);
|
|
323
|
+
```
|
|
580
324
|
|
|
581
|
-
|
|
582
|
-
const { isEnabled, count } = get();
|
|
325
|
+
## RPC Actions
|
|
583
326
|
|
|
584
|
-
|
|
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
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
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
|
-
|
|
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
|
-
|
|
598
|
-
|
|
599
|
-
|
|
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
|
-
|
|
366
|
+
const result = await agent.actions.increment(5);
|
|
367
|
+
console.log(result.count); // 5
|
|
609
368
|
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
369
|
+
const { user } = await agent.actions.fetchUser("123");
|
|
370
|
+
console.log(user.name);
|
|
371
|
+
```
|
|
613
372
|
|
|
614
|
-
|
|
373
|
+
### ActionContext
|
|
615
374
|
|
|
616
|
-
|
|
375
|
+
Action handlers receive a context object:
|
|
617
376
|
|
|
618
377
|
```typescript
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
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
|
-
|
|
630
|
-
import { useCrann } from "crann";
|
|
631
|
-
import type { MyState } from "./types";
|
|
386
|
+
## State Persistence
|
|
632
387
|
|
|
633
|
-
|
|
634
|
-
|
|
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
|
-
|
|
638
|
-
import { useMyCrann } from "./hooks";
|
|
413
|
+
### Storage Keys
|
|
639
414
|
|
|
640
|
-
|
|
641
|
-
const { get, set } = useMyCrann();
|
|
415
|
+
Crann uses structured storage keys: `crann:{name}:v{version}:{key}`
|
|
642
416
|
|
|
643
|
-
|
|
644
|
-
const { user } = get();
|
|
417
|
+
This prevents collisions and enables clean migrations.
|
|
645
418
|
|
|
646
|
-
|
|
647
|
-
set({
|
|
648
|
-
user: {
|
|
649
|
-
name: "Alice",
|
|
650
|
-
age: 30,
|
|
651
|
-
},
|
|
652
|
-
});
|
|
653
|
-
};
|
|
419
|
+
## Migration from v1
|
|
654
420
|
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
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
|
-
|
|
434
|
+
### Migration Steps
|
|
665
435
|
|
|
666
|
-
|
|
436
|
+
1. **Update config to use `createConfig()`:**
|
|
667
437
|
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
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
|
-
|
|
450
|
+
const store = createStore(config);
|
|
451
|
+
```
|
|
674
452
|
|
|
675
|
-
|
|
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
|
-
|
|
682
|
-
|
|
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
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
console.log("Filter changed:", changes.filter);
|
|
702
|
-
},
|
|
703
|
-
["filter"]
|
|
704
|
-
);
|
|
459
|
+
// After (v2)
|
|
460
|
+
scope: Scope.Agent
|
|
461
|
+
```
|
|
705
462
|
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
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
|
-
|
|
475
|
+
4. **Update action calls:**
|
|
723
476
|
|
|
724
|
-
|
|
477
|
+
```typescript
|
|
478
|
+
// Before (v1)
|
|
479
|
+
await callAction("increment", 5);
|
|
725
480
|
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
- **Side Panels, DevTools Pages:** Other specialized UI or inspection contexts.
|
|
481
|
+
// After (v2)
|
|
482
|
+
await agent.actions.increment(5);
|
|
483
|
+
```
|
|
730
484
|
|
|
731
|
-
|
|
485
|
+
## Why Crann?
|
|
732
486
|
|
|
733
|
-
|
|
487
|
+
Browser extensions have multiple isolated contexts that need to share state:
|
|
734
488
|
|
|
735
|
-
|
|
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
|
-
|
|
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
|
-

|
|
741
497
|
|
|
742
|
-
|
|
743
|
-
_Crann's centralized state management simplifies the architecture by eliminating the need for manual message passing._
|
|
498
|
+
---
|
|
744
499
|
|
|
745
|
-
|
|
500
|
+
**License:** ISC
|
|
746
501
|
|
|
747
|
-
|
|
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)
|