Skip to content

Latest commit

 

History

History
1141 lines (801 loc) · 29.5 KB

File metadata and controls

1141 lines (801 loc) · 29.5 KB

🔄 tab-bridge

Real-time State Synchronization Across Browser Tabs

One function call. Every tab in sync. Zero dependencies.


npm version bundle size TypeScript license GitHub stars


tab-bridge sync diagram

Getting Started · API · React · Zustand · Jotai · Redux · DevTools · Next.js · Architecture · Examples · Live Demo


Why tab-bridge?

When users open your app in multiple tabs, things break — stale data, duplicated WebSocket connections, conflicting writes.

tab-bridge solves all of this with a single function call:

const sync = createTabSync({ initial: { theme: 'light', count: 0 } });

Every tab now shares the same state. One tab is automatically elected as leader. You can call functions across tabs like they're local. No server needed.


✨ Feature Highlights

⚡ State Sync

LWW conflict resolution with batched broadcasts and custom merge strategies

👑 Leader Election

Bully algorithm with heartbeat monitoring and automatic failover

📡 Cross-Tab RPC

Fully typed arguments, Promise-based calls with callAll broadcast support

🔄 Atomic Transactions

transaction() for safe multi-key updates with abort support

⚛️ React Hooks

7 hooks built on useSyncExternalStore — zero-tear concurrent rendering

🛡️ Middleware Pipeline

Intercept, validate, and transform state changes before they're applied

💾 State Persistence

Survive page reloads with key whitelisting and custom storage backends

🐻 Zustand / Jotai / Redux

First-class integrations — tabSync, atomWithTabSync, tabSyncEnhancer

🔧 DevTools Panel

Floating <TabSyncDevTools /> with state inspection, tab list, and event log

📦 Zero Dependencies

Native browser APIs only, ~4KB gzipped, fully tree-shakable




📦 Getting Started

npm install tab-bridge
import { createTabSync } from 'tab-bridge';

const sync = createTabSync({
  initial: { theme: 'light', count: 0 },
});

// Read & write — synced to all tabs instantly
sync.get('theme');          // 'light'
sync.set('theme', 'dark'); // → every tab updates

// Subscribe to changes
const off = sync.on('count', (value, meta) => {
  console.log(`count is now ${value} (${meta.isLocal ? 'local' : 'remote'})`);
});

// Leader election — automatic
sync.onLeader(() => {
  const ws = new WebSocket('wss://api.example.com');
  return () => ws.close(); // cleanup when leadership is lost
});

// Cross-tab RPC
sync.handle('double', (n: number) => n * 2);
const result = await sync.call('leader', 'double', 21); // 42



📖 API Reference

createTabSync<TState, TRPCMap>(options?)

The single entry point. Returns a fully-typed TabSyncInstance.

const sync = createTabSync<MyState>({
  initial: { theme: 'light', count: 0 },
  channel: 'my-app',
  debug: true,
});
📋 Full Options Table
Option Type Default Description
initial TState {} Initial state before first sync
channel string 'tab-sync' Channel name — only matching tabs communicate
transport 'broadcast-channel' | 'local-storage' auto Force a specific transport layer
merge (local, remote, key) => value LWW Custom conflict resolution
leader boolean | LeaderOptions true Leader election config
debug boolean false Enable colored console logging
persist PersistOptions | boolean false State persistence config
middlewares Middleware[] [] Middleware pipeline
onError (error: Error) => void noop Global error callback

Instance Methods

📊 State
sync.get('theme')                       // Read single key
sync.getAll()                           // Read full state (stable reference)
sync.set('theme', 'dark')              // Write single key → broadcasts to all tabs
sync.patch({ theme: 'dark', count: 5 }) // Write multiple keys in one broadcast

// Atomic multi-key update — return null to abort
sync.transaction((state) => {
  if (state.count >= 100) return null;  // abort
  return { count: state.count + 1, lastUpdated: Date.now() };
});
🔔 Subscriptions
const off = sync.on('count', (value, meta) => { /* ... */ });
off(); // unsubscribe

sync.once('theme', (value) => console.log('Theme changed:', value));

sync.onChange((state, changedKeys, meta) => { /* ... */ });

sync.select(
  (state) => state.items.filter(i => i.done).length,
  (doneCount) => updateBadge(doneCount),
);

// Debounced derived state — callback fires at most once per 200ms
sync.select(
  (state) => state.items.length,
  (count) => analytics.track('item_count', count),
  { debounce: 200 },
);
👑 Leader Election
sync.isLeader()                // → boolean
sync.getLeader()               // → TabInfo | null

sync.onLeader(() => {
  const ws = new WebSocket('wss://...');
  return () => ws.close();     // Cleanup on resign
});

const leader = await sync.waitForLeader(); // Promise-based
📋 Tab Registry
sync.id                        // This tab's UUID
sync.getTabs()                 // → TabInfo[]
sync.getTabCount()             // → number

sync.onTabChange((tabs) => {
  console.log(`${tabs.length} tabs open`);
});
📡 Cross-Tab RPC
sync.handle('getServerTime', () => ({
  iso: new Date().toISOString(),
}));

const { iso } = await sync.call('leader', 'getServerTime');
const result  = await sync.call(tabId, 'compute', payload, 10_000);

// Broadcast RPC to ALL other tabs and collect responses
const results = await sync.callAll('getStatus');
// results: Array<{ tabId: string; result?: T; error?: string }>
♻️ Lifecycle
sync.ready      // false after destroy
sync.destroy()  // graceful shutdown, safe to call multiple times



🔷 Typed RPC

Define an RPC contract and get full end-to-end type inference — arguments, return types, and method names are all checked at compile time:

interface MyRPC {
  getTime: { args: void;                     result: { iso: string } };
  add:     { args: { a: number; b: number }; result: number };
  search:  { args: string;                   result: string[] };
}

const sync = createTabSync<MyState, MyRPC>({
  initial: { count: 0 },
});

sync.handle('add', ({ a, b }) => a + b);          // args are typed
const { iso } = await sync.call('leader', 'getTime'); // result is typed
const results = await sync.call(tabId, 'search', 'query'); // string[]



🛡️ Middleware

Intercept, validate, and transform state changes before they're applied:

const sync = createTabSync({
  initial: { name: '', age: 0 },
  middlewares: [
    {
      name: 'validator',
      onSet({ key, value, previousValue, meta }) {
        if (key === 'age' && (value as number) < 0)  return false;   // reject
        if (key === 'name') return { value: String(value).trim() };  // transform
      },
      afterChange(key, value, meta) {
        analytics.track('state_change', { key, source: meta.sourceTabId });
      },
      onDestroy() { /* cleanup */ },
    },
  ],
});

middleware pipeline diagram




💾 Persistence

State survives page reloads automatically:

// Simple — persist everything to localStorage
createTabSync({ initial: { ... }, persist: true });

// Advanced — fine-grained control
createTabSync({
  initial: { theme: 'light', tempData: null },
  persist: {
    key: 'my-app:state',
    include: ['theme'],        // only persist these keys
    debounce: 200,             // debounce writes (ms)
    storage: sessionStorage,   // custom storage backend
  },
});



⚛️ React

First-class React integration built on useSyncExternalStore for zero-tear concurrent rendering.

import {
  TabSyncProvider, useTabSync, useTabSyncValue, useTabSyncSelector,
  useIsLeader, useTabs, useLeaderInfo, useTabSyncActions,
} from 'tab-bridge/react';

TabSyncProvider — Context Provider
<TabSyncProvider options={{ initial: { count: 0 }, channel: 'app' }}>
  <App />
</TabSyncProvider>
useTabSync() — All-in-one hook
function Counter() {
  const { state, set, isLeader, tabs } = useTabSync<MyState>();

  return (
    <div>
      <h2>Count: {state.count}</h2>
      <button onClick={() => set('count', state.count + 1)}>+1</button>
      <p>{isLeader ? '👑 Leader' : 'Follower'} · {tabs.length} tabs</p>
    </div>
  );
}
useTabSyncValue(key) — Single key, minimal re-renders
function ThemeDisplay() {
  const theme = useTabSyncValue<MyState, 'theme'>('theme');
  return <div className={`app ${theme}`}>Current theme: {theme}</div>;
}
useTabSyncSelector(selector) — Derived state with memoization
function DoneCount() {
  const count = useTabSyncSelector<MyState, number>(
    (state) => state.todos.filter(t => t.done).length,
  );
  return <span className="badge">{count} done</span>;
}
useIsLeader() — Leadership status
function LeaderIndicator() {
  const isLeader = useIsLeader();
  if (!isLeader) return null;
  return <span className="badge badge-leader">Leader Tab</span>;
}
useTabs() — Active tab list
function TabList() {
  const tabs = useTabs();
  return <p>{tabs.length} tab(s) open</p>;
}
useLeaderInfo() — Leader tab info
function LeaderDisplay() {
  const leader = useLeaderInfo();
  if (!leader) return <p>No leader yet</p>;
  return <p>Leader: {leader.id}</p>;
}
useTabSyncActions() — Write-only (no re-renders)
function IncrementButton() {
  const { set, patch, transaction } = useTabSyncActions<MyState>();
  return <button onClick={() => set('count', prev => prev + 1)}>+1</button>;
}

Components using only useTabSyncActions never re-render due to state changes — perfect for write-only controls.




🐻 Zustand

One-line integration for Zustand stores — all tabs stay in sync automatically.

npm install zustand
import { create } from 'zustand';
import { tabSync } from 'tab-bridge/zustand';

const useStore = create(
  tabSync(
    (set) => ({
      count: 0,
      theme: 'light',
      inc: () => set((s) => ({ count: s.count + 1 })),
      setTheme: (t: string) => set({ theme: t }),
    }),
    { channel: 'my-app' }
  )
);

// That's it — all tabs now share the same state.
// Functions (actions) are never synced, only data.
📋 Middleware Options
Option Type Default Description
channel string 'tab-sync-zustand' Channel name for cross-tab communication
include string[] Only sync these keys (mutually exclusive with exclude)
exclude string[] Exclude these keys from syncing (mutually exclusive with include)
merge (local, remote, key) => value LWW Custom conflict resolution
transport 'broadcast-channel' | 'local-storage' auto Force a specific transport
debug boolean false Enable debug logging
onError (error) => void Error callback
onSyncReady (instance) => void Access the underlying TabSyncInstance for RPC/leader features
🔑 Selective Key Sync
const useStore = create(
  tabSync(
    (set) => ({
      count: 0,
      theme: 'light',
      localDraft: '',       // won't be synced
      inc: () => set((s) => ({ count: s.count + 1 })),
    }),
    {
      channel: 'my-app',
      exclude: ['localDraft'],   // keep this key local-only
    }
  )
);
🤝 Works with Zustand persist

Compose with Zustand's persist middleware — order doesn't matter:

import { persist } from 'zustand/middleware';

const useStore = create(
  persist(
    tabSync(
      (set) => ({
        count: 0,
        inc: () => set((s) => ({ count: s.count + 1 })),
      }),
      { channel: 'my-app' }
    ),
    { name: 'my-store' }
  )
);
🚀 Advanced: Access tab-bridge Instance

Use onSyncReady to access the underlying TabSyncInstance for RPC, leader election, and other advanced features:

let syncInstance: TabSyncInstance | null = null;

const useStore = create(
  tabSync(
    (set) => ({ count: 0 }),
    {
      channel: 'my-app',
      onSyncReady: (instance) => {
        syncInstance = instance;

        instance.handle('getCount', () => useStore.getState().count);

        instance.onLeader(() => {
          console.log('This tab is now the leader');
          return () => console.log('Leadership lost');
        });
      },
    }
  )
);



🧪 Jotai

Synchronize individual Jotai atoms across browser tabs with zero boilerplate.

npm install tab-bridge jotai

Quick Start

import { atomWithTabSync } from 'tab-bridge/jotai';

const countAtom = atomWithTabSync('count', 0, { channel: 'my-app' });
const themeAtom = atomWithTabSync('theme', 'light', { channel: 'my-app' });

Use them like regular atoms — useAtom(countAtom) works as expected. Changes are automatically synced to all tabs.

Options

Option Type Default Description
channel string 'tab-sync-jotai' Channel name for cross-tab communication
transport 'broadcast-channel' | 'local-storage' auto-detect Force a specific transport
debug boolean false Enable debug logging
onError (error: Error) => void Error callback
onSyncReady (instance: TabSyncInstance) => void Access underlying instance

How It Works

Each atom creates its own createTabSync instance scoped to ${channel}:${key}. The instance is created when the atom is first subscribed to and destroyed when the last subscriber unmounts.

Derived Atoms

Derived atoms work out of the box:

import { atom } from 'jotai';

const doubledAtom = atom((get) => get(countAtom) * 2);
// Automatically updates when countAtom syncs from another tab



🏪 Redux

Synchronize your Redux (or Redux Toolkit) store across browser tabs via a store enhancer.

npm install tab-bridge redux       # or @reduxjs/toolkit

Quick Start

import { configureStore } from '@reduxjs/toolkit';
import { tabSyncEnhancer } from 'tab-bridge/redux';

const store = configureStore({
  reducer: { counter: counterReducer, theme: themeReducer },
  enhancers: (getDefault) =>
    getDefault().concat(tabSyncEnhancer({ channel: 'my-app' })),
});

Every dispatch that changes state is automatically synced. Remote changes are merged via an internal @@tab-bridge/MERGE action — no reducer changes needed.

Options

Option Type Default Description
channel string 'tab-sync-redux' Channel name
include string[] Only sync these top-level keys (slice names)
exclude string[] Exclude these top-level keys
merge (local, remote, key) => unknown LWW Custom conflict resolution
transport 'broadcast-channel' | 'local-storage' auto-detect Force transport
debug boolean false Debug logging
onError (error: Error) => void Error callback
onSyncReady (instance: TabSyncInstance) => void Access underlying instance

Selective Sync

tabSyncEnhancer({
  channel: 'my-app',
  include: ['counter', 'settings'],  // only sync these slices
  // exclude: ['auth'],              // or exclude specific slices
})

Design Decision: State-Based Sync

The enhancer diffs top-level state keys after each dispatch rather than replaying actions. This guarantees consistency regardless of reducer purity and works with any middleware stack.




🔧 DevTools

A floating development panel for inspecting tab-bridge state, tabs, and events in real time.

import { TabSyncDevTools } from 'tab-bridge/react';

function App() {
  return (
    <TabSyncProvider options={{ initial: { count: 0 }, channel: 'app' }}>
      <MyApp />
      {process.env.NODE_ENV === 'development' && <TabSyncDevTools />}
    </TabSyncProvider>
  );
}

Features

Tab Description
State Live JSON view of current state + manual editing (textarea → Apply)
Tabs Active tab list with leader badge and "you" indicator
Log Real-time event stream — state changes, tab joins/leaves

Props

Prop Type Default Description
position 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left' 'bottom-right' Panel position
defaultOpen boolean false Start expanded

Tree-shakeable — if you never import TabSyncDevTools, it won't appear in your production bundle.




📘 Next.js

Using tab-bridge with Next.js App Router? Since tab-bridge relies on browser APIs, all usage must be in Client Components.

// app/providers/tab-sync-provider.tsx
'use client';

import { TabSyncProvider } from 'tab-bridge/react';

export function AppTabSyncProvider({ children }: { children: React.ReactNode }) {
  return (
    <TabSyncProvider options={{ initial: { count: 0 }, channel: 'my-app' }}>
      {children}
    </TabSyncProvider>
  );
}
// app/layout.tsx
import { AppTabSyncProvider } from './providers/tab-sync-provider';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html><body>
      <AppTabSyncProvider>{children}</AppTabSyncProvider>
    </body></html>
  );
}

Full guide: See docs/NEXTJS.md for SSR safety patterns, hydration mismatch prevention, useEffect initialization, and Zustand integration with Next.js.




🚨 Error Handling

Structured errors with error codes for precise catch handling:

import { TabSyncError, ErrorCode } from 'tab-bridge';

try {
  await sync.call('leader', 'getData');
} catch (err) {
  if (err instanceof TabSyncError) {
    switch (err.code) {
      case ErrorCode.RPC_TIMEOUT:    // call timed out
      case ErrorCode.RPC_NO_LEADER:  // no leader elected yet
      case ErrorCode.RPC_NO_HANDLER: // method not registered on target
      case ErrorCode.DESTROYED:      // instance was destroyed
    }
  }
}

// Global error handler
createTabSync({ onError: (err) => Sentry.captureException(err) });



🏗️ Architecture

architecture diagram

How State Sync Works

state sync sequence diagram

How Leader Election Works

leader election sequence diagram




🔧 Advanced

🔌 Custom Transport Layer
import { createChannel } from 'tab-bridge';

createTabSync({ transport: 'local-storage' });

const channel = createChannel('my-channel', 'broadcast-channel');
🔢 Protocol Versioning

All messages include a version field. The library automatically ignores messages from incompatible protocol versions, enabling safe rolling deployments — old and new tabs can coexist without errors.

import { PROTOCOL_VERSION } from 'tab-bridge';
console.log(PROTOCOL_VERSION); // 1
🐛 Debug Mode
createTabSync({ debug: true });

Outputs colored, structured logs:

[tab-sync:a1b2c3d4] → STATE_UPDATE { theme: 'dark' }
[tab-sync:a1b2c3d4] ← LEADER_CLAIM { createdAt: 1708900000 }
[tab-sync:a1b2c3d4] ♛ Became leader
🧩 Exported Internals

For library authors or advanced use cases, all internal modules are exported:

import {
  StateManager, TabRegistry, LeaderElection, RPCHandler,
  Emitter, createMessage, generateTabId, monotonic,
} from 'tab-bridge';



💡 Examples

🎯 Interactive Demos

Try these demos live — open multiple tabs to see real-time synchronization in action:

Demo Description Features
Collaborative Editor Multi-tab real-time text editing State Sync, Typing Indicators
Shopping Cart Cart synced across all tabs + persistent State Sync, Persistence
Leader Dashboard Only leader fetches data, followers use RPC Leader Election, RPC, callAll
Full Feature Demo All features in one page Everything

Code Examples

🔐 Shared Authentication State
const auth = createTabSync({
  initial: { user: null, token: null },
  channel: 'auth',
  persist: { include: ['token'] },
});

auth.on('user', (user) => {
  if (user) showDashboard(user);
  else      redirectToLogin();
});

function logout() {
  auth.patch({ user: null, token: null }); // logout everywhere
}
🌐 Single WebSocket Connection (Leader Pattern)
websocket leader pattern diagram
const sync = createTabSync({
  initial: { messages: [] as Message[] },
});

sync.onLeader(() => {
  const ws = new WebSocket('wss://chat.example.com');

  ws.onmessage = (e) => {
    const msg = JSON.parse(e.data);
    sync.set('messages', [...sync.get('messages'), msg]);
  };

  return () => ws.close(); // cleanup on leadership loss
});
🔔 Cross-Tab Notifications
interface NotifyRPC {
  notify: {
    args: { title: string; body: string };
    result: void;
  };
}

const sync = createTabSync<{}, NotifyRPC>({ channel: 'notifications' });

sync.onLeader(() => {
  sync.handle('notify', ({ title, body }) => {
    new Notification(title, { body });
  });
  return () => {};
});

await sync.call('leader', 'notify', {
  title: 'New Message',
  body: 'You have 3 unread messages',
});
🛒 React — Shopping Cart Sync
interface CartState {
  items: Array<{ id: string; name: string; qty: number }>;
  total: number;
}

function Cart() {
  const { state, set } = useTabSync<CartState>();

  const itemCount = useTabSyncSelector<CartState, number>(
    (s) => s.items.reduce((sum, i) => sum + i.qty, 0),
  );

  return (
    <div>
      <h2>Cart ({itemCount} items)</h2>
      {state.items.map(item => (
        <div key={item.id}>
          {item.name} × {item.qty}
        </div>
      ))}
    </div>
  );
}



🌐 Browser Support

Browser Version Transport
🟢 Chrome 54+ BroadcastChannel
🟠 Firefox 38+ BroadcastChannel
🔵 Safari 15.4+ BroadcastChannel
🔷 Edge 79+ BroadcastChannel
Older browsers localStorage (auto-fallback)



📄 License

MIT © serbi2012


If this library helped you, consider giving it a ⭐


GitHub   npm