C
connexis
v1.0.7 MIT license TypeScript-first connected

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.

Terminal
# 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/react

Transports

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.

websocket-setup.ts
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.

sse-setup.ts
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.

polling-setup.ts
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.

auth-rotation.ts
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.

stabilization.ts
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.

isolated

Every client instance owns its own transport. Good for admin panels or debug consoles that need independent streams.

shared

Exactly one socket exists browser-wide. All tabs delegate to it through the elected leader.

hybrid

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.

custom-policy.ts
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.

Realtime server 1 active socket
Leader
Tab A
connected · owns socket
Follower
Tab B
delegates to Tab A
Follower
Tab C
delegates to Tab A

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.

coordination.ts
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.

middleware.ts
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.

Provider.tsx
import { RealtimeProvider } from '@connexis/react';
import { client } from './client';

export default function App() {
  return (
    <RealtimeProvider client={client}>
      <Dashboard />
    </RealtimeProvider>
  );
}

Hooks

  • useRealtime() — the active RealtimeClient instance.
  • useConnection() — current connection state and metrics.
  • useSubscription(topic, handler), aliased useChannel — subscribes on mount, unsubscribes on unmount.
  • usePublish() — a callback for publishing events.
Dashboard.tsx
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.

KanbanBoard.tsx
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.

client-interface.d.ts
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.

RealtimeClientConfig
interface RealtimeClientConfig {
  transport: Transport;
  connectionPolicy?: ConnectionPolicy; // 'shared' | 'isolated' | 'hybrid' | fn
  reconnectOptions?: ReconnectOptions;
  heartbeatOptions?: HeartbeatOptions;
  coordinator?: ICoordinator;
  debug?: boolean;
}
ReconnectOptions
interface ReconnectOptions {
  maxAttempts?: number;                     // default: Infinity
  delay?: number | ((attempt: number) => number);
  timeout?: number;                         // handshake timeout, ms
  stableThreshold?: number;                 // default: 5000ms
}
HeartbeatOptions
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.

events.ts
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.

OperationCountDurationThroughput
Subscribe + unsubscribe 10,000 1.24 s 16,098 ops/s
Publish message 50,000 0.13 s 374,174 ops/s