@diugemi/kabarcast-client 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +160 -0
- package/dist/index.cjs +296 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +148 -0
- package/dist/index.d.ts +148 -0
- package/dist/index.js +268 -0
- package/dist/index.js.map +1 -0
- package/package.json +50 -0
package/README.md
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# @diugemi/kabarcast-client
|
|
2
|
+
|
|
3
|
+
TypeScript client for [kabarcast](https://github.com/BerieGithub/kabarcast) -
|
|
4
|
+
realtime message broadcasting with channel-scoped auth and automatic reconnect.
|
|
5
|
+
|
|
6
|
+
Works in the browser, and in Node 22+ (which ships a global `WebSocket`).
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
npm install @diugemi/kabarcast-client
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Usage
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
import { KabarcastClient } from '@diugemi/kabarcast-client';
|
|
16
|
+
|
|
17
|
+
const kabar = new KabarcastClient({
|
|
18
|
+
url: import.meta.env.VITE_KABARCAST_URL, // wss://kabarcast.example.com
|
|
19
|
+
getToken: async () => {
|
|
20
|
+
// Your own backend mints a short-lived, channel-scoped token.
|
|
21
|
+
const { data } = await api.get('/realtime/token');
|
|
22
|
+
return data.token;
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
await kabar.connect();
|
|
27
|
+
await kabar.subscribe(`ssap:user:${userId}`);
|
|
28
|
+
|
|
29
|
+
kabar.on('notification.created', (n) => {
|
|
30
|
+
showToast(n.title);
|
|
31
|
+
});
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## What it handles for you
|
|
35
|
+
|
|
36
|
+
- **Token refresh.** `getToken()` is called on *every* connect attempt, so an
|
|
37
|
+
expired short-lived token can never wedge a reconnect.
|
|
38
|
+
- **Reconnect with jittered backoff.** Full jitter, so a fleet of clients
|
|
39
|
+
disconnected by a deploy does not stampede the hub when it returns.
|
|
40
|
+
- **Automatic re-subscription.** Channels you subscribed to are restored after
|
|
41
|
+
a reconnect. Your application code does nothing.
|
|
42
|
+
- **Ack correlation.** `subscribe()` resolves when the hub acknowledges, and
|
|
43
|
+
rejects if the channel is refused, so authorisation failures surface as
|
|
44
|
+
errors instead of silence.
|
|
45
|
+
- **Refused channels are not retried.** A channel your token does not grant is
|
|
46
|
+
dropped from the restore set rather than retried on every reconnect.
|
|
47
|
+
|
|
48
|
+
## API
|
|
49
|
+
|
|
50
|
+
### `new KabarcastClient(options)`
|
|
51
|
+
|
|
52
|
+
| Option | Default | Description |
|
|
53
|
+
|---|---|---|
|
|
54
|
+
| `url` | required | Hub base URL, e.g. `wss://kabarcast.example.com` |
|
|
55
|
+
| `getToken` | required | Returns a channel token (sync or async) |
|
|
56
|
+
| `minReconnectDelayMs` | `500` | Backoff floor |
|
|
57
|
+
| `maxReconnectDelayMs` | `30000` | Backoff ceiling |
|
|
58
|
+
| `maxReconnectAttempts` | `Infinity` | Give up after N consecutive failures |
|
|
59
|
+
| `ackTimeoutMs` | `10000` | How long to wait for a subscribe ack |
|
|
60
|
+
| `webSocketFactory` | - | Supply a WebSocket implementation (Node < 22) |
|
|
61
|
+
| `debug` | `false` | Log lifecycle to `console.debug` |
|
|
62
|
+
|
|
63
|
+
### Methods
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
await kabar.connect(); // open the connection
|
|
67
|
+
const sub = await kabar.subscribe('channel'); // resolves on ack
|
|
68
|
+
await sub.unsubscribe(); // or kabar.unsubscribe('channel')
|
|
69
|
+
|
|
70
|
+
const off = kabar.on('event.name', (data, meta) => {});
|
|
71
|
+
const offAll = kabar.on('*', (data, meta) => {}); // every event
|
|
72
|
+
off(); // remove handler
|
|
73
|
+
|
|
74
|
+
kabar.onStateChange((s) => console.log(s));
|
|
75
|
+
// 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'closed'
|
|
76
|
+
|
|
77
|
+
kabar.connectionState; // current state
|
|
78
|
+
kabar.close(); // close and stop reconnecting
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Handlers receive `(data, meta)` where `meta` is
|
|
82
|
+
`{ channel, event, ts }`. Type the payload with a generic:
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
type Notification = { id: string; title: string };
|
|
86
|
+
kabar.on<Notification>('notification.created', (n) => n.title);
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## React
|
|
90
|
+
|
|
91
|
+
One client per app, shared through context or a module singleton. Do not
|
|
92
|
+
create one per component.
|
|
93
|
+
|
|
94
|
+
```tsx
|
|
95
|
+
// realtime.ts
|
|
96
|
+
export const kabar = new KabarcastClient({
|
|
97
|
+
url: import.meta.env.VITE_KABARCAST_URL,
|
|
98
|
+
getToken: () => api.get('/realtime/token').then((r) => r.data.token),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// useChannel.ts
|
|
102
|
+
export function useChannel<T>(channel: string, event: string, onEvent: (d: T) => void) {
|
|
103
|
+
const handler = useRef(onEvent);
|
|
104
|
+
handler.current = onEvent; // avoid resubscribing on every render
|
|
105
|
+
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
let sub: Subscription | undefined;
|
|
108
|
+
let cancelled = false;
|
|
109
|
+
|
|
110
|
+
kabar.connect()
|
|
111
|
+
.then(() => kabar.subscribe(channel))
|
|
112
|
+
.then((s) => { if (cancelled) s.unsubscribe(); else sub = s; })
|
|
113
|
+
.catch(console.error);
|
|
114
|
+
|
|
115
|
+
const off = kabar.on<T>(event, (d) => handler.current(d));
|
|
116
|
+
|
|
117
|
+
return () => { cancelled = true; off(); sub?.unsubscribe(); };
|
|
118
|
+
}, [channel, event]);
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
```tsx
|
|
123
|
+
useChannel<Notification>(`ssap:user:${userId}`, 'notification.created', (n) => {
|
|
124
|
+
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
|
125
|
+
});
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Node
|
|
129
|
+
|
|
130
|
+
Node 22+ works out of the box. On older Node, pass a factory:
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
import WebSocket from 'ws';
|
|
134
|
+
|
|
135
|
+
const kabar = new KabarcastClient({
|
|
136
|
+
url: process.env.KABARCAST_URL!,
|
|
137
|
+
getToken: () => mintToken(),
|
|
138
|
+
webSocketFactory: (url) => new WebSocket(url) as any,
|
|
139
|
+
});
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Publishing is a server concern
|
|
143
|
+
|
|
144
|
+
This package only **receives**. Broadcasting requires the service secret,
|
|
145
|
+
which must never reach a browser. Publish from your backend with a plain HTTP
|
|
146
|
+
call to `POST /v1/publish` (see the
|
|
147
|
+
[main README](https://github.com/BerieGithub/kabarcast#integrating-from-your-backend)).
|
|
148
|
+
|
|
149
|
+
## Development
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
npm install
|
|
153
|
+
npm run typecheck
|
|
154
|
+
npm run build
|
|
155
|
+
npm test # runs against a stand-in hub over real WebSockets
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## License
|
|
159
|
+
|
|
160
|
+
MIT
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
KabarcastClient: () => KabarcastClient,
|
|
24
|
+
backoffDelay: () => backoffDelay
|
|
25
|
+
});
|
|
26
|
+
module.exports = __toCommonJS(index_exports);
|
|
27
|
+
|
|
28
|
+
// src/backoff.ts
|
|
29
|
+
function backoffDelay(attempt, minMs, maxMs) {
|
|
30
|
+
const exp = Math.min(maxMs, minMs * 2 ** Math.max(0, attempt - 1));
|
|
31
|
+
return Math.floor(Math.random() * exp);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// src/client.ts
|
|
35
|
+
var OPEN = 1;
|
|
36
|
+
var KabarcastClient = class {
|
|
37
|
+
opts;
|
|
38
|
+
ws = null;
|
|
39
|
+
state = "idle";
|
|
40
|
+
/** Channels the caller wants; replayed after every reconnect. */
|
|
41
|
+
desired = /* @__PURE__ */ new Set();
|
|
42
|
+
pending = /* @__PURE__ */ new Map();
|
|
43
|
+
handlers = /* @__PURE__ */ new Map();
|
|
44
|
+
stateHandlers = /* @__PURE__ */ new Set();
|
|
45
|
+
attempt = 0;
|
|
46
|
+
reconnectTimer = null;
|
|
47
|
+
closedByUser = false;
|
|
48
|
+
connecting = null;
|
|
49
|
+
constructor(options) {
|
|
50
|
+
this.opts = {
|
|
51
|
+
minReconnectDelayMs: 500,
|
|
52
|
+
maxReconnectDelayMs: 3e4,
|
|
53
|
+
maxReconnectAttempts: Number.POSITIVE_INFINITY,
|
|
54
|
+
ackTimeoutMs: 1e4,
|
|
55
|
+
debug: false,
|
|
56
|
+
...options
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/** Current connection state. */
|
|
60
|
+
get connectionState() {
|
|
61
|
+
return this.state;
|
|
62
|
+
}
|
|
63
|
+
/** Opens the connection. Resolves once the socket is open. */
|
|
64
|
+
connect() {
|
|
65
|
+
if (this.state === "connected") return Promise.resolve();
|
|
66
|
+
if (this.connecting) return this.connecting;
|
|
67
|
+
this.closedByUser = false;
|
|
68
|
+
this.connecting = this.open().finally(() => {
|
|
69
|
+
this.connecting = null;
|
|
70
|
+
});
|
|
71
|
+
return this.connecting;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Subscribes to a channel and resolves once the hub acknowledges it.
|
|
75
|
+
*
|
|
76
|
+
* The channel is remembered, so it is restored automatically after a
|
|
77
|
+
* reconnect without the caller doing anything.
|
|
78
|
+
*/
|
|
79
|
+
async subscribe(channel) {
|
|
80
|
+
this.desired.add(channel);
|
|
81
|
+
if (this.state === "connected") {
|
|
82
|
+
await this.send("subscribe", channel);
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
channel,
|
|
86
|
+
unsubscribe: () => this.unsubscribe(channel)
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/** Stops receiving events for a channel. */
|
|
90
|
+
async unsubscribe(channel) {
|
|
91
|
+
this.desired.delete(channel);
|
|
92
|
+
if (this.state === "connected") {
|
|
93
|
+
await this.send("unsubscribe", channel);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Registers a handler for an event name. The name '*' receives every event.
|
|
98
|
+
* Returns a function that removes the handler.
|
|
99
|
+
*/
|
|
100
|
+
on(event, handler) {
|
|
101
|
+
let set = this.handlers.get(event);
|
|
102
|
+
if (!set) {
|
|
103
|
+
set = /* @__PURE__ */ new Set();
|
|
104
|
+
this.handlers.set(event, set);
|
|
105
|
+
}
|
|
106
|
+
set.add(handler);
|
|
107
|
+
return () => this.off(event, handler);
|
|
108
|
+
}
|
|
109
|
+
off(event, handler) {
|
|
110
|
+
this.handlers.get(event)?.delete(handler);
|
|
111
|
+
}
|
|
112
|
+
/** Observes connection state changes. Returns an unsubscribe function. */
|
|
113
|
+
onStateChange(handler) {
|
|
114
|
+
this.stateHandlers.add(handler);
|
|
115
|
+
return () => {
|
|
116
|
+
this.stateHandlers.delete(handler);
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/** Closes the connection and stops reconnecting. */
|
|
120
|
+
close() {
|
|
121
|
+
this.closedByUser = true;
|
|
122
|
+
if (this.reconnectTimer) {
|
|
123
|
+
clearTimeout(this.reconnectTimer);
|
|
124
|
+
this.reconnectTimer = null;
|
|
125
|
+
}
|
|
126
|
+
this.failPending(new Error("kabarcast: client closed"));
|
|
127
|
+
this.ws?.close(1e3, "client closed");
|
|
128
|
+
this.ws = null;
|
|
129
|
+
this.setState("closed");
|
|
130
|
+
}
|
|
131
|
+
// ---------------------------------------------------------------- internals
|
|
132
|
+
async open() {
|
|
133
|
+
this.setState(this.attempt === 0 ? "connecting" : "reconnecting");
|
|
134
|
+
const token = await this.opts.getToken();
|
|
135
|
+
const base = this.opts.url.replace(/\/$/, "");
|
|
136
|
+
const url = `${base}/v1/ws?token=${encodeURIComponent(token)}`;
|
|
137
|
+
const ws = this.createSocket(url);
|
|
138
|
+
this.ws = ws;
|
|
139
|
+
return new Promise((resolve, reject) => {
|
|
140
|
+
let settled = false;
|
|
141
|
+
ws.onopen = () => {
|
|
142
|
+
settled = true;
|
|
143
|
+
this.attempt = 0;
|
|
144
|
+
this.setState("connected");
|
|
145
|
+
this.log("connected");
|
|
146
|
+
for (const channel of this.desired) {
|
|
147
|
+
this.send("subscribe", channel).catch(
|
|
148
|
+
(e) => this.log("resubscribe failed", channel, e)
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
resolve();
|
|
152
|
+
};
|
|
153
|
+
ws.onmessage = (ev) => this.handleFrame(ev.data);
|
|
154
|
+
ws.onerror = () => {
|
|
155
|
+
if (!settled) {
|
|
156
|
+
settled = true;
|
|
157
|
+
reject(new Error("kabarcast: connection failed"));
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
ws.onclose = () => {
|
|
161
|
+
this.ws = null;
|
|
162
|
+
this.failPending(new Error("kabarcast: connection closed"));
|
|
163
|
+
if (this.closedByUser) {
|
|
164
|
+
this.setState("closed");
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
this.scheduleReconnect();
|
|
168
|
+
};
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
createSocket(url) {
|
|
172
|
+
if (this.opts.webSocketFactory) return this.opts.webSocketFactory(url);
|
|
173
|
+
const g = globalThis;
|
|
174
|
+
if (!g.WebSocket) {
|
|
175
|
+
throw new Error(
|
|
176
|
+
"kabarcast: no WebSocket available. Pass options.webSocketFactory (for example the ws package on Node < 22)."
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
return new g.WebSocket(url);
|
|
180
|
+
}
|
|
181
|
+
scheduleReconnect() {
|
|
182
|
+
if (this.attempt >= this.opts.maxReconnectAttempts) {
|
|
183
|
+
this.log("giving up after", this.attempt, "attempts");
|
|
184
|
+
this.setState("closed");
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
this.attempt += 1;
|
|
188
|
+
const delay = backoffDelay(
|
|
189
|
+
this.attempt,
|
|
190
|
+
this.opts.minReconnectDelayMs,
|
|
191
|
+
this.opts.maxReconnectDelayMs
|
|
192
|
+
);
|
|
193
|
+
this.setState("reconnecting");
|
|
194
|
+
this.log(`reconnecting in ${delay}ms (attempt ${this.attempt})`);
|
|
195
|
+
this.reconnectTimer = setTimeout(() => {
|
|
196
|
+
this.open().catch((e) => {
|
|
197
|
+
this.log("reconnect failed", e);
|
|
198
|
+
this.scheduleReconnect();
|
|
199
|
+
});
|
|
200
|
+
}, delay);
|
|
201
|
+
}
|
|
202
|
+
send(action, channel) {
|
|
203
|
+
const ws = this.ws;
|
|
204
|
+
if (!ws || ws.readyState !== OPEN) {
|
|
205
|
+
return Promise.reject(new Error("kabarcast: not connected"));
|
|
206
|
+
}
|
|
207
|
+
const key = `${action}:${channel}`;
|
|
208
|
+
return new Promise((resolve, reject) => {
|
|
209
|
+
const timer = setTimeout(() => {
|
|
210
|
+
this.pending.delete(key);
|
|
211
|
+
reject(
|
|
212
|
+
new Error(
|
|
213
|
+
`kabarcast: timed out waiting for ${action} ack on ${channel}`
|
|
214
|
+
)
|
|
215
|
+
);
|
|
216
|
+
}, this.opts.ackTimeoutMs);
|
|
217
|
+
this.pending.set(key, { resolve, reject, timer });
|
|
218
|
+
ws.send(JSON.stringify({ action, channel }));
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
handleFrame(raw) {
|
|
222
|
+
let frame;
|
|
223
|
+
try {
|
|
224
|
+
frame = JSON.parse(typeof raw === "string" ? raw : String(raw));
|
|
225
|
+
} catch {
|
|
226
|
+
this.log("dropped unparseable frame");
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if ("type" in frame) {
|
|
230
|
+
if (frame.type === "ack") {
|
|
231
|
+
this.settle(`${frame.action}:${frame.channel}`, null);
|
|
232
|
+
} else if (frame.type === "error") {
|
|
233
|
+
const err = new Error(`kabarcast: ${frame.message}`);
|
|
234
|
+
if (frame.action && frame.channel) {
|
|
235
|
+
if (frame.action === "subscribe") this.desired.delete(frame.channel);
|
|
236
|
+
this.settle(`${frame.action}:${frame.channel}`, err);
|
|
237
|
+
} else {
|
|
238
|
+
this.log("server error", frame.message);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
this.dispatch(frame);
|
|
244
|
+
}
|
|
245
|
+
settle(key, err) {
|
|
246
|
+
const p = this.pending.get(key);
|
|
247
|
+
if (!p) return;
|
|
248
|
+
clearTimeout(p.timer);
|
|
249
|
+
this.pending.delete(key);
|
|
250
|
+
if (err) {
|
|
251
|
+
p.reject(err);
|
|
252
|
+
} else {
|
|
253
|
+
p.resolve();
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
failPending(err) {
|
|
257
|
+
for (const [, p] of this.pending) {
|
|
258
|
+
clearTimeout(p.timer);
|
|
259
|
+
p.reject(err);
|
|
260
|
+
}
|
|
261
|
+
this.pending.clear();
|
|
262
|
+
}
|
|
263
|
+
dispatch(ev) {
|
|
264
|
+
const meta = { channel: ev.channel, event: ev.event, ts: ev.ts };
|
|
265
|
+
for (const key of [ev.event, "*"]) {
|
|
266
|
+
const set = this.handlers.get(key);
|
|
267
|
+
if (!set) continue;
|
|
268
|
+
for (const h of set) {
|
|
269
|
+
try {
|
|
270
|
+
h(ev.data, meta);
|
|
271
|
+
} catch (e) {
|
|
272
|
+
this.log("handler threw", e);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
setState(s) {
|
|
278
|
+
if (this.state === s) return;
|
|
279
|
+
this.state = s;
|
|
280
|
+
for (const h of this.stateHandlers) {
|
|
281
|
+
try {
|
|
282
|
+
h(s);
|
|
283
|
+
} catch {
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
log(...args) {
|
|
288
|
+
if (this.opts.debug) console.debug("[kabarcast]", ...args);
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
292
|
+
0 && (module.exports = {
|
|
293
|
+
KabarcastClient,
|
|
294
|
+
backoffDelay
|
|
295
|
+
});
|
|
296
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/backoff.ts","../src/client.ts"],"sourcesContent":["export { KabarcastClient } from './client.js';\nexport { backoffDelay } from './backoff.js';\nexport type {\n ConnectionState,\n ControlFrame,\n EventHandler,\n KabarcastEvent,\n KabarcastOptions,\n ServerFrame,\n StateHandler,\n Subscription,\n WebSocketFactory,\n WebSocketLike,\n} from './types.js';\n","/**\n * Exponential backoff with full jitter.\n *\n * Jitter matters more than the exponent here: without it, every client\n * disconnected by a deploy retries on the same schedule and stampedes the hub\n * the moment it comes back.\n */\nexport function backoffDelay(\n attempt: number,\n minMs: number,\n maxMs: number,\n): number {\n const exp = Math.min(maxMs, minMs * 2 ** Math.max(0, attempt - 1));\n return Math.floor(Math.random() * exp);\n}\n","import { backoffDelay } from './backoff.js';\nimport type {\n ConnectionState,\n EventHandler,\n KabarcastEvent,\n KabarcastOptions,\n ServerFrame,\n StateHandler,\n Subscription,\n WebSocketLike,\n} from './types.js';\n\nconst OPEN = 1;\n\ninterface PendingAck {\n resolve: () => void;\n reject: (err: Error) => void;\n timer: ReturnType<typeof setTimeout>;\n}\n\n/**\n * Client for a kabarcast hub.\n *\n * Handles the parts you do not want to reimplement in every app: token\n * refresh, reconnect with jittered backoff, and restoring subscriptions after\n * a drop.\n *\n * ```ts\n * const kabar = new KabarcastClient({\n * url: 'wss://kabarcast.example.com',\n * getToken: () => api.get('/realtime/token').then(r => r.data.token),\n * });\n * await kabar.connect();\n * await kabar.subscribe('ssap:user:123');\n * kabar.on('notification.created', (n) => showToast(n));\n * ```\n */\nexport class KabarcastClient {\n private readonly opts: KabarcastOptions & {\n minReconnectDelayMs: number;\n maxReconnectDelayMs: number;\n maxReconnectAttempts: number;\n ackTimeoutMs: number;\n debug: boolean;\n };\n\n private ws: WebSocketLike | null = null;\n private state: ConnectionState = 'idle';\n\n /** Channels the caller wants; replayed after every reconnect. */\n private readonly desired = new Set<string>();\n private readonly pending = new Map<string, PendingAck>();\n private readonly handlers = new Map<string, Set<EventHandler<any>>>();\n private readonly stateHandlers = new Set<StateHandler>();\n\n private attempt = 0;\n private reconnectTimer: ReturnType<typeof setTimeout> | null = null;\n private closedByUser = false;\n private connecting: Promise<void> | null = null;\n\n constructor(options: KabarcastOptions) {\n this.opts = {\n minReconnectDelayMs: 500,\n maxReconnectDelayMs: 30_000,\n maxReconnectAttempts: Number.POSITIVE_INFINITY,\n ackTimeoutMs: 10_000,\n debug: false,\n ...options,\n };\n }\n\n /** Current connection state. */\n get connectionState(): ConnectionState {\n return this.state;\n }\n\n /** Opens the connection. Resolves once the socket is open. */\n connect(): Promise<void> {\n if (this.state === 'connected') return Promise.resolve();\n if (this.connecting) return this.connecting;\n\n this.closedByUser = false;\n this.connecting = this.open().finally(() => {\n this.connecting = null;\n });\n return this.connecting;\n }\n\n /**\n * Subscribes to a channel and resolves once the hub acknowledges it.\n *\n * The channel is remembered, so it is restored automatically after a\n * reconnect without the caller doing anything.\n */\n async subscribe(channel: string): Promise<Subscription> {\n this.desired.add(channel);\n if (this.state === 'connected') {\n await this.send('subscribe', channel);\n }\n return {\n channel,\n unsubscribe: () => this.unsubscribe(channel),\n };\n }\n\n /** Stops receiving events for a channel. */\n async unsubscribe(channel: string): Promise<void> {\n this.desired.delete(channel);\n if (this.state === 'connected') {\n await this.send('unsubscribe', channel);\n }\n }\n\n /**\n * Registers a handler for an event name. The name '*' receives every event.\n * Returns a function that removes the handler.\n */\n on<T = unknown>(event: string, handler: EventHandler<T>): () => void {\n let set = this.handlers.get(event);\n if (!set) {\n set = new Set();\n this.handlers.set(event, set);\n }\n set.add(handler as EventHandler<any>);\n return () => this.off(event, handler);\n }\n\n off<T = unknown>(event: string, handler: EventHandler<T>): void {\n this.handlers.get(event)?.delete(handler as EventHandler<any>);\n }\n\n /** Observes connection state changes. Returns an unsubscribe function. */\n onStateChange(handler: StateHandler): () => void {\n this.stateHandlers.add(handler);\n return () => {\n this.stateHandlers.delete(handler);\n };\n }\n\n /** Closes the connection and stops reconnecting. */\n close(): void {\n this.closedByUser = true;\n if (this.reconnectTimer) {\n clearTimeout(this.reconnectTimer);\n this.reconnectTimer = null;\n }\n this.failPending(new Error('kabarcast: client closed'));\n this.ws?.close(1000, 'client closed');\n this.ws = null;\n this.setState('closed');\n }\n\n // ---------------------------------------------------------------- internals\n\n private async open(): Promise<void> {\n this.setState(this.attempt === 0 ? 'connecting' : 'reconnecting');\n\n // Fetched on every attempt: channel tokens are short-lived, so a stale one\n // must never be the reason a reconnect fails.\n const token = await this.opts.getToken();\n const base = this.opts.url.replace(/\\/$/, '');\n const url = `${base}/v1/ws?token=${encodeURIComponent(token)}`;\n const ws = this.createSocket(url);\n this.ws = ws;\n\n return new Promise<void>((resolve, reject) => {\n let settled = false;\n\n ws.onopen = () => {\n settled = true;\n this.attempt = 0;\n this.setState('connected');\n this.log('connected');\n // Restore everything the caller asked for before the drop.\n for (const channel of this.desired) {\n this.send('subscribe', channel).catch((e) =>\n this.log('resubscribe failed', channel, e),\n );\n }\n resolve();\n };\n\n ws.onmessage = (ev) => this.handleFrame(ev.data);\n\n ws.onerror = () => {\n if (!settled) {\n settled = true;\n reject(new Error('kabarcast: connection failed'));\n }\n };\n\n ws.onclose = () => {\n this.ws = null;\n this.failPending(new Error('kabarcast: connection closed'));\n if (this.closedByUser) {\n this.setState('closed');\n return;\n }\n this.scheduleReconnect();\n };\n });\n }\n\n private createSocket(url: string): WebSocketLike {\n if (this.opts.webSocketFactory) return this.opts.webSocketFactory(url);\n const g = globalThis as { WebSocket?: new (url: string) => WebSocketLike };\n if (!g.WebSocket) {\n throw new Error(\n 'kabarcast: no WebSocket available. Pass options.webSocketFactory (for example the ws package on Node < 22).',\n );\n }\n return new g.WebSocket(url);\n }\n\n private scheduleReconnect(): void {\n if (this.attempt >= this.opts.maxReconnectAttempts) {\n this.log('giving up after', this.attempt, 'attempts');\n this.setState('closed');\n return;\n }\n this.attempt += 1;\n const delay = backoffDelay(\n this.attempt,\n this.opts.minReconnectDelayMs,\n this.opts.maxReconnectDelayMs,\n );\n this.setState('reconnecting');\n this.log(`reconnecting in ${delay}ms (attempt ${this.attempt})`);\n this.reconnectTimer = setTimeout(() => {\n this.open().catch((e) => {\n this.log('reconnect failed', e);\n this.scheduleReconnect();\n });\n }, delay);\n }\n\n private send(\n action: 'subscribe' | 'unsubscribe',\n channel: string,\n ): Promise<void> {\n const ws = this.ws;\n if (!ws || ws.readyState !== OPEN) {\n return Promise.reject(new Error('kabarcast: not connected'));\n }\n const key = `${action}:${channel}`;\n return new Promise<void>((resolve, reject) => {\n const timer = setTimeout(() => {\n this.pending.delete(key);\n reject(\n new Error(\n `kabarcast: timed out waiting for ${action} ack on ${channel}`,\n ),\n );\n }, this.opts.ackTimeoutMs);\n\n this.pending.set(key, { resolve, reject, timer });\n ws.send(JSON.stringify({ action, channel }));\n });\n }\n\n private handleFrame(raw: unknown): void {\n let frame: ServerFrame;\n try {\n frame = JSON.parse(typeof raw === 'string' ? raw : String(raw));\n } catch {\n this.log('dropped unparseable frame');\n return;\n }\n\n // Control frames carry `type`; broadcast events do not.\n if ('type' in frame) {\n if (frame.type === 'ack') {\n this.settle(`${frame.action}:${frame.channel}`, null);\n } else if (frame.type === 'error') {\n const err = new Error(`kabarcast: ${frame.message}`);\n if (frame.action && frame.channel) {\n // A refused subscribe must not be retried forever on reconnect.\n if (frame.action === 'subscribe') this.desired.delete(frame.channel);\n this.settle(`${frame.action}:${frame.channel}`, err);\n } else {\n this.log('server error', frame.message);\n }\n }\n return;\n }\n\n this.dispatch(frame);\n }\n\n private settle(key: string, err: Error | null): void {\n const p = this.pending.get(key);\n if (!p) return;\n clearTimeout(p.timer);\n this.pending.delete(key);\n if (err) {\n p.reject(err);\n } else {\n p.resolve();\n }\n }\n\n private failPending(err: Error): void {\n for (const [, p] of this.pending) {\n clearTimeout(p.timer);\n p.reject(err);\n }\n this.pending.clear();\n }\n\n private dispatch(ev: KabarcastEvent): void {\n const meta = { channel: ev.channel, event: ev.event, ts: ev.ts };\n for (const key of [ev.event, '*']) {\n const set = this.handlers.get(key);\n if (!set) continue;\n for (const h of set) {\n try {\n h(ev.data, meta);\n } catch (e) {\n this.log('handler threw', e);\n }\n }\n }\n }\n\n private setState(s: ConnectionState): void {\n if (this.state === s) return;\n this.state = s;\n for (const h of this.stateHandlers) {\n try {\n h(s);\n } catch {\n /* a bad observer must not break the client */\n }\n }\n }\n\n private log(...args: unknown[]): void {\n if (this.opts.debug) console.debug('[kabarcast]', ...args);\n }\n}\n"],"mappings":"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","names":[]}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/** An event broadcast by your backend and delivered to subscribers. */
|
|
2
|
+
interface KabarcastEvent<T = unknown> {
|
|
3
|
+
/** Channel the event was published to, e.g. "ssap:user:123". */
|
|
4
|
+
channel: string;
|
|
5
|
+
/** Event name, e.g. "notification.created". */
|
|
6
|
+
event: string;
|
|
7
|
+
/** Application payload. */
|
|
8
|
+
data: T;
|
|
9
|
+
/** Server timestamp, epoch milliseconds. */
|
|
10
|
+
ts: number;
|
|
11
|
+
}
|
|
12
|
+
/** Control frames the server sends in response to client actions. */
|
|
13
|
+
type ControlFrame = {
|
|
14
|
+
type: 'ack';
|
|
15
|
+
action: 'subscribe' | 'unsubscribe';
|
|
16
|
+
channel: string;
|
|
17
|
+
} | {
|
|
18
|
+
type: 'error';
|
|
19
|
+
action?: string;
|
|
20
|
+
channel?: string;
|
|
21
|
+
message: string;
|
|
22
|
+
} | {
|
|
23
|
+
type: 'pong';
|
|
24
|
+
};
|
|
25
|
+
type ServerFrame = ControlFrame | KabarcastEvent;
|
|
26
|
+
type ConnectionState = 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'closed';
|
|
27
|
+
/** Minimal WebSocket surface, so browser, Node and `ws` all satisfy it. */
|
|
28
|
+
interface WebSocketLike {
|
|
29
|
+
readyState: number;
|
|
30
|
+
send(data: string): void;
|
|
31
|
+
close(code?: number, reason?: string): void;
|
|
32
|
+
onopen: ((ev: any) => void) | null;
|
|
33
|
+
onclose: ((ev: any) => void) | null;
|
|
34
|
+
onerror: ((ev: any) => void) | null;
|
|
35
|
+
onmessage: ((ev: {
|
|
36
|
+
data: any;
|
|
37
|
+
}) => void) | null;
|
|
38
|
+
}
|
|
39
|
+
type WebSocketFactory = (url: string) => WebSocketLike;
|
|
40
|
+
interface KabarcastOptions {
|
|
41
|
+
/** Base URL of the hub, e.g. "wss://kabarcast.example.com". */
|
|
42
|
+
url: string;
|
|
43
|
+
/**
|
|
44
|
+
* Returns a short-lived channel token minted by YOUR backend. Called on
|
|
45
|
+
* every connect attempt, so an expired token never blocks a reconnect.
|
|
46
|
+
*/
|
|
47
|
+
getToken: () => string | Promise<string>;
|
|
48
|
+
/** Reconnect backoff floor. Default 500ms. */
|
|
49
|
+
minReconnectDelayMs?: number;
|
|
50
|
+
/** Reconnect backoff ceiling. Default 30_000ms. */
|
|
51
|
+
maxReconnectDelayMs?: number;
|
|
52
|
+
/** Give up after this many consecutive failures. Default Infinity. */
|
|
53
|
+
maxReconnectAttempts?: number;
|
|
54
|
+
/** How long to wait for a subscribe/unsubscribe ack. Default 10_000ms. */
|
|
55
|
+
ackTimeoutMs?: number;
|
|
56
|
+
/** Supply a WebSocket implementation (e.g. `ws` on older Node). */
|
|
57
|
+
webSocketFactory?: WebSocketFactory;
|
|
58
|
+
/** Log connection lifecycle to console. Default false. */
|
|
59
|
+
debug?: boolean;
|
|
60
|
+
}
|
|
61
|
+
type EventHandler<T = unknown> = (data: T, meta: {
|
|
62
|
+
channel: string;
|
|
63
|
+
event: string;
|
|
64
|
+
ts: number;
|
|
65
|
+
}) => void;
|
|
66
|
+
type StateHandler = (state: ConnectionState) => void;
|
|
67
|
+
/** Handle returned by subscribe(); call unsubscribe() to stop listening. */
|
|
68
|
+
interface Subscription {
|
|
69
|
+
channel: string;
|
|
70
|
+
unsubscribe: () => Promise<void>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Client for a kabarcast hub.
|
|
75
|
+
*
|
|
76
|
+
* Handles the parts you do not want to reimplement in every app: token
|
|
77
|
+
* refresh, reconnect with jittered backoff, and restoring subscriptions after
|
|
78
|
+
* a drop.
|
|
79
|
+
*
|
|
80
|
+
* ```ts
|
|
81
|
+
* const kabar = new KabarcastClient({
|
|
82
|
+
* url: 'wss://kabarcast.example.com',
|
|
83
|
+
* getToken: () => api.get('/realtime/token').then(r => r.data.token),
|
|
84
|
+
* });
|
|
85
|
+
* await kabar.connect();
|
|
86
|
+
* await kabar.subscribe('ssap:user:123');
|
|
87
|
+
* kabar.on('notification.created', (n) => showToast(n));
|
|
88
|
+
* ```
|
|
89
|
+
*/
|
|
90
|
+
declare class KabarcastClient {
|
|
91
|
+
private readonly opts;
|
|
92
|
+
private ws;
|
|
93
|
+
private state;
|
|
94
|
+
/** Channels the caller wants; replayed after every reconnect. */
|
|
95
|
+
private readonly desired;
|
|
96
|
+
private readonly pending;
|
|
97
|
+
private readonly handlers;
|
|
98
|
+
private readonly stateHandlers;
|
|
99
|
+
private attempt;
|
|
100
|
+
private reconnectTimer;
|
|
101
|
+
private closedByUser;
|
|
102
|
+
private connecting;
|
|
103
|
+
constructor(options: KabarcastOptions);
|
|
104
|
+
/** Current connection state. */
|
|
105
|
+
get connectionState(): ConnectionState;
|
|
106
|
+
/** Opens the connection. Resolves once the socket is open. */
|
|
107
|
+
connect(): Promise<void>;
|
|
108
|
+
/**
|
|
109
|
+
* Subscribes to a channel and resolves once the hub acknowledges it.
|
|
110
|
+
*
|
|
111
|
+
* The channel is remembered, so it is restored automatically after a
|
|
112
|
+
* reconnect without the caller doing anything.
|
|
113
|
+
*/
|
|
114
|
+
subscribe(channel: string): Promise<Subscription>;
|
|
115
|
+
/** Stops receiving events for a channel. */
|
|
116
|
+
unsubscribe(channel: string): Promise<void>;
|
|
117
|
+
/**
|
|
118
|
+
* Registers a handler for an event name. The name '*' receives every event.
|
|
119
|
+
* Returns a function that removes the handler.
|
|
120
|
+
*/
|
|
121
|
+
on<T = unknown>(event: string, handler: EventHandler<T>): () => void;
|
|
122
|
+
off<T = unknown>(event: string, handler: EventHandler<T>): void;
|
|
123
|
+
/** Observes connection state changes. Returns an unsubscribe function. */
|
|
124
|
+
onStateChange(handler: StateHandler): () => void;
|
|
125
|
+
/** Closes the connection and stops reconnecting. */
|
|
126
|
+
close(): void;
|
|
127
|
+
private open;
|
|
128
|
+
private createSocket;
|
|
129
|
+
private scheduleReconnect;
|
|
130
|
+
private send;
|
|
131
|
+
private handleFrame;
|
|
132
|
+
private settle;
|
|
133
|
+
private failPending;
|
|
134
|
+
private dispatch;
|
|
135
|
+
private setState;
|
|
136
|
+
private log;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Exponential backoff with full jitter.
|
|
141
|
+
*
|
|
142
|
+
* Jitter matters more than the exponent here: without it, every client
|
|
143
|
+
* disconnected by a deploy retries on the same schedule and stampedes the hub
|
|
144
|
+
* the moment it comes back.
|
|
145
|
+
*/
|
|
146
|
+
declare function backoffDelay(attempt: number, minMs: number, maxMs: number): number;
|
|
147
|
+
|
|
148
|
+
export { type ConnectionState, type ControlFrame, type EventHandler, KabarcastClient, type KabarcastEvent, type KabarcastOptions, type ServerFrame, type StateHandler, type Subscription, type WebSocketFactory, type WebSocketLike, backoffDelay };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/** An event broadcast by your backend and delivered to subscribers. */
|
|
2
|
+
interface KabarcastEvent<T = unknown> {
|
|
3
|
+
/** Channel the event was published to, e.g. "ssap:user:123". */
|
|
4
|
+
channel: string;
|
|
5
|
+
/** Event name, e.g. "notification.created". */
|
|
6
|
+
event: string;
|
|
7
|
+
/** Application payload. */
|
|
8
|
+
data: T;
|
|
9
|
+
/** Server timestamp, epoch milliseconds. */
|
|
10
|
+
ts: number;
|
|
11
|
+
}
|
|
12
|
+
/** Control frames the server sends in response to client actions. */
|
|
13
|
+
type ControlFrame = {
|
|
14
|
+
type: 'ack';
|
|
15
|
+
action: 'subscribe' | 'unsubscribe';
|
|
16
|
+
channel: string;
|
|
17
|
+
} | {
|
|
18
|
+
type: 'error';
|
|
19
|
+
action?: string;
|
|
20
|
+
channel?: string;
|
|
21
|
+
message: string;
|
|
22
|
+
} | {
|
|
23
|
+
type: 'pong';
|
|
24
|
+
};
|
|
25
|
+
type ServerFrame = ControlFrame | KabarcastEvent;
|
|
26
|
+
type ConnectionState = 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'closed';
|
|
27
|
+
/** Minimal WebSocket surface, so browser, Node and `ws` all satisfy it. */
|
|
28
|
+
interface WebSocketLike {
|
|
29
|
+
readyState: number;
|
|
30
|
+
send(data: string): void;
|
|
31
|
+
close(code?: number, reason?: string): void;
|
|
32
|
+
onopen: ((ev: any) => void) | null;
|
|
33
|
+
onclose: ((ev: any) => void) | null;
|
|
34
|
+
onerror: ((ev: any) => void) | null;
|
|
35
|
+
onmessage: ((ev: {
|
|
36
|
+
data: any;
|
|
37
|
+
}) => void) | null;
|
|
38
|
+
}
|
|
39
|
+
type WebSocketFactory = (url: string) => WebSocketLike;
|
|
40
|
+
interface KabarcastOptions {
|
|
41
|
+
/** Base URL of the hub, e.g. "wss://kabarcast.example.com". */
|
|
42
|
+
url: string;
|
|
43
|
+
/**
|
|
44
|
+
* Returns a short-lived channel token minted by YOUR backend. Called on
|
|
45
|
+
* every connect attempt, so an expired token never blocks a reconnect.
|
|
46
|
+
*/
|
|
47
|
+
getToken: () => string | Promise<string>;
|
|
48
|
+
/** Reconnect backoff floor. Default 500ms. */
|
|
49
|
+
minReconnectDelayMs?: number;
|
|
50
|
+
/** Reconnect backoff ceiling. Default 30_000ms. */
|
|
51
|
+
maxReconnectDelayMs?: number;
|
|
52
|
+
/** Give up after this many consecutive failures. Default Infinity. */
|
|
53
|
+
maxReconnectAttempts?: number;
|
|
54
|
+
/** How long to wait for a subscribe/unsubscribe ack. Default 10_000ms. */
|
|
55
|
+
ackTimeoutMs?: number;
|
|
56
|
+
/** Supply a WebSocket implementation (e.g. `ws` on older Node). */
|
|
57
|
+
webSocketFactory?: WebSocketFactory;
|
|
58
|
+
/** Log connection lifecycle to console. Default false. */
|
|
59
|
+
debug?: boolean;
|
|
60
|
+
}
|
|
61
|
+
type EventHandler<T = unknown> = (data: T, meta: {
|
|
62
|
+
channel: string;
|
|
63
|
+
event: string;
|
|
64
|
+
ts: number;
|
|
65
|
+
}) => void;
|
|
66
|
+
type StateHandler = (state: ConnectionState) => void;
|
|
67
|
+
/** Handle returned by subscribe(); call unsubscribe() to stop listening. */
|
|
68
|
+
interface Subscription {
|
|
69
|
+
channel: string;
|
|
70
|
+
unsubscribe: () => Promise<void>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Client for a kabarcast hub.
|
|
75
|
+
*
|
|
76
|
+
* Handles the parts you do not want to reimplement in every app: token
|
|
77
|
+
* refresh, reconnect with jittered backoff, and restoring subscriptions after
|
|
78
|
+
* a drop.
|
|
79
|
+
*
|
|
80
|
+
* ```ts
|
|
81
|
+
* const kabar = new KabarcastClient({
|
|
82
|
+
* url: 'wss://kabarcast.example.com',
|
|
83
|
+
* getToken: () => api.get('/realtime/token').then(r => r.data.token),
|
|
84
|
+
* });
|
|
85
|
+
* await kabar.connect();
|
|
86
|
+
* await kabar.subscribe('ssap:user:123');
|
|
87
|
+
* kabar.on('notification.created', (n) => showToast(n));
|
|
88
|
+
* ```
|
|
89
|
+
*/
|
|
90
|
+
declare class KabarcastClient {
|
|
91
|
+
private readonly opts;
|
|
92
|
+
private ws;
|
|
93
|
+
private state;
|
|
94
|
+
/** Channels the caller wants; replayed after every reconnect. */
|
|
95
|
+
private readonly desired;
|
|
96
|
+
private readonly pending;
|
|
97
|
+
private readonly handlers;
|
|
98
|
+
private readonly stateHandlers;
|
|
99
|
+
private attempt;
|
|
100
|
+
private reconnectTimer;
|
|
101
|
+
private closedByUser;
|
|
102
|
+
private connecting;
|
|
103
|
+
constructor(options: KabarcastOptions);
|
|
104
|
+
/** Current connection state. */
|
|
105
|
+
get connectionState(): ConnectionState;
|
|
106
|
+
/** Opens the connection. Resolves once the socket is open. */
|
|
107
|
+
connect(): Promise<void>;
|
|
108
|
+
/**
|
|
109
|
+
* Subscribes to a channel and resolves once the hub acknowledges it.
|
|
110
|
+
*
|
|
111
|
+
* The channel is remembered, so it is restored automatically after a
|
|
112
|
+
* reconnect without the caller doing anything.
|
|
113
|
+
*/
|
|
114
|
+
subscribe(channel: string): Promise<Subscription>;
|
|
115
|
+
/** Stops receiving events for a channel. */
|
|
116
|
+
unsubscribe(channel: string): Promise<void>;
|
|
117
|
+
/**
|
|
118
|
+
* Registers a handler for an event name. The name '*' receives every event.
|
|
119
|
+
* Returns a function that removes the handler.
|
|
120
|
+
*/
|
|
121
|
+
on<T = unknown>(event: string, handler: EventHandler<T>): () => void;
|
|
122
|
+
off<T = unknown>(event: string, handler: EventHandler<T>): void;
|
|
123
|
+
/** Observes connection state changes. Returns an unsubscribe function. */
|
|
124
|
+
onStateChange(handler: StateHandler): () => void;
|
|
125
|
+
/** Closes the connection and stops reconnecting. */
|
|
126
|
+
close(): void;
|
|
127
|
+
private open;
|
|
128
|
+
private createSocket;
|
|
129
|
+
private scheduleReconnect;
|
|
130
|
+
private send;
|
|
131
|
+
private handleFrame;
|
|
132
|
+
private settle;
|
|
133
|
+
private failPending;
|
|
134
|
+
private dispatch;
|
|
135
|
+
private setState;
|
|
136
|
+
private log;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Exponential backoff with full jitter.
|
|
141
|
+
*
|
|
142
|
+
* Jitter matters more than the exponent here: without it, every client
|
|
143
|
+
* disconnected by a deploy retries on the same schedule and stampedes the hub
|
|
144
|
+
* the moment it comes back.
|
|
145
|
+
*/
|
|
146
|
+
declare function backoffDelay(attempt: number, minMs: number, maxMs: number): number;
|
|
147
|
+
|
|
148
|
+
export { type ConnectionState, type ControlFrame, type EventHandler, KabarcastClient, type KabarcastEvent, type KabarcastOptions, type ServerFrame, type StateHandler, type Subscription, type WebSocketFactory, type WebSocketLike, backoffDelay };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
// src/backoff.ts
|
|
2
|
+
function backoffDelay(attempt, minMs, maxMs) {
|
|
3
|
+
const exp = Math.min(maxMs, minMs * 2 ** Math.max(0, attempt - 1));
|
|
4
|
+
return Math.floor(Math.random() * exp);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
// src/client.ts
|
|
8
|
+
var OPEN = 1;
|
|
9
|
+
var KabarcastClient = class {
|
|
10
|
+
opts;
|
|
11
|
+
ws = null;
|
|
12
|
+
state = "idle";
|
|
13
|
+
/** Channels the caller wants; replayed after every reconnect. */
|
|
14
|
+
desired = /* @__PURE__ */ new Set();
|
|
15
|
+
pending = /* @__PURE__ */ new Map();
|
|
16
|
+
handlers = /* @__PURE__ */ new Map();
|
|
17
|
+
stateHandlers = /* @__PURE__ */ new Set();
|
|
18
|
+
attempt = 0;
|
|
19
|
+
reconnectTimer = null;
|
|
20
|
+
closedByUser = false;
|
|
21
|
+
connecting = null;
|
|
22
|
+
constructor(options) {
|
|
23
|
+
this.opts = {
|
|
24
|
+
minReconnectDelayMs: 500,
|
|
25
|
+
maxReconnectDelayMs: 3e4,
|
|
26
|
+
maxReconnectAttempts: Number.POSITIVE_INFINITY,
|
|
27
|
+
ackTimeoutMs: 1e4,
|
|
28
|
+
debug: false,
|
|
29
|
+
...options
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/** Current connection state. */
|
|
33
|
+
get connectionState() {
|
|
34
|
+
return this.state;
|
|
35
|
+
}
|
|
36
|
+
/** Opens the connection. Resolves once the socket is open. */
|
|
37
|
+
connect() {
|
|
38
|
+
if (this.state === "connected") return Promise.resolve();
|
|
39
|
+
if (this.connecting) return this.connecting;
|
|
40
|
+
this.closedByUser = false;
|
|
41
|
+
this.connecting = this.open().finally(() => {
|
|
42
|
+
this.connecting = null;
|
|
43
|
+
});
|
|
44
|
+
return this.connecting;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Subscribes to a channel and resolves once the hub acknowledges it.
|
|
48
|
+
*
|
|
49
|
+
* The channel is remembered, so it is restored automatically after a
|
|
50
|
+
* reconnect without the caller doing anything.
|
|
51
|
+
*/
|
|
52
|
+
async subscribe(channel) {
|
|
53
|
+
this.desired.add(channel);
|
|
54
|
+
if (this.state === "connected") {
|
|
55
|
+
await this.send("subscribe", channel);
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
channel,
|
|
59
|
+
unsubscribe: () => this.unsubscribe(channel)
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/** Stops receiving events for a channel. */
|
|
63
|
+
async unsubscribe(channel) {
|
|
64
|
+
this.desired.delete(channel);
|
|
65
|
+
if (this.state === "connected") {
|
|
66
|
+
await this.send("unsubscribe", channel);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Registers a handler for an event name. The name '*' receives every event.
|
|
71
|
+
* Returns a function that removes the handler.
|
|
72
|
+
*/
|
|
73
|
+
on(event, handler) {
|
|
74
|
+
let set = this.handlers.get(event);
|
|
75
|
+
if (!set) {
|
|
76
|
+
set = /* @__PURE__ */ new Set();
|
|
77
|
+
this.handlers.set(event, set);
|
|
78
|
+
}
|
|
79
|
+
set.add(handler);
|
|
80
|
+
return () => this.off(event, handler);
|
|
81
|
+
}
|
|
82
|
+
off(event, handler) {
|
|
83
|
+
this.handlers.get(event)?.delete(handler);
|
|
84
|
+
}
|
|
85
|
+
/** Observes connection state changes. Returns an unsubscribe function. */
|
|
86
|
+
onStateChange(handler) {
|
|
87
|
+
this.stateHandlers.add(handler);
|
|
88
|
+
return () => {
|
|
89
|
+
this.stateHandlers.delete(handler);
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
/** Closes the connection and stops reconnecting. */
|
|
93
|
+
close() {
|
|
94
|
+
this.closedByUser = true;
|
|
95
|
+
if (this.reconnectTimer) {
|
|
96
|
+
clearTimeout(this.reconnectTimer);
|
|
97
|
+
this.reconnectTimer = null;
|
|
98
|
+
}
|
|
99
|
+
this.failPending(new Error("kabarcast: client closed"));
|
|
100
|
+
this.ws?.close(1e3, "client closed");
|
|
101
|
+
this.ws = null;
|
|
102
|
+
this.setState("closed");
|
|
103
|
+
}
|
|
104
|
+
// ---------------------------------------------------------------- internals
|
|
105
|
+
async open() {
|
|
106
|
+
this.setState(this.attempt === 0 ? "connecting" : "reconnecting");
|
|
107
|
+
const token = await this.opts.getToken();
|
|
108
|
+
const base = this.opts.url.replace(/\/$/, "");
|
|
109
|
+
const url = `${base}/v1/ws?token=${encodeURIComponent(token)}`;
|
|
110
|
+
const ws = this.createSocket(url);
|
|
111
|
+
this.ws = ws;
|
|
112
|
+
return new Promise((resolve, reject) => {
|
|
113
|
+
let settled = false;
|
|
114
|
+
ws.onopen = () => {
|
|
115
|
+
settled = true;
|
|
116
|
+
this.attempt = 0;
|
|
117
|
+
this.setState("connected");
|
|
118
|
+
this.log("connected");
|
|
119
|
+
for (const channel of this.desired) {
|
|
120
|
+
this.send("subscribe", channel).catch(
|
|
121
|
+
(e) => this.log("resubscribe failed", channel, e)
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
resolve();
|
|
125
|
+
};
|
|
126
|
+
ws.onmessage = (ev) => this.handleFrame(ev.data);
|
|
127
|
+
ws.onerror = () => {
|
|
128
|
+
if (!settled) {
|
|
129
|
+
settled = true;
|
|
130
|
+
reject(new Error("kabarcast: connection failed"));
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
ws.onclose = () => {
|
|
134
|
+
this.ws = null;
|
|
135
|
+
this.failPending(new Error("kabarcast: connection closed"));
|
|
136
|
+
if (this.closedByUser) {
|
|
137
|
+
this.setState("closed");
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
this.scheduleReconnect();
|
|
141
|
+
};
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
createSocket(url) {
|
|
145
|
+
if (this.opts.webSocketFactory) return this.opts.webSocketFactory(url);
|
|
146
|
+
const g = globalThis;
|
|
147
|
+
if (!g.WebSocket) {
|
|
148
|
+
throw new Error(
|
|
149
|
+
"kabarcast: no WebSocket available. Pass options.webSocketFactory (for example the ws package on Node < 22)."
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
return new g.WebSocket(url);
|
|
153
|
+
}
|
|
154
|
+
scheduleReconnect() {
|
|
155
|
+
if (this.attempt >= this.opts.maxReconnectAttempts) {
|
|
156
|
+
this.log("giving up after", this.attempt, "attempts");
|
|
157
|
+
this.setState("closed");
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
this.attempt += 1;
|
|
161
|
+
const delay = backoffDelay(
|
|
162
|
+
this.attempt,
|
|
163
|
+
this.opts.minReconnectDelayMs,
|
|
164
|
+
this.opts.maxReconnectDelayMs
|
|
165
|
+
);
|
|
166
|
+
this.setState("reconnecting");
|
|
167
|
+
this.log(`reconnecting in ${delay}ms (attempt ${this.attempt})`);
|
|
168
|
+
this.reconnectTimer = setTimeout(() => {
|
|
169
|
+
this.open().catch((e) => {
|
|
170
|
+
this.log("reconnect failed", e);
|
|
171
|
+
this.scheduleReconnect();
|
|
172
|
+
});
|
|
173
|
+
}, delay);
|
|
174
|
+
}
|
|
175
|
+
send(action, channel) {
|
|
176
|
+
const ws = this.ws;
|
|
177
|
+
if (!ws || ws.readyState !== OPEN) {
|
|
178
|
+
return Promise.reject(new Error("kabarcast: not connected"));
|
|
179
|
+
}
|
|
180
|
+
const key = `${action}:${channel}`;
|
|
181
|
+
return new Promise((resolve, reject) => {
|
|
182
|
+
const timer = setTimeout(() => {
|
|
183
|
+
this.pending.delete(key);
|
|
184
|
+
reject(
|
|
185
|
+
new Error(
|
|
186
|
+
`kabarcast: timed out waiting for ${action} ack on ${channel}`
|
|
187
|
+
)
|
|
188
|
+
);
|
|
189
|
+
}, this.opts.ackTimeoutMs);
|
|
190
|
+
this.pending.set(key, { resolve, reject, timer });
|
|
191
|
+
ws.send(JSON.stringify({ action, channel }));
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
handleFrame(raw) {
|
|
195
|
+
let frame;
|
|
196
|
+
try {
|
|
197
|
+
frame = JSON.parse(typeof raw === "string" ? raw : String(raw));
|
|
198
|
+
} catch {
|
|
199
|
+
this.log("dropped unparseable frame");
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if ("type" in frame) {
|
|
203
|
+
if (frame.type === "ack") {
|
|
204
|
+
this.settle(`${frame.action}:${frame.channel}`, null);
|
|
205
|
+
} else if (frame.type === "error") {
|
|
206
|
+
const err = new Error(`kabarcast: ${frame.message}`);
|
|
207
|
+
if (frame.action && frame.channel) {
|
|
208
|
+
if (frame.action === "subscribe") this.desired.delete(frame.channel);
|
|
209
|
+
this.settle(`${frame.action}:${frame.channel}`, err);
|
|
210
|
+
} else {
|
|
211
|
+
this.log("server error", frame.message);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
this.dispatch(frame);
|
|
217
|
+
}
|
|
218
|
+
settle(key, err) {
|
|
219
|
+
const p = this.pending.get(key);
|
|
220
|
+
if (!p) return;
|
|
221
|
+
clearTimeout(p.timer);
|
|
222
|
+
this.pending.delete(key);
|
|
223
|
+
if (err) {
|
|
224
|
+
p.reject(err);
|
|
225
|
+
} else {
|
|
226
|
+
p.resolve();
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
failPending(err) {
|
|
230
|
+
for (const [, p] of this.pending) {
|
|
231
|
+
clearTimeout(p.timer);
|
|
232
|
+
p.reject(err);
|
|
233
|
+
}
|
|
234
|
+
this.pending.clear();
|
|
235
|
+
}
|
|
236
|
+
dispatch(ev) {
|
|
237
|
+
const meta = { channel: ev.channel, event: ev.event, ts: ev.ts };
|
|
238
|
+
for (const key of [ev.event, "*"]) {
|
|
239
|
+
const set = this.handlers.get(key);
|
|
240
|
+
if (!set) continue;
|
|
241
|
+
for (const h of set) {
|
|
242
|
+
try {
|
|
243
|
+
h(ev.data, meta);
|
|
244
|
+
} catch (e) {
|
|
245
|
+
this.log("handler threw", e);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
setState(s) {
|
|
251
|
+
if (this.state === s) return;
|
|
252
|
+
this.state = s;
|
|
253
|
+
for (const h of this.stateHandlers) {
|
|
254
|
+
try {
|
|
255
|
+
h(s);
|
|
256
|
+
} catch {
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
log(...args) {
|
|
261
|
+
if (this.opts.debug) console.debug("[kabarcast]", ...args);
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
export {
|
|
265
|
+
KabarcastClient,
|
|
266
|
+
backoffDelay
|
|
267
|
+
};
|
|
268
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/backoff.ts","../src/client.ts"],"sourcesContent":["/**\n * Exponential backoff with full jitter.\n *\n * Jitter matters more than the exponent here: without it, every client\n * disconnected by a deploy retries on the same schedule and stampedes the hub\n * the moment it comes back.\n */\nexport function backoffDelay(\n attempt: number,\n minMs: number,\n maxMs: number,\n): number {\n const exp = Math.min(maxMs, minMs * 2 ** Math.max(0, attempt - 1));\n return Math.floor(Math.random() * exp);\n}\n","import { backoffDelay } from './backoff.js';\nimport type {\n ConnectionState,\n EventHandler,\n KabarcastEvent,\n KabarcastOptions,\n ServerFrame,\n StateHandler,\n Subscription,\n WebSocketLike,\n} from './types.js';\n\nconst OPEN = 1;\n\ninterface PendingAck {\n resolve: () => void;\n reject: (err: Error) => void;\n timer: ReturnType<typeof setTimeout>;\n}\n\n/**\n * Client for a kabarcast hub.\n *\n * Handles the parts you do not want to reimplement in every app: token\n * refresh, reconnect with jittered backoff, and restoring subscriptions after\n * a drop.\n *\n * ```ts\n * const kabar = new KabarcastClient({\n * url: 'wss://kabarcast.example.com',\n * getToken: () => api.get('/realtime/token').then(r => r.data.token),\n * });\n * await kabar.connect();\n * await kabar.subscribe('ssap:user:123');\n * kabar.on('notification.created', (n) => showToast(n));\n * ```\n */\nexport class KabarcastClient {\n private readonly opts: KabarcastOptions & {\n minReconnectDelayMs: number;\n maxReconnectDelayMs: number;\n maxReconnectAttempts: number;\n ackTimeoutMs: number;\n debug: boolean;\n };\n\n private ws: WebSocketLike | null = null;\n private state: ConnectionState = 'idle';\n\n /** Channels the caller wants; replayed after every reconnect. */\n private readonly desired = new Set<string>();\n private readonly pending = new Map<string, PendingAck>();\n private readonly handlers = new Map<string, Set<EventHandler<any>>>();\n private readonly stateHandlers = new Set<StateHandler>();\n\n private attempt = 0;\n private reconnectTimer: ReturnType<typeof setTimeout> | null = null;\n private closedByUser = false;\n private connecting: Promise<void> | null = null;\n\n constructor(options: KabarcastOptions) {\n this.opts = {\n minReconnectDelayMs: 500,\n maxReconnectDelayMs: 30_000,\n maxReconnectAttempts: Number.POSITIVE_INFINITY,\n ackTimeoutMs: 10_000,\n debug: false,\n ...options,\n };\n }\n\n /** Current connection state. */\n get connectionState(): ConnectionState {\n return this.state;\n }\n\n /** Opens the connection. Resolves once the socket is open. */\n connect(): Promise<void> {\n if (this.state === 'connected') return Promise.resolve();\n if (this.connecting) return this.connecting;\n\n this.closedByUser = false;\n this.connecting = this.open().finally(() => {\n this.connecting = null;\n });\n return this.connecting;\n }\n\n /**\n * Subscribes to a channel and resolves once the hub acknowledges it.\n *\n * The channel is remembered, so it is restored automatically after a\n * reconnect without the caller doing anything.\n */\n async subscribe(channel: string): Promise<Subscription> {\n this.desired.add(channel);\n if (this.state === 'connected') {\n await this.send('subscribe', channel);\n }\n return {\n channel,\n unsubscribe: () => this.unsubscribe(channel),\n };\n }\n\n /** Stops receiving events for a channel. */\n async unsubscribe(channel: string): Promise<void> {\n this.desired.delete(channel);\n if (this.state === 'connected') {\n await this.send('unsubscribe', channel);\n }\n }\n\n /**\n * Registers a handler for an event name. The name '*' receives every event.\n * Returns a function that removes the handler.\n */\n on<T = unknown>(event: string, handler: EventHandler<T>): () => void {\n let set = this.handlers.get(event);\n if (!set) {\n set = new Set();\n this.handlers.set(event, set);\n }\n set.add(handler as EventHandler<any>);\n return () => this.off(event, handler);\n }\n\n off<T = unknown>(event: string, handler: EventHandler<T>): void {\n this.handlers.get(event)?.delete(handler as EventHandler<any>);\n }\n\n /** Observes connection state changes. Returns an unsubscribe function. */\n onStateChange(handler: StateHandler): () => void {\n this.stateHandlers.add(handler);\n return () => {\n this.stateHandlers.delete(handler);\n };\n }\n\n /** Closes the connection and stops reconnecting. */\n close(): void {\n this.closedByUser = true;\n if (this.reconnectTimer) {\n clearTimeout(this.reconnectTimer);\n this.reconnectTimer = null;\n }\n this.failPending(new Error('kabarcast: client closed'));\n this.ws?.close(1000, 'client closed');\n this.ws = null;\n this.setState('closed');\n }\n\n // ---------------------------------------------------------------- internals\n\n private async open(): Promise<void> {\n this.setState(this.attempt === 0 ? 'connecting' : 'reconnecting');\n\n // Fetched on every attempt: channel tokens are short-lived, so a stale one\n // must never be the reason a reconnect fails.\n const token = await this.opts.getToken();\n const base = this.opts.url.replace(/\\/$/, '');\n const url = `${base}/v1/ws?token=${encodeURIComponent(token)}`;\n const ws = this.createSocket(url);\n this.ws = ws;\n\n return new Promise<void>((resolve, reject) => {\n let settled = false;\n\n ws.onopen = () => {\n settled = true;\n this.attempt = 0;\n this.setState('connected');\n this.log('connected');\n // Restore everything the caller asked for before the drop.\n for (const channel of this.desired) {\n this.send('subscribe', channel).catch((e) =>\n this.log('resubscribe failed', channel, e),\n );\n }\n resolve();\n };\n\n ws.onmessage = (ev) => this.handleFrame(ev.data);\n\n ws.onerror = () => {\n if (!settled) {\n settled = true;\n reject(new Error('kabarcast: connection failed'));\n }\n };\n\n ws.onclose = () => {\n this.ws = null;\n this.failPending(new Error('kabarcast: connection closed'));\n if (this.closedByUser) {\n this.setState('closed');\n return;\n }\n this.scheduleReconnect();\n };\n });\n }\n\n private createSocket(url: string): WebSocketLike {\n if (this.opts.webSocketFactory) return this.opts.webSocketFactory(url);\n const g = globalThis as { WebSocket?: new (url: string) => WebSocketLike };\n if (!g.WebSocket) {\n throw new Error(\n 'kabarcast: no WebSocket available. Pass options.webSocketFactory (for example the ws package on Node < 22).',\n );\n }\n return new g.WebSocket(url);\n }\n\n private scheduleReconnect(): void {\n if (this.attempt >= this.opts.maxReconnectAttempts) {\n this.log('giving up after', this.attempt, 'attempts');\n this.setState('closed');\n return;\n }\n this.attempt += 1;\n const delay = backoffDelay(\n this.attempt,\n this.opts.minReconnectDelayMs,\n this.opts.maxReconnectDelayMs,\n );\n this.setState('reconnecting');\n this.log(`reconnecting in ${delay}ms (attempt ${this.attempt})`);\n this.reconnectTimer = setTimeout(() => {\n this.open().catch((e) => {\n this.log('reconnect failed', e);\n this.scheduleReconnect();\n });\n }, delay);\n }\n\n private send(\n action: 'subscribe' | 'unsubscribe',\n channel: string,\n ): Promise<void> {\n const ws = this.ws;\n if (!ws || ws.readyState !== OPEN) {\n return Promise.reject(new Error('kabarcast: not connected'));\n }\n const key = `${action}:${channel}`;\n return new Promise<void>((resolve, reject) => {\n const timer = setTimeout(() => {\n this.pending.delete(key);\n reject(\n new Error(\n `kabarcast: timed out waiting for ${action} ack on ${channel}`,\n ),\n );\n }, this.opts.ackTimeoutMs);\n\n this.pending.set(key, { resolve, reject, timer });\n ws.send(JSON.stringify({ action, channel }));\n });\n }\n\n private handleFrame(raw: unknown): void {\n let frame: ServerFrame;\n try {\n frame = JSON.parse(typeof raw === 'string' ? raw : String(raw));\n } catch {\n this.log('dropped unparseable frame');\n return;\n }\n\n // Control frames carry `type`; broadcast events do not.\n if ('type' in frame) {\n if (frame.type === 'ack') {\n this.settle(`${frame.action}:${frame.channel}`, null);\n } else if (frame.type === 'error') {\n const err = new Error(`kabarcast: ${frame.message}`);\n if (frame.action && frame.channel) {\n // A refused subscribe must not be retried forever on reconnect.\n if (frame.action === 'subscribe') this.desired.delete(frame.channel);\n this.settle(`${frame.action}:${frame.channel}`, err);\n } else {\n this.log('server error', frame.message);\n }\n }\n return;\n }\n\n this.dispatch(frame);\n }\n\n private settle(key: string, err: Error | null): void {\n const p = this.pending.get(key);\n if (!p) return;\n clearTimeout(p.timer);\n this.pending.delete(key);\n if (err) {\n p.reject(err);\n } else {\n p.resolve();\n }\n }\n\n private failPending(err: Error): void {\n for (const [, p] of this.pending) {\n clearTimeout(p.timer);\n p.reject(err);\n }\n this.pending.clear();\n }\n\n private dispatch(ev: KabarcastEvent): void {\n const meta = { channel: ev.channel, event: ev.event, ts: ev.ts };\n for (const key of [ev.event, '*']) {\n const set = this.handlers.get(key);\n if (!set) continue;\n for (const h of set) {\n try {\n h(ev.data, meta);\n } catch (e) {\n this.log('handler threw', e);\n }\n }\n }\n }\n\n private setState(s: ConnectionState): void {\n if (this.state === s) return;\n this.state = s;\n for (const h of this.stateHandlers) {\n try {\n h(s);\n } catch {\n /* a bad observer must not break the client */\n }\n }\n }\n\n private log(...args: unknown[]): void {\n if (this.opts.debug) console.debug('[kabarcast]', ...args);\n }\n}\n"],"mappings":"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","names":[]}
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@diugemi/kabarcast-client",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "TypeScript client for kabarcast - realtime message broadcasting with channel-scoped auth and automatic reconnect.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.cjs",
|
|
8
|
+
"module": "./dist/index.js",
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"import": "./dist/index.js",
|
|
14
|
+
"require": "./dist/index.cjs"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist",
|
|
19
|
+
"README.md"
|
|
20
|
+
],
|
|
21
|
+
"sideEffects": false,
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "tsup src/index.ts --format esm,cjs --dts --clean --sourcemap",
|
|
24
|
+
"typecheck": "tsc --noEmit",
|
|
25
|
+
"test": "node --test test/*.test.js",
|
|
26
|
+
"prepublishOnly": "npm run build"
|
|
27
|
+
},
|
|
28
|
+
"keywords": [
|
|
29
|
+
"websocket",
|
|
30
|
+
"realtime",
|
|
31
|
+
"pubsub",
|
|
32
|
+
"broadcast",
|
|
33
|
+
"kabarcast",
|
|
34
|
+
"notifications",
|
|
35
|
+
"sdk"
|
|
36
|
+
],
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "git+https://github.com/BerieGithub/kabarcast.git",
|
|
40
|
+
"directory": "clients/typescript"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"tsup": "^8.3.5",
|
|
44
|
+
"typescript": "^5.7.2",
|
|
45
|
+
"ws": "^8.18.0"
|
|
46
|
+
},
|
|
47
|
+
"engines": {
|
|
48
|
+
"node": ">=18"
|
|
49
|
+
}
|
|
50
|
+
}
|