@kispi/chat 0.2.0 → 0.2.2

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 CHANGED
@@ -204,6 +204,13 @@ var Timeline = class {
204
204
  shared = false;
205
205
  /** The `loadOlder` in flight, so a scroll handler firing twice asks once. */
206
206
  older;
207
+ /**
208
+ * The lowest seq the room still keeps (`minSeq` from the subscribe ack;
209
+ * 1 until told). Below it there is nothing to read, whatever seq says.
210
+ */
211
+ floor = 1;
212
+ /** A `loadOlder` came back short: the top of what the server keeps was reached. */
213
+ exhausted = false;
207
214
  constructor(options) {
208
215
  this.options = options;
209
216
  }
@@ -212,6 +219,22 @@ var Timeline = class {
212
219
  this.shared = true;
213
220
  return this.items;
214
221
  }
222
+ /**
223
+ * Whether `loadOlder()` would find anything, answered without asking.
224
+ *
225
+ * `loadOlder()`의 `hasMore`와 같은 판정이다: 맨 위 행이 방의 보존 하한
226
+ * (`minSeq`, seq는 1부터 구멍 없이 붙는다)보다 위에 있고, 앞선 `loadOlder`가
227
+ * 덜 찬 페이지로 끝을 알린 적이 없으면 더 있다. 첫 `loadOlder()` 전에 "이전
228
+ * 메시지 더 보기"를 그릴지 정할 수 있게 한다. 목록이 비었으면 false다.
229
+ */
230
+ get hasOlder() {
231
+ const first = this.items[0];
232
+ return !this.exhausted && first !== void 0 && first.seq > this.floor;
233
+ }
234
+ /** Records the room's retention floor (`minSeq`). */
235
+ setFloor(minSeq) {
236
+ this.floor = Math.max(1, minSeq);
237
+ }
215
238
  /** The highest seq this timeline holds, hole or no hole. */
216
239
  get highestSeq() {
217
240
  return this.items.at(-1)?.seq ?? 0;
@@ -256,6 +279,7 @@ var Timeline = class {
256
279
  this.fills.clear();
257
280
  this.pendingDeletes.clear();
258
281
  this.items = [];
282
+ this.exhausted = false;
259
283
  this.shared = false;
260
284
  this.changed();
261
285
  }
@@ -283,6 +307,7 @@ var Timeline = class {
283
307
  const page = [...messages].sort((a, b) => a.seq - b.seq);
284
308
  const top = page.at(-1)?.seq ?? 0;
285
309
  this.items = [...page, ...this.items.filter((m) => m.seq > top)];
310
+ this.exhausted = false;
286
311
  this.shared = false;
287
312
  for (const m of this.items) {
288
313
  if (this.pendingDeletes.has(m.id)) this.applyDelete(m.id);
@@ -333,20 +358,35 @@ var Timeline = class {
333
358
  *
334
359
  * The server sends the new `count` with the event, so this is a
335
360
  * replacement rather than an increment: two clients reacting at once
336
- * cannot drift the way `+1`/`-1` would.
361
+ * cannot drift the way `+1`/`-1` would. `count` undefined leaves the
362
+ * aggregate alone (the react ack path); `mine` undefined leaves
363
+ * `myReactions` alone (somebody else's event).
337
364
  */
338
- reaction(messageId, emoji, count) {
365
+ reaction(messageId, emoji, count, mine) {
339
366
  const at = this.items.findIndex((m) => m.id === messageId);
340
367
  if (at === -1) return;
341
368
  const current = this.items[at];
342
- const existing = current.reactions ?? [];
343
- const slot = existing.findIndex((r) => r.emoji === emoji);
344
- let next;
345
- if (count <= 0) next = existing.filter((r) => r.emoji !== emoji);
346
- else if (slot === -1) next = [...existing, { emoji, count }];
347
- else next = existing.map((r, i) => i === slot ? { emoji, count } : r);
369
+ let reactions = current.reactions;
370
+ if (count !== void 0) {
371
+ const existing = current.reactions ?? [];
372
+ const slot = existing.findIndex((r) => r.emoji === emoji);
373
+ if (count <= 0) reactions = existing.filter((r) => r.emoji !== emoji);
374
+ else if (slot === -1) reactions = [...existing, { emoji, count }];
375
+ else reactions = existing.map((r, i) => i === slot ? { emoji, count } : r);
376
+ }
377
+ let myReactions = current.myReactions;
378
+ if (mine !== void 0) {
379
+ const held = current.myReactions ?? [];
380
+ const has = held.includes(emoji);
381
+ if (mine && !has) myReactions = [...held, emoji];
382
+ else if (!mine && has) myReactions = held.filter((e) => e !== emoji);
383
+ }
384
+ if (reactions === current.reactions && myReactions === current.myReactions) return;
385
+ const next = { ...current };
386
+ if (reactions !== void 0) next.reactions = reactions;
387
+ if (myReactions !== void 0) next.myReactions = myReactions;
348
388
  this.own();
349
- this.items[at] = { ...current, reactions: next };
389
+ this.items[at] = next;
350
390
  this.changed();
351
391
  }
352
392
  /** Applies a `thread.updated` to the root message's aggregate. */
@@ -383,14 +423,16 @@ var Timeline = class {
383
423
  }
384
424
  async readOlder(limit) {
385
425
  const first = this.items[0];
386
- if (first === void 0 || first.seq <= 1) return { messages: [], hasMore: false };
426
+ if (first === void 0 || first.seq <= this.floor) return { messages: [], hasMore: false };
387
427
  const epoch = this.epoch;
388
428
  const page = await this.options.fetchRange({ after: 0, before: first.seq, limit });
389
429
  if (this.epoch !== epoch) return { messages: [], hasMore: true };
390
430
  for (const m of page) this.insert(m, false);
391
431
  if (page.length > 0) this.changed();
392
432
  const top = page[0];
393
- return { messages: page, hasMore: page.length >= limit && top !== void 0 && top.seq > 1 };
433
+ const hasMore = page.length >= limit && top !== void 0 && top.seq > this.floor;
434
+ this.exhausted = !hasMore;
435
+ return { messages: page, hasMore };
394
436
  }
395
437
  /**
396
438
  * Fetches everything between what we have and `upTo`.
@@ -481,6 +523,8 @@ function createTimeline(options) {
481
523
 
482
524
  // src/room.ts
483
525
  var HistoryPageSize = 100;
526
+ var MaxRepairs = 5;
527
+ var RepairBaseMs = 1e3;
484
528
  var Room = class extends Emitter {
485
529
  /** Resolved on subscribe when the room was addressed by key. */
486
530
  id;
@@ -518,6 +562,9 @@ var Room = class extends Emitter {
518
562
  */
519
563
  wanted = false;
520
564
  subscribing;
565
+ /** Self-repair: the armed retry, and how many have run since the last success. */
566
+ repairTimer;
567
+ repairs = 0;
521
568
  constructor(chat, rest, address) {
522
569
  super();
523
570
  this.chat = chat;
@@ -533,6 +580,17 @@ var Room = class extends Emitter {
533
580
  onChange: (messages) => this.emit("messages", messages)
534
581
  });
535
582
  }
583
+ /**
584
+ * Whether older history exists above the first message held -- what
585
+ * `loadOlder()` would report as `hasMore`, known before calling it.
586
+ *
587
+ * Derived from the first row's seq against the room's retention floor
588
+ * (the subscribe ack's `minSeq`), and false once a `loadOlder()` came
589
+ * back short. False while nothing is loaded. Re-read it on `messages`.
590
+ */
591
+ get hasOlder() {
592
+ return this.timeline.hasOlder;
593
+ }
536
594
  /** Everything this client knows about the room, in seq order. A new array whenever it changes. */
537
595
  get messages() {
538
596
  return this.timeline.messages;
@@ -584,7 +642,12 @@ var Room = class extends Emitter {
584
642
  this.wanted = true;
585
643
  if (this.subscribing !== void 0) return this.subscribing;
586
644
  if (this.subscribed) return;
587
- const attempt = this.doSubscribe().finally(() => {
645
+ const attempt = this.doSubscribe().then(() => {
646
+ const successor = this.subscribing;
647
+ if (successor !== void 0 && successor !== attempt) return successor;
648
+ if (this.subscribed) this.repairs = 0;
649
+ return void 0;
650
+ }).finally(() => {
588
651
  if (this.subscribing === attempt) this.subscribing = void 0;
589
652
  });
590
653
  this.subscribing = attempt;
@@ -593,6 +656,10 @@ var Room = class extends Emitter {
593
656
  async doSubscribe() {
594
657
  const gen = this.generation;
595
658
  const current = () => gen === this.generation;
659
+ if (this.chat.state !== "open") {
660
+ await this.chat.whenOpen();
661
+ if (!current()) return;
662
+ }
596
663
  const data = this.id !== void 0 ? { roomId: this.id } : { key: this.key };
597
664
  const since = this.timeline.contiguousSeq;
598
665
  if (since > 0) data["since"] = since;
@@ -602,6 +669,7 @@ var Room = class extends Emitter {
602
669
  this.id = ack.roomId;
603
670
  this.chat.registerRoomId(ack.roomId, this);
604
671
  this.lastSeq = ack.lastSeq;
672
+ if (typeof ack.minSeq === "number") this.timeline.setFloor(ack.minSeq);
605
673
  if (ack.presence !== void 0) this.presence = ack.presence;
606
674
  if (attempt.wasReset) {
607
675
  await this.loadRecent(current);
@@ -645,12 +713,20 @@ var Room = class extends Emitter {
645
713
  });
646
714
  return page.messages;
647
715
  }
648
- /** Adds a reaction. Idempotent, like the frame. */
716
+ /**
717
+ * Adds a reaction. Idempotent, like the frame.
718
+ *
719
+ * The ack updates the row's `myReactions` (not its count -- see below),
720
+ * so the "I reacted" state is right even if the broadcast is missed.
721
+ */
649
722
  async react(messageId, emoji) {
650
723
  await this.chat.send("react", { roomId: await this.resolveId("reacting"), messageId, emoji, op: "add" });
724
+ this.timeline.reaction(messageId, emoji, void 0, true);
651
725
  }
726
+ /** Removes a reaction. Idempotent; the ack clears it from `myReactions`. */
652
727
  async unreact(messageId, emoji) {
653
728
  await this.chat.send("react", { roomId: await this.resolveId("reacting"), messageId, emoji, op: "remove" });
729
+ this.timeline.reaction(messageId, emoji, void 0, false);
654
730
  }
655
731
  /**
656
732
  * Moves this user's read cursor.
@@ -722,13 +798,33 @@ var Room = class extends Emitter {
722
798
  * transient 500 takes a room out of the live feed for the life of the
723
799
  * page.
724
800
  */
725
- async resume() {
801
+ async resume(restart = true) {
726
802
  if (!this.wanted) return;
803
+ if (!restart) return this.subscribe();
727
804
  this.generation++;
728
805
  this.subscribing = void 0;
729
806
  this.subscribed = false;
730
807
  await this.subscribe();
731
808
  }
809
+ /**
810
+ * Reports a failure no caller was waiting for, and repairs.
811
+ *
812
+ * 백그라운드 실패(재접속 뒤 다시 구독, 구멍 메우기)에는 거절을 받을 호출자가
813
+ * 없다. 이벤트만 내고 두면 방은 다음 재접속이나 탭 복귀까지 구멍을 안고 —
814
+ * 구독 프레임부터 실패했다면 라이브 피드 밖에서 — 머문다. 그래서 스스로 다시
815
+ * 구독한다. 횟수를 묶는 것은 고칠 수 없는 실패(권한, 없어진 방)가 서버를
816
+ * 계속 두드리지 않게 하려는 것이고, 그 뒤는 재접속과 `subscribe()`의 몫이다.
817
+ */
818
+ failed(err) {
819
+ this.emit("error", err instanceof Error ? err : new Error(String(err)));
820
+ if (this.repairTimer !== void 0 || this.repairs >= MaxRepairs) return;
821
+ const delay = RepairBaseMs * 2 ** this.repairs++;
822
+ this.repairTimer = setTimeout(() => {
823
+ this.repairTimer = void 0;
824
+ if (!this.wanted || this.subscribed || this.chat.state !== "open") return;
825
+ this.subscribe().catch((e) => this.failed(e));
826
+ }, delay);
827
+ }
732
828
  /**
733
829
  * Sends `subscribe` and recovers from the one error it has an answer
734
830
  * for.
@@ -810,7 +906,7 @@ var Room = class extends Emitter {
810
906
  if (data.seq > this.lastSeq) this.lastSeq = data.seq;
811
907
  this.timeline.add(data).catch((err) => {
812
908
  this.subscribed = false;
813
- this.emit("error", err instanceof Error ? err : new Error(String(err)));
909
+ this.failed(err);
814
910
  });
815
911
  break;
816
912
  case "message.updated":
@@ -825,7 +921,9 @@ var Room = class extends Emitter {
825
921
  case "reaction.added":
826
922
  case "reaction.removed": {
827
923
  const r = data;
828
- this.timeline.reaction(r.messageId, r.emoji, r.count);
924
+ const me = this.chat.user?.id;
925
+ const mine = me !== void 0 && r.userId === me ? type === "reaction.added" : void 0;
926
+ this.timeline.reaction(r.messageId, r.emoji, r.count, mine);
829
927
  break;
830
928
  }
831
929
  case "thread.updated": {
@@ -879,8 +977,11 @@ var ChatClient = class extends Emitter {
879
977
  heartbeat;
880
978
  retryTimer;
881
979
  attempt = 0;
882
- /** Set by close(), and the only thing that stops the reconnect loop. */
883
- closedByCaller = false;
980
+ /**
981
+ * The reconnect loop is off: set by close(), and by a failure that
982
+ * retrying cannot fix (see `stop`). Cleared by connect().
983
+ */
984
+ stopped = false;
884
985
  nextFrameId = 0;
885
986
  opening;
886
987
  rest;
@@ -958,7 +1059,7 @@ var ChatClient = class extends Emitter {
958
1059
  * gap-filled via REST.
959
1060
  */
960
1061
  async resume() {
961
- if (this.closedByCaller) return;
1062
+ if (this.stopped) return;
962
1063
  if (this.state !== "open" || this.socket === void 0) {
963
1064
  this.clearTimers();
964
1065
  return this.connect();
@@ -970,9 +1071,7 @@ var ChatClient = class extends Emitter {
970
1071
  return this.connect();
971
1072
  }
972
1073
  for (const room of this.handles.values()) {
973
- void room.resume().catch((err) => {
974
- room.emit("error", err instanceof Error ? err : new Error(String(err)));
975
- });
1074
+ void room.resume().catch((err) => room.failed(err));
976
1075
  }
977
1076
  }
978
1077
  /** The account-level routes: the rooms this user is in, and unread. */
@@ -1064,7 +1163,7 @@ var ChatClient = class extends Emitter {
1064
1163
  }
1065
1164
  /** Opens the connection and resolves when `hello` arrives. */
1066
1165
  async connect() {
1067
- this.closedByCaller = false;
1166
+ this.stopped = false;
1068
1167
  if (this.opening !== void 0) return this.opening;
1069
1168
  if (this.state === "open" && this.socket !== void 0) return;
1070
1169
  this.clearTimers();
@@ -1073,15 +1172,58 @@ var ChatClient = class extends Emitter {
1073
1172
  });
1074
1173
  return this.opening;
1075
1174
  }
1175
+ /**
1176
+ * Re-authenticates: calls `token()` again and replaces the socket, keeping
1177
+ * every room handle and every subscription.
1178
+ *
1179
+ * For when the identity the server holds has to change mid-session --
1180
+ * a nickname change, a new avatar, fresh claims. The server reads the
1181
+ * token once, at `auth`, so `sender.name` on the next message is the old
1182
+ * one until the connection is re-authenticated. `close()` + `connect()`
1183
+ * does that too, but passes through `closed` (a consumer's "you are
1184
+ * offline" screen) and tears down the page listeners.
1185
+ *
1186
+ * States: `open` → `reconnecting` → `open`, never `closed`. Rooms
1187
+ * resubscribe from what they hold and catch up the gap, as after any
1188
+ * drop; `messages` is kept. Frames awaiting an ack on the old socket are
1189
+ * rejected with `closed`. `chat.user` is the new identity once this
1190
+ * resolves. If `token()` fails the client keeps retrying in the
1191
+ * background like any reconnect (and this rejects); a `ChatError` from
1192
+ * `token()` stops it at `closed`, as on connect.
1193
+ *
1194
+ * On a client that is not connected (never connected, or `close()`d) it
1195
+ * is `connect()`.
1196
+ */
1197
+ async reconnect() {
1198
+ if (this.opening !== void 0) await this.opening.catch(() => {
1199
+ });
1200
+ const socket = this.socket;
1201
+ if (socket === void 0 || this.state !== "open") return this.connect();
1202
+ this.clearTimers();
1203
+ this.retired.add(socket);
1204
+ this.socket = void 0;
1205
+ this.failPending(new ChatError("closed", "the connection was replaced by reconnect()"));
1206
+ try {
1207
+ socket.close(1e3, "client reconnecting");
1208
+ } catch {
1209
+ }
1210
+ this.opening = this.openOnce("reconnecting").finally(() => {
1211
+ this.opening = void 0;
1212
+ });
1213
+ return this.opening;
1214
+ }
1215
+ /** Sockets `reconnect()` replaced: their late events are not this client's any more. */
1216
+ retired = /* @__PURE__ */ new WeakSet();
1076
1217
  /**
1077
1218
  * Closes for good.
1078
1219
  *
1079
1220
  * The distinction from a dropped socket is the whole point of the state
1080
- * machine: this is the only path that reaches `closed`, and a consumer
1081
- * showing "disconnected, retrying" versus "disconnected" needs it.
1221
+ * machine: `closed` means nothing is coming back, and a consumer showing
1222
+ * "disconnected, retrying" versus "disconnected" needs it. The other
1223
+ * ways to reach it are refusals retrying cannot fix -- see `stop`.
1082
1224
  */
1083
1225
  async close() {
1084
- this.closedByCaller = true;
1226
+ this.stopped = true;
1085
1227
  this.teardowns.forEach((t) => {
1086
1228
  try {
1087
1229
  t();
@@ -1098,7 +1240,53 @@ var ChatClient = class extends Emitter {
1098
1240
  } catch {
1099
1241
  }
1100
1242
  }
1243
+ this.settleOpenWaiters(new ChatError("closed", "the connection is closed"));
1244
+ this.setState("closed");
1245
+ }
1246
+ /**
1247
+ * Stops for a reason retrying cannot fix, and says which.
1248
+ *
1249
+ * `close()` without the teardown of the page listeners -- a consumer
1250
+ * that logs back in calls `connect()` on the same client -- and with an
1251
+ * `error` event, because on a reconnect there is no caller to reject.
1252
+ */
1253
+ stop(err) {
1254
+ this.stopped = true;
1255
+ this.clearTimers();
1256
+ const socket = this.socket;
1257
+ this.socket = void 0;
1258
+ if (socket !== void 0) {
1259
+ try {
1260
+ socket.close(1e3, "client stopped");
1261
+ } catch {
1262
+ }
1263
+ }
1264
+ this.failPending(err);
1265
+ this.settleOpenWaiters(new ChatError("closed", "the connection is closed"));
1101
1266
  this.setState("closed");
1267
+ this.emit("error", err);
1268
+ }
1269
+ /**
1270
+ * Resolves once the connection is open. Used by `Room.subscribe`.
1271
+ *
1272
+ * 연결 전에 부른 `subscribe()`를 `closed`로 거절하면 소비자는 "언제 다시
1273
+ * 부를지"를 스스로 알아내야 하고, 첫 connect가 실패했다면 그 때는 영영 오지
1274
+ * 않는다. 붙을 때까지 기다리면 그 신호가 필요 없다. 멈춘 클라이언트(close,
1275
+ * 인증 거절)는 붙을 일이 없으니 거절한다.
1276
+ */
1277
+ whenOpen() {
1278
+ if (this.state === "open" && this.socket !== void 0) return Promise.resolve();
1279
+ if (this.stopped) return Promise.reject(new ChatError("closed", "the connection is closed"));
1280
+ return new Promise((resolve, reject) => void this.openWaiters.push({ resolve, reject }));
1281
+ }
1282
+ openWaiters = [];
1283
+ settleOpenWaiters(err) {
1284
+ const waiters = this.openWaiters;
1285
+ this.openWaiters = [];
1286
+ for (const w of waiters) {
1287
+ if (err === void 0) w.resolve();
1288
+ else w.reject(err);
1289
+ }
1102
1290
  }
1103
1291
  /** Sends a frame and resolves with its ack, or rejects with its error. */
1104
1292
  send(type, data, timeoutMs = 15e3) {
@@ -1139,16 +1327,20 @@ var ChatClient = class extends Emitter {
1139
1327
  this.state = next;
1140
1328
  this.emit("state", next);
1141
1329
  }
1142
- async openOnce() {
1143
- this.setState(this.attempt === 0 ? "connecting" : "reconnecting");
1330
+ async openOnce(state = this.attempt === 0 ? "connecting" : "reconnecting") {
1331
+ this.setState(state);
1144
1332
  let authData;
1145
1333
  try {
1146
1334
  authData = await this.authData();
1147
1335
  } catch (err) {
1336
+ if (isChatError(err)) {
1337
+ this.stop(err);
1338
+ throw err;
1339
+ }
1148
1340
  this.scheduleReconnect();
1149
1341
  throw err;
1150
1342
  }
1151
- if (this.closedByCaller) {
1343
+ if (this.stopped) {
1152
1344
  throw new ChatError("closed", "the client was closed while connecting");
1153
1345
  }
1154
1346
  const socket = new this.makeSocket(`${this.options.url}/v1/ws?v=1`);
@@ -1162,6 +1354,7 @@ var ChatClient = class extends Emitter {
1162
1354
  else reject(err);
1163
1355
  };
1164
1356
  socket.addEventListener("message", (ev) => {
1357
+ if (this.retired.has(socket)) return;
1165
1358
  const frame = JSON.parse(String(ev.data));
1166
1359
  if (frame.type === "hello") {
1167
1360
  this.onHello(frame.data);
@@ -1169,14 +1362,20 @@ var ChatClient = class extends Emitter {
1169
1362
  return;
1170
1363
  }
1171
1364
  if (frame.type === "error" && !settled) {
1172
- this.closedByCaller = true;
1173
- this.setState("closed");
1174
- finish(errorFrom(frame.data));
1365
+ const err = errorFrom(frame.data);
1366
+ if (err.code === "rate_limited" || err.code === "internal") {
1367
+ if (err.retryAfterMs !== void 0 && err.retryAfterMs > 0) this.plannedDelayMs = err.retryAfterMs;
1368
+ finish(err);
1369
+ return;
1370
+ }
1371
+ finish(err);
1372
+ this.stop(err);
1175
1373
  return;
1176
1374
  }
1177
1375
  this.onFrame(frame);
1178
1376
  });
1179
1377
  socket.addEventListener("close", () => {
1378
+ if (this.retired.has(socket)) return;
1180
1379
  if (this.socket !== void 0 && this.socket !== socket) return;
1181
1380
  this.clearTimers();
1182
1381
  this.socket = void 0;
@@ -1204,12 +1403,11 @@ var ChatClient = class extends Emitter {
1204
1403
  const reconnected = this.everOpened;
1205
1404
  this.everOpened = true;
1206
1405
  this.setState("open");
1207
- if (reconnected) {
1208
- for (const room of this.handles.values()) {
1209
- void room.resume().catch((err) => {
1210
- room.emit("error", err instanceof Error ? err : new Error(String(err)));
1211
- });
1212
- }
1406
+ this.settleOpenWaiters();
1407
+ for (const room of this.handles.values()) {
1408
+ void room.resume(reconnected).catch((err) => {
1409
+ room.failed(err);
1410
+ });
1213
1411
  }
1214
1412
  }
1215
1413
  onFrame(frame) {
@@ -1279,7 +1477,7 @@ var ChatClient = class extends Emitter {
1279
1477
  }, Math.max(1e3, intervalMs));
1280
1478
  }
1281
1479
  scheduleReconnect() {
1282
- if (this.closedByCaller) return;
1480
+ if (this.stopped) return;
1283
1481
  this.setState("reconnecting");
1284
1482
  const delay = this.nextDelay();
1285
1483
  this.retryTimer = setTimeout(() => {
@@ -1331,6 +1529,9 @@ var ChatClient = class extends Emitter {
1331
1529
  const token = await this.options.token();
1332
1530
  this.lastToken = token;
1333
1531
  return token;
1532
+ } catch (err) {
1533
+ if (isChatError(err)) this.stop(err);
1534
+ throw err;
1334
1535
  } finally {
1335
1536
  this.refreshing = void 0;
1336
1537
  }
@@ -1352,6 +1553,9 @@ var ChatClient = class extends Emitter {
1352
1553
  this.retryTimer = void 0;
1353
1554
  }
1354
1555
  };
1556
+ function isChatError(err) {
1557
+ return err instanceof ChatError || err instanceof Error && err.name === "ChatError";
1558
+ }
1355
1559
  function createChatClient(options) {
1356
1560
  return new ChatClient(options);
1357
1561
  }