@kispi/chat 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/dist/index.cjs ADDED
@@ -0,0 +1,1175 @@
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 src_exports = {};
22
+ __export(src_exports, {
23
+ ChatClient: () => ChatClient,
24
+ ChatError: () => ChatError,
25
+ Room: () => Room,
26
+ Timeline: () => Timeline,
27
+ createChatClient: () => createChatClient,
28
+ createTimeline: () => createTimeline
29
+ });
30
+ module.exports = __toCommonJS(src_exports);
31
+
32
+ // src/emitter.ts
33
+ var Emitter = class {
34
+ listeners = /* @__PURE__ */ new Map();
35
+ on(event, fn) {
36
+ let set = this.listeners.get(event);
37
+ if (set === void 0) {
38
+ set = /* @__PURE__ */ new Set();
39
+ this.listeners.set(event, set);
40
+ }
41
+ set.add(fn);
42
+ return () => void set.delete(fn);
43
+ }
44
+ emit(event, value) {
45
+ const set = this.listeners.get(event);
46
+ if (set === void 0) return;
47
+ for (const fn of [...set]) {
48
+ try {
49
+ ;
50
+ fn(value);
51
+ } catch (err) {
52
+ queueMicrotask(() => {
53
+ throw err;
54
+ });
55
+ }
56
+ }
57
+ }
58
+ clear() {
59
+ this.listeners.clear();
60
+ }
61
+ };
62
+
63
+ // src/errors.ts
64
+ var ChatError = class extends Error {
65
+ code;
66
+ /** Present on rate limits, in milliseconds. */
67
+ retryAfterMs;
68
+ /** The consumer's own code, when a before_publish hook denied this. */
69
+ appCode;
70
+ constructor(code, message, extra) {
71
+ super(message);
72
+ this.name = "ChatError";
73
+ this.code = code;
74
+ if (extra?.retryAfterMs !== void 0) this.retryAfterMs = extra.retryAfterMs;
75
+ if (extra?.appCode !== void 0) this.appCode = extra.appCode;
76
+ }
77
+ };
78
+ function errorFrom(data, fallback = "the server refused the request") {
79
+ if (typeof data === "object" && data !== null) {
80
+ const d = data;
81
+ const inner = d["error"] ?? d;
82
+ const code = typeof inner["code"] === "string" ? inner["code"] : "internal";
83
+ const message = typeof inner["message"] === "string" ? inner["message"] : fallback;
84
+ const extra = {};
85
+ if (typeof inner["retryAfterMs"] === "number") extra.retryAfterMs = inner["retryAfterMs"];
86
+ if (typeof inner["appCode"] === "string") extra.appCode = inner["appCode"];
87
+ return new ChatError(code, message, extra);
88
+ }
89
+ return new ChatError("internal", fallback);
90
+ }
91
+
92
+ // src/rest.ts
93
+ var Rest = class {
94
+ options;
95
+ constructor(options) {
96
+ this.options = options;
97
+ }
98
+ async get(path, query) {
99
+ return this.call("GET", path + queryString(query));
100
+ }
101
+ async post(path, body) {
102
+ return this.call("POST", path, body);
103
+ }
104
+ async patch(path, body) {
105
+ return this.call("PATCH", path, body);
106
+ }
107
+ async put(path, body) {
108
+ return this.call("PUT", path, body);
109
+ }
110
+ async delete(path) {
111
+ return this.call("DELETE", path);
112
+ }
113
+ async call(method, path, body) {
114
+ const token = await this.options.token();
115
+ if (token === void 0) {
116
+ throw new ChatError(
117
+ "unauthorized",
118
+ "no user token yet: connect() fetches one, and REST calls before that have nothing to present"
119
+ );
120
+ }
121
+ const headers = {
122
+ Authorization: `Bearer ${token}`,
123
+ "X-Chat-Key": this.options.key
124
+ };
125
+ if (body !== void 0) headers["Content-Type"] = "application/json";
126
+ const res = await this.options.fetch(`${this.options.url}${path}`, {
127
+ method,
128
+ headers,
129
+ ...body === void 0 ? {} : { body: JSON.stringify(body) }
130
+ });
131
+ if (res.status === 204) return void 0;
132
+ const text = await res.text();
133
+ const parsed = text === "" ? void 0 : JSON.parse(text);
134
+ if (!res.ok) {
135
+ const err = errorFrom(parsed, `${method} ${path} failed with ${res.status}`);
136
+ throw err;
137
+ }
138
+ return parsed;
139
+ }
140
+ };
141
+ function queryString(query) {
142
+ if (query === void 0) return "";
143
+ const params = new URLSearchParams();
144
+ for (const [k, v] of Object.entries(query)) {
145
+ if (v !== void 0) params.set(k, String(v));
146
+ }
147
+ const s = params.toString();
148
+ return s === "" ? "" : `?${s}`;
149
+ }
150
+ function restURLFrom(socketURL) {
151
+ if (socketURL.startsWith("wss://")) return `https://${socketURL.slice("wss://".length)}`;
152
+ if (socketURL.startsWith("ws://")) return `http://${socketURL.slice("ws://".length)}`;
153
+ return socketURL;
154
+ }
155
+
156
+ // src/timeline.ts
157
+ var MaxPageSize = 100;
158
+ var Timeline = class {
159
+ options;
160
+ items = [];
161
+ /**
162
+ * Deletes for messages this client has not seen yet.
163
+ *
164
+ * A `message.deleted` can arrive for a seq inside a hole. If it were
165
+ * dropped, the fill that follows would bring the row back **alive**,
166
+ * because the history read reflects the delete only if it happened
167
+ * before the read. The client would then show a message the server
168
+ * considers gone, and nothing would ever correct it.
169
+ */
170
+ pendingDeletes = /* @__PURE__ */ new Set();
171
+ /** In-flight fills, keyed by the range they cover, to avoid duplicates. */
172
+ fills = /* @__PURE__ */ new Map();
173
+ /**
174
+ * Bumped by every reset.
175
+ *
176
+ * A fill started before a reset must not be applied after one: the
177
+ * range it was asked for described a timeline that no longer exists.
178
+ * Without this, a reconnect that lands mid-fill mixes the old room's
179
+ * rows into the new list.
180
+ */
181
+ epoch = 0;
182
+ constructor(options) {
183
+ this.options = options;
184
+ }
185
+ get messages() {
186
+ return this.items;
187
+ }
188
+ /** The highest seq this timeline holds, hole or no hole. */
189
+ get highestSeq() {
190
+ return this.items.at(-1)?.seq ?? 0;
191
+ }
192
+ /**
193
+ * The highest seq with nothing missing below it.
194
+ *
195
+ * This is the number a resubscribe must send as `since`, and it is not
196
+ * `highestSeq`. Holding 5,6,7,8,9,14 with 10..13 missing, the top of
197
+ * the list is 14 and the top of what this client actually has is 9.
198
+ * Sending 14 tells the server "I have everything up to 14", the
199
+ * catch-up computes that there is nothing to fetch, and the hole
200
+ * becomes permanent -- which is exactly the failure the whole gap
201
+ * machinery exists to prevent, arriving through the number that
202
+ * describes it.
203
+ *
204
+ * A hole below the first row held is not visible here and not this
205
+ * client's business: history begins where the last load began.
206
+ */
207
+ get contiguousSeq() {
208
+ const first = this.items[0];
209
+ if (first === void 0) return 0;
210
+ let last = first.seq;
211
+ for (const item of this.items) {
212
+ if (item.seq === last || item.seq === last + 1) {
213
+ last = item.seq;
214
+ continue;
215
+ }
216
+ break;
217
+ }
218
+ return last;
219
+ }
220
+ /**
221
+ * Forgets everything, including the pending deletes.
222
+ *
223
+ * Used when the server says the cursor is too old: what this timeline
224
+ * holds is no longer connected to what the room holds, so carrying any
225
+ * of it forward would leave a hole that nothing fills.
226
+ */
227
+ discard() {
228
+ this.epoch++;
229
+ this.fills.clear();
230
+ this.pendingDeletes.clear();
231
+ this.items = [];
232
+ this.changed();
233
+ }
234
+ /**
235
+ * Replaces the window with a recent page. Used by the initial load and
236
+ * by recovery.
237
+ *
238
+ * Rows already held **above** the page's top survive it. A recent page
239
+ * is the last N messages as of the moment the server read them, which
240
+ * makes it evidence about the range it covers and no evidence at all
241
+ * about anything newer. Live frames that arrived while the page was in
242
+ * flight are exactly that -- newer -- and dropping them would open a
243
+ * hole above the page that nothing goes looking for, because a hole is
244
+ * only detected on the next arrival.
245
+ *
246
+ * That race is not exotic: the README points consumers at `reload()`
247
+ * for the moment after a reconnect, which is the moment the missed
248
+ * frames are arriving.
249
+ *
250
+ * A caller that wants the list genuinely emptied has `discard()`.
251
+ */
252
+ reset(messages) {
253
+ this.epoch++;
254
+ this.fills.clear();
255
+ const page = [...messages].sort((a, b) => a.seq - b.seq);
256
+ const top = page.at(-1)?.seq ?? 0;
257
+ this.items = [...page, ...this.items.filter((m) => m.seq > top)];
258
+ for (const m of this.items) {
259
+ if (this.pendingDeletes.has(m.id)) this.applyDelete(m.id);
260
+ }
261
+ this.changed();
262
+ }
263
+ /**
264
+ * Takes one live message, in order or not, and fills what it reveals.
265
+ *
266
+ * Returns the fill promise so a caller can await settling; nothing in
267
+ * the SDK does, because a consumer's UI should show what arrived and
268
+ * let the rest land when it lands.
269
+ */
270
+ add(message) {
271
+ const gapFrom = this.highestSeq;
272
+ this.insert(message);
273
+ if (gapFrom > 0 && message.seq > gapFrom + 1) {
274
+ return this.fill(gapFrom, message.seq);
275
+ }
276
+ return Promise.resolve();
277
+ }
278
+ /**
279
+ * Applies a `message.updated`.
280
+ *
281
+ * A row that is already a tombstone stays one. The edit and the delete
282
+ * can arrive in either order -- reordering is the input this module
283
+ * exists for -- and applying a late edit to a deleted row puts its body
284
+ * back on screen under a `deletedAt` that says it is gone. From the
285
+ * person who deleted it, that is a delete that did not work.
286
+ */
287
+ update(message) {
288
+ const at = this.items.findIndex((m) => m.id === message.id);
289
+ if (at === -1) return;
290
+ const current = this.items[at];
291
+ if (current.deletedAt !== void 0) return;
292
+ this.items[at] = { ...current, ...message };
293
+ this.changed();
294
+ }
295
+ /**
296
+ * Applies a `reaction.added` or `reaction.removed` to the aggregate the
297
+ * message carries.
298
+ *
299
+ * `Message.reactions` is filled by the history read and a consumer
300
+ * renders from it, so leaving it alone would give them a count that is
301
+ * correct at load and frozen after -- inside a live session, with the
302
+ * event that should have changed it arriving on another channel.
303
+ *
304
+ * The server sends the new `count` with the event, so this is a
305
+ * replacement rather than an increment: two clients reacting at once
306
+ * cannot drift the way `+1`/`-1` would.
307
+ */
308
+ reaction(messageId, emoji, count) {
309
+ const at = this.items.findIndex((m) => m.id === messageId);
310
+ if (at === -1) return;
311
+ const current = this.items[at];
312
+ const existing = current.reactions ?? [];
313
+ const slot = existing.findIndex((r) => r.emoji === emoji);
314
+ let next;
315
+ if (count <= 0) next = existing.filter((r) => r.emoji !== emoji);
316
+ else if (slot === -1) next = [...existing, { emoji, count }];
317
+ else next = existing.map((r, i) => i === slot ? { emoji, count } : r);
318
+ this.items[at] = { ...current, reactions: next };
319
+ this.changed();
320
+ }
321
+ /** Applies a `thread.updated` to the root message's aggregate. */
322
+ thread(rootId, count, lastSeq) {
323
+ const at = this.items.findIndex((m) => m.id === rootId);
324
+ if (at === -1) return;
325
+ const current = this.items[at];
326
+ this.items[at] = { ...current, thread: lastSeq === void 0 ? { count } : { count, lastSeq } };
327
+ this.changed();
328
+ }
329
+ /** Applies a `message.deleted`, remembering it if the row is not here. */
330
+ remove(id) {
331
+ if (!this.applyDelete(id)) this.pendingDeletes.add(id);
332
+ }
333
+ /**
334
+ * Fetches everything between what we have and `upTo`.
335
+ *
336
+ * Used after a reconnect, where the ack's `lastSeq` says how far the
337
+ * room got while we were away.
338
+ */
339
+ async catchUp(upTo) {
340
+ const from = this.contiguousSeq;
341
+ if (upTo <= from) return;
342
+ await this.fill(from, upTo + 1);
343
+ }
344
+ /**
345
+ * Walks a range, page by page, and inserts what comes back.
346
+ *
347
+ * The loop is the point. `GET .../messages` caps `limit` at 100, so a
348
+ * client that was away while a busy room moved 250 messages gets a
349
+ * third of its gap from one call -- and a single-call implementation
350
+ * would believe the hole was filled. That failure is silent: the list
351
+ * looks continuous because the missing rows were never known about.
352
+ */
353
+ fill(after, before) {
354
+ const key = `${after}:${before}`;
355
+ const existing = this.fills.get(key);
356
+ if (existing !== void 0) return existing;
357
+ const epoch = this.epoch;
358
+ const pageSize = this.options.pageSize ?? MaxPageSize;
359
+ const run = (async () => {
360
+ let cursor = after;
361
+ while (cursor < before - 1) {
362
+ const page = await this.options.fetchRange({ after: cursor, before, limit: pageSize });
363
+ if (this.epoch !== epoch) return;
364
+ if (page.length === 0) return;
365
+ for (const m of page) this.insert(m);
366
+ const highest = page.at(-1)?.seq ?? cursor;
367
+ if (highest <= cursor) return;
368
+ cursor = highest;
369
+ }
370
+ })().finally(() => {
371
+ if (this.fills.get(key) === run) this.fills.delete(key);
372
+ });
373
+ this.fills.set(key, run);
374
+ return run;
375
+ }
376
+ /** Inserts at the seq position, replacing an existing row with that seq. */
377
+ insert(message) {
378
+ const pending = this.pendingDeletes.has(message.id);
379
+ if (pending) this.pendingDeletes.delete(message.id);
380
+ const withDelete = pending ? emptied(message) : message;
381
+ const at = this.items.findIndex((m) => m.seq >= withDelete.seq);
382
+ if (at === -1) {
383
+ this.items.push(withDelete);
384
+ } else if (this.items[at]?.seq === withDelete.seq) {
385
+ const current = this.items[at];
386
+ this.items[at] = current.deletedAt !== void 0 ? current : withDelete;
387
+ } else {
388
+ this.items.splice(at, 0, withDelete);
389
+ }
390
+ this.changed();
391
+ }
392
+ applyDelete(id) {
393
+ const at = this.items.findIndex((m) => m.id === id);
394
+ if (at === -1) return false;
395
+ this.pendingDeletes.delete(id);
396
+ this.items[at] = emptied(this.items[at]);
397
+ this.changed();
398
+ return true;
399
+ }
400
+ changed() {
401
+ this.options.onChange?.(this.items);
402
+ }
403
+ };
404
+ function emptied(message) {
405
+ return { ...message, body: {}, deletedAt: message.deletedAt ?? Date.now() };
406
+ }
407
+ function createTimeline(options) {
408
+ return new Timeline(options);
409
+ }
410
+
411
+ // src/room.ts
412
+ var HistoryPageSize = 100;
413
+ var Room = class extends Emitter {
414
+ /** Resolved on subscribe when the room was addressed by key. */
415
+ id;
416
+ key;
417
+ /** The last seq the server reported for this room. */
418
+ lastSeq = 0;
419
+ presence;
420
+ chat;
421
+ rest;
422
+ timeline;
423
+ /** The live feed is on and history is loaded. */
424
+ subscribed = false;
425
+ /**
426
+ * Invalidates work in flight.
427
+ *
428
+ * A subscribe is several awaits long -- the frame, then a history read
429
+ * -- and the socket can die in the middle of it. The promise that was
430
+ * in flight then completes against a **dead socket's** ack and marks
431
+ * the room subscribed, while the new socket has never heard of it. The
432
+ * room is then live nowhere and believes it is live, and no public
433
+ * call fixes that because every entry point short-circuits on the flag
434
+ * it just set.
435
+ *
436
+ * Each attempt carries the generation it started in and applies
437
+ * nothing once that has moved.
438
+ */
439
+ generation = 0;
440
+ /**
441
+ * A subscribe was asked for at least once, whether or not it finished.
442
+ *
443
+ * `subscribed` cannot carry this. A catch-up that fails leaves a room
444
+ * that is *not* subscribed, and if that were the only flag a reconnect
445
+ * would skip it -- the room drops out of the live feed permanently and
446
+ * silently, which is worse than the failure that started it.
447
+ */
448
+ wanted = false;
449
+ subscribing;
450
+ constructor(chat, rest, address) {
451
+ super();
452
+ this.chat = chat;
453
+ this.rest = rest;
454
+ this.id = address.id;
455
+ this.key = address.key;
456
+ this.timeline = createTimeline({
457
+ fetchRange: ({ after, before, limit }) => this.rest.get(`/v1/rooms/${this.id}/messages`, {
458
+ after,
459
+ before,
460
+ limit
461
+ }).then((page) => page.messages),
462
+ onChange: (messages) => this.emit("messages", messages)
463
+ });
464
+ }
465
+ /** Everything this client knows about the room, in seq order. */
466
+ get messages() {
467
+ return this.timeline.messages;
468
+ }
469
+ /**
470
+ * Reloads recent history, discarding what is held.
471
+ *
472
+ * A reconnect recovers the **message timeline** and nothing else: the
473
+ * only history route is `GET .../messages`, and reactions, thread
474
+ * counts, read cursors, membership and room metadata ride along only
475
+ * for the rows that page happens to contain. So a reaction added to an
476
+ * older message while this client was away is never seen, and no
477
+ * amount of gap filling will find it.
478
+ *
479
+ * The SDK does not hide that behind a background refresh, because the
480
+ * refresh would have to be either wasteful or wrong. It gives the
481
+ * consumer the one honest tool -- ask again -- and says when to use it.
482
+ */
483
+ async reload() {
484
+ const gen = this.generation;
485
+ await this.loadRecent(() => gen === this.generation);
486
+ }
487
+ /**
488
+ * Starts the live feed and loads recent history.
489
+ *
490
+ * Both halves, because neither is enough: `subscribe` does not replay
491
+ * anything that already happened, and a history read has no way to
492
+ * learn about what happens next. A room that only did the first looks
493
+ * empty until somebody speaks.
494
+ */
495
+ async subscribe() {
496
+ this.wanted = true;
497
+ if (this.subscribing !== void 0) return this.subscribing;
498
+ if (this.subscribed) return;
499
+ const attempt = this.doSubscribe().finally(() => {
500
+ if (this.subscribing === attempt) this.subscribing = void 0;
501
+ });
502
+ this.subscribing = attempt;
503
+ return attempt;
504
+ }
505
+ async doSubscribe() {
506
+ const gen = this.generation;
507
+ const current = () => gen === this.generation;
508
+ const data = this.id !== void 0 ? { roomId: this.id } : { key: this.key };
509
+ const since = this.timeline.contiguousSeq;
510
+ if (since > 0) data["since"] = since;
511
+ const attempt = await this.subscribeFrame(data);
512
+ const ack = attempt.ack;
513
+ if (!current()) return;
514
+ this.id = ack.roomId;
515
+ this.chat.registerRoomId(ack.roomId, this);
516
+ this.lastSeq = ack.lastSeq;
517
+ if (ack.presence !== void 0) this.presence = ack.presence;
518
+ if (attempt.wasReset) {
519
+ await this.loadRecent(current);
520
+ if (!current()) return;
521
+ this.subscribed = true;
522
+ this.emit("reset", this.timeline.messages);
523
+ return;
524
+ }
525
+ if (since > 0) {
526
+ await this.timeline.catchUp(ack.lastSeq);
527
+ if (!current()) return;
528
+ this.subscribed = true;
529
+ return;
530
+ }
531
+ if (ack.lastSeq > 0) {
532
+ await this.loadRecent(current);
533
+ }
534
+ if (!current()) return;
535
+ this.subscribed = true;
536
+ }
537
+ /**
538
+ * Reads a page of history directly, without touching `messages`.
539
+ *
540
+ * The list this room keeps is the live one; this is for a consumer
541
+ * scrolling back, which owns its own window and does not want the
542
+ * bottom of the room rearranged under it.
543
+ *
544
+ * `view` defaults to the server's, which is every message including
545
+ * thread replies. **`view: 'main'` is a display filter, not a sync
546
+ * cursor** -- its last seq skips replies, so feeding it back as
547
+ * `since` would ask the server to resend every reply since.
548
+ */
549
+ async history(options = {}) {
550
+ const view = options.view === void 0 ? void 0 : typeof options.view === "string" ? options.view : `thread:${options.view.thread}`;
551
+ const page = await this.rest.get(`/v1/rooms/${this.requireId("reading history")}/messages`, {
552
+ before: options.before,
553
+ after: options.after,
554
+ limit: options.limit,
555
+ view
556
+ });
557
+ return page.messages;
558
+ }
559
+ /** Adds a reaction. Idempotent, like the frame. */
560
+ async react(messageId, emoji) {
561
+ await this.chat.send("react", { roomId: this.requireId("reacting"), messageId, emoji, op: "add" });
562
+ }
563
+ async unreact(messageId, emoji) {
564
+ await this.chat.send("react", { roomId: this.requireId("reacting"), messageId, emoji, op: "remove" });
565
+ }
566
+ /**
567
+ * Moves this user's read cursor.
568
+ *
569
+ * The cursor only moves forward: the server ignores a lower value and
570
+ * **does not call that an error**, so the ack's `advanced` is the only
571
+ * way to know what happened. It is returned rather than swallowed.
572
+ */
573
+ async markRead(seq, options = {}) {
574
+ const data = { roomId: this.requireId("marking read"), seq };
575
+ if (options.threadId !== void 0) data["threadId"] = options.threadId;
576
+ return await this.chat.send("read", data);
577
+ }
578
+ /**
579
+ * Says this user is typing.
580
+ *
581
+ * Fire and forget: the frame is ephemeral, the server throttles it,
582
+ * and a caller awaiting an ack on every keystroke would be doing the
583
+ * one thing this event is designed to avoid.
584
+ *
585
+ * **The event comes back to the sender too.** The server broadcasts it
586
+ * to the room without excluding whoever sent it, so a consumer drawing
587
+ * "X is typing" has to skip its own `userId` -- the SDK does not
588
+ * filter it out, because a room view that shows every typist and one
589
+ * that shows everyone else are both legitimate and only the consumer
590
+ * knows which it is drawing.
591
+ */
592
+ typing() {
593
+ try {
594
+ void this.chat.send("typing", { roomId: this.requireId("typing") }).catch(() => {
595
+ });
596
+ } catch {
597
+ }
598
+ }
599
+ /** Joins a public or channel room. Idempotent. */
600
+ async join() {
601
+ await this.rest.post(`/v1/rooms/${this.requireId("joining")}/join`);
602
+ }
603
+ /** Leaves. Idempotent from the caller's side. */
604
+ async leave() {
605
+ await this.rest.delete(`/v1/rooms/${this.requireId("leaving")}/members/me`);
606
+ }
607
+ /** The full presence list, for rooms too large to send it in the ack. */
608
+ async presenceList() {
609
+ return this.rest.get(`/v1/rooms/${this.requireId("reading presence")}/presence`, { full: "true" });
610
+ }
611
+ /**
612
+ * Who reacted, one row per (user, emoji) pair.
613
+ *
614
+ * A person who put three emoji on one message is three rows, and the
615
+ * cursor pages over pairs for that reason: keyed on the user alone, a
616
+ * page boundary falling inside someone's set would drop the rest of
617
+ * their emoji from every page. `limit` is 50 by default and 100 at
618
+ * most, and a full page still carries a cursor when more follows.
619
+ */
620
+ async reactionsOf(messageId, options = {}) {
621
+ return this.rest.get(`/v1/rooms/${this.requireId("reading reactions")}/messages/${messageId}/reactions`, {
622
+ emoji: options.emoji,
623
+ cursor: options.cursor,
624
+ limit: options.limit
625
+ });
626
+ }
627
+ /**
628
+ * Re-subscribes after a reconnect. Called by the client.
629
+ *
630
+ * The test is "was this room ever asked for", not "did the last
631
+ * subscribe finish". A room whose catch-up failed is in exactly the
632
+ * state that needs another try, and skipping it there is how a single
633
+ * transient 500 takes a room out of the live feed for the life of the
634
+ * page.
635
+ */
636
+ async resume() {
637
+ if (!this.wanted) return;
638
+ this.generation++;
639
+ this.subscribing = void 0;
640
+ this.subscribed = false;
641
+ await this.subscribe();
642
+ }
643
+ /**
644
+ * Sends `subscribe` and recovers from the one error it has an answer
645
+ * for.
646
+ *
647
+ * `cursor_too_old` means the room's retention has passed the point
648
+ * this client last saw: the server cannot bridge the gap and neither
649
+ * can we. The only honest response is to drop what we hold and start
650
+ * from recent history -- and to **say so**, because a consumer that
651
+ * quietly appends onto its old list keeps a hole forever.
652
+ */
653
+ async subscribeFrame(data) {
654
+ try {
655
+ return { ack: await this.chat.send("subscribe", data), wasReset: false };
656
+ } catch (err) {
657
+ if (err.code !== "cursor_too_old") throw err;
658
+ this.timeline.discard();
659
+ const retry = { ...data };
660
+ delete retry["since"];
661
+ return { ack: await this.chat.send("subscribe", retry), wasReset: true };
662
+ }
663
+ }
664
+ async unsubscribe() {
665
+ if (this.id === void 0) return;
666
+ this.wanted = false;
667
+ this.subscribed = false;
668
+ this.generation++;
669
+ this.subscribing = void 0;
670
+ await this.chat.send("unsubscribe", { roomId: this.id });
671
+ }
672
+ /**
673
+ * The room's id, or a refusal.
674
+ *
675
+ * A room addressed by key has no id until it subscribes, and every
676
+ * route below builds a path out of it. Without this the path is
677
+ * `/v1/rooms/undefined/...` and the frame carries `roomId: undefined`
678
+ * -- a 404 or a validation error that says nothing about the actual
679
+ * mistake.
680
+ */
681
+ requireId(what) {
682
+ if (this.id === void 0) {
683
+ throw new ChatError("closed", `subscribe to the room before ${what}: its id is not known yet`);
684
+ }
685
+ return this.id;
686
+ }
687
+ /** Publishes and resolves when the server acks. */
688
+ async send(input, options = {}) {
689
+ const roomId = this.requireId("sending");
690
+ const body = {};
691
+ if (input.text !== void 0) body["text"] = input.text;
692
+ if (input.entities !== void 0) body["entities"] = input.entities;
693
+ if (input.attachments !== void 0) body["attachments"] = input.attachments;
694
+ const frame = {
695
+ roomId,
696
+ clientMessageId: options.clientMessageId ?? newClientMessageId(),
697
+ body
698
+ };
699
+ if (input.replyTo !== void 0) frame["replyTo"] = input.replyTo;
700
+ if (input.threadId !== void 0) frame["threadId"] = input.threadId;
701
+ if (input.meta !== void 0) frame["meta"] = input.meta;
702
+ return await this.chat.send("publish", frame);
703
+ }
704
+ /** Routes a frame the client decided belongs to this room. */
705
+ handle(type, data) {
706
+ switch (type) {
707
+ case "message.created":
708
+ if (data.seq > this.lastSeq) this.lastSeq = data.seq;
709
+ this.timeline.add(data).catch((err) => {
710
+ this.subscribed = false;
711
+ this.emit("error", err instanceof Error ? err : new Error(String(err)));
712
+ });
713
+ break;
714
+ case "message.updated":
715
+ this.timeline.update(data);
716
+ break;
717
+ case "message.deleted": {
718
+ const deleted = data;
719
+ if (deleted.seq !== void 0 && deleted.seq > this.lastSeq) this.lastSeq = deleted.seq;
720
+ this.timeline.remove(deleted.id);
721
+ break;
722
+ }
723
+ case "reaction.added":
724
+ case "reaction.removed": {
725
+ const r = data;
726
+ this.timeline.reaction(r.messageId, r.emoji, r.count);
727
+ break;
728
+ }
729
+ case "thread.updated": {
730
+ const t = data;
731
+ this.timeline.thread(t.rootId, t.count, t.lastSeq);
732
+ break;
733
+ }
734
+ case "presence":
735
+ this.presence = data;
736
+ break;
737
+ default:
738
+ break;
739
+ }
740
+ this.emit(type, data);
741
+ }
742
+ /**
743
+ * Replaces the timeline with the most recent page.
744
+ *
745
+ * `stillCurrent` guards the write, not the read. This is a replacement
746
+ * -- the whole point of it -- so a page fetched for a socket that has
747
+ * since died would throw away everything the socket that replaced it
748
+ * has already loaded. Marking the room subscribed is guarded for the
749
+ * same reason one step later; both are the same await, and the guard
750
+ * has to cover the part that touches shared state.
751
+ */
752
+ async loadRecent(stillCurrent) {
753
+ const page = await this.rest.get(`/v1/rooms/${this.requireId("reading history")}/messages`, {
754
+ limit: HistoryPageSize
755
+ });
756
+ if (!stillCurrent()) return;
757
+ this.timeline.reset(page.messages);
758
+ }
759
+ };
760
+ function newClientMessageId() {
761
+ const c = globalThis.crypto;
762
+ if (c?.randomUUID !== void 0) return `cm-${c.randomUUID()}`;
763
+ return `cm-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
764
+ }
765
+
766
+ // src/client.ts
767
+ var MinBackoffMs = 500;
768
+ var MaxBackoffMs = 3e4;
769
+ var ChatClient = class extends Emitter {
770
+ state = "closed";
771
+ user;
772
+ connectionId;
773
+ hello;
774
+ options;
775
+ makeSocket;
776
+ socket;
777
+ heartbeat;
778
+ retryTimer;
779
+ attempt = 0;
780
+ /** Set by close(), and the only thing that stops the reconnect loop. */
781
+ closedByCaller = false;
782
+ nextFrameId = 0;
783
+ opening;
784
+ rest;
785
+ /**
786
+ * Room handles this client has handed out, keyed by how they were
787
+ * asked for. Nothing is ever removed: a handle a consumer holds has to
788
+ * keep being resumed after a reconnect.
789
+ */
790
+ handles = /* @__PURE__ */ new Map();
791
+ /**
792
+ * Which handle a push frame for a given room id goes to.
793
+ *
794
+ * Separate from `handles` because the two questions are different.
795
+ * Filing a resolved id **into** `handles` meant a second handle for
796
+ * the same room evicted the first -- and an evicted handle is not just
797
+ * unrouted, it stops being resumed at all, which is worse than the
798
+ * problem the eviction was solving.
799
+ */
800
+ routes = /* @__PURE__ */ new Map();
801
+ /** The token most recently sent, so REST can present the same one. */
802
+ lastToken;
803
+ /** True once a socket has reached `hello`, so the next one is a return. */
804
+ everOpened = false;
805
+ constructor(options) {
806
+ super();
807
+ this.options = options;
808
+ const ctor = options.WebSocket ?? globalThis.WebSocket;
809
+ if (ctor === void 0) {
810
+ throw new TypeError(
811
+ "no WebSocket available: pass one as options.WebSocket (Node 22 and every current browser have a global)"
812
+ );
813
+ }
814
+ this.makeSocket = ctor;
815
+ this.rest = new Rest({
816
+ url: options.restURL ?? restURLFrom(options.url),
817
+ key: options.key,
818
+ token: async () => this.lastToken,
819
+ fetch: options.fetch ?? globalThis.fetch.bind(globalThis)
820
+ });
821
+ }
822
+ /** The account-level routes: the rooms this user is in, and unread. */
823
+ get rooms() {
824
+ return {
825
+ list: (options) => this.rest.get("/v1/me/rooms", { cursor: options?.cursor, limit: options?.limit }),
826
+ discover: (options) => this.rest.get("/v1/rooms", { type: options?.type, cursor: options?.cursor, limit: options?.limit }),
827
+ members: (roomId) => ({
828
+ list: (options) => this.rest.get(`/v1/rooms/${roomId}/members`, { cursor: options?.cursor, limit: options?.limit }),
829
+ add: async (userId, role) => {
830
+ await this.rest.put(`/v1/rooms/${roomId}/members/${userId}`, role === void 0 ? {} : { role });
831
+ },
832
+ remove: async (userId) => {
833
+ await this.rest.delete(`/v1/rooms/${roomId}/members/${userId}`);
834
+ }
835
+ })
836
+ };
837
+ }
838
+ /**
839
+ * The unread totals across every room this user is a member of.
840
+ *
841
+ * The field names are the route's own: `unread` and `unreadMentions`,
842
+ * **not** the `total`/`mentions` that `hello.unread` uses for the same
843
+ * two numbers. The server says it both ways and the SDK does not pick
844
+ * a third; renaming here would mean a consumer reading the protocol
845
+ * document finds a name that does not exist in either place.
846
+ *
847
+ * `roomsCapped` means the totals cover a subset -- this route does not
848
+ * page, so past 500 rooms it stops counting and says so.
849
+ */
850
+ async unread() {
851
+ return this.rest.get("/v1/me/unread");
852
+ }
853
+ /**
854
+ * Updates this user's own profile.
855
+ *
856
+ * **`meta` only.** The spec's example is `me.update({ name })`, and the
857
+ * server answers 400 to `name` and `avatar` on this route: a name comes
858
+ * from the token the consumer's backend signs, or from `PUT
859
+ * /users/{id}` with an `sk_`. A signature that always fails is not an
860
+ * API, it is a trap, so it is not offered.
861
+ *
862
+ * The merge is one level deep and a `null` deletes its key -- a nested
863
+ * object is **replaced**, not merged into.
864
+ */
865
+ async updateMe(meta) {
866
+ return this.rest.patch("/v1/me", { meta });
867
+ }
868
+ /**
869
+ * A handle on a room by id.
870
+ *
871
+ * Handles are cached, so two callers asking for the same room get the
872
+ * same object and the same message list -- two lists of one room would
873
+ * drift the moment one of them filled a gap.
874
+ */
875
+ room(roomId) {
876
+ const routed = this.routes.get(roomId);
877
+ if (routed !== void 0) return routed;
878
+ return this.roomHandle(roomId, { id: roomId });
879
+ }
880
+ /** The same, addressed by the app's own key. Resolved on subscribe. */
881
+ roomByKey(key) {
882
+ return this.roomHandle(`key:${key}`, { key });
883
+ }
884
+ roomHandle(cacheKey, address) {
885
+ const existing = this.handles.get(cacheKey);
886
+ if (existing !== void 0) return existing;
887
+ const room = new Room(this, this.rest, address);
888
+ this.handles.set(cacheKey, room);
889
+ return room;
890
+ }
891
+ /**
892
+ * Files a room under its id once `subscribe` has resolved one.
893
+ *
894
+ * A room addressed by key is filed under `key:<key>`, and every push
895
+ * frame names a room by **id** -- so without this the handle is
896
+ * unreachable from the read loop and a `roomByKey` room receives its
897
+ * hundred messages of history and then nothing, forever. The events do
898
+ * not vanish silently: they come out of `chat.on('frame')` as
899
+ * unrouted, which is how this was found.
900
+ *
901
+ * Registering under both keys also makes `chat.room(id)` and
902
+ * `chat.roomByKey(key)` return the **same** handle for the same room,
903
+ * which is the property `roomHandle` exists for -- two lists of one
904
+ * room drift the moment either fills a gap.
905
+ */
906
+ registerRoomId(roomId, room) {
907
+ this.routes.set(roomId, room);
908
+ }
909
+ /** Opens the connection and resolves when `hello` arrives. */
910
+ async connect() {
911
+ this.closedByCaller = false;
912
+ if (this.opening !== void 0) return this.opening;
913
+ if (this.state === "open" && this.socket !== void 0) return;
914
+ this.clearTimers();
915
+ this.opening = this.openOnce().finally(() => {
916
+ this.opening = void 0;
917
+ });
918
+ return this.opening;
919
+ }
920
+ /**
921
+ * Closes for good.
922
+ *
923
+ * The distinction from a dropped socket is the whole point of the state
924
+ * machine: this is the only path that reaches `closed`, and a consumer
925
+ * showing "disconnected, retrying" versus "disconnected" needs it.
926
+ */
927
+ async close() {
928
+ this.closedByCaller = true;
929
+ this.clearTimers();
930
+ const socket = this.socket;
931
+ this.socket = void 0;
932
+ if (socket !== void 0) {
933
+ try {
934
+ socket.close(1e3, "client closed");
935
+ } catch {
936
+ }
937
+ }
938
+ this.setState("closed");
939
+ }
940
+ /** Sends a frame and resolves with its ack, or rejects with its error. */
941
+ send(type, data, timeoutMs = 15e3) {
942
+ const socket = this.socket;
943
+ if (socket === void 0 || this.state !== "open") {
944
+ return Promise.reject(new ChatError("closed", `cannot send ${type}: the connection is ${this.state}`));
945
+ }
946
+ const id = `c${++this.nextFrameId}`;
947
+ return new Promise((resolve, reject) => {
948
+ const timer = setTimeout(() => {
949
+ this.pending.delete(id);
950
+ reject(new ChatError("timeout", `no ack for ${type} within ${timeoutMs}ms`));
951
+ }, timeoutMs);
952
+ this.pending.set(id, {
953
+ resolve: (value) => {
954
+ clearTimeout(timer);
955
+ resolve(value);
956
+ },
957
+ reject: (err) => {
958
+ clearTimeout(timer);
959
+ reject(err);
960
+ }
961
+ });
962
+ socket.send(JSON.stringify({ type, id, data }));
963
+ });
964
+ }
965
+ /**
966
+ * Frames waiting for an ack, by frame id.
967
+ *
968
+ * A table rather than "the next reply is mine": the protocol lets a
969
+ * client have several frames in flight, and the server answers each
970
+ * with the id it was given. Assuming order would pair a publish's ack
971
+ * with a react's.
972
+ */
973
+ pending = /* @__PURE__ */ new Map();
974
+ setState(next) {
975
+ if (this.state === next) return;
976
+ this.state = next;
977
+ this.emit("state", next);
978
+ }
979
+ async openOnce() {
980
+ this.setState(this.attempt === 0 ? "connecting" : "reconnecting");
981
+ const authData = await this.authData();
982
+ if (this.closedByCaller) {
983
+ throw new ChatError("closed", "the client was closed while connecting");
984
+ }
985
+ const socket = new this.makeSocket(`${this.options.url}/v1/ws?v=1`);
986
+ this.socket = socket;
987
+ return new Promise((resolve, reject) => {
988
+ let settled = false;
989
+ const finish = (err) => {
990
+ if (settled) return;
991
+ settled = true;
992
+ if (err === void 0) resolve();
993
+ else reject(err);
994
+ };
995
+ socket.addEventListener("message", (ev) => {
996
+ const frame = JSON.parse(String(ev.data));
997
+ if (frame.type === "hello") {
998
+ this.onHello(frame.data);
999
+ finish();
1000
+ return;
1001
+ }
1002
+ if (frame.type === "error" && !settled) {
1003
+ this.closedByCaller = true;
1004
+ this.setState("closed");
1005
+ finish(errorFrom(frame.data));
1006
+ return;
1007
+ }
1008
+ this.onFrame(frame);
1009
+ });
1010
+ socket.addEventListener("close", () => {
1011
+ if (this.socket !== void 0 && this.socket !== socket) return;
1012
+ this.clearTimers();
1013
+ this.socket = void 0;
1014
+ this.failPending(new ChatError("closed", "the connection closed"));
1015
+ if (settled) {
1016
+ this.scheduleReconnect();
1017
+ } else {
1018
+ finish(new ChatError("closed", "the connection closed before hello"));
1019
+ this.scheduleReconnect();
1020
+ }
1021
+ });
1022
+ socket.addEventListener("error", () => {
1023
+ });
1024
+ socket.addEventListener("open", () => {
1025
+ socket.send(JSON.stringify({ type: "auth", id: "auth", data: authData }));
1026
+ });
1027
+ });
1028
+ }
1029
+ onHello(hello) {
1030
+ this.hello = hello;
1031
+ this.user = hello.user;
1032
+ this.connectionId = hello.connectionId;
1033
+ this.attempt = 0;
1034
+ this.startHeartbeat(hello.heartbeatMs);
1035
+ const reconnected = this.everOpened;
1036
+ this.everOpened = true;
1037
+ this.setState("open");
1038
+ if (reconnected) {
1039
+ for (const room of this.handles.values()) {
1040
+ void room.resume().catch((err) => {
1041
+ room.emit("error", err instanceof Error ? err : new Error(String(err)));
1042
+ });
1043
+ }
1044
+ }
1045
+ }
1046
+ onFrame(frame) {
1047
+ if (frame.id !== void 0) {
1048
+ const waiter = this.pending.get(frame.id);
1049
+ if (waiter !== void 0) {
1050
+ this.pending.delete(frame.id);
1051
+ if (frame.type === "error") waiter.reject(errorFrom(frame.data));
1052
+ else waiter.resolve(frame.data);
1053
+ return;
1054
+ }
1055
+ }
1056
+ switch (frame.type) {
1057
+ case "push":
1058
+ this.onPush(frame);
1059
+ return;
1060
+ case "pong":
1061
+ return;
1062
+ case "reconnect":
1063
+ this.plannedDelayMs = frame.data?.delayMs;
1064
+ return;
1065
+ default:
1066
+ this.emit("frame", frame);
1067
+ }
1068
+ }
1069
+ /**
1070
+ * Unwraps a `push` frame and gives it to whoever it is about.
1071
+ *
1072
+ * Every server-initiated event arrives inside one envelope --
1073
+ * `{type:"push", data:{roomId?, event, data}}` -- rather than as a
1074
+ * frame type of its own. That is deliberate on the server's side: the
1075
+ * event name lives in the payload, so a message body containing
1076
+ * `"type":"control"` can never be mistaken for a control frame. The
1077
+ * cost is this hop, and the SDK pays it once here so a consumer never
1078
+ * sees the envelope.
1079
+ */
1080
+ onPush(frame) {
1081
+ const push = frame.data;
1082
+ const event = push?.event;
1083
+ if (event === void 0) {
1084
+ this.emit("frame", frame);
1085
+ return;
1086
+ }
1087
+ if (event === "notification.message") {
1088
+ this.emit("notification", push?.data);
1089
+ return;
1090
+ }
1091
+ if (event === "user.presence") {
1092
+ this.emit("user.presence", push?.data);
1093
+ return;
1094
+ }
1095
+ const room = push?.roomId === void 0 ? void 0 : this.routes.get(push.roomId);
1096
+ if (room === void 0) {
1097
+ this.emit("frame", frame);
1098
+ return;
1099
+ }
1100
+ room.handle(event, push?.data);
1101
+ }
1102
+ plannedDelayMs;
1103
+ startHeartbeat(intervalMs) {
1104
+ this.clearHeartbeat();
1105
+ this.heartbeat = setInterval(() => {
1106
+ try {
1107
+ this.socket?.send(JSON.stringify({ type: "ping", id: `p${++this.nextFrameId}` }));
1108
+ } catch {
1109
+ }
1110
+ }, Math.max(1e3, intervalMs));
1111
+ }
1112
+ scheduleReconnect() {
1113
+ if (this.closedByCaller) return;
1114
+ this.setState("reconnecting");
1115
+ const delay = this.nextDelay();
1116
+ this.retryTimer = setTimeout(() => {
1117
+ this.opening = this.openOnce().finally(() => {
1118
+ this.opening = void 0;
1119
+ });
1120
+ void this.opening.catch(() => {
1121
+ });
1122
+ }, delay);
1123
+ }
1124
+ /**
1125
+ * Exponential with full jitter, or whatever the server asked for.
1126
+ *
1127
+ * Jitter is not decoration. A node shutting down drops every socket it
1128
+ * holds at the same instant, and a fixed schedule brings all of them
1129
+ * back at the same instant too -- onto the node that is still up.
1130
+ */
1131
+ nextDelay() {
1132
+ const planned = this.plannedDelayMs;
1133
+ this.plannedDelayMs = void 0;
1134
+ if (planned !== void 0) return planned;
1135
+ const ceiling = Math.min(MaxBackoffMs, MinBackoffMs * 2 ** this.attempt);
1136
+ this.attempt++;
1137
+ return MinBackoffMs + Math.random() * (ceiling - MinBackoffMs);
1138
+ }
1139
+ async authData() {
1140
+ const data = { key: this.options.key };
1141
+ if (this.options.externalToken !== void 0) {
1142
+ data["externalToken"] = await this.options.externalToken();
1143
+ return data;
1144
+ }
1145
+ const token = await this.options.token();
1146
+ this.lastToken = token;
1147
+ data["token"] = token;
1148
+ return data;
1149
+ }
1150
+ failPending(err) {
1151
+ for (const [, waiter] of this.pending) waiter.reject(err);
1152
+ this.pending.clear();
1153
+ }
1154
+ clearHeartbeat() {
1155
+ if (this.heartbeat !== void 0) clearInterval(this.heartbeat);
1156
+ this.heartbeat = void 0;
1157
+ }
1158
+ clearTimers() {
1159
+ this.clearHeartbeat();
1160
+ if (this.retryTimer !== void 0) clearTimeout(this.retryTimer);
1161
+ this.retryTimer = void 0;
1162
+ }
1163
+ };
1164
+ function createChatClient(options) {
1165
+ return new ChatClient(options);
1166
+ }
1167
+ // Annotate the CommonJS export names for ESM import in node:
1168
+ 0 && (module.exports = {
1169
+ ChatClient,
1170
+ ChatError,
1171
+ Room,
1172
+ Timeline,
1173
+ createChatClient,
1174
+ createTimeline
1175
+ });