crann 1.0.34 → 1.0.35

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 (2) hide show
  1. package/README.md +430 -45
  2. package/package.json +1 -1
package/README.md CHANGED
@@ -2,86 +2,471 @@
2
2
 
3
3
  `npm i crann`
4
4
 
5
- Crann synchronizes state in Web Extensions, with full Typescript support.
5
+ ## Table of Contents
6
+
7
+ - [Core Features](#core-features)
8
+ - [The Challenge](#the-challenge-state-across-extension-contexts)
9
+ - [The Solution](#the-solution-how-crann-simplifies-synchronization)
10
+ - [Quick Start](#quick-start-a-simple-synchronization-example)
11
+ - [Getting Started](#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
+ - [React Integration](#react-integration)
18
+
19
+ ## Crann: Effortless State Synchronization for Web Extensions
20
+
21
+ 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.
22
+
23
+ **Core Features:**
6
24
 
7
25
  - Minimal size (< 5kb)
8
- - Syncs state between any context you might want - Content Scripts, Devtools, Sidepanels, Popup etc.
9
- - Removes the need for a tangled web of message passing
10
- - React to state! Better coding patterns.
11
- - Optionally persist any value to storage (local or session) via config.
26
+ - Syncs state between any context (Content Scripts, Service Worker, Devtools, Sidepanels, Popup, etc.)
27
+ - Eliminates manual `chrome.runtime.sendMessage` / `onMessage` boilerplate
28
+ - Reactive state updates via subscriptions (`subscribe`)
29
+ - Optional state persistence (`Persistence.Local` / `Persistence.Session`)
30
+ - Strong TypeScript inference and support for type safety
31
+
32
+ ## The Challenge: State Across Extension Contexts
33
+
34
+ Browser extensions often have multiple components:
35
+
36
+ - **Service Worker:** A background script handling core logic and events.
37
+ - **Content Scripts:** JavaScript injected directly into web pages.
38
+ - **Popup:** A small window shown when clicking the extension icon.
39
+ - **Side Panels, DevTools Pages:** Other specialized UI or inspection contexts.
40
+
41
+ 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.
42
+
43
+ ## The Solution: How Crann Simplifies Synchronization
12
44
 
13
- ### First, create a Crann instance
45
+ 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.
14
46
 
15
- Crann needs a service worker to coordinate state and access
47
+ **Visualizing the Problem: Manual Message Passing vs. Crann's Centralized State**
48
+
49
+ ![with_messages](img/with_messages.png)
50
+ _Traditional message passing requires complex, bidirectional communication between all parts._
51
+
52
+ ![with_crann](img/with_crann.png)
53
+ _Crann's centralized state management simplifies the architecture by eliminating the need for manual message passing._
54
+
55
+ This dramatically simplifies your architecture:
56
+
57
+ - **No more manual messaging:** Crann handles the communication internally.
58
+ - **Single source of truth:** State is managed centrally.
59
+ - **Reactivity:** Components automatically react to state changes they care about.
60
+
61
+ ### Quick Start: A Simple Synchronization Example
62
+
63
+ 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.
64
+
65
+ **1. Define the state in your Service Worker:**
16
66
 
17
67
  ```typescript
18
- // service worker environment
19
- import { create } from 'crann'
68
+ // service-worker.ts
69
+ import { create } from "crann";
20
70
 
21
- // Create an state with some defaults
22
71
  const crann = create({
23
- active: {default: false} // types are inferred from provided default
24
- trees: {default: 0}
25
- name: {default: '', partition: Partition.Instance} // partitioned state will be different for each connected context, so everyone connected except the service worker
72
+ isBorderEnabled: { default: false }, // Single shared state item
26
73
  });
27
74
 
28
- // Get the state whenever you like.
29
- const {active, trees} = crann.get() // Not passing in a key will only get state that is service to all, aka no partitioned state
30
- const {active, trees, name} = crann.get('instancekey') // Passing in a key will return the service state AND the partitioned state for that instance
75
+ console.log("Crann hub initialized.");
76
+ // Keep the service worker alive if needed (e.g., using chrome.runtime.connect)
77
+ // Crann itself doesn't automatically keep the SW alive.
78
+ ```
31
79
 
32
- // subscribe to listen for state changes
33
- crann.subscribe( (state, changes, key) => {
34
- const { active, trees, name } = state;
35
- console.log('Service state: active: ', active, ', trees: ', trees);
36
- console.log(`Instance state for ${key}: `, name);
37
- // or just look at changes.
80
+ **2. Control the state from your Popup:**
38
81
 
39
- // key will be the id of the context that made the state update, or null if the update came from the service worker (here)
82
+ ```typescript
83
+ // popup.ts
84
+ import { connect } from "crann";
85
+
86
+ const { set, get } = connect(); // Connect to the Crann hub
87
+
88
+ const toggleButton = document.getElementById("toggleBorder");
89
+
90
+ // Set initial button state
91
+ const currentState = get();
92
+ toggleButton.textContent = currentState.isBorderEnabled
93
+ ? "Disable Border"
94
+ : "Enable Border";
95
+
96
+ // Add click listener to update state
97
+ toggleButton.addEventListener("click", () => {
98
+ const newState = !get().isBorderEnabled; // Get current state before setting
99
+ set({ isBorderEnabled: newState });
100
+ // Update button text immediately (or subscribe to changes)
101
+ toggleButton.textContent = newState ? "Disable Border" : "Enable Border";
40
102
  });
103
+ ```
104
+
105
+ **3. React to the state in your Content Script:**
106
+
107
+ ```typescript
108
+ // content-script.ts
109
+ import { connect } from "crann";
110
+
111
+ const { subscribe } = connect(); // Connect to the Crann hub
112
+
113
+ console.log("Content script connected to Crann.");
114
+
115
+ // Subscribe to changes in 'isBorderEnabled'
116
+ subscribe(
117
+ (state) => {
118
+ console.log("Border state changed:", state.isBorderEnabled);
119
+ document.body.style.border = state.isBorderEnabled ? "5px solid green" : "";
120
+ },
121
+ ["isBorderEnabled"]
122
+ ); // Optional: Only trigger for specific key changes
41
123
 
42
- // Set the state (either for an instance or for the service state)
43
- crann.set({active: true}) // Will notify all connected contexts that active is now true.
44
- crann.set({name: 'ContentScript'}, 'instancekey'); // Or set for a particular instance if you like
124
+ // Apply initial state
125
+ const initialState = connect().get(); // Can call connect() again or store result
126
+ document.body.style.border = initialState.isBorderEnabled
127
+ ? "5px solid green"
128
+ : "";
45
129
  ```
46
130
 
47
- ### Then, connect to your Crann instance from any context you like -- eg. content scripts
131
+ **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.
132
+
133
+ ## Getting Started: Core Usage
134
+
135
+ ### Step 1: Create the State Hub (Service Worker)
136
+
137
+ The service worker is where you initialize your shared state. Here's a more detailed example showing how to define different types of state:
138
+
139
+ ```typescript
140
+ // service-worker.ts
141
+ import { create, Partition, Persistence } from "crann";
142
+
143
+ const crann = create({
144
+ // Basic state with default value
145
+ active: { default: false },
146
+
147
+ // State that's unique to each context
148
+ name: {
149
+ default: "",
150
+ partition: Partition.Instance,
151
+ },
152
+
153
+ // State that persists between sessions
154
+ timesUsed: {
155
+ default: 0,
156
+ persistence: Persistence.Local,
157
+ },
158
+
159
+ // State that resets when the browser closes
160
+ sessionStart: {
161
+ default: new Date(),
162
+ persistence: Persistence.Session,
163
+ },
164
+ });
165
+
166
+ // Get shared state (no instance state)
167
+ const { active, timesUsed } = crann.get();
168
+
169
+ // Optionally: Get state for a specific instance (includes instance state)
170
+ const { active, timesUsed, name } = crann.get("instanceKey");
171
+
172
+ // Subscribe to state changes
173
+ crann.subscribe((state, changes, key) => {
174
+ // state contains all state (shared + relevant partition)
175
+ // changes contains only the keys that changed
176
+ // key identifies which context made the change (null if from service worker)
177
+ console.log("State changed:", changes);
178
+ });
179
+ ```
180
+
181
+ ### Step 2: Connect from Other Contexts
182
+
183
+ Other parts of your extension connect to the state hub. They automatically get access to both shared and their own partitioned state:
48
184
 
49
185
  ```typescript
186
+ // popup.ts or content-script.ts
50
187
  import { connect } from "crann";
188
+
51
189
  const { get, set, subscribe } = connect();
52
190
 
53
- // Similar to the service worker environment, except we will always be dealing with service state AND our own instance state. No distinction from our point of view.
54
- const { active, trees, name } = get();
191
+ // Get all state (shared + this context's partition)
192
+ const { active, name, timesUsed } = get();
55
193
 
56
- // Set the state
57
- set({ name: "My own name" });
194
+ // Set state
195
+ set({ name: "My Context's Name" });
58
196
 
59
- // subscribe to listen for a particular item of state changing, or any change
197
+ // Subscribe to specific state changes
60
198
  subscribe(
61
199
  (changes) => {
62
- console.log("Trees changed! new value: ", changes.trees);
200
+ console.log("Times used changed:", changes.timesUsed);
63
201
  },
64
- ["trees"]
202
+ ["timesUsed"]
65
203
  );
66
204
  ```
67
205
 
68
- ### Finally, Persist state!
206
+ ## Advanced Features
69
207
 
70
- You can persist items so that they will survive a refresh or context closure.
208
+ ### Handling Complex Types
71
209
 
72
- Session state: Will last between page refreshes, generally will reset if the user closes the tab or browser.
73
- Local state: Will persist long-term (unless the user specifically clears it in browser settings)
210
+ Sometimes the default value alone isn't enough for TypeScript to infer the full type. Use type assertions to specify the complete type:
74
211
 
75
212
  ```typescript
76
- // Simply add persistence to your config when creating crann in the service-worker
213
+ import { create } from "crann";
214
+
215
+ // Example 1: Custom object type with null default
216
+ type CustomType = { name: string; age: number };
217
+
218
+ // Example 2: Specific string literal union
219
+ type ConnectionStatus = "idle" | "connecting" | "connected" | "error";
77
220
 
78
221
  const crann = create({
79
- active: {default: false} // types are inferred from provided default
80
- trees: {default: 0}
81
- name: {default: '', partition: Partition.Instance} // partitioned state will be different for each connected context, so everyone connected except the service worker
82
- timesUsed: {default: 0, Persistence.Local}
83
- firstOpened: {default: new Date(), Persistence.Session}
222
+ person: {
223
+ default: null as null | CustomType,
224
+ },
225
+ connectionStatus: {
226
+ default: "idle" as ConnectionStatus,
227
+ },
228
+ userStatus: {
229
+ default: "active" as "active" | "inactive",
230
+ persistence: Persistence.Local,
231
+ },
84
232
  });
85
233
 
86
- // note: Persisted state is Service state by default (shared with all contexts)
234
+ // Now TypeScript understands the full potential types
235
+ const state = crann.get();
236
+ // state.person could be null or { name: string; age: number }
237
+ // state.connectionStatus could be 'idle', 'connecting', 'connected', or 'error'
238
+ ```
239
+
240
+ ### Understanding Partitioned State
241
+
242
+ Partitioned state (`Partition.Instance`) is useful when you want each context to have its own version of a state variable. For example:
243
+
244
+ - Each content script might need its own `selectedElement` state
245
+ - Each popup might need its own `isOpen` state
246
+ - Each devtools panel might need its own `activeTab` state
247
+
248
+ 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.
249
+
250
+ ### State Persistence Options
251
+
252
+ Crann offers two levels of persistence:
253
+
254
+ - **Session Storage** (`Persistence.Session`): State persists between page refreshes but resets when the browser closes
255
+ - **Local Storage** (`Persistence.Local`): State persists long-term until explicitly cleared
256
+
257
+ ```typescript
258
+ const crann = create({
259
+ // Will be remembered between browser sessions
260
+ userPreferences: {
261
+ default: { theme: "light" },
262
+ persistence: Persistence.Local,
263
+ },
264
+
265
+ // Will reset when browser closes
266
+ currentSession: {
267
+ default: { startTime: new Date() },
268
+ persistence: Persistence.Session,
269
+ },
270
+ });
271
+ ```
272
+
273
+ Remember: Persisted state is always shared state (not partitioned).
274
+
275
+ ### Advanced API Functions
276
+
277
+ Crann provides additional functions for monitoring and managing instance connections:
278
+
279
+ ```typescript
280
+ // In the service worker
281
+ const crann = create({
282
+ // ... state configuration
283
+ });
284
+
285
+ // Listen for new instance connections
286
+ crann.onInstanceConnect((instanceKey) => {
287
+ console.log(`New instance connected: ${instanceKey}`);
288
+ // You can access this instance's partitioned state
289
+ const instanceState = crann.get(instanceKey);
290
+ });
291
+
292
+ // Listen for instance disconnections
293
+ crann.onInstanceDisconnect((instanceKey) => {
294
+ console.log(`Instance disconnected: ${instanceKey}`);
295
+ // Clean up any resources associated with this instance
296
+ });
297
+
298
+ // Get a list of all currently connected instances
299
+ const connectedInstances = crann.getConnectedInstances();
300
+ console.log("Connected instances:", connectedInstances);
301
+
302
+ // In any context (including service worker)
303
+ const { getInstanceKey } = connect();
304
+ // Get this context's unique instance key
305
+ const myInstanceKey = getInstanceKey();
306
+ ```
307
+
308
+ These functions are particularly useful for:
309
+
310
+ - Tracking which content scripts are currently active
311
+ - Cleaning up resources when instances disconnect
312
+ - Debugging connection issues
313
+ - Managing instance-specific resources in the service worker
314
+
315
+ The `getInstanceKey()` function is available in all contexts and can be useful for:
316
+
317
+ - Logging and debugging
318
+ - Coordinating with other systems that need to identify specific instances
319
+ - Managing instance-specific resources
320
+
321
+ ### React Integration
322
+
323
+ 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.
324
+
325
+ ```typescript
326
+ // In your React component
327
+ import { useCrann } from "crann";
328
+
329
+ function MyReactComponent() {
330
+ // The hook returns the same interface as connect()
331
+ const { get, set, subscribe } = useCrann();
332
+
333
+ // Get the current state
334
+ const { isEnabled, count } = get();
335
+
336
+ // Set state (triggers re-render)
337
+ const toggleEnabled = () => {
338
+ set({ isEnabled: !isEnabled });
339
+ };
340
+
341
+ // Subscribe to specific state changes
342
+ subscribe(
343
+ (changes) => {
344
+ console.log("Count changed:", changes.count);
345
+ },
346
+ ["count"]
347
+ );
348
+
349
+ return (
350
+ <div>
351
+ <button onClick={toggleEnabled}>
352
+ {isEnabled ? "Disable" : "Enable"}
353
+ </button>
354
+ <p>Count: {count}</p>
355
+ </div>
356
+ );
357
+ }
358
+ ```
359
+
360
+ The `useCrann` hook provides the same functionality as `connect()`, but with React-specific optimizations:
361
+
362
+ - Automatically re-renders components when subscribed state changes
363
+ - Handles cleanup of subscriptions when components unmount
364
+ - Provides TypeScript support for your state types
365
+
366
+ #### Using with TypeScript
367
+
368
+ For better type safety, you can create a custom hook that includes your state types:
369
+
370
+ ```typescript
371
+ // types.ts
372
+ interface MyState {
373
+ isEnabled: boolean;
374
+ count: number;
375
+ user: {
376
+ name: string;
377
+ age: number;
378
+ } | null;
379
+ }
380
+
381
+ // hooks.ts
382
+ import { useCrann } from "crann";
383
+ import type { MyState } from "./types";
384
+
385
+ export function useMyCrann() {
386
+ return useCrann<MyState>();
387
+ }
388
+
389
+ // MyComponent.tsx
390
+ import { useMyCrann } from "./hooks";
391
+
392
+ function MyComponent() {
393
+ const { get, set } = useMyCrann();
394
+
395
+ // TypeScript now knows the shape of your state
396
+ const { user } = get();
397
+
398
+ const updateUser = () => {
399
+ set({
400
+ user: {
401
+ name: "Alice",
402
+ age: 30,
403
+ },
404
+ });
405
+ };
406
+
407
+ return (
408
+ <div>
409
+ {user && <p>Hello, {user.name}!</p>}
410
+ <button onClick={updateUser}>Update User</button>
411
+ </div>
412
+ );
413
+ }
414
+ ```
415
+
416
+ #### Performance Considerations
417
+
418
+ The `useCrann` hook is optimized for React usage:
419
+
420
+ - Only re-renders when subscribed state actually changes
421
+ - Batches multiple state updates to minimize re-renders
422
+ - Automatically cleans up subscriptions on unmount
423
+ - Supports selective subscription to specific state keys
424
+
425
+ For best performance:
426
+
427
+ 1. Subscribe only to the state keys your component needs
428
+ 2. Use the second parameter of `subscribe` to specify which keys to listen for
429
+ 3. Consider using `useMemo` for derived state
430
+ 4. Use `useCallback` for event handlers that update state
431
+
432
+ ```typescript
433
+ function OptimizedComponent() {
434
+ const { get, set, subscribe } = useCrann();
435
+ const { items, filter } = get();
436
+
437
+ // Only re-render when items or filter changes
438
+ const filteredItems = useMemo(() => {
439
+ return items.filter((item) => item.includes(filter));
440
+ }, [items, filter]);
441
+
442
+ // Memoize the handler
443
+ const handleFilterChange = useCallback(
444
+ (newFilter: string) => {
445
+ set({ filter: newFilter });
446
+ },
447
+ [set]
448
+ );
449
+
450
+ // Only subscribe to the keys we care about
451
+ subscribe(
452
+ (changes) => {
453
+ console.log("Filter changed:", changes.filter);
454
+ },
455
+ ["filter"]
456
+ );
457
+
458
+ return (
459
+ <div>
460
+ <input
461
+ value={filter}
462
+ onChange={(e) => handleFilterChange(e.target.value)}
463
+ />
464
+ <ul>
465
+ {filteredItems.map((item) => (
466
+ <li key={item}>{item}</li>
467
+ ))}
468
+ </ul>
469
+ </div>
470
+ );
471
+ }
87
472
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crann",
3
- "version": "1.0.34",
3
+ "version": "1.0.35",
4
4
  "description": "",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",