npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

crann

v2.1.0

Published

Effortless State Synchronization for Web Extensions

Readme

Crann: Effortless State Synchronization for Web Extensions

crann_logo

npm i crann

Table of Contents

Core Features

  • Minimal size (< 5kb gzipped)
  • Multi-context sync - Content Scripts, Service Worker, Devtools, Sidepanels, Popup
  • No message boilerplate - Eliminates chrome.runtime.sendMessage / onMessage
  • Reactive updates - Subscribe to state changes
  • Persistence - Optional local or session storage
  • Full TypeScript - Complete type inference from config
  • React hooks - First-class React integration via crann/react
  • RPC Actions - Execute logic in the service worker from any context

Quick Start

1. Define Your Config

// config.ts
import { createConfig, Persist } from "crann";

export const config = createConfig({
  name: "myExtension", // Required: unique store name
  version: 1, // Optional: for migrations

  // Define your state
  isEnabled: { default: false },
  count: { default: 0, persist: Persist.Local },

  // Define actions (RPC)
  actions: {
    increment: {
      handler: async (ctx, amount: number = 1) => {
        return { count: ctx.state.count + amount };
      },
    },
  },
});

2. Initialize the Store (Service Worker)

// service-worker.ts
import { createStore } from "crann";
import { config } from "./config";

const store = createStore(config);

store.subscribe((state, changes) => {
  console.log("State changed:", changes);
});

3. Connect from Any Context

// popup.ts or content-script.ts
import { connectStore } from "crann";
import { config } from "./config";

const agent = connectStore(config);

agent.onReady(() => {
  console.log("Connected! Current state:", agent.getState());

  // Update state
  agent.setState({ isEnabled: true });

  // Call actions
  agent.actions.increment(5);
});

4. Use with React

// hooks.ts
import { createCrannHooks } from "crann/react";
import { config } from "./config";

export const { useCrannState, useCrannActions, useCrannReady } =
  createCrannHooks(config);

// Counter.tsx
function Counter() {
  const count = useCrannState((s) => s.count);
  const { increment } = useCrannActions();
  const isReady = useCrannReady();

  if (!isReady) return <div>Loading...</div>;

  return <button onClick={() => increment(1)}>Count: {count}</button>;
}

Configuration

The createConfig function defines your store schema:

import { createConfig, Scope, Persist } from "crann";

const config = createConfig({
  // Required: unique identifier for this store
  name: "myStore",

  // Optional: version number for migrations (default: 1)
  version: 1,

  // State definitions
  count: { default: 0 },

  // With persistence
  theme: {
    default: "light" as "light" | "dark",
    persist: Persist.Local, // Persist.Local | Persist.Session | Persist.None
  },

  // Agent-scoped state (each tab/frame gets its own copy)
  selectedElement: {
    default: null as HTMLElement | null,
    scope: Scope.Agent, // Scope.Shared (default) | Scope.Agent
  },

  // Actions (RPC handlers)
  actions: {
    doSomething: {
      handler: async (ctx, arg1: string, arg2: number) => {
        // ctx.state - current state
        // ctx.setState - update state
        // ctx.agentId - calling agent's ID
        return { result: "value" };
      },
      validate: (arg1, arg2) => {
        if (!arg1) throw new Error("arg1 required");
      },
    },
  },
});

Store API (Service Worker)

The Store runs in the service worker and manages all state:

import { createStore } from "crann";

const store = createStore(config, {
  debug: true, // Enable debug logging
});

// Get current state
const state = store.getState();

// Update state
await store.setState({ count: 5 });

// Get agent-scoped state for a specific agent
const agentState = store.getAgentState(agentId);

// Subscribe to all state changes
const unsubscribe = store.subscribe((state, changes, agentInfo) => {
  console.log("Changed:", changes);
});

// Listen for agent connections
store.onAgentConnect((agent) => {
  console.log(`Agent ${agent.id} connected from tab ${agent.tabId}`);
});

store.onAgentDisconnect((agent) => {
  console.log(`Agent ${agent.id} disconnected`);
});

// Get all connected agents
const agents = store.getAgents();
const contentScripts = store.getAgents({ context: "contentscript" });

// Clear all state to defaults
await store.clear();

// Destroy the store (cleanup)
store.destroy();
// Or clear persisted data on destroy:
store.destroy({ clearPersisted: true });

Agent API

Agents connect to the store from content scripts, popups, and other contexts:

import { connectStore } from "crann";

const agent = connectStore(config, {
  debug: true,
});

// Wait for connection to be ready
agent.onReady(() => {
  console.log("Connected!");
});

// Or use the promise
await agent.ready();

// Get current state
const state = agent.getState();

// Update state
await agent.setState({ count: 10 });

// Subscribe to changes
const unsubscribe = agent.subscribe((changes, state) => {
  console.log("State changed:", changes);
});

// Call actions (RPC)
const result = await agent.actions.doSomething("arg1", 42);

// Get agent info
const info = agent.getInfo();
// { id, tabId, frameId, context }

// Handle disconnect/reconnect
agent.onDisconnect(() => console.log("Disconnected"));
agent.onReconnect(() => console.log("Reconnected"));

// Clean up
agent.disconnect();

React Integration

Import from crann/react for React hooks:

import { createCrannHooks } from "crann/react";
import { config } from "./config";

// Create hooks bound to your config
export const {
  useCrannState,
  useCrannActions,
  useCrannReady,
  useAgent,
  CrannProvider,
} = createCrannHooks(config);

useCrannState

Two patterns for reading state:

// Selector pattern - returns selected value
const count = useCrannState((s) => s.count);
const theme = useCrannState((s) => s.settings.theme);

// Key pattern - returns [value, setValue] tuple
const [count, setCount] = useCrannState("count");
setCount(10); // Updates state

useCrannActions

Returns typed actions with stable references (won't cause re-renders):

const { increment, fetchData } = useCrannActions();

// Actions are async
await increment(5);
const result = await fetchData("https://api.example.com");

Important: Event Handler Usage

When using actions as event handlers, always wrap them in an arrow function:

// ✓ Correct
<button onClick={() => increment()}>Click me</button>

// ✗ Incorrect - will fail silently
<button onClick={increment}>Click me</button>

Why? When you pass increment directly, React calls it with a MouseEvent as the first argument. DOM events are not serializable and cannot be sent through Chrome's messaging API, causing the action to fail silently.

In development mode, Crann will log a warning if it detects an event being passed to an action.

useCrannReady

Check connection status:

const isReady = useCrannReady();

if (!isReady) {
  return <LoadingSpinner />;
}

CrannProvider (Optional)

For testing or dependency injection:

// In tests
const mockAgent = createMockAgent();

render(
  <CrannProvider agent={mockAgent}>
    <MyComponent />
  </CrannProvider>
);

RPC Actions

Actions execute in the service worker but can be called from any context:

// In config
const config = createConfig({
  name: "myStore",
  count: { default: 0 },

  actions: {
    increment: {
      handler: async (ctx, amount: number = 1) => {
        const newCount = ctx.state.count + amount;
        // Option 1: Return state updates
        return { count: newCount };

        // Option 2: Use ctx.setState
        // await ctx.setState({ count: newCount });
        // return { success: true };
      },
    },

    fetchUser: {
      handler: async (ctx, userId: string) => {
        // Runs in service worker - can make network requests
        const response = await fetch(`/api/users/${userId}`);
        const user = await response.json();
        return { user };
      },
      validate: (userId) => {
        if (!userId) throw new Error("userId required");
      },
    },
  },
});

// From any context (popup, content script, etc.)
const agent = connectStore(config);
await agent.ready();

const result = await agent.actions.increment(5);
console.log(result.count); // 5

const { user } = await agent.actions.fetchUser("123");
console.log(user.name);

ActionContext

Action handlers receive a context object:

interface ActionContext<TState> {
  state: TState; // Current state snapshot
  setState: (partial: Partial<TState>) => Promise<void>; // Update state
  agentId: string; // Calling agent's ID
  agentLocation: BrowserLocation; // Tab/frame info
}

State Persistence

Control how state is persisted:

import { createConfig, Persist } from "crann";

const config = createConfig({
  name: "myStore",

  // No persistence (default) - resets on service worker restart
  volatile: { default: null },

  // Local storage - persists across browser sessions
  preferences: {
    default: { theme: "light" },
    persist: Persist.Local,
  },

  // Session storage - persists until browser closes
  sessionData: {
    default: {},
    persist: Persist.Session,
  },
});

Storage Keys

Crann uses structured storage keys: crann:{name}:v{version}:{key}

This prevents collisions and enables clean migrations.

Migration from v1

Key Changes

| v1 | v2 | | ------------------------- | ------------------------- | | create() | createStore() | | connect() | connectStore() | | Partition.Instance | Scope.Agent | | Partition.Service | Scope.Shared | | crann.set() | store.setState() | | crann.get() | store.getState() | | callAction("name", arg) | agent.actions.name(arg) | | Config object literal | createConfig() |

Migration Steps

  1. Update config to use createConfig():
// Before (v1)
const crann = create({
  count: { default: 0 },
});

// After (v2)
const config = createConfig({
  name: "myStore", // Required in v2
  count: { default: 0 },
});

const store = createStore(config);
  1. Update terminology:
// Before (v1)
partition: Partition.Instance;

// After (v2)
scope: Scope.Agent;
  1. Update React hooks:
// Before (v1)
import { useCrann } from "crann";
const { get, set, callAction } = useCrann();

// After (v2)
import { createCrannHooks } from "crann/react";
const { useCrannState, useCrannActions } = createCrannHooks(config);
  1. Update action calls:
// Before (v1)
await callAction("increment", 5);

// After (v2)
await agent.actions.increment(5);

Why Crann?

Browser extensions have multiple isolated contexts (content scripts, popup, devtools, sidepanel) that need to share state. The traditional approach using sendMessage/onMessage forces a painful pattern:

Message Router Problem

The problem with sendMessage / onMessage:

  • Agents can't message each other directly—everything routes through the service worker
  • Your service worker becomes a message router with growing switch/case statements
  • Every new feature means more message types, more handlers, more coupling
  • Manual async handling (return true in Chrome, different in Firefox)
  • Hand-rolled TypeScript types that may or may not stay in sync

With Crann:

  • Define your state and actions in one place
  • Agents sync automatically through the central store
  • Full TypeScript inference—no manual type definitions
  • No message routing, no relay logic, no return true
  • Focus on your features, not the plumbing

License: ISC

Repository: github.com/moclei/crann