{"_id":"@divyesh_shani/ts-web-pubsub-client","name":"@divyesh_shani/ts-web-pubsub-client","dist-tags":{"latest":"1.0.0"},"versions":{"1.0.0":{"name":"@divyesh_shani/ts-web-pubsub-client","version":"1.0.0","description":"Lightweight Azure Web PubSub client wrapper with typed event support, auto-reconnect, and retry.","author":{"name":"Divyesh M. Shani"},"license":"MIT","main":"dist/index.js","module":"dist/index.mjs","types":"dist/index.d.ts","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.mjs","require":"./dist/index.js"}},"scripts":{"build":"tsup src/index.ts --format cjs,esm --dts --dts-resolve --clean","dev":"tsup src/index.ts --format cjs,esm --dts --watch","typecheck":"tsc --noEmit","test":"vitest run","test:watch":"vitest","test:coverage":"vitest run --coverage","prepublishOnly":"npm run build && npm run typecheck"},"keywords":["azure","web-pubsub","websocket","pubsub","real-time","typescript"],"dependencies":{"@azure/web-pubsub-client":"^1.0.0"},"devDependencies":{"tsup":"^8.0.0","typescript":"^5.4.0","vitest":"^4.1.0"},"peerDependencies":{"typescript":">=4.7.0"},"peerDependenciesMeta":{"typescript":{"optional":true}},"_id":"@divyesh_shani/ts-web-pubsub-client@1.0.0","_nodeVersion":"20.18.2","_npmVersion":"11.4.2","dist":{"integrity":"sha512-qNAXozRYjeMfmcxGPPcocOxcENVaRSf2xzKMjwHd8/PKC7Z3OPt75Qsf6WfrbxJ46eieb97dmRFlUKuQLRQ/Zg==","shasum":"3aba1c5a0038065e6c78d3ee43d673079401d2ce","tarball":"https://registry.npmjs.org/@divyesh_shani/ts-web-pubsub-client/-/ts-web-pubsub-client-1.0.0.tgz","fileCount":7,"unpackedSize":172359,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEUCIQDtseednIGso6/TyscAuq3o1rdXf/9flsHRPKSVknBB+AIgXGSv8sBlHqZZ5fPbd/C4qLXKI7ASiVZOO7fiJXmB4Ww="}]},"_npmUser":{"name":"er.divyesh.shani","email":"er.divyesh.shani@gmail.com"},"directories":{},"maintainers":[{"name":"er.divyesh.shani","email":"er.divyesh.shani@gmail.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/ts-web-pubsub-client_1.0.0_1774325848944_0.3739646756960917"},"_hasShrinkwrap":false}},"time":{"created":"2026-03-24T04:17:28.867Z","1.0.0":"2026-03-24T04:17:29.149Z","modified":"2026-03-24T04:17:29.418Z"},"maintainers":[{"name":"er.divyesh.shani","email":"er.divyesh.shani@gmail.com"}],"description":"Lightweight Azure Web PubSub client wrapper with typed event support, auto-reconnect, and retry.","keywords":["azure","web-pubsub","websocket","pubsub","real-time","typescript"],"author":{"name":"Divyesh M. Shani"},"license":"MIT","readme":"# @divyesh_shani/ts-web-pubsub-client\n\nLightweight Azure Web PubSub client wrapper for TypeScript. Provides typed custom events, automatic reconnection with message recovery, send-retry with deduplication, and full dependency injection for testing.\n\n[[_TOC_]]\n\n---\n\n## Overview\n\nThis package wraps [`@azure/web-pubsub-client`](https://www.npmjs.com/package/@azure/web-pubsub-client) and solves the problems that come up when using the Azure SDK directly in a TypeScript project:\n\n| Raw Azure SDK | This package |\n|---|---|\n| No typed event routing — one `server-message` handler for everything | Named `on(\"event\", handler)` per event type |\n| Basic protocol — no message recovery after disconnect | Reliable protocol — server buffers and replays missed messages |\n| Token captured once — expires silently on reconnect | URL factory called on every reconnect for fresh tokens |\n| No send retry — fire and forget | Exponential back-off retry with ackId deduplication |\n| No input validation — cryptic Azure errors | `PubSubError` with typed codes before anything reaches Azure |\n| Not injectable — impossible to unit test | All dependencies have interfaces, injected via constructor |\n| Manual presence/join sends on every reconnect | `sendOnConnect` config — auto-sends a list of events on every connect/reconnect |\n| Must use callback to queue pre-connect messages | `sendWhenConnected()` — queues a message and sends it the moment the connection opens |\n\n---\n\n## Installation\n\n```bash\nnpm install @divyesh_shani/ts-web-pubsub-client\n```\n\n**Requirements:**\n- Node.js 18+ or a modern browser\n- TypeScript 4.7+ (optional but recommended)\n\n---\n\n## Core Concepts\n\n### How messages flow\n\n```\nAzure Web PubSub Service\n        │\n        │  WebSocket (reliable protocol)\n        ▼\n  AzureEventHandler          ← handles raw Azure events\n        │                       OnServerDataMessageArgs.message.data  (ServerDataMessage)\n        │                       OnGroupDataMessageArgs.message.data   (GroupDataMessage)\n        │                       data: JSONTypes | ArrayBuffer\n        │\n        │  MessageParser: parses { event, data } envelope\n        ▼\n     EventBus                ← routes by event name\n        │\n        ▼\n  your on(\"chat\", handler)   ← your code receives typed data field directly\n```\n\n### Message envelope\n\nEvery message sent or received through this package uses this JSON structure:\n\n```json\n{ \"event\": \"chat\", \"data\": { \"text\": \"hello\", \"from\": \"Alice\" } }\n```\n\nThe `event` field is the routing key. The `data` field is what your handler receives.\n\n**Your server MUST send messages in this exact format.** Here is what actually happens internally, using the real Azure SDK types:\n\n```\nAzure SDK fires:\n  OnServerDataMessageArgs  →  { message: ServerDataMessage }\n  ServerDataMessage        →  { kind: \"serverData\", dataType: \"text\" | \"json\" | ..., data: JSONTypes | ArrayBuffer }\n  JSONTypes                =  string | number | boolean | object\n\n  data field (when dataType = \"text\") = '{\"event\":\"chat\",\"data\":{...}}'  ← JSON string\n  data field (when dataType = \"json\") = { event: \"chat\", data: {...} }   ← already an object\n\n                                    ↓ package parses this into { event, data } envelope\n                                    ↓ if \"event\" field is missing or blank → silently dropped\n                                    ↓\n                      client.on(\"chat\", handler) → handler(data)\n```\n\n**Supported `dataType` values:**\n\n| `dataType` | `data` type | Parsed by this package |\n|---|---|---|\n| `\"text\"` | `string` | Yes — parsed as JSON string |\n| `\"json\"` | `object` | Yes — used directly as object |\n| `\"binary\"` | `ArrayBuffer` | **No** — silently dropped |\n| `\"protobuf\"` | `ArrayBuffer` | **No** — silently dropped |\n\nSend messages from your server using `dataType: \"text\"` or `dataType: \"json\"`.\n\n**What gets silently dropped:**\n\n```ts\n// Missing event field — handler will never fire\n{ \"text\": \"hello\", \"from\": \"Alice\" }\n\n// Flat objects without event field — not routed\n{ \"type\": \"ChatMessage\", \"text\": \"hello\" }\n\n// Binary data (ArrayBuffer) — not supported, silently dropped\ndataType: \"binary\"\n```\n\n**What works:**\n\n```ts\n// Correct — has event + data envelope, sent as text or json\n{ \"event\": \"chat\", \"data\": { \"text\": \"hello\", \"from\": \"Alice\" } }  // dataType: \"text\" or \"json\"\n```\n\n### Reliable protocol\n\nThe package always uses `WebPubSubJsonReliableProtocol`. This means:\n\n- The Azure service assigns a **sequence number** to every message\n- During a disconnect, the service **buffers** messages for a few minutes\n- On reconnect, the client sends the last received sequence number and the service **replays** anything missed\n- Groups are **automatically rejoined** after reconnection\n\nThis is invisible to you — it just works.\n\n---\n\n## Quick Start\n\n```ts\nimport { PubSubClient } from \"@divyesh_shani/ts-web-pubsub-client\";\n\n// 1. Create the client\nconst client = new PubSubClient(\n  {\n    getClientAccessUrl: async () => {\n      const res = await fetch(\"/api/pubsub/negotiate\");\n      return (await res.json()).url;\n    },\n    // Auto-send these events on every connect AND reconnect — no onConnected callback needed\n    sendOnConnect: [\n      { event: \"presence\", data: { status: \"online\", userId: \"u1\" } },\n    ],\n  },\n  {\n    onConnected:    (id)  => console.log(\"Connected:\", id),\n    onDisconnected: (msg) => console.warn(\"Disconnected:\", msg),\n    onStopped:      ()    => console.error(\"Stopped — create a new instance to reconnect\"),\n  }\n);\n\n// 2. Register handlers BEFORE connecting\nclient.on<{ text: string; from: string }>(\"chat\", (data) => {\n  console.log(`${data.from}: ${data.text}`);\n});\n\n// 3. Connect  (sendOnConnect events fire automatically when connected)\nawait client.connect();\n\n// 4. Send immediately\nawait client.send(\"chat\", { text: \"hello\", from: \"Alice\" });\n\n// 5. Or queue a message to send the moment the connection opens\nclient.sendWhenConnected(\"notification\", { msg: \"client ready\" });\n```\n\n---\n\n## API Reference\n\n### Constructor\n\n```ts\nnew PubSubClient(config, callbacks?, deps?)\n```\n\n| Parameter | Type | Required | Description |\n|---|---|---|---|\n| `config` | [`PubSubConfig`](#pubsubconfig) | **Yes** | Connection settings — URL, retry strategy, Azure options |\n| `callbacks` | [`ConnectionCallbacks`](#connectioncallbacks) | No | Lifecycle hooks for connection state changes |\n| `deps` | [`PubSubClientDeps`](#pubsubclientdeps) | No | Inject mocks for unit testing without a real WebSocket |\n\n---\n\n### Methods\n\n#### `connect()`\n\n```ts\nawait client.connect(): Promise<void>\n```\n\nOpens the WebSocket connection. Retries on failure using the [`connectRetry`](#connectretrystrategy) strategy (default: exponential back-off, 5 retries).\n\n| Client state when called | Behaviour |\n|---|---|\n| Not connected | Opens WebSocket. Retries on failure per strategy. |\n| `connect()` already in progress | No-op — concurrent call is ignored. |\n| Already connected | No-op. |\n| `onStopped` has fired | Throws `PubSubError(\"CLIENT_STOPPED\")` — create a new instance. |\n\n**Throws:**\n\n| Code | When |\n|---|---|\n| `CLIENT_STOPPED` | Called after `onStopped` fired. Create a new `PubSubClient`. |\n\n---\n\n#### `disconnect()`\n\n```ts\nclient.disconnect(): void\n```\n\nCloses the connection gracefully. Safe to call before `connect()` — no-op if not connected. After `disconnect()` you can call `connect()` again.\n\n| Client state when called | Behaviour |\n|---|---|\n| Connected | Closes WebSocket. `onStopped` will NOT fire (not treated as an error). |\n| `connect()` in progress | Cancels. |\n| Not connected | No-op. |\n\n---\n\n#### `on(event, handler)`\n\n```ts\nclient.on<TData>(event: string, handler: (data: TData) => void): this\n```\n\nSubscribe to a named custom event. Returns `this` for chaining.\n\n| Parameter | Type | Description |\n|---|---|---|\n| `event` | `string` | Routing key — matches the `event` field in the incoming `{ event, data }` envelope. |\n| `handler` | `(data: TData) => void` | Called with the `data` field from the envelope. |\n| **Returns** | `this` | Enables chaining: `.on(...).on(...).on(...)` |\n\n**Throws:** `PubSubError(\"INVALID_EVENT_NAME\")` if `event` is empty or whitespace-only.\n\n```ts\nclient\n  .on<ChatMessage>(\"chat\", handleChat)\n  .on<UserEvent>(\"user-joined\", handleUserJoined)\n  .on<Notification>(\"notification\", handleNotification);\n```\n\n---\n\n#### `off(event, handler)`\n\n```ts\nclient.off<TData>(event: string, handler: (data: TData) => void): this\n```\n\nUnsubscribe a previously registered handler. Must pass the **same function reference** used in `on()` — anonymous functions cannot be unsubscribed.\n\n| Parameter | Type | Description |\n|---|---|---|\n| `event` | `string` | The event name used when subscribing. |\n| `handler` | `(data: TData) => void` | The exact function reference passed to `on()`. |\n| **Returns** | `this` | Enables chaining. |\n\n**Throws:** `PubSubError(\"INVALID_EVENT_NAME\")` if `event` is empty or whitespace-only.\n\n---\n\n#### `send(event, data, retryOptions?)`\n\n```ts\nawait client.send<TData>(event: string, data: TData, retryOptions?: RetryOptions): Promise<void>\n```\n\nSend a custom event to the **application server** as a Web PubSub user-event. Wraps `data` in `{ event, data }` envelope and sends with automatic retry.\n\n| Parameter | Type | Required | Description |\n|---|---|---|---|\n| `event` | `string` | **Yes** | Event name. Must be non-empty. |\n| `data` | `TData` | **Yes** | Any JSON-serialisable value. Sent as the `data` field in the envelope. |\n| `retryOptions` | [`RetryOptions`](#retryoptions) | No | Override default retry behaviour for this call only. |\n| **Returns** | `Promise<void>` | — | Resolves when the Azure service acknowledges the message. |\n\n**Throws:**\n\n| Code | When |\n|---|---|\n| `NOT_CONNECTED` | Called before `connect()` or after `disconnect()`. |\n| `CLIENT_STOPPED` | Called after `onStopped` fired. |\n| `INVALID_EVENT_NAME` | `event` is empty or whitespace-only. |\n\n---\n\n#### `sendWhenConnected(event, data, retryOptions?)`\n\n```ts\nawait client.sendWhenConnected<TData>(event: string, data: TData, retryOptions?: RetryOptions): Promise<void>\n```\n\nQueue a custom event to be sent **as soon as the client is connected**.\n\n| When called | Behaviour |\n|---|---|\n| Already connected | Sends immediately — equivalent to `send()`. |\n| Not yet connected | Held in queue. Sent the moment `connected` event fires. |\n| Client is stopped | Rejects immediately with `CLIENT_STOPPED`. |\n| `disconnect()` called while queued | Queue is preserved — items flush on the next `connect()` call. |\n\nUnlike `send()`, this does **not** throw `NOT_CONNECTED` — it waits for the connection.\nQueued items are sent concurrently when the connection opens. Each resolves or rejects independently.\n\n> **Use this for one-off messages** (e.g. session init payloads) that should fire once when the connection opens.\n> **Use [`sendOnConnect`](#sendonconnect) in config** for messages that must fire on every connect AND reconnect (e.g. presence).\n\n| Parameter | Type | Required | Description |\n|---|---|---|---|\n| `event` | `string` | **Yes** | Event name. Must be non-empty. |\n| `data` | `TData` | **Yes** | Any JSON-serialisable value. |\n| `retryOptions` | [`RetryOptions`](#retryoptions) | No | Override default retry behaviour. |\n| **Returns** | `Promise<void>` | — | Resolves when the Azure service acknowledges the message. |\n\n**Throws:**\n\n| Code | When |\n|---|---|\n| `CLIENT_STOPPED` | Called after `onStopped` fired. |\n| `INVALID_EVENT_NAME` | `event` is empty or whitespace-only. |\n\n```ts\n// Queue before connect() is called — sends the moment the connection opens\nclient.sendWhenConnected(\"session-init\", { sessionId: \"abc\", userId: \"u1\" });\nawait client.connect(); // ← session-init fires here automatically\n\n// Or queue during a retry window — sends when the retry eventually succeeds\nclient.connect(); // might be retrying\nclient.sendWhenConnected(\"ready\", { timestamp: Date.now() });\n```\n\n---\n\n#### `sendToGroup(group, event, data, retryOptions?)`\n\n```ts\nawait client.sendToGroup<TData>(group: string, event: string, data: TData, retryOptions?: RetryOptions): Promise<void>\n```\n\nBroadcast a custom event to **all clients that have joined a group**. Wraps `data` in `{ event, data }` envelope and sends with automatic retry.\n\n| Parameter | Type | Required | Description |\n|---|---|---|---|\n| `group` | `string` | **Yes** | Target group name. Must be non-empty. |\n| `event` | `string` | **Yes** | Event name. Must be non-empty. |\n| `data` | `TData` | **Yes** | Any JSON-serialisable value. |\n| `retryOptions` | [`RetryOptions`](#retryoptions) | No | Override default retry behaviour for this call only. |\n| **Returns** | `Promise<void>` | — | Resolves when the Azure service acknowledges the message. |\n\n**Throws:**\n\n| Code | When |\n|---|---|\n| `NOT_CONNECTED` | Called before `connect()` or after `disconnect()`. |\n| `CLIENT_STOPPED` | Called after `onStopped` fired. |\n| `INVALID_GROUP_NAME` | `group` is empty or whitespace-only. |\n| `INVALID_EVENT_NAME` | `event` is empty or whitespace-only. |\n\n---\n\n#### `joinGroup(group)`\n\n```ts\nawait client.joinGroup(group: string): Promise<void>\n```\n\nJoin a group on the Azure service. Once joined, messages broadcast to the group via `sendToGroup()` are delivered to this client's `on()` handlers. When `autoRejoinGroups: true` (default), the client rejoins automatically after every reconnection.\n\n| Parameter | Type | Required | Description |\n|---|---|---|---|\n| `group` | `string` | **Yes** | Group name. Must be non-empty. |\n| **Returns** | `Promise<void>` | — | Resolves when the Azure service confirms the join. |\n\n**Throws:**\n\n| Code | When |\n|---|---|\n| `NOT_CONNECTED` | Called before `connect()` or after `disconnect()`. |\n| `CLIENT_STOPPED` | Called after `onStopped` fired. |\n| `INVALID_GROUP_NAME` | `group` is empty or whitespace-only. |\n\n---\n\n#### `leaveGroup(group)`\n\n```ts\nawait client.leaveGroup(group: string): Promise<void>\n```\n\nLeave a group. After leaving, group messages will no longer be delivered to this client.\n\n| Parameter | Type | Required | Description |\n|---|---|---|---|\n| `group` | `string` | **Yes** | Group name. Must be non-empty. |\n| **Returns** | `Promise<void>` | — | Resolves when the Azure service confirms the leave. |\n\n**Throws:**\n\n| Code | When |\n|---|---|\n| `NOT_CONNECTED` | Called before `connect()` or after `disconnect()`. |\n| `CLIENT_STOPPED` | Called after `onStopped` fired. |\n| `INVALID_GROUP_NAME` | `group` is empty or whitespace-only. |\n\n---\n\n## Configuration Reference\n\n### `PubSubConfig`\n\nPassed as the **first argument** to the constructor.\n\n| Property | Type | Required | Default | Description |\n|---|---|---|---|---|\n| `getClientAccessUrl` | `string \\| ClientAccessUrlFactory` | **Yes** | — | Static URL or async factory. Factory is called on every (re)connect — use it in production to always get a fresh token. |\n| `autoRejoinGroups` | `boolean` | No | `true` | Automatically rejoin all previously joined groups after every reconnection. |\n| `connectRetry` | [`ConnectRetryStrategy`](#connectretrystrategy) | No | built-in exp. back-off | Custom retry schedule for the initial `connect()` call. |\n| `sendOnConnect` | [`AutoSendItem[]`](#autosenditem) | No | `[]` | Events to auto-send on every connect and reconnect. No callback needed — the package fires them automatically. |\n| `clientOptions` | [`clientOptions`](#clientoptions--azure-sdk-pass-through) | No | `{}` | Azure SDK options forwarded to `WebPubSubClient`. `autoRejoinGroups` is excluded (managed by this package). |\n\n**`ClientAccessUrlFactory`:**\n\n```ts\ntype ClientAccessUrlFactory = () => Promise<string> | string;\n```\n\n```ts\n// Static URL — dev/testing only. Token expires, not suitable for production.\n{ getClientAccessUrl: \"wss://my-service.webpubsub.azure.com/client/hubs/chat?access_token=...\" }\n\n// Factory — recommended for production. Fresh token on every connect.\n{\n  getClientAccessUrl: async () => {\n    const res = await fetch(\"/api/pubsub/negotiate\");\n    return (await res.json()).url;\n  }\n}\n```\n\n---\n\n### `AutoSendItem`\n\nEach entry in `sendOnConnect` is an `AutoSendItem`:\n\n```ts\ninterface AutoSendItem {\n  event:        string;          // event name — must be non-empty\n  data:         unknown;         // any JSON-serialisable payload\n  retryOptions?: RetryOptions;   // optional per-item retry override\n}\n```\n\nItems are sent concurrently on every `connected` event (initial connect + every auto-reconnect). Errors after retry exhaustion are silently dropped — the connection remains open and other items are unaffected.\n\n```ts\nconst client = new PubSubClient({\n  getClientAccessUrl: async () => (await fetch(\"/api/negotiate\")).json().url,\n\n  sendOnConnect: [\n    // Presence — tell the server this user is online\n    { event: \"presence\",  data: { status: \"online\", userId: currentUser.id } },\n\n    // Room join — re-announce current room on every reconnect\n    { event: \"join-room\", data: { roomId: activeRoom.id } },\n\n    // Critical init — override retry for this specific item\n    { event: \"session-ready\", data: { version: APP_VERSION }, retryOptions: { maxRetries: 5 } },\n  ],\n});\n```\n\n> **Tip:** `sendOnConnect` fires on every reconnect too — perfect for presence and room membership announcements that must stay current after a network drop.\n\n---\n\n### `ConnectRetryStrategy`\n\n```ts\ntype ConnectRetryStrategy = (attempt: number, error: unknown) => number | null;\n```\n\n| Parameter | Type | Description |\n|---|---|---|\n| `attempt` | `number` | Retry number — `1` = first retry after the initial failure, `2` = second retry, … |\n| `error` | `unknown` | The error thrown by the failed `azure.start()` call. |\n| **Returns** | `number \\| null` | Milliseconds to wait before the next attempt, or `null` to stop retrying and throw. |\n\n**Default built-in schedule** (used when `connectRetry` is not set):\n\n| Attempt | Delay before this attempt |\n|---|---|\n| 1 (first try) | 0 ms — immediate |\n| 2 (retry 1) | 1 000 ms |\n| 3 (retry 2) | 2 000 ms |\n| 4 (retry 3) | 4 000 ms |\n| 5 (retry 4) | 8 000 ms |\n| 6 (retry 5) | 16 000 ms → throws |\n\n**Custom strategy examples:**\n\n```ts\n// No retry — fail immediately on first error\nconnectRetry: () => null\n\n// Fixed 2 s delay, max 3 retries\nconnectRetry: (attempt) => attempt > 3 ? null : 2_000\n\n// Retry forever with exponential back-off capped at 30 s\nconnectRetry: (attempt) => Math.min(1000 * 2 ** (attempt - 1), 30_000)\n\n// Log + exponential back-off\nconnectRetry: (attempt, error) => {\n  console.warn(`connect attempt ${attempt} failed:`, error);\n  return attempt > 5 ? null : 1000 * Math.pow(2, attempt - 1);\n}\n```\n\n---\n\n### `ConnectionCallbacks`\n\nPassed as the **second argument** to the constructor. All callbacks are optional.\n\n| Callback | Signature | When it fires |\n|---|---|---|\n| `onConnected` | `(connectionId: string) => void` | WebSocket opened — initial connect AND after every auto-reconnect |\n| `onDisconnected` | `(message?: string) => void` | Connection dropped — auto-recovery is in progress (not a final failure) |\n| `onStopped` | `() => void` | All Azure reconnect attempts exhausted — client is permanently stopped |\n| `onRejoinGroupFailed` | `(group: string, error: Error) => void` | Reconnected but could not auto-rejoin a group (`autoRejoinGroups: true` only) |\n| `onConnectRetry` | `(attempt: number, error: unknown) => void` | `connect()` failed and will retry — fires before each retry, NOT after the final failure |\n\n```ts\nconst callbacks: ConnectionCallbacks = {\n  onConnected(connectionId) {\n    updateStatus(\"connected\");        // safe to send messages now\n  },\n\n  onDisconnected(message) {\n    updateStatus(\"disconnected\");     // show \"reconnecting…\" — recovery is automatic\n  },\n\n  onStopped() {\n    updateStatus(\"stopped\");          // permanent — must create a new PubSubClient\n    showError(\"Connection lost. Please refresh.\");\n  },\n\n  onRejoinGroupFailed(group, error) {\n    console.error(`Could not rejoin \"${group}\":`, error.message);\n  },\n\n  onConnectRetry(attempt, error) {\n    console.warn(`connect() attempt ${attempt} failed — retrying…`, error);\n  },\n};\n```\n\n---\n\n### `RetryOptions`\n\nPassed as the **optional last argument** to `send()` and `sendToGroup()`. Controls retry behaviour for that specific call only.\n\n| Property | Type | Default | Description |\n|---|---|---|---|\n| `maxRetries` | `number` | `3` | Maximum number of retries after the first failure. `0` = no retry. |\n| `baseDelayMs` | `number` | `200` | Initial delay in ms before the first retry. Doubles on each subsequent retry (exponential back-off). |\n\n**Back-off schedule with defaults (`maxRetries: 3`, `baseDelayMs: 200`):**\n\n| Attempt | Delay before this attempt |\n|---|---|\n| 1 (first try) | 0 ms — immediate |\n| 2 (retry 1) | 200 ms |\n| 3 (retry 2) | 400 ms |\n| 4 (retry 3) | 800 ms → throws if still failing |\n\n```ts\n// More aggressive retry for critical messages\nawait client.send(\"audit-log\", data, { maxRetries: 6, baseDelayMs: 300 });\n\n// Fail fast — fire-and-forget events\nawait client.send(\"typing\", data, { maxRetries: 0 });\n```\n\n---\n\n### `clientOptions` — Azure SDK pass-through\n\n`PubSubConfig.clientOptions` is forwarded directly to the underlying Azure `WebPubSubClient`.\n\n> `autoRejoinGroups` is **excluded** — use `PubSubConfig.autoRejoinGroups` instead.\n\n| Property | Type | Default | Description |\n|---|---|---|---|\n| `autoReconnect` | `boolean` | `true` | Whether the Azure SDK automatically reconnects after a connection drop. |\n| `messageRetryOptions` | [`WebPubSubRetryOptions`](#webpubsubretryoptions) | — | Azure-level retry for `joinGroup`, `leaveGroup`, `sendToGroup`, `sendEvent` at the SDK level. |\n| `reconnectRetryOptions` | [`WebPubSubRetryOptions`](#webpubsubretryoptions) | — | Azure-level retry for automatic reconnection. Only applies when `autoReconnect: true`. |\n| `protocol` | `WebPubSubClientProtocol` | `JsonReliableProtocol` | Wire protocol. This package always uses the reliable JSON protocol — do not override. |\n\n#### `WebPubSubRetryOptions`\n\n| Property | Type | Default | Description |\n|---|---|---|---|\n| `maxRetries` | `number` | `3` | Number of retry attempts. |\n| `retryDelayInMs` | `number` | — | Base delay between retries in ms. Used in both Fixed and Exponential modes. |\n| `maxRetryDelayInMs` | `number` | — | Maximum delay cap between retries. Only applies in Exponential mode. |\n| `mode` | `\"Fixed\" \\| \"Exponential\"` | `\"Fixed\"` | Retry timing mode. |\n\n---\n\n### `PubSubError`\n\nExtends `Error`. Always carries a typed `code` property — use it instead of parsing message strings.\n\n```ts\nclass PubSubError extends Error {\n  readonly code: PubSubErrorCode; // typed error code\n  readonly message: string;       // human-readable description\n  readonly name: \"PubSubError\";\n}\n```\n\n```ts\ntry {\n  await client.send(\"chat\", data);\n} catch (err) {\n  if (err instanceof PubSubError) {\n    switch (err.code) {\n      case \"NOT_CONNECTED\":    showToast(\"Still connecting…\"); break;\n      case \"CLIENT_STOPPED\":  showToast(\"Connection lost. Refresh to reconnect.\"); break;\n      default:                console.error(err.message);\n    }\n  }\n}\n```\n\n**Error codes:**\n\n| Code | Thrown by | When |\n|---|---|---|\n| `NOT_CONNECTED` | `send`, `sendToGroup`, `joinGroup`, `leaveGroup` | Called before `connect()` completes, or after `disconnect()`. |\n| `CLIENT_STOPPED` | `connect`, `send`, `sendWhenConnected`, `sendToGroup`, `joinGroup`, `leaveGroup` | Called after `onStopped` fired. Create a new `PubSubClient` to reconnect. |\n| `INVALID_EVENT_NAME` | `on`, `off`, `send`, `sendWhenConnected`, `sendToGroup` | `event` argument is empty string or whitespace-only. |\n| `INVALID_GROUP_NAME` | `joinGroup`, `leaveGroup`, `sendToGroup` | `group` argument is empty string or whitespace-only. |\n| `INVALID_CONFIG` | Constructor | `getClientAccessUrl` is an empty string. |\n\n---\n\n### `PubSubClientDeps`\n\nPassed as the **optional third argument** to the constructor. Inject mock implementations to test without a real WebSocket connection.\n\n| Property | Type | Description |\n|---|---|---|\n| `azureClient` | `IAzureWebPubSubClient` | Replace the real Azure `WebPubSubClient`. Implement `start`, `stop`, `on`, `joinGroup`, `leaveGroup`, `sendEvent`, `sendToGroup`. |\n| `eventBus` | `IEventBus` | Replace the internal `EventBus`. Implement `subscribe`, `unsubscribe`, `dispatch`, `hasListeners`. |\n| `messageParser` | `IMessageParser` | Replace the internal `MessageParser`. Implement `parse(raw): PubSubMessage \\| null`. |\n\n```ts\nimport type { IAzureWebPubSubClient } from \"@divyesh_shani/ts-web-pubsub-client\";\n\nconst mockAzure: IAzureWebPubSubClient = {\n  start:       vi.fn().mockResolvedValue(undefined),\n  stop:        vi.fn(),\n  joinGroup:   vi.fn().mockResolvedValue(undefined),\n  leaveGroup:  vi.fn().mockResolvedValue(undefined),\n  sendEvent:   vi.fn().mockResolvedValue(undefined),\n  sendToGroup: vi.fn().mockResolvedValue(undefined),\n  on:          vi.fn(),\n};\n\nconst client = new PubSubClient(\n  { getClientAccessUrl: \"wss://unused\" },\n  {},\n  { azureClient: mockAzure }\n);\n```\n\n---\n\n## Type Glossary\n\nAll types exported from this package.\n\n| Type | Kind | Description |\n|---|---|---|\n| `PubSubConfig` | `interface` | First constructor argument — URL, retry, `sendOnConnect`, Azure pass-through options |\n| `AutoSendItem` | `interface` | One entry in `sendOnConnect` — `{ event, data, retryOptions? }` |\n| `ConnectionCallbacks` | `interface` | Second constructor argument — lifecycle hooks |\n| `PubSubClientDeps` | `interface` | Third constructor argument — dependency injection for testing |\n| `RetryOptions` | `interface` | Per-call retry tuning for `send()`, `sendWhenConnected()`, and `sendToGroup()` |\n| `ConnectRetryStrategy` | `type` | `(attempt: number, error: unknown) => number \\| null` — custom initial connect retry |\n| `ClientAccessUrlFactory` | `type` | `() => Promise<string> \\| string` — factory variant of `getClientAccessUrl` |\n| `PubSubMessage<TData>` | `interface` | Wire envelope: `{ event: string, data: TData }` — used by server and parser |\n| `EventHandler<TData>` | `type` | `(data: TData) => void` — handler passed to `on()` / `off()` |\n| `PubSubError` | `class` | Thrown for consumer mistakes. Has `code: PubSubErrorCode` and `message: string`. |\n| `PubSubErrorCode` | `type` | `\"NOT_CONNECTED\" \\| \"CLIENT_STOPPED\" \\| \"INVALID_EVENT_NAME\" \\| \"INVALID_GROUP_NAME\" \\| \"INVALID_CONFIG\"` |\n| `IAzureWebPubSubClient` | `interface` | Contract for the Azure WebSocket client (inject via `deps.azureClient`) |\n| `IEventBus` | `interface` | Contract for the internal event bus (inject via `deps.eventBus`) |\n| `IMessageParser` | `interface` | Contract for the message parser (inject via `deps.messageParser`) |\n\n---\n\n## Features\n\n### Typed custom events\n\nEvery event has a name and a typed payload. TypeScript will catch payload shape mismatches at compile time.\n\n```ts\ninterface OrderUpdate {\n  orderId: string;\n  status: \"processing\" | \"shipped\" | \"delivered\";\n  updatedAt: string;\n}\n\nclient.on<OrderUpdate>(\"order-update\", (data) => {\n  // data.orderId    ✓ string\n  // data.status     ✓ \"processing\" | \"shipped\" | \"delivered\"\n  // data.mistyped   ✗ TypeScript error\n});\n```\n\n---\n\n### Auto-reconnection and message recovery\n\nWhen the connection drops:\n\n```\nConnection drops\n     │\n     ▼\n1. RECOVER — reconnect with same connection ID\n   Service replays buffered messages (missed during disconnect)\n     │\n     ▼  recovery fails\n2. RECONNECT — new connection ID\n   Groups rejoined automatically (autoRejoinGroups: true)\n     │\n     ▼  all attempts exhausted\n3. STOPPED — onStopped fires, client is done\n```\n\nNo code required from you. Use `onConnected`, `onDisconnected`, and `onStopped` callbacks to update your UI.\n\n---\n\n### Auto-send on connect / reconnect (`sendOnConnect`)\n\nPass a list of events in config and the package sends them automatically every time the connection opens — including after every automatic reconnect. No callback wiring needed.\n\n```ts\nconst client = new PubSubClient({\n  getClientAccessUrl: async () => (await fetch(\"/api/negotiate\")).json().url,\n\n  sendOnConnect: [\n    { event: \"presence\",  data: { status: \"online\", userId: currentUser.id } },\n    { event: \"join-room\", data: { roomId: activeRoom } },\n  ],\n});\n\nawait client.connect();\n// ↑ \"presence\" and \"join-room\" are sent automatically after connected fires.\n// On every reconnect they fire again — presence stays current without any extra code.\n```\n\n**When to use `sendOnConnect` vs `onConnected` callback:**\n\n| Scenario | Use |\n|---|---|\n| Presence / room join that must re-fire on every reconnect | `sendOnConnect` |\n| Multiple events that all re-fire on every reconnect | `sendOnConnect` |\n| Conditional logic before sending (e.g. check a flag) | `onConnected` callback |\n| Update UI state on connect | `onConnected` callback |\n\n---\n\n### Queue a one-off message before connecting (`sendWhenConnected`)\n\nSend a message that fires exactly once — the moment the connection opens. Useful for session-init payloads or messages that must be queued before `connect()` is called.\n\n```ts\n// Queue BEFORE connect() — fires automatically when connected\nclient.sendWhenConnected(\"session-init\", { sessionId: \"abc\", userId: currentUser.id });\nawait client.connect(); // ← session-init fires here\n\n// Or queue during a retry window\nclient.connect(); // retrying in background\nclient.sendWhenConnected(\"ready\", { timestamp: Date.now() }); // queued, sends when connected\n```\n\n**Differences from `sendOnConnect`:**\n\n| Feature | `sendOnConnect` (config) | `sendWhenConnected()` (method) |\n|---|---|---|\n| Fires on reconnect | **Yes** — every time | **No** — once only |\n| Configured at | Constructor | Call site |\n| Result | Fire-and-forget | `Promise<void>` you can `await` |\n| Use for | Presence, room membership | Session init, one-time payloads |\n\n---\n\n### Groups\n\nGroups let you broadcast messages to a subset of connected clients — like chat rooms, notification channels, or tenant-scoped feeds.\n\n```ts\n// Join\nawait client.joinGroup(\"tenant-abc\");\n\n// Send to everyone in the group\nawait client.sendToGroup(\"tenant-abc\", \"notification\", {\n  title: \"New report available\",\n  reportId: \"rpt-123\",\n});\n\n// Leave\nawait client.leaveGroup(\"tenant-abc\");\n```\n\nGroups are rejoined automatically after reconnection when `autoRejoinGroups: true`.\n\n---\n\n### Send retry with deduplication\n\n`send()` and `sendToGroup()` retry on failure using exponential back-off. On each retry the same `ackId` is reused, so the Azure service can deduplicate — a message that was delivered before the error was thrown will not be delivered twice.\n\n---\n\n### Dependency injection for testing\n\nAll collaborators implement public interfaces and can be replaced via the `deps` constructor argument. No real WebSocket is needed in tests.\n\n```ts\nimport type { IAzureWebPubSubClient } from \"@divyesh_shani/ts-web-pubsub-client\";\n\nconst mockAzure: IAzureWebPubSubClient = {\n  start: vi.fn().mockResolvedValue(undefined),\n  stop: vi.fn(),\n  joinGroup: vi.fn().mockResolvedValue(undefined),\n  leaveGroup: vi.fn().mockResolvedValue(undefined),\n  sendEvent: vi.fn().mockResolvedValue(undefined),\n  sendToGroup: vi.fn().mockResolvedValue(undefined),\n  on: vi.fn(),\n};\n\nconst client = new PubSubClient(\n  { getClientAccessUrl: \"wss://unused\" },\n  {},\n  { azureClient: mockAzure }\n);\n```\n\n---\n\n## Usage Examples\n\n### Example 1 — Basic setup (plain TypeScript)\n\n```ts\n// pubsub/client.ts\nimport { PubSubClient } from \"@divyesh_shani/ts-web-pubsub-client\";\nimport apiClient from \"../lib/apiClient\";\nimport { currentUser } from \"../auth\";\n\nexport const client = new PubSubClient(\n  {\n    getClientAccessUrl: async () => {\n      const res = await apiClient.get(\"/pubsub/negotiate\");\n      return res.data.url;\n    },\n    autoRejoinGroups: true,\n\n    // Auto-sent on every connect and reconnect — no onConnected callback needed\n    sendOnConnect: [\n      { event: \"presence\", data: { status: \"online\", userId: currentUser.id } },\n    ],\n  },\n  {\n    onConnected:    (id)  => console.log(\"[PubSub] connected:\", id),\n    onDisconnected: ()    => console.warn(\"[PubSub] disconnected — recovering\"),\n    onStopped:      ()    => console.error(\"[PubSub] stopped\"),\n  }\n);\n```\n\n```ts\n// main.ts\nimport { client } from \"./pubsub/client\";\nimport { registerAllHandlers } from \"./pubsub/events\";\n\nregisterAllHandlers();\nawait client.connect();\n```\n\n---\n\n### Example 2 — React with a custom hook\n\n```ts\n// hooks/usePubSub.ts\nimport { useEffect, useRef, useState } from \"react\";\nimport { PubSubClient } from \"@divyesh_shani/ts-web-pubsub-client\";\nimport type { ConnectionCallbacks } from \"@divyesh_shani/ts-web-pubsub-client\";\n\nexport type ConnectionStatus = \"idle\" | \"connecting\" | \"connected\" | \"disconnected\" | \"stopped\";\n\nexport function usePubSub(getClientAccessUrl: () => Promise<string>) {\n  const [status, setStatus] = useState<ConnectionStatus>(\"idle\");\n  const clientRef = useRef<PubSubClient | null>(null);\n\n  useEffect(() => {\n    const callbacks: ConnectionCallbacks = {\n      onConnected:    () => setStatus(\"connected\"),\n      onDisconnected: () => setStatus(\"disconnected\"),\n      onStopped:      () => setStatus(\"stopped\"),\n    };\n\n    const instance = new PubSubClient({ getClientAccessUrl }, callbacks);\n    clientRef.current = instance;\n\n    setStatus(\"connecting\");\n    instance.connect();\n\n    return () => instance.disconnect();\n  }, []);\n\n  return { client: clientRef.current, status };\n}\n```\n\n```tsx\n// App.tsx\nimport { usePubSub } from \"./hooks/usePubSub\";\n\nexport function App() {\n  const { client, status } = usePubSub(async () => {\n    const res = await fetch(\"/api/pubsub/negotiate\");\n    return (await res.json()).url;\n  });\n\n  useEffect(() => {\n    if (!client) return;\n    client.on<{ text: string }>(\"chat\", (data) => console.log(data.text));\n  }, [client]);\n\n  return <span>Status: {status}</span>;\n}\n```\n\n---\n\n### Example 3 — React context (recommended for multi-component apps)\n\n```tsx\n// context/PubSubContext.tsx\nimport { createContext, useContext, useEffect, useRef, useState } from \"react\";\nimport { PubSubClient } from \"@divyesh_shani/ts-web-pubsub-client\";\nimport type { ConnectionCallbacks } from \"@divyesh_shani/ts-web-pubsub-client\";\n\ntype Status = \"idle\" | \"connecting\" | \"connected\" | \"disconnected\" | \"stopped\";\n\ninterface PubSubContextValue {\n  client: PubSubClient | null;\n  status: Status;\n}\n\nconst PubSubContext = createContext<PubSubContextValue>({ client: null, status: \"idle\" });\n\nexport function PubSubProvider({ children }: { children: React.ReactNode }) {\n  const [status, setStatus] = useState<Status>(\"idle\");\n  const clientRef = useRef<PubSubClient | null>(null);\n\n  useEffect(() => {\n    const callbacks: ConnectionCallbacks = {\n      onConnected:    () => setStatus(\"connected\"),\n      onDisconnected: () => setStatus(\"disconnected\"),\n      onStopped:      () => setStatus(\"stopped\"),\n    };\n\n    const instance = new PubSubClient(\n      {\n        getClientAccessUrl: async () => {\n          const res = await fetch(\"/api/pubsub/negotiate\");\n          return (await res.json()).url;\n        },\n      },\n      callbacks\n    );\n\n    clientRef.current = instance;\n    setStatus(\"connecting\");\n    instance.connect();\n\n    return () => instance.disconnect();\n  }, []);\n\n  return (\n    <PubSubContext.Provider value={{ client: clientRef.current, status }}>\n      {children}\n    </PubSubContext.Provider>\n  );\n}\n\nexport const usePubSubContext = () => useContext(PubSubContext);\n```\n\n```tsx\n// components/StatusBadge.tsx\nimport { usePubSubContext } from \"../context/PubSubContext\";\n\nconst labels = {\n  idle:         \"Not connected\",\n  connecting:   \"Connecting…\",\n  connected:    \"Live\",\n  disconnected: \"Reconnecting…\",\n  stopped:      \"Connection lost\",\n};\n\nexport function StatusBadge() {\n  const { status } = usePubSubContext();\n  return <span>{labels[status]}</span>;\n}\n```\n\n---\n\n### Example 4 — Zustand integration\n\n```ts\n// stores/pubsub.store.ts\nimport { create } from \"zustand\";\nimport { PubSubClient, PubSubError } from \"@divyesh_shani/ts-web-pubsub-client\";\n\ntype Status = \"idle\" | \"connecting\" | \"connected\" | \"disconnected\" | \"stopped\";\n\ninterface PubSubState {\n  client: PubSubClient | null;\n  status: Status;\n  init: () => void;\n  teardown: () => void;\n}\n\nexport const usePubSubStore = create<PubSubState>((set, get) => ({\n  client: null,\n  status: \"idle\",\n\n  init() {\n    const instance = new PubSubClient(\n      {\n        getClientAccessUrl: async () => {\n          const res = await fetch(\"/api/pubsub/negotiate\");\n          return (await res.json()).url;\n        },\n      },\n      {\n        onConnected:    () => set({ status: \"connected\" }),\n        onDisconnected: () => set({ status: \"disconnected\" }),\n        onStopped:      () => set({ status: \"stopped\" }),\n      }\n    );\n\n    set({ client: instance, status: \"connecting\" });\n    instance.connect();\n  },\n\n  teardown() {\n    get().client?.disconnect();\n    set({ client: null, status: \"idle\" });\n  },\n}));\n```\n\n```tsx\n// App.tsx\nimport { useEffect } from \"react\";\nimport { usePubSubStore } from \"./stores/pubsub.store\";\n\nexport function App() {\n  const { init, teardown, status } = usePubSubStore();\n\n  useEffect(() => {\n    init();\n    return () => teardown();\n  }, []);\n\n  return <span>{status}</span>;\n}\n```\n\n---\n\n### Example 5 — Event handlers in separate files\n\n```ts\n// pubsub/events/chat.handler.ts\nimport { client } from \"../client\";\n\nexport interface ChatMessage {\n  id: string;\n  text: string;\n  from: string;\n  roomId: string;\n  sentAt: string;\n}\n\nfunction handleChat(data: ChatMessage): void {\n  // update your store, dispatch to Redux, etc.\n  console.log(`[${data.roomId}] ${data.from}: ${data.text}`);\n}\n\nexport function registerChatHandler():   void { client.on<ChatMessage>(\"chat\", handleChat); }\nexport function unregisterChatHandler(): void { client.off<ChatMessage>(\"chat\", handleChat); }\n```\n\n```ts\n// pubsub/events/notification.handler.ts\nimport { client } from \"../client\";\n\nexport interface Notification {\n  id: string;\n  title: string;\n  body: string;\n  severity: \"info\" | \"warning\" | \"error\";\n}\n\nfunction handleNotification(data: Notification): void {\n  console.log(`[${data.severity}] ${data.title}`);\n}\n\nexport function registerNotificationHandler():   void { client.on<Notification>(\"notification\", handleNotification); }\nexport function unregisterNotificationHandler(): void { client.off<Notification>(\"notification\", handleNotification); }\n```\n\n```ts\n// pubsub/events/index.ts  ← single entry point for all handlers\nexport { registerChatHandler, unregisterChatHandler }             from \"./chat.handler\";\nexport { registerNotificationHandler, unregisterNotificationHandler } from \"./notification.handler\";\nexport { registerPresenceHandlers, unregisterPresenceHandlers }   from \"./presence.handler\";\n```\n\n```ts\n// pubsub/index.ts  ← the only file the rest of your app imports from\nimport { client } from \"./client\";\nimport {\n  registerChatHandler,\n  registerNotificationHandler,\n  registerPresenceHandlers,\n} from \"./events\";\n\nexport async function startPubSub(): Promise<void> {\n  registerChatHandler();\n  registerNotificationHandler();\n  registerPresenceHandlers();\n  await client.connect();\n}\n\nexport function stopPubSub(): void {\n  client.disconnect();\n}\n\nexport { client };\n```\n\n---\n\n### Example 6 — Typing indicator\n\n```ts\n// pubsub/events/typing.handler.ts\nimport { client } from \"../client\";\n\ninterface TypingEvent { userId: string; roomId: string; }\n\nlet hideTypingTimer: ReturnType<typeof setTimeout> | null = null;\n\nfunction handleTyping(data: TypingEvent): void {\n  showTypingIndicator(data.userId);\n\n  // Auto-hide if no further events arrive within 2 seconds\n  if (hideTypingTimer) clearTimeout(hideTypingTimer);\n  hideTypingTimer = setTimeout(() => hideTypingIndicator(), 2000);\n}\n\nexport function registerTypingHandler():   void { client.on<TypingEvent>(\"typing\", handleTyping); }\nexport function unregisterTypingHandler(): void { client.off<TypingEvent>(\"typing\", handleTyping); }\n```\n\nSending a typing event:\n\n```ts\n// Throttle to avoid flooding — send at most once every 800ms\nlet lastTypingSent = 0;\n\nfunction onInputChange(): void {\n  const now = Date.now();\n  if (now - lastTypingSent < 800) return;\n  lastTypingSent = now;\n\n  client.send(\"typing\", { userId: currentUserId, roomId: activeRoom })\n    .catch(() => {}); // typing events are fire-and-forget\n}\n```\n\n---\n\n### Example 7 — Manual connection control\n\n```tsx\n// components/ChatRoom.tsx\nimport { useRef, useState } from \"react\";\nimport { PubSubClient, PubSubError } from \"@divyesh_shani/ts-web-pubsub-client\";\nimport { registerChatHandler, unregisterChatHandler } from \"../pubsub/events/chat.handler\";\n\nexport function ChatRoom({ roomId }: { roomId: string }) {\n  const clientRef = useRef<PubSubClient | null>(null);\n  const [joined, setJoined] = useState(false);\n  const [error, setError] = useState<string | null>(null);\n\n  async function join() {\n    try {\n      const instance = new PubSubClient({\n        getClientAccessUrl: async () => {\n          const res = await fetch(`/api/pubsub/negotiate?room=${roomId}`);\n          return (await res.json()).url;\n        },\n      });\n\n      clientRef.current = instance;\n      registerChatHandler();\n\n      await instance.connect();\n      await instance.joinGroup(roomId);\n      setJoined(true);\n    } catch (err) {\n      if (err instanceof PubSubError) setError(err.message);\n    }\n  }\n\n  function leave() {\n    unregisterChatHandler();\n    clientRef.current?.leaveGroup(roomId);\n    clientRef.current?.disconnect();\n    clientRef.current = null;\n    setJoined(false);\n  }\n\n  if (error) return <p>Error: {error}</p>;\n\n  return joined\n    ? <button onClick={leave}>Leave room</button>\n    : <button onClick={join}>Join room</button>;\n}\n```\n\n---\n\n### Example 8 — Error handling\n\n```ts\nimport { PubSubClient, PubSubError } from \"@divyesh_shani/ts-web-pubsub-client\";\n\nasync function safeSend(event: string, data: unknown): Promise<void> {\n  try {\n    await client.send(event, data);\n  } catch (err) {\n    if (!(err instanceof PubSubError)) throw err; // re-throw unknown errors\n\n    switch (err.code) {\n      case \"NOT_CONNECTED\":\n        showToast(\"Still connecting — please try again in a moment\");\n        break;\n\n      case \"CLIENT_STOPPED\":\n        showToast(\"Connection lost. Refresh the page to reconnect.\");\n        break;\n\n      case \"INVALID_EVENT_NAME\":\n      case \"INVALID_GROUP_NAME\":\n        // These are programmer errors — they should never reach production\n        console.error(\"[BUG]\", err.message);\n        break;\n\n      default:\n        console.error(\"PubSub send failed:\", err.message);\n    }\n  }\n}\n```\n\n---\n\n### Example 9 — Presence and session init with `sendOnConnect` + `sendWhenConnected`\n\n```ts\n// pubsub/client.ts\nimport { PubSubClient } from \"@divyesh_shani/ts-web-pubsub-client\";\nimport { getAuthToken } from \"../auth\";\n\n// Presence event that must fire on EVERY connect and reconnect\nconst client = new PubSubClient(\n  {\n    getClientAccessUrl: async () => {\n      const { url } = await (await fetch(\"/api/pubsub/negotiate\")).json();\n      return url;\n    },\n    sendOnConnect: [\n      // Sent automatically after every connect — keeps presence status current\n      { event: \"presence\",   data: { status: \"online\" } },\n      // Re-announce active room membership on every reconnect\n      { event: \"join-room\",  data: { roomId: getActiveRoom() } },\n    ],\n  },\n  {\n    onConnected:    (id) => console.log(\"connected:\", id),\n    onDisconnected: ()   => updateStatusBadge(\"reconnecting\"),\n    onStopped:      ()   => updateStatusBadge(\"offline\"),\n  }\n);\n\nexport { client };\n```\n\n```ts\n// app-init.ts — queue a one-off session-init before connecting\nimport { client } from \"./pubsub/client\";\nimport { getSession } from \"./auth\";\n\n// sendWhenConnected queues the message and sends it once the connection opens.\n// Unlike sendOnConnect, this does NOT repeat on reconnect.\nclient.sendWhenConnected(\"session-init\", {\n  sessionId: getSession().id,\n  appVersion: APP_VERSION,\n  locale: navigator.language,\n});\n\n// Register all event handlers\nclient.on(\"chat\",         handleChat);\nclient.on(\"notification\", handleNotification);\n\n// Connect — \"session-init\" fires automatically when the connection opens.\n// \"presence\" and \"join-room\" also fire now (and on every future reconnect).\nawait client.connect();\n```\n\n---\n\n## Consumer Project Structure\n\n### Minimal structure — single feature\n\n```\nsrc/\n  pubsub/\n    client.ts          ← PubSubClient singleton\n    events.ts          ← all on() registrations in one file\n  App.tsx\n```\n\n### Standard structure — multiple features\n\n```\nsrc/\n  pubsub/\n    client.ts              ← PubSubClient singleton\n    index.ts               ← startPubSub() / stopPubSub()\n    events/\n      chat.handler.ts      ← \"chat\" event\n      notification.handler.ts\n      presence.handler.ts\n      index.ts             ← re-exports all register/unregister\n  hooks/\n    usePubSubStatus.ts     ← connection status hook\n  App.tsx\n```\n\n### Large app structure — domain-driven\n\n```\nsrc/\n  pubsub/\n    client.ts              ← singleton client\n    index.ts               ← startPubSub() / stopPubSub()\n\n  features/\n    chat/\n      chat.handler.ts      ← registers chat events, updates chat store\n      chat.store.ts\n      Chat.tsx\n\n    notifications/\n      notification.handler.ts\n      notification.store.ts\n      NotificationBell.tsx\n\n    presence/\n      presence.handler.ts\n      presence.store.ts\n      OnlineUsers.tsx\n\n  shared/\n    hooks/\n      usePubSubStatus.ts\n    components/\n      ConnectionBadge.tsx\n```\n\nEach feature folder owns its own handler file. No cross-feature imports. The `pubsub/index.ts` imports from each feature and calls `registerXxxHandler()` in one place.\n\n---\n\n## Testing Guide\n\n### Unit test — event handler\n\n```ts\n// events/chat.handler.test.ts\nimport { describe, it, expect, vi } from \"vitest\";\nimport { EventBus } from \"@divyesh_shani/ts-web-pubsub-client\";\n\ndescribe(\"chat handler\", () => {\n  it(\"logs the message\", () => {\n    const bus = new EventBus();\n    const spy = vi.fn();\n\n    bus.subscribe(\"chat\", spy);\n    bus.dispatch(\"chat\", { text: \"hello\", from: \"Alice\" });\n\n    expect(spy).toHaveBeenCalledWith({ text: \"hello\", from: \"Alice\" });\n  });\n});\n```\n\n### Unit test — PubSubClient with mocked Azure\n\n```ts\n// client/pub-sub-client.test.ts\nimport { describe, it, expect, vi, beforeEach } from \"vitest\";\nimport { PubSubClient, PubSubError } from \"@divyesh_shani/ts-web-pubsub-client\";\nimport type { IAzureWebPubSubClient } from \"@divyesh_shani/ts-web-pubsub-client\";\n\nfunction buildMockAzure(): IAzureWebPubSubClient {\n  return {\n    start:       vi.fn().mockResolvedValue(undefined),\n    stop:        vi.fn(),\n    joinGroup:   vi.fn().mockResolvedValue(undefined),\n    leaveGroup:  vi.fn().mockResolvedValue(undefined),\n    sendEvent:   vi.fn().mockResolvedValue(undefined),\n    sendToGroup: vi.fn().mockResolvedValue(undefined),\n    on:          vi.fn(),\n  };\n}\n\ndescribe(\"PubSubClient\", () => {\n  let mockAzure: IAzureWebPubSubClient;\n  let client: PubSubClient;\n\n  beforeEach(() => {\n    mockAzure = buildMockAzure();\n    client = new PubSubClient(\n      { getClientAccessUrl: \"wss://unused\" },\n      {},\n      { azureClient: mockAzure }\n    );\n  });\n\n  it(\"calls azure.start on connect\", async () => {\n    await client.connect();\n    expect(mockAzure.start).toHaveBeenCalledOnce();\n  });\n\n  it(\"does not call start twice if already connected\", async () => {\n    await client.connect();\n    await client.connect();\n    expect(mockAzure.start).toHaveBeenCalledOnce();\n  });\n\n  it(\"throws NOT_CONNECTED if send called before connect\", async () => {\n    await expect(client.send(\"chat\", {}))\n      .rejects.toMatchObject({ code: \"NOT_CONNECTED\" });\n  });\n\n  it(\"throws INVALID_EVENT_NAME for blank event\", async () => {\n    await client.connect();\n    await expect(client.send(\"  \", {}))\n      .rejects.toMatchObject({ code: \"INVALID_EVENT_NAME\" });\n  });\n\n  it(\"routes incoming server-message to on() handler\", () => {\n    // Get the listener registered for \"server-message\"\n    const listeners = new Map<string, (e: unknown) => void>();\n    vi.mocked(mockAzure.on).mockImplementation((event: string, fn: (e: unknown) => void) => {\n      listeners.set(event, fn);\n    });\n\n    const newClient = new PubSubClient(\n      { getClientAccessUrl: \"wss://unused\" },\n      {},\n      { azureClient: mockAzure }\n    );\n\n    const handler = vi.fn();\n    newClient.on(\"chat\", handler);\n\n    // Simulate incoming message — mirrors the real Azure SDK shape:\n    // OnServerDataMessageArgs → { message: ServerDataMessage }\n    // ServerDataMessage       → { kind: \"serverData\", dataType: \"text\", data: JSONTypes | ArrayBuffer }\n    listeners.get(\"server-message\")?.({\n      message: { kind: \"serverData\", dataType: \"text\", data: JSON.stringify({ event: \"chat\", data: { text: \"hi\" } }) },\n    });\n\n    expect(handler).toHaveBeenCalledWith({ text: \"hi\" });\n  });\n});\n```\n\n### Unit test — MessageParser in isolation\n\n```ts\n// core/message-parser.test.ts\nimport { describe, it, expect } from \"vitest\";\nimport { MessageParser } from \"@divyesh_shani/ts-web-pubsub-client\";\n\ndescribe(\"MessageParser\", () => {\n  const parser = new MessageParser();\n\n  it(\"parses a valid JSON string\", () => {\n    const result = parser.parse('{\"event\":\"chat\",\"data\":{\"text\":\"hi\"}}');\n    expect(result).toEqual({ event: \"chat\", data: { text: \"hi\" } });\n  });\n\n  it(\"parses a plain object\", () => {\n    const result = parser.parse({ event: \"chat\", data: { text: \"hi\" } });\n    expect(result).toEqual({ event: \"chat\", data: { text: \"hi\" } });\n  });\n\n  it(\"returns null for missing event field\", () => {\n    expect(parser.parse({ data: { text: \"hi\" } })).toBeNull();\n  });\n\n  it(\"returns null for blank event name\", () => {\n    expect(parser.parse({ event: \"  \", data: {} })).toBeNull();\n  });\n\n  it(\"returns null for non-JSON string\", () => {\n    expect(parser.parse(\"not json\")).toBeNull();\n  });\n\n  it(\"returns null for null input\", () => {\n    expect(parser.parse(null)).toBeNull();\n  });\n});\n```\n\n---\n\n## Common Mistakes\n\n### Mistake 1 — Listening to `\"server-message\"` as an event name\n\n```ts\n// WRONG — this registers \"server-message\" as a custom event name in the EventBus,\n// NOT the Azure transport event. It will never fire for incoming server messages.\nclient.on(\"server-message\", (data) => {\n  console.log(data); // ✗ never called\n});\n```\n\nThe Azure SDK fires a raw `\"server-message\"` transport event internally (`OnServerDataMessageArgs`, where `message.data` is `JSONTypes | ArrayBuffer`). This package handles that raw event for you, parses the `{ event, data }` envelope from `message.data`, and dispatches by the envelope's `event` field. You register handlers by the **envelope's `event` field**, not by the Azure transport event name.\n\n```ts\n// CORRECT — register by the event name inside the envelope\nclient.on(\"chat\", (data) => {\n  console.log(data); // ✓ called with the \"data\" field from the envelope\n});\n```\n\n---\n\n### Mistake 2 — Server sending flat messages without an envelope\n\nIf your server sends a flat object without the `event` field, your handlers will never fire:\n\n```ts\n// Server sends (missing event field):\n{ \"value\": \"ok\" }                                 // ✗ no \"event\" field → silently dropped\n{ \"type\": \"notification\", \"title\": \"Hello\" }      // ✗ no \"event\" field → silently dropped\n```\n\nYour server must wrap the payload in a `{ event, data }` envelope:\n\n```ts\n// Server sends:\n{ \"event\": \"ODResponse\",    \"data\": { \"value\": \"ok\" } }      // ✓\n{ \"event\": \"notification\",  \"data\": { \"title\": \"Hello\" } }   // ✓\n```\n\nClient then registers:\n\n```ts\nclient.on(\"ODResponse\",   (data) => console.log(data.value));\nclient.on(\"notification\", (data) => console.log(data.title));\n```\n\n---\n\n### Mistake 3 — Calling `send()` before `connect()`\n\n```ts\n// WRONG — connect() not awaited yet\nclient.connect();\nawait client.send(\"chat\", data); // ✗ throws PubSubError(\"NOT_CONNECTED\")\n\n// CORRECT — await connect() first\nawait client.connect();\nawait client.send(\"chat\", data); // ✓\n\n// ALSO CORRECT — use sendWhenConnected() to queue and auto-send when connected\nclient.sendWhenConnected(\"chat\", data); // ✓ queued, fires the moment connection opens\nawait client.connect();\n```\n\n---\n\n### Mistake 4 — Creating a new client on every render (React)\n\n```tsx\n// WRONG — new WebSocket on every render\nfunction MyComponent() {\n  const client = new PubSubClient(...); // ✗ new instance every render\n}\n\n// CORRECT — stable ref\nfunction MyComponent() {\n  const clientRef = useRef(new PubSubClient(...)); // ✓ created once\n}\n```\n\n---\n\n## Reconnection Behaviour\n\n| Scenario | What happens |\n|---|---|\n| Brief network blip (< buffer window) | Connection recovers silently. Missed messages are replayed. `onDisconnected` then `onConnected` fire. |\n| Network down for a few minutes | Same as above if within buffer window. |\n| Network down beyond buffer window | Reconnects but missed messages during the gap are lost. |\n| Azure service restart | Client reconnects to the new instance automatically. |\n| Expired token on reconnect | URL factory is called fresh — new token is fetched. |\n| All reconnect attempts fail | `onStopped` fires. Create a new `PubSubClient` to try again. |\n\n**`send()` does not queue offline messages** — it throws `PubSubError(\"NOT_CONNECTED\")` when not connected. Show the user feedback or use `sendWhenConnected()` if you want the message to wait:\n\n```ts\n// Option A — fail immediately, show feedback\ntry {\n  await client.send(\"chat\", data);\n} catch (err) {\n  if (err instanceof PubSubError && err.code === \"NOT_CONNECTED\") {\n    showToast(\"Not connected. Message not sent.\");\n  }\n}\n\n// Option B — queue the message; it sends the moment the connection opens\nawait client.sendWhenConnected(\"chat\", data);\n```\n\n---\n\n## React StrictMode Warning\n\nIn development, React 18 StrictMode mounts every component twice. If you create and connect your client inside a `useEffect` without a cleanup function, you will end up with two simultaneous WebSocket connections.\n\n```tsx\n// BAD — two connections in StrictMode\nuseEffect(() => {\n  client.connect();\n}, []);\n\n// GOOD — cleanup always closes the connection\nuseEffect(() => {\n  client.connect();\n  return () => client.disconnect();\n}, []);\n```\n\nKeep the client instance in a `useRef`, not `useState`. A WebSocket connection is not UI state.\n\n---\n\n## Source Structure\n\n```\nsrc/\n  client/\n    pub-sub-client.ts        Public API — connect, disconnect, on, off, send, groups\n    azure-event-handler.ts   Handles all Azure WebSocket events, routes to EventBus\n    guards.ts                Input validation — throws PubSubError with typed codes\n\n  core/\n    event-bus.ts             Subscribe / unsubscribe / dispatch (no Azure dependency)\n    message-parser.ts        Raw unknown → typed PubSubMessage | null\n    retry.ts                 Exponential back-off with ackId deduplication\n\n  azure/\n    client-factory.ts        Creates the underlying Azure WebPubSubClient\n\n  errors/\n    pub-sub-error.ts         PubSubError with typed PubSubErrorCode\n\n  types/\n    config.ts                PubSubConfig, ClientAccessUrlFactory, PubSubClientDeps\n    message.ts               PubSubMessage, EventHandler\n    callbacks.ts             ConnectionCallbacks, RetryOptions\n    interfaces.ts            IEventBus, IMessageParser, IAzureWebPubSubClient\n\n  index.ts                   Package entry point — all public exports\n```\n","readmeFilename":"README.md","_rev":"1-5e5067ab60a1ad66c61bd32af382c779"}