A realtime connection manager for
every browser tab, one socket.
@connexis is a framework-agnostic client library for WebSocket, SSE, and polling transports. It pools connections across tabs, elects a leader to own the socket, deduplicates identical subscriptions, and exposes a small Koa-style middleware pipeline for inspecting traffic.
This guide covers installation, transport configuration, connection policies, and the React bindings. Use the sidebar or press / to search.
Key features
Transport agnostic
WebSocket, Server-Sent Events, and HTTP polling adapters, with automatic failover between them.
Multi-tab sharing
One elected leader tab holds the socket. Followers delegate subscriptions over BroadcastChannel.
Policy driven
Choose isolated, shared, or hybrid connection reuse — or supply your own key function.
Installation
Install the core manager, then add the transport adapters your app needs. React bindings are optional and published separately.
# Core manager
pnpm add @connexis/core
# Transport adapters
pnpm add @connexis/transport-websocket @connexis/transport-sse @connexis/transport-polling
# React bindings (optional)
pnpm add @connexis/reactTransports
Initialize a client with whichever transport fits your infrastructure. All three implement the same interface, so switching later means changing one constructor call.
1. WebSocket
Bi-directional, low-latency, full duplex. The right default for chat, live cursors, and collaborative editing.
import { createRealtimeClient } from '@connexis/core';
import { WebSocketTransport } from '@connexis/transport-websocket';
const client = createRealtimeClient({
transport: new WebSocketTransport('wss://api.example.com/events'),
connectionPolicy: 'isolated',
});2. Server-sent events
Unidirectional, HTTP-based. Beyond a plain GET stream, @connexis supports the QUERY method so auth headers and a subscription body can travel with the handshake.
import { SSETransport } from '@connexis/transport-sse';
// Standard GET stream
const client = createRealtimeClient({
transport: new SSETransport('https://api.example.com/stream', {
publishUrl: 'https://api.example.com/publish', // fallback POST for publish()
}),
});
// QUERY stream with headers and a body payload
const scopedClient = createRealtimeClient({
transport: new SSETransport('https://api.example.com/stream', {
method: 'QUERY',
headers: { Authorization: 'Bearer my-token' },
body: { topics: ['news', 'alerts'], userId: '123' },
}),
});3. HTTP polling
An interval-driven fallback for environments that can't hold a persistent connection open.
import { PollingTransport } from '@connexis/transport-polling';
const client = createRealtimeClient({
transport: new PollingTransport('https://api.example.com/poll', {
pollInterval: 5000, // ms
}),
});Authentication & token rotation
Every transport accepts either a static token or an async callback, plus a stabilization guard to stop reconnect storms caused by bad credentials.
1. Dynamic token provider
Pass a function returning Promise<string> and it's invoked before every connect or reconnect attempt — useful for refreshing a JWT just before it expires.
const transport = new WebSocketTransport('wss://api.example.com/events', {
// Called on the initial connect and every retry
authToken: async () => {
const token = await fetchFreshJWT();
return token;
},
});2. Connection stabilization
If bad credentials cause the server to open and immediately close the socket, an unguarded client retries in a tight loop. Set stableThreshold so the retry counter only resets after the connection has genuinely held for that many milliseconds.
const client = createRealtimeClient({
transport,
reconnectOptions: {
maxAttempts: 5, // give up after 5 attempts
delay: 1000, // wait 1s between attempts
stableThreshold: 5000 // must stay open 5s to count as stable
},
});Connection policies
A policy decides whether two subscriptions share one socket or open separate ones.
Every client instance owns its own transport. Good for admin panels or debug consoles that need independent streams.
Exactly one socket exists browser-wide. All tabs delegate to it through the elected leader.
Identical topic + filter pairs are deduplicated onto one socket; distinct filters get their own connection.
Custom policy selector
Pass a function instead of a string. It receives the subscription request and returns a key — subscriptions that resolve to the same key share one transport.
const client = createRealtimeClient({
transport: new WebSocketTransport('wss://api.example.com/events'),
connectionPolicy: (sub) => {
if (sub.metadata?.tier === 'premium') return 'premium-conn';
return 'default-shared-conn';
},
});Policy playground
A simplified, three-tab picture of what each policy does to socket count and leader election. Switch policies or kill the leader to see the log update.
Multi-tab coordination
Passing a Coordinator activates the sync layer. It tries a SharedWorker first, falling back to BroadcastChannel where SharedWorker isn't available, such as mobile Safari.
Automatic failover
When the leader tab closes, it broadcasts leader_exit. Followers immediately re-elect a leader by tab age, and the newly promoted tab opens the socket and re-subscribes everyone without dropping messages.
import { Coordinator } from '@connexis/coordinator';
const client = createRealtimeClient({
transport: new WebSocketTransport('wss://api.example.com/events'),
connectionPolicy: 'shared',
coordinator: new Coordinator('my_app_namespace'),
});Koa-style middleware
Register async middleware to inspect, modify, or reject inbound and outbound payloads.
Each middleware yields with await next(). Throwing inside a middleware aborts the pipeline for that message.
client.use(async (context, next) => {
if (context.direction === 'outbound' && context.topic === 'orders') {
context.payload.timestamp = Date.now();
}
if (context.direction === 'inbound' && context.payload.isSpam) {
throw new Error('Rejected spam message'); // stops the pipeline
}
await next();
});React integration
@connexis/react binds component state to subscriptions, handling setup and teardown automatically — including React Strict Mode's double-invoke behavior.
Provider setup
Wrap your tree with RealtimeProvider so any descendant can reach the client through hooks.
import { RealtimeProvider } from '@connexis/react';
import { client } from './client';
export default function App() {
return (
<RealtimeProvider client={client}>
<Dashboard />
</RealtimeProvider>
);
}Hooks
useRealtime()— the activeRealtimeClientinstance.useConnection()— current connection state and metrics.useSubscription(topic, handler), aliaseduseChannel— subscribes on mount, unsubscribes on unmount.usePublish()— a callback for publishing events.
import { useConnection, useSubscription, usePublish } from '@connexis/react';
function Dashboard() {
const { state, metrics } = useConnection();
const publish = usePublish();
useSubscription('orders', { filter: { region: 'US' } }, (order) => {
console.log('New US order:', order);
});
return (
<div>
<h3>Status: {state}</h3>
<p>Latency: {metrics.latency.toFixed(1)} ms</p>
<button onClick={() => publish('alerts', { text: 'Emergency alert' })}>
Broadcast alert
</button>
</div>
);
}Example: collaborative kanban board
A minimal board where dragging a card publishes a move event over the shared connection, so every open tab updates immediately.
import { useState } from 'react';
import { useConnection, useSubscription, usePublish } from '@connexis/react';
type Column = 'todo' | 'progress' | 'done';
interface Card { id: string; title: string; column: Column; }
function KanbanBoard() {
const { state } = useConnection();
const publish = usePublish();
const [cards, setCards] = useState<Card[]>([
{ id: '1', title: 'Design DB schema', column: 'todo' },
{ id: '2', title: 'Implement auth pipeline', column: 'progress' },
]);
useSubscription('kanban_moves', (payload: { id: string; column: Column }) => {
setCards((prev) =>
prev.map((c) => (c.id === payload.id ? { ...c, column: payload.column } : c))
);
});
const moveCard = async (id: string, column: Column) => {
setCards((prev) => prev.map((c) => (c.id === id ? { ...c, column } : c)));
await publish('kanban_moves', { id, column });
};
return (
<div>
<p>Connection: {state}</p>
{/* columns + moveCard() calls on drop */}
</div>
);
}API reference
1. RealtimeClient
The instance returned by createRealtimeClient(). Extends a Node-style EventEmitter.
class RealtimeClient<TEvents = any> extends EventEmitter {
constructor(config: RealtimeClientConfig);
/** Registers Koa-style middleware */
use(middleware: Middleware): this;
/** Current connection state */
readonly state: ConnectionState;
/** Current aggregated metrics */
readonly metrics: Metrics;
/** Subscribes to a topic; resolves to an unsubscribe function */
subscribe<K>(topic: K, handler: (data: any) => void): Promise<() => Promise<void>>;
subscribe<K>(topic: K, options: SubscribeOptions, handler: (data: any) => void): Promise<() => Promise<void>>;
/** Publishes a payload to a topic */
publish<K>(topic: K, payload: any): Promise<void>;
/** Tears down the client and closes all connections */
destroy(): Promise<void>;
}2. Configuration types
Shapes for connection, retry, and heartbeat behavior.
interface RealtimeClientConfig {
transport: Transport;
connectionPolicy?: ConnectionPolicy; // 'shared' | 'isolated' | 'hybrid' | fn
reconnectOptions?: ReconnectOptions;
heartbeatOptions?: HeartbeatOptions;
coordinator?: ICoordinator;
debug?: boolean;
}interface ReconnectOptions {
maxAttempts?: number; // default: Infinity
delay?: number | ((attempt: number) => number);
timeout?: number; // handshake timeout, ms
stableThreshold?: number; // default: 5000ms
}interface HeartbeatOptions {
enabled?: boolean; // default: true
interval?: number; // ping frequency, default: 30000ms
timeout?: number; // pong timeout, default: 10000ms
message?: any; // custom ping payload
}3. Events
The client emits events for status tracking and logging.
client.on('stateChange', ({ state }) => {
console.log(`Connection state is now: ${state}`);
});
client.on('metricsChange', (metrics) => {
console.log(`Round-trip latency: ${metrics.latency}ms`);
});Benchmarks
Measured on a local loopback connection; treat these as an order-of-magnitude reference rather than a guarantee.
| Operation | Count | Duration | Throughput |
|---|---|---|---|
| Subscribe + unsubscribe | 10,000 | 1.24 s | 16,098 ops/s |
| Publish message | 50,000 | 0.13 s | 374,174 ops/s |