Skip to content

Rovniy/tabs-broadcast

Repository files navigation

TabsBroadcast

npm version npm downloads types minzipped size License

TabsBroadcast is a tiny, zero-dependency TypeScript library for inter-tab communication over the BroadcastChannel API. It elects a single primary tab (leader) — via the Web Locks API with a localStorage fallback — so exactly one tab performs work that must not run in duplicate, while every tab can publish and subscribe to typed events, optionally isolated into layers.

🔎 Live demo: tabs-broadcast.ravy.pro

Table of contents

Features

  • Inter-tab communication over the BroadcastChannel API.
  • Primary/slave (leader) election with automatic failover when the primary tab closes.
  • Web Locks-based leadership (race-free, self-healing) with a localStorage heartbeat fallback.
  • Layers to isolate event streams (great for micro-frontends).
  • Typed events via an optional generic — full autocomplete and payload type-checking.
  • Wildcard (*) listeners, one-time listeners, and bulk registration.
  • Extensible through plugins.
  • Official Nuxt 3/4 module (tabs-broadcast-nuxt) with a client plugin and a useTabsBroadcast() composable.
  • Zero dependencies, ESM + UMD builds, first-class TypeScript types.

Installation

npm install tabs-broadcast
# or: pnpm add tabs-broadcast / yarn add tabs-broadcast / bun add tabs-broadcast

Using Nuxt? Install the official module tabs-broadcast-nuxt instead — it wires everything up and exposes a useTabsBroadcast() composable. See Framework usage → Nuxt.

Quick start

import TabsBroadcast from 'tabs-broadcast';
// or: import { TabsBroadcast } from 'tabs-broadcast';

const bus = new TabsBroadcast();

// Subscribe (in every tab)
bus.on('cart:update', ({ payload }) => {
  console.log('cart changed:', payload);
});

// Publish (only the primary tab emits by default)
if (bus.primary) {
  bus.emit('cart:update', { items: 3 });
}

TabsBroadcast is a singleton: constructing it again returns the existing instance. Call destroy() to tear it down.

Configuration

Pass options to the constructor (or setConfig):

Option Type Default Description
channelName string xploit_tab_channel BroadcastChannel name. Use the same value across micro-frontends to make them talk; use distinct values for fully independent instances.
listenOwnChannel boolean false If true, the emitting tab also receives its own messages locally.
emitByPrimaryOnly boolean true If true, emit() is a no-op on non-primary tabs.
onBecomePrimary (detail: { tabId: string }) => void () => {} Called when this tab acquires primary status.
disableInternalErrors boolean true If false, internal errors are logged via console.error.
const bus = new TabsBroadcast({
  channelName: 'my_app',
  emitByPrimaryOnly: true,
  onBecomePrimary: ({ tabId }) => console.log('I am primary now:', tabId),
});

To make a channel harder for unrelated same-origin scripts to target, use a non-default, hard-to-guess channelName (see Security considerations).

Primary / slave tabs

The library guarantees that exactly one open tab is the primary; the rest are slaves. When the primary tab is closed or refreshed, another tab is promoted automatically. This is useful to:

  • Avoid conflicts — run server sync / writes in one tab only.
  • Save resources — perform background polling once instead of per-tab.
  • Centralize state & notifications — one tab coordinates and de-duplicates alerts.
const bus = new TabsBroadcast({ onBecomePrimary: () => startBackgroundSync() });

bus.on('data:sync', ({ payload }) => updateLocalCache(payload));

if (bus.primary) {
  // only the primary tab reaches the server
  bus.emit('data:sync', await fetchLatest());
}

Layers

Layers split events within one channel into independent streams. A listener registered in a layer only receives events emitted to that layer. Layers also improve performance (fewer iterations) and memory use, and are ideal for isolating micro-frontends sharing one channel.

bus.on('update', onCheckout, 'CHECKOUT');
bus.on('update', onCatalog, 'CATALOG');

bus.emit('update', { id: 1 }, 'CHECKOUT');            // only onCheckout fires
bus.emit('update', { id: 2 }, ['CHECKOUT', 'CATALOG']); // both fire

Typed events

Provide an event map as a generic argument to get full type-safety on on/once/emit:

type Events = {
  'cart:update': { items: number };
  'user:logout': null;
  ping: number;
};

const bus = new TabsBroadcast<Events>();

bus.on('cart:update', ({ payload }) => payload.items.toFixed()); // payload: { items: number }
bus.emit('ping', 42);                                            // ✅
bus.emit('ping', 'oops');                                        // ❌ type error
bus.on('unknown', () => {});                                     // ❌ type error

// Wildcard listeners receive the union of all payloads
bus.on('*', (event) => console.log(event.type, event.payload));

The generic is optional — new TabsBroadcast() stays fully permissive, so existing untyped code keeps working unchanged.

API

on(type, callback, layer?)

Register a persistent listener. Use '*' to capture every event in a layer.

bus.on('eventName', ({ payload }) => console.log(payload));
bus.on('*', (event) => console.log('any event:', event), 'APP_LAYER_0');

once(type, callback, layer?)

Like on, but the listener is removed after the first matching event.

onList(list) / onceList(list)

Register many listeners at once. Each item is [type, callback, layer?].

bus.onList([
  ['eventA', onA],
  ['eventB', onB, 'APP_LAYER_0'],
]);

off(type, layer?)

Unregister all callbacks of type. Omit layer to remove them from every layer.

bus.off('eventName');              // all layers
bus.off('eventName', 'APP_LAYER_0'); // one layer

deleteLayer(layer)

Remove an entire layer and all of its listeners.

bus.deleteLayer('APP_LAYER_0');

emit(type, payload?, layers?)

Emit a message to all listening tabs (and to this tab if listenOwnChannel is enabled). No-op on non-primary tabs when emitByPrimaryOnly is true. layers may be a single name or an array.

bus.emit('eventName');
bus.emit('eventName', { id: 1 });
bus.emit('eventName', { id: 1 }, ['APP_LAYER_0', 'APP_LAYER_3']);

setConfig(config)

Override configuration properties (merged over defaults).

destroy(delay?)

Tear down the channel and election worker, clear all listeners/layers, relinquish leadership, and reset the singleton. Optional delay (ms) defers destruction.

await bus.destroy();     // immediately
await bus.destroy(500);  // after 500ms

getEvents() / getLayers()

Inspect registered listeners and layer names.

bus.getEvents();  // TCallbackItem[]
bus.getLayers();  // e.g. ['APP_LAYER_0', 'APP_LAYER_1']

primary: boolean

Whether the current tab is the primary tab.

if (bus.primary) bus.emit('tick');

isPrimary() still exists but is deprecated — use the primary property.

Plugins

A plugin is a function (instance) => void that extends the instance with new methods or wraps existing ones. Register it with use.

const emitToAllLayersPlugin = (instance) => {
  instance.emitToAllLayers = function (type, payload) {
    this.emit(type, payload, Object.keys(this.layers));
  };
};

const bus = new TabsBroadcast();
bus.use(emitToAllLayersPlugin);
bus.emitToAllLayers('globalEvent', { synced: true });
// Auto-logging plugin
const autoLogPlugin = (instance) => {
  const originalEmit = instance.emit.bind(instance);
  instance.emit = (type, payload, layers) => {
    console.log('[LOG] emit', type, payload, layers);
    originalEmit(type, payload, layers);
  };
};

Security considerations

BroadcastChannel is same-origin, but it is not a private channel. Any script running on the same origin — third-party tags, browser extensions, or injected code via XSS — that knows the channel name can post messages to it. Therefore:

  • Treat every received payload as untrusted input. Do not pass it directly to innerHTML, eval, navigation, or other sensitive sinks without validating it first.
  • TabsBroadcast validates the shape of incoming messages and silently ignores malformed ones. Incoming messages are only delivered to listeners of already-registered layers — a remote message can never create new layers in your instance.
  • If you need to make the channel harder to target, set a non-default, hard-to-guess channelName.

Primary tab election

Election uses the Web Locks API (navigator.locks) when available: leadership is held for as long as the tab is alive and released automatically by the browser on close/crash — so there are no stale entries and no election races. Without Web Locks, the library falls back to a localStorage heartbeat with automatic stale-primary recovery and a deterministic tab-id tie-break. All election state is namespaced per channelName, so independent channels never contend for the same primary slot.

onBecomePrimary receives a { tabId } detail identifying the tab that became primary.

Browser support

Works in all modern browsers that implement BroadcastChannel (Chrome/Edge, Firefox, Safari 15.4+). Where the Web Locks API is available (the common case) it is used for election; otherwise the localStorage fallback kicks in automatically. SSR is safe — the library no-ops without a window.

Framework usage

A minimal React hook:

import { useEffect, useRef, useState } from 'react';
import TabsBroadcast from 'tabs-broadcast';

export function useTabsBroadcast() {
  const ref = useRef<TabsBroadcast>();
  const [isPrimary, setPrimary] = useState(false);

  if (!ref.current) {
    ref.current = new TabsBroadcast({ onBecomePrimary: () => setPrimary(true) });
    setPrimary(ref.current.primary);
  }

  // Note: TabsBroadcast is a singleton; destroy on full app teardown, not per-component.
  return { bus: ref.current, isPrimary };
}

Nuxt

For Nuxt 3/4 there is an official module, tabs-broadcast-nuxt, that registers a client-only plugin and auto-imports a useTabsBroadcast() composable:

// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['tabs-broadcast-nuxt'],
  tabsBroadcast: { channelName: 'my-app', listenOwnChannel: true },
});
<script setup lang="ts">
const bus = useTabsBroadcast();
onMounted(() => bus.on('tab:open', ({ payload }) => console.log(payload)));
</script>

See the module README for SSR notes and primary-tab emit caveats.

Migration: 3.x → 4.0

4.0 is a correctness/security release with a few breaking changes:

  • Namespaced election statelocalStorage/lock keys are now per-channelName. State written by 3.x is not read by 4.0.
  • listenOwnChannel now defaults to false (matches the docs). Pass true explicitly to keep the old self-listening behavior.
  • onBecomePrimary detail is now { tabId } (the isPrimary field was removed).

See the CHANGELOG for the full list, including the off() and disableInternalErrors fixes and the Web Locks election.

Contributing

See CONTRIBUTING.md. In short:

npm install
npm test          # unit tests (Vitest + happy-dom)
npm run test:types # type-level tests
npm run lint
npm run build

License & author

MIT — see LICENSE.

Created by Andrei (Ravy) Rovnyicontact@ravy.pro · ravy.pro

About

TabsBroadcast is a library for managing inter-tab communication via the BroadcastChannel API.

Topics

Resources

License

Contributing

Stars

22 stars

Watchers

1 watching

Forks

Packages

 
 
 

Contributors