Skip to content

let-sunny/purrtabby

Repository files navigation

purrtabby

purrtabby
npm build status bundle size (gzipped)

Lightweight browser tab communication and leader election library using BroadcastChannel and localStorage

A lightweight library for cross-tab communication and leader election in browser environments. Perfect for coordinating multiple tabs/windows and electing a single leader tab.

Highlights

Microscopic: weighs 6.3KB minified (2.1KB gzipped)

Reliable: leader election with lease-based heartbeat mechanism

Modern: async iterables for generator-based message streams

Type-Safe: full TypeScript support

Zero Dependencies: uses native Browser APIs only (BroadcastChannel, localStorage)

Flexible: callback-based or generator-based APIs, your choice

🎮 Try It Live

Interactive Demo →

Try purrtabby in your browser with our interactive demo. Test cross-tab communication and leader election between multiple tabs.

📚 Documentation

Architecture Documentation →

Learn about purrtabby's internal architecture and design decisions.

Features

  • Cross-tab communication using BroadcastChannel
  • Leader election with localStorage-based lease and heartbeat
  • Generator-based streams for async iteration
  • TypeScript support
  • Browser compatible (requires BroadcastChannel and localStorage support)
  • Zero dependencies (uses native Browser APIs)
  • Tiny bundle size
  • AbortSignal support for stream cancellation

Table of Contents

Installation

npm:

npm install purrtabby

UMD build (via unpkg):

<script src="https://unpkg.com/purrtabby/dist/index.global.js"></script>

Requirements

Runtime:

  • Browser: Modern browsers with BroadcastChannel and localStorage support
  • Node.js: Not supported (requires browser APIs)

Development:

  • Node.js: 24.13.0 (tested with this version)

Usage

TabBus - Cross-tab Communication

import { createBus } from 'purrtabby';

const bus = createBus({
  channel: 'my-app-channel',
});

// Subscribe to specific message types
const unsubscribe = bus.subscribe('user-action', (message) => {
  console.log('Received:', message.payload);
  console.log('From tab:', message.tabId);
});

// Publish messages to all tabs
bus.publish('user-action', { action: 'click', target: 'button' });

// Subscribe to all messages
bus.subscribeAll((message) => {
  console.log('Any message:', message.type, message.payload);
});

// Cleanup
unsubscribe();
bus.close();

LeaderElector - Leader Election

import { createLeaderElector } from 'purrtabby';

const leader = createLeaderElector({
  key: 'my-app-leader',
  tabId: 'tab-1', // Optional: auto-generated if not provided
  leaseMs: 5000, // Lease duration in milliseconds
  heartbeatMs: 2000, // Heartbeat interval
  jitterMs: 500, // Jitter to avoid thundering herd
});

// Start leader election
leader.start();

// Check if this tab is the leader
if (leader.isLeader()) {
  console.log('This tab is the leader!');
}

// Listen for leader events
leader.on('acquire', () => {
  console.log('Became the leader!');
});

leader.on('lose', (event) => {
  console.log('Lost leadership:', event.meta?.newLeader);
});

leader.on('change', (event) => {
  console.log('Leadership changed to:', event.meta?.newLeader);
});

// Stop leader election
leader.stop();

Choosing Between Callback and Generator APIs

purrtabby provides two ways to consume messages and events: callbacks and generators. Choose based on your needs:

  • Callbacks: Simple, event-driven, good for one-off handlers
  • Generators: Modern async/await patterns, better for complex flows, supports AbortSignal

Callback-based API

const bus = createBus({ channel: 'my-channel' });

// Subscribe to specific message types
const unsubscribeMessage = bus.subscribe('user-action', (message) => {
  console.log('Message:', message.payload);
});

// Subscribe to all messages
const unsubscribeAll = bus.subscribeAll((message) => {
  console.log('Any message:', message.type, message.payload);
});

// Unsubscribe when done
unsubscribeMessage();
unsubscribeAll();

const leader = createLeaderElector({
  key: 'my-leader',
  tabId: 'tab-1',
});

leader.start();

// Subscribe to specific leader events
const unsubscribeAcquired = leader.on('acquire', (event) => {
  console.log('Became leader');
});

const unsubscribeLost = leader.on('lose', (event) => {
  console.log('Lost leadership');
});

// Subscribe to all leader events
const unsubscribeAllEvents = leader.onAll((event) => {
  console.log('Leader event:', event.type);
});

// Unsubscribe when done
unsubscribeAcquired();
unsubscribeLost();
unsubscribeAllEvents();

Generator-based Streams

const bus = createBus({ channel: 'my-channel' });

// Consume messages as async iterable
(async () => {
  for await (const message of bus.stream()) {
    console.log('Received:', message.type, message.payload);
  }
})();

const leader = createLeaderElector({
  key: 'my-leader',
  tabId: 'tab-1',
});

leader.start();

// Consume leader events as async iterable
(async () => {
  for await (const event of leader.stream()) {
    console.log('Leader event:', event.type, event.ts);
  }
})();

With AbortSignal

const controller = new AbortController();

(async () => {
  for await (const message of bus.stream({ signal: controller.signal })) {
    console.log(message);
    if (shouldStop) {
      controller.abort();
    }
  }
})();

API

createBus(options)

Creates a new TabBus instance for cross-tab communication.

Options

Option Type Default Description
channel string required BroadcastChannel name
tabId string auto-generated Unique tab identifier

TabBus Methods

publish(type, payload?)

Publishes a message to all tabs listening on the same channel.

bus.publish('user-action', { action: 'click' });
subscribe(type, handler)

Subscribes to messages of a specific type. Returns an unsubscribe function.

const unsubscribe = bus.subscribe('user-action', (message) => {
  console.log(message);
});
subscribeAll(handler)

Subscribes to all messages regardless of type. Returns an unsubscribe function.

const unsubscribe = bus.subscribeAll((message) => {
  console.log(message);
});
stream(options?)

Returns an async iterable of all messages.

for await (const message of bus.stream({ signal: abortSignal })) {
  console.log(message);
}
getTabId()

Returns the current tab's unique identifier.

close()

Closes the bus and cleans up resources.

createLeaderElector(options)

Creates a new LeaderElector instance for leader election.

Options

Option Type Default Description
key string required localStorage key for leader lease
tabId string required Unique tab identifier
leaseMs number 5000 Lease duration in milliseconds
heartbeatMs number 2000 Heartbeat interval in milliseconds
jitterMs number 500 Jitter range to avoid synchronization
buffer BufferConfig { size: 100, overflow: 'oldest' } Buffer configuration for stream generators

BufferConfig:

  • size: Maximum queue size (default: 100)
  • overflow: Overflow policy - 'oldest' (drop oldest), 'newest' (drop newest), or 'error' (throw error)

LeaderElector Methods

start()

Starts the leader election process.

stop()

Stops leader election and releases leadership if held.

isLeader()

Returns true if this tab is currently the leader.

on(event, handler)

Subscribes to a specific leader event. Returns an unsubscribe function.

Events:

  • 'acquire' - This tab became the leader
  • 'lose' - This tab lost leadership
  • 'change' - Leadership changed to another tab
onAll(handler)

Subscribes to all leader events. Returns an unsubscribe function.

stream(options?)

Returns an async iterable of leader events.

getTabId()

Returns the current tab's unique identifier.

Examples

Complete Example: Tab Coordination

import { createBus, createLeaderElector } from 'purrtabby';

// Set up cross-tab communication
const bus = createBus({ channel: 'my-app' });

// Set up leader election
const leader = createLeaderElector({
  key: 'my-app-leader',
  tabId: `tab-${Date.now()}`,
  leaseMs: 5000,
  heartbeatMs: 2000,
});

// Start leader election
leader.start();

// Handle leader events
leader.on('acquire', () => {
  console.log('This tab is now the leader');
  // Only the leader performs certain tasks
  bus.publish('leader-announcement', { tabId: leader.getTabId() });
});

leader.on('lose', () => {
  console.log('This tab is no longer the leader');
});

// Listen for messages from other tabs
bus.subscribe('user-action', (message) => {
  console.log('User action from tab:', message.tabId, message.payload);

  // If we're the leader, process the action
  if (leader.isLeader()) {
    console.log('Processing action as leader');
  }
});

// Publish user actions
bus.publish('user-action', { action: 'click', target: 'button' });

// Cleanup
leader.stop();
bus.close();

Using with purrcat (WebSocket)

📖 Full WebSocket Usage Guide → - Learn how to share WebSocket connections across tabs, implement request-response patterns, and handle edge cases.

import createSocket from 'purrcat';
import { createBus, createLeaderElector } from 'purrtabby';

const bus = createBus({ channel: 'ws-coordinator' });
const leader = createLeaderElector({
  key: 'ws-leader',
  tabId: `tab-${Date.now()}`,
});

leader.start();

// Only the leader maintains WebSocket connection
leader.on('acquire', () => {
  const socket = createSocket({
    url: 'wss://api.example.com/ws',
  });

  // Forward messages from WebSocket to all tabs
  socket.onMessage((message) => {
    bus.publish('ws-message', message);
  });

  // Forward messages from tabs to WebSocket
  bus.subscribe('send-to-ws', (message) => {
    socket.send(message.payload);
  });
});

// All tabs can receive WebSocket messages
bus.subscribe('ws-message', (message) => {
  console.log('WebSocket message:', message.payload);
});

// Any tab can send to WebSocket (leader forwards it)
bus.publish('send-to-ws', { type: 'ping' });

License

MIT

About

Lightweight browser tab communication and leader election library using BroadcastChannel and localStorage

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors