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