@kispi/chat 0.2.0 → 0.2.1

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
@@ -481,6 +481,8 @@ function createTimeline(options) {
481
481
 
482
482
  // src/room.ts
483
483
  var HistoryPageSize = 100;
484
+ var MaxRepairs = 5;
485
+ var RepairBaseMs = 1e3;
484
486
  var Room = class extends Emitter {
485
487
  /** Resolved on subscribe when the room was addressed by key. */
486
488
  id;
@@ -518,6 +520,9 @@ var Room = class extends Emitter {
518
520
  */
519
521
  wanted = false;
520
522
  subscribing;
523
+ /** Self-repair: the armed retry, and how many have run since the last success. */
524
+ repairTimer;
525
+ repairs = 0;
521
526
  constructor(chat, rest, address) {
522
527
  super();
523
528
  this.chat = chat;
@@ -584,7 +589,12 @@ var Room = class extends Emitter {
584
589
  this.wanted = true;
585
590
  if (this.subscribing !== void 0) return this.subscribing;
586
591
  if (this.subscribed) return;
587
- const attempt = this.doSubscribe().finally(() => {
592
+ const attempt = this.doSubscribe().then(() => {
593
+ const successor = this.subscribing;
594
+ if (successor !== void 0 && successor !== attempt) return successor;
595
+ if (this.subscribed) this.repairs = 0;
596
+ return void 0;
597
+ }).finally(() => {
588
598
  if (this.subscribing === attempt) this.subscribing = void 0;
589
599
  });
590
600
  this.subscribing = attempt;
@@ -593,6 +603,10 @@ var Room = class extends Emitter {
593
603
  async doSubscribe() {
594
604
  const gen = this.generation;
595
605
  const current = () => gen === this.generation;
606
+ if (this.chat.state !== "open") {
607
+ await this.chat.whenOpen();
608
+ if (!current()) return;
609
+ }
596
610
  const data = this.id !== void 0 ? { roomId: this.id } : { key: this.key };
597
611
  const since = this.timeline.contiguousSeq;
598
612
  if (since > 0) data["since"] = since;
@@ -722,13 +736,33 @@ var Room = class extends Emitter {
722
736
  * transient 500 takes a room out of the live feed for the life of the
723
737
  * page.
724
738
  */
725
- async resume() {
739
+ async resume(restart = true) {
726
740
  if (!this.wanted) return;
741
+ if (!restart) return this.subscribe();
727
742
  this.generation++;
728
743
  this.subscribing = void 0;
729
744
  this.subscribed = false;
730
745
  await this.subscribe();
731
746
  }
747
+ /**
748
+ * Reports a failure no caller was waiting for, and repairs.
749
+ *
750
+ * 백그라운드 실패(재접속 뒤 다시 구독, 구멍 메우기)에는 거절을 받을 호출자가
751
+ * 없다. 이벤트만 내고 두면 방은 다음 재접속이나 탭 복귀까지 구멍을 안고 —
752
+ * 구독 프레임부터 실패했다면 라이브 피드 밖에서 — 머문다. 그래서 스스로 다시
753
+ * 구독한다. 횟수를 묶는 것은 고칠 수 없는 실패(권한, 없어진 방)가 서버를
754
+ * 계속 두드리지 않게 하려는 것이고, 그 뒤는 재접속과 `subscribe()`의 몫이다.
755
+ */
756
+ failed(err) {
757
+ this.emit("error", err instanceof Error ? err : new Error(String(err)));
758
+ if (this.repairTimer !== void 0 || this.repairs >= MaxRepairs) return;
759
+ const delay = RepairBaseMs * 2 ** this.repairs++;
760
+ this.repairTimer = setTimeout(() => {
761
+ this.repairTimer = void 0;
762
+ if (!this.wanted || this.subscribed || this.chat.state !== "open") return;
763
+ this.subscribe().catch((e) => this.failed(e));
764
+ }, delay);
765
+ }
732
766
  /**
733
767
  * Sends `subscribe` and recovers from the one error it has an answer
734
768
  * for.
@@ -810,7 +844,7 @@ var Room = class extends Emitter {
810
844
  if (data.seq > this.lastSeq) this.lastSeq = data.seq;
811
845
  this.timeline.add(data).catch((err) => {
812
846
  this.subscribed = false;
813
- this.emit("error", err instanceof Error ? err : new Error(String(err)));
847
+ this.failed(err);
814
848
  });
815
849
  break;
816
850
  case "message.updated":
@@ -879,8 +913,11 @@ var ChatClient = class extends Emitter {
879
913
  heartbeat;
880
914
  retryTimer;
881
915
  attempt = 0;
882
- /** Set by close(), and the only thing that stops the reconnect loop. */
883
- closedByCaller = false;
916
+ /**
917
+ * The reconnect loop is off: set by close(), and by a failure that
918
+ * retrying cannot fix (see `stop`). Cleared by connect().
919
+ */
920
+ stopped = false;
884
921
  nextFrameId = 0;
885
922
  opening;
886
923
  rest;
@@ -958,7 +995,7 @@ var ChatClient = class extends Emitter {
958
995
  * gap-filled via REST.
959
996
  */
960
997
  async resume() {
961
- if (this.closedByCaller) return;
998
+ if (this.stopped) return;
962
999
  if (this.state !== "open" || this.socket === void 0) {
963
1000
  this.clearTimers();
964
1001
  return this.connect();
@@ -970,9 +1007,7 @@ var ChatClient = class extends Emitter {
970
1007
  return this.connect();
971
1008
  }
972
1009
  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
- });
1010
+ void room.resume().catch((err) => room.failed(err));
976
1011
  }
977
1012
  }
978
1013
  /** The account-level routes: the rooms this user is in, and unread. */
@@ -1064,7 +1099,7 @@ var ChatClient = class extends Emitter {
1064
1099
  }
1065
1100
  /** Opens the connection and resolves when `hello` arrives. */
1066
1101
  async connect() {
1067
- this.closedByCaller = false;
1102
+ this.stopped = false;
1068
1103
  if (this.opening !== void 0) return this.opening;
1069
1104
  if (this.state === "open" && this.socket !== void 0) return;
1070
1105
  this.clearTimers();
@@ -1077,11 +1112,12 @@ var ChatClient = class extends Emitter {
1077
1112
  * Closes for good.
1078
1113
  *
1079
1114
  * 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.
1115
+ * machine: `closed` means nothing is coming back, and a consumer showing
1116
+ * "disconnected, retrying" versus "disconnected" needs it. The other
1117
+ * ways to reach it are refusals retrying cannot fix -- see `stop`.
1082
1118
  */
1083
1119
  async close() {
1084
- this.closedByCaller = true;
1120
+ this.stopped = true;
1085
1121
  this.teardowns.forEach((t) => {
1086
1122
  try {
1087
1123
  t();
@@ -1098,7 +1134,53 @@ var ChatClient = class extends Emitter {
1098
1134
  } catch {
1099
1135
  }
1100
1136
  }
1137
+ this.settleOpenWaiters(new ChatError("closed", "the connection is closed"));
1138
+ this.setState("closed");
1139
+ }
1140
+ /**
1141
+ * Stops for a reason retrying cannot fix, and says which.
1142
+ *
1143
+ * `close()` without the teardown of the page listeners -- a consumer
1144
+ * that logs back in calls `connect()` on the same client -- and with an
1145
+ * `error` event, because on a reconnect there is no caller to reject.
1146
+ */
1147
+ stop(err) {
1148
+ this.stopped = true;
1149
+ this.clearTimers();
1150
+ const socket = this.socket;
1151
+ this.socket = void 0;
1152
+ if (socket !== void 0) {
1153
+ try {
1154
+ socket.close(1e3, "client stopped");
1155
+ } catch {
1156
+ }
1157
+ }
1158
+ this.failPending(err);
1159
+ this.settleOpenWaiters(new ChatError("closed", "the connection is closed"));
1101
1160
  this.setState("closed");
1161
+ this.emit("error", err);
1162
+ }
1163
+ /**
1164
+ * Resolves once the connection is open. Used by `Room.subscribe`.
1165
+ *
1166
+ * 연결 전에 부른 `subscribe()`를 `closed`로 거절하면 소비자는 "언제 다시
1167
+ * 부를지"를 스스로 알아내야 하고, 첫 connect가 실패했다면 그 때는 영영 오지
1168
+ * 않는다. 붙을 때까지 기다리면 그 신호가 필요 없다. 멈춘 클라이언트(close,
1169
+ * 인증 거절)는 붙을 일이 없으니 거절한다.
1170
+ */
1171
+ whenOpen() {
1172
+ if (this.state === "open" && this.socket !== void 0) return Promise.resolve();
1173
+ if (this.stopped) return Promise.reject(new ChatError("closed", "the connection is closed"));
1174
+ return new Promise((resolve, reject) => void this.openWaiters.push({ resolve, reject }));
1175
+ }
1176
+ openWaiters = [];
1177
+ settleOpenWaiters(err) {
1178
+ const waiters = this.openWaiters;
1179
+ this.openWaiters = [];
1180
+ for (const w of waiters) {
1181
+ if (err === void 0) w.resolve();
1182
+ else w.reject(err);
1183
+ }
1102
1184
  }
1103
1185
  /** Sends a frame and resolves with its ack, or rejects with its error. */
1104
1186
  send(type, data, timeoutMs = 15e3) {
@@ -1145,10 +1227,14 @@ var ChatClient = class extends Emitter {
1145
1227
  try {
1146
1228
  authData = await this.authData();
1147
1229
  } catch (err) {
1230
+ if (isChatError(err)) {
1231
+ this.stop(err);
1232
+ throw err;
1233
+ }
1148
1234
  this.scheduleReconnect();
1149
1235
  throw err;
1150
1236
  }
1151
- if (this.closedByCaller) {
1237
+ if (this.stopped) {
1152
1238
  throw new ChatError("closed", "the client was closed while connecting");
1153
1239
  }
1154
1240
  const socket = new this.makeSocket(`${this.options.url}/v1/ws?v=1`);
@@ -1169,9 +1255,14 @@ var ChatClient = class extends Emitter {
1169
1255
  return;
1170
1256
  }
1171
1257
  if (frame.type === "error" && !settled) {
1172
- this.closedByCaller = true;
1173
- this.setState("closed");
1174
- finish(errorFrom(frame.data));
1258
+ const err = errorFrom(frame.data);
1259
+ if (err.code === "rate_limited" || err.code === "internal") {
1260
+ if (err.retryAfterMs !== void 0 && err.retryAfterMs > 0) this.plannedDelayMs = err.retryAfterMs;
1261
+ finish(err);
1262
+ return;
1263
+ }
1264
+ finish(err);
1265
+ this.stop(err);
1175
1266
  return;
1176
1267
  }
1177
1268
  this.onFrame(frame);
@@ -1204,12 +1295,11 @@ var ChatClient = class extends Emitter {
1204
1295
  const reconnected = this.everOpened;
1205
1296
  this.everOpened = true;
1206
1297
  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
- }
1298
+ this.settleOpenWaiters();
1299
+ for (const room of this.handles.values()) {
1300
+ void room.resume(reconnected).catch((err) => {
1301
+ room.failed(err);
1302
+ });
1213
1303
  }
1214
1304
  }
1215
1305
  onFrame(frame) {
@@ -1279,7 +1369,7 @@ var ChatClient = class extends Emitter {
1279
1369
  }, Math.max(1e3, intervalMs));
1280
1370
  }
1281
1371
  scheduleReconnect() {
1282
- if (this.closedByCaller) return;
1372
+ if (this.stopped) return;
1283
1373
  this.setState("reconnecting");
1284
1374
  const delay = this.nextDelay();
1285
1375
  this.retryTimer = setTimeout(() => {
@@ -1331,6 +1421,9 @@ var ChatClient = class extends Emitter {
1331
1421
  const token = await this.options.token();
1332
1422
  this.lastToken = token;
1333
1423
  return token;
1424
+ } catch (err) {
1425
+ if (isChatError(err)) this.stop(err);
1426
+ throw err;
1334
1427
  } finally {
1335
1428
  this.refreshing = void 0;
1336
1429
  }
@@ -1352,6 +1445,9 @@ var ChatClient = class extends Emitter {
1352
1445
  this.retryTimer = void 0;
1353
1446
  }
1354
1447
  };
1448
+ function isChatError(err) {
1449
+ return err instanceof ChatError || err instanceof Error && err.name === "ChatError";
1450
+ }
1355
1451
  function createChatClient(options) {
1356
1452
  return new ChatClient(options);
1357
1453
  }
package/dist/index.d.cts CHANGED
@@ -14,6 +14,28 @@ declare class Emitter<Events extends Record<string, unknown>> {
14
14
  clear(): void;
15
15
  }
16
16
 
17
+ /**
18
+ * Every failure this SDK raises, with the server's own code on it.
19
+ *
20
+ * The code matters more than the message. A consumer branches on
21
+ * `rate_limited` versus `moderation_denied` versus `unauthorized`, and a
22
+ * bare `Error` carrying prose forces them to match on strings the server
23
+ * is free to reword.
24
+ */
25
+ declare class ChatError extends Error {
26
+ readonly code: string;
27
+ /** Present on rate limits, in milliseconds. */
28
+ readonly retryAfterMs?: number;
29
+ /** The consumer's own code, when a before_publish hook denied this. */
30
+ readonly appCode?: string;
31
+ /** The HTTP status, when this came from a REST call. */
32
+ status?: number;
33
+ constructor(code: string, message: string, extra?: {
34
+ retryAfterMs?: number;
35
+ appCode?: string;
36
+ });
37
+ }
38
+
17
39
  /**
18
40
  * The wire shapes this SDK reads and writes.
19
41
  *
@@ -166,11 +188,11 @@ type RoomEvents = {
166
188
  * socket's read loop where there is no caller to throw to -- and
167
189
  * swallowing it is what leaves a hole nothing ever fills.
168
190
  *
169
- * The retry to offer is **`room.subscribe()`**. A room that emitted
170
- * this is left not-subscribed on purpose, precisely so that call does
171
- * something: it re-subscribes and reloads. Marking it subscribed and
172
- * telling the consumer to retry would be advice that returns
173
- * immediately having done nothing, which is worse than no advice.
191
+ * The room repairs itself: it re-subscribes with backoff (1 s doubling,
192
+ * up to five tries while connected), and every reconnect tries again.
193
+ * `room.subscribe()` is still the retry to offer a person pressing a
194
+ * button -- a room that emitted this is left not-subscribed on purpose,
195
+ * precisely so that call does something.
174
196
  */
175
197
  error: Error;
176
198
  /**
@@ -252,6 +274,9 @@ declare class Room extends Emitter<RoomEvents> {
252
274
  */
253
275
  private wanted;
254
276
  private subscribing;
277
+ /** Self-repair: the armed retry, and how many have run since the last success. */
278
+ private repairTimer;
279
+ private repairs;
255
280
  constructor(chat: ChatClient, rest: Rest, address: {
256
281
  id?: string;
257
282
  key?: string;
@@ -387,7 +412,17 @@ declare class Room extends Emitter<RoomEvents> {
387
412
  * transient 500 takes a room out of the live feed for the life of the
388
413
  * page.
389
414
  */
390
- resume(): Promise<void>;
415
+ resume(restart?: boolean): Promise<void>;
416
+ /**
417
+ * Reports a failure no caller was waiting for, and repairs.
418
+ *
419
+ * 백그라운드 실패(재접속 뒤 다시 구독, 구멍 메우기)에는 거절을 받을 호출자가
420
+ * 없다. 이벤트만 내고 두면 방은 다음 재접속이나 탭 복귀까지 구멍을 안고 —
421
+ * 구독 프레임부터 실패했다면 라이브 피드 밖에서 — 머문다. 그래서 스스로 다시
422
+ * 구독한다. 횟수를 묶는 것은 고칠 수 없는 실패(권한, 없어진 방)가 서버를
423
+ * 계속 두드리지 않게 하려는 것이고, 그 뒤는 재접속과 `subscribe()`의 몫이다.
424
+ */
425
+ failed(err: unknown): void;
391
426
  /**
392
427
  * Sends `subscribe` and recovers from the one error it has an answer
393
428
  * for.
@@ -474,7 +509,9 @@ type ChatClientOptions = {
474
509
  * Called again on every connect, and again when a REST call is
475
510
  * answered 401 (the call is then retried once), so an expired token is
476
511
  * replaced rather than reused. A throw here is retried with backoff
477
- * like a dropped socket; only `close()` stops that.
512
+ * like a dropped socket -- **unless it throws a `ChatError`**, which
513
+ * means "do not retry" (a ban, a session that ended): the client goes
514
+ * to `closed` and emits it as `error`.
478
515
  *
479
516
  * **Never sign these in the browser** -- signing needs the `sk_`, and
480
517
  * a `sk_` in a browser is the whole app.
@@ -497,6 +534,15 @@ type ChatClientEvents = {
497
534
  * SDK learns about it.
498
535
  */
499
536
  frame: Frame;
537
+ /**
538
+ * 클라이언트가 스스로 `closed`로 멈췄고, 이게 그 이유다.
539
+ *
540
+ * 멈추는 길은 셋이다: `close()`, 서버의 인증 거절, `token()`이 던진
541
+ * `ChatError`. 첫 connect라면 거절로도 알 수 있지만 재접속 중이나 REST의
542
+ * 토큰 갱신 중에는 받을 호출자가 없어서, 이것 없이는 `state`가 `closed`로
543
+ * 바뀌는 것만 보이고 왜인지는 사라진다. `close()`로 닫을 때는 내지 않는다.
544
+ */
545
+ error: ChatError;
500
546
  };
501
547
  declare class ChatClient extends Emitter<ChatClientEvents> {
502
548
  state: ConnectionState;
@@ -509,8 +555,11 @@ declare class ChatClient extends Emitter<ChatClientEvents> {
509
555
  private heartbeat;
510
556
  private retryTimer;
511
557
  private attempt;
512
- /** Set by close(), and the only thing that stops the reconnect loop. */
513
- private closedByCaller;
558
+ /**
559
+ * The reconnect loop is off: set by close(), and by a failure that
560
+ * retrying cannot fix (see `stop`). Cleared by connect().
561
+ */
562
+ private stopped;
514
563
  private nextFrameId;
515
564
  private opening;
516
565
  private readonly rest;
@@ -641,10 +690,30 @@ declare class ChatClient extends Emitter<ChatClientEvents> {
641
690
  * Closes for good.
642
691
  *
643
692
  * The distinction from a dropped socket is the whole point of the state
644
- * machine: this is the only path that reaches `closed`, and a consumer
645
- * showing "disconnected, retrying" versus "disconnected" needs it.
693
+ * machine: `closed` means nothing is coming back, and a consumer showing
694
+ * "disconnected, retrying" versus "disconnected" needs it. The other
695
+ * ways to reach it are refusals retrying cannot fix -- see `stop`.
646
696
  */
647
697
  close(): Promise<void>;
698
+ /**
699
+ * Stops for a reason retrying cannot fix, and says which.
700
+ *
701
+ * `close()` without the teardown of the page listeners -- a consumer
702
+ * that logs back in calls `connect()` on the same client -- and with an
703
+ * `error` event, because on a reconnect there is no caller to reject.
704
+ */
705
+ private stop;
706
+ /**
707
+ * Resolves once the connection is open. Used by `Room.subscribe`.
708
+ *
709
+ * 연결 전에 부른 `subscribe()`를 `closed`로 거절하면 소비자는 "언제 다시
710
+ * 부를지"를 스스로 알아내야 하고, 첫 connect가 실패했다면 그 때는 영영 오지
711
+ * 않는다. 붙을 때까지 기다리면 그 신호가 필요 없다. 멈춘 클라이언트(close,
712
+ * 인증 거절)는 붙을 일이 없으니 거절한다.
713
+ */
714
+ whenOpen(): Promise<void>;
715
+ private openWaiters;
716
+ private settleOpenWaiters;
648
717
  /** Sends a frame and resolves with its ack, or rejects with its error. */
649
718
  send(type: string, data?: unknown, timeoutMs?: number): Promise<unknown>;
650
719
  /**
@@ -700,28 +769,6 @@ declare class ChatClient extends Emitter<ChatClientEvents> {
700
769
  }
701
770
  declare function createChatClient(options: ChatClientOptions): ChatClient;
702
771
 
703
- /**
704
- * Every failure this SDK raises, with the server's own code on it.
705
- *
706
- * The code matters more than the message. A consumer branches on
707
- * `rate_limited` versus `moderation_denied` versus `unauthorized`, and a
708
- * bare `Error` carrying prose forces them to match on strings the server
709
- * is free to reword.
710
- */
711
- declare class ChatError extends Error {
712
- readonly code: string;
713
- /** Present on rate limits, in milliseconds. */
714
- readonly retryAfterMs?: number;
715
- /** The consumer's own code, when a before_publish hook denied this. */
716
- readonly appCode?: string;
717
- /** The HTTP status, when this came from a REST call. */
718
- status?: number;
719
- constructor(code: string, message: string, extra?: {
720
- retryAfterMs?: number;
721
- appCode?: string;
722
- });
723
- }
724
-
725
772
  /**
726
773
  * The message list, in `seq` order, with its holes filled.
727
774
  *
package/dist/index.d.ts CHANGED
@@ -14,6 +14,28 @@ declare class Emitter<Events extends Record<string, unknown>> {
14
14
  clear(): void;
15
15
  }
16
16
 
17
+ /**
18
+ * Every failure this SDK raises, with the server's own code on it.
19
+ *
20
+ * The code matters more than the message. A consumer branches on
21
+ * `rate_limited` versus `moderation_denied` versus `unauthorized`, and a
22
+ * bare `Error` carrying prose forces them to match on strings the server
23
+ * is free to reword.
24
+ */
25
+ declare class ChatError extends Error {
26
+ readonly code: string;
27
+ /** Present on rate limits, in milliseconds. */
28
+ readonly retryAfterMs?: number;
29
+ /** The consumer's own code, when a before_publish hook denied this. */
30
+ readonly appCode?: string;
31
+ /** The HTTP status, when this came from a REST call. */
32
+ status?: number;
33
+ constructor(code: string, message: string, extra?: {
34
+ retryAfterMs?: number;
35
+ appCode?: string;
36
+ });
37
+ }
38
+
17
39
  /**
18
40
  * The wire shapes this SDK reads and writes.
19
41
  *
@@ -166,11 +188,11 @@ type RoomEvents = {
166
188
  * socket's read loop where there is no caller to throw to -- and
167
189
  * swallowing it is what leaves a hole nothing ever fills.
168
190
  *
169
- * The retry to offer is **`room.subscribe()`**. A room that emitted
170
- * this is left not-subscribed on purpose, precisely so that call does
171
- * something: it re-subscribes and reloads. Marking it subscribed and
172
- * telling the consumer to retry would be advice that returns
173
- * immediately having done nothing, which is worse than no advice.
191
+ * The room repairs itself: it re-subscribes with backoff (1 s doubling,
192
+ * up to five tries while connected), and every reconnect tries again.
193
+ * `room.subscribe()` is still the retry to offer a person pressing a
194
+ * button -- a room that emitted this is left not-subscribed on purpose,
195
+ * precisely so that call does something.
174
196
  */
175
197
  error: Error;
176
198
  /**
@@ -252,6 +274,9 @@ declare class Room extends Emitter<RoomEvents> {
252
274
  */
253
275
  private wanted;
254
276
  private subscribing;
277
+ /** Self-repair: the armed retry, and how many have run since the last success. */
278
+ private repairTimer;
279
+ private repairs;
255
280
  constructor(chat: ChatClient, rest: Rest, address: {
256
281
  id?: string;
257
282
  key?: string;
@@ -387,7 +412,17 @@ declare class Room extends Emitter<RoomEvents> {
387
412
  * transient 500 takes a room out of the live feed for the life of the
388
413
  * page.
389
414
  */
390
- resume(): Promise<void>;
415
+ resume(restart?: boolean): Promise<void>;
416
+ /**
417
+ * Reports a failure no caller was waiting for, and repairs.
418
+ *
419
+ * 백그라운드 실패(재접속 뒤 다시 구독, 구멍 메우기)에는 거절을 받을 호출자가
420
+ * 없다. 이벤트만 내고 두면 방은 다음 재접속이나 탭 복귀까지 구멍을 안고 —
421
+ * 구독 프레임부터 실패했다면 라이브 피드 밖에서 — 머문다. 그래서 스스로 다시
422
+ * 구독한다. 횟수를 묶는 것은 고칠 수 없는 실패(권한, 없어진 방)가 서버를
423
+ * 계속 두드리지 않게 하려는 것이고, 그 뒤는 재접속과 `subscribe()`의 몫이다.
424
+ */
425
+ failed(err: unknown): void;
391
426
  /**
392
427
  * Sends `subscribe` and recovers from the one error it has an answer
393
428
  * for.
@@ -474,7 +509,9 @@ type ChatClientOptions = {
474
509
  * Called again on every connect, and again when a REST call is
475
510
  * answered 401 (the call is then retried once), so an expired token is
476
511
  * replaced rather than reused. A throw here is retried with backoff
477
- * like a dropped socket; only `close()` stops that.
512
+ * like a dropped socket -- **unless it throws a `ChatError`**, which
513
+ * means "do not retry" (a ban, a session that ended): the client goes
514
+ * to `closed` and emits it as `error`.
478
515
  *
479
516
  * **Never sign these in the browser** -- signing needs the `sk_`, and
480
517
  * a `sk_` in a browser is the whole app.
@@ -497,6 +534,15 @@ type ChatClientEvents = {
497
534
  * SDK learns about it.
498
535
  */
499
536
  frame: Frame;
537
+ /**
538
+ * 클라이언트가 스스로 `closed`로 멈췄고, 이게 그 이유다.
539
+ *
540
+ * 멈추는 길은 셋이다: `close()`, 서버의 인증 거절, `token()`이 던진
541
+ * `ChatError`. 첫 connect라면 거절로도 알 수 있지만 재접속 중이나 REST의
542
+ * 토큰 갱신 중에는 받을 호출자가 없어서, 이것 없이는 `state`가 `closed`로
543
+ * 바뀌는 것만 보이고 왜인지는 사라진다. `close()`로 닫을 때는 내지 않는다.
544
+ */
545
+ error: ChatError;
500
546
  };
501
547
  declare class ChatClient extends Emitter<ChatClientEvents> {
502
548
  state: ConnectionState;
@@ -509,8 +555,11 @@ declare class ChatClient extends Emitter<ChatClientEvents> {
509
555
  private heartbeat;
510
556
  private retryTimer;
511
557
  private attempt;
512
- /** Set by close(), and the only thing that stops the reconnect loop. */
513
- private closedByCaller;
558
+ /**
559
+ * The reconnect loop is off: set by close(), and by a failure that
560
+ * retrying cannot fix (see `stop`). Cleared by connect().
561
+ */
562
+ private stopped;
514
563
  private nextFrameId;
515
564
  private opening;
516
565
  private readonly rest;
@@ -641,10 +690,30 @@ declare class ChatClient extends Emitter<ChatClientEvents> {
641
690
  * Closes for good.
642
691
  *
643
692
  * The distinction from a dropped socket is the whole point of the state
644
- * machine: this is the only path that reaches `closed`, and a consumer
645
- * showing "disconnected, retrying" versus "disconnected" needs it.
693
+ * machine: `closed` means nothing is coming back, and a consumer showing
694
+ * "disconnected, retrying" versus "disconnected" needs it. The other
695
+ * ways to reach it are refusals retrying cannot fix -- see `stop`.
646
696
  */
647
697
  close(): Promise<void>;
698
+ /**
699
+ * Stops for a reason retrying cannot fix, and says which.
700
+ *
701
+ * `close()` without the teardown of the page listeners -- a consumer
702
+ * that logs back in calls `connect()` on the same client -- and with an
703
+ * `error` event, because on a reconnect there is no caller to reject.
704
+ */
705
+ private stop;
706
+ /**
707
+ * Resolves once the connection is open. Used by `Room.subscribe`.
708
+ *
709
+ * 연결 전에 부른 `subscribe()`를 `closed`로 거절하면 소비자는 "언제 다시
710
+ * 부를지"를 스스로 알아내야 하고, 첫 connect가 실패했다면 그 때는 영영 오지
711
+ * 않는다. 붙을 때까지 기다리면 그 신호가 필요 없다. 멈춘 클라이언트(close,
712
+ * 인증 거절)는 붙을 일이 없으니 거절한다.
713
+ */
714
+ whenOpen(): Promise<void>;
715
+ private openWaiters;
716
+ private settleOpenWaiters;
648
717
  /** Sends a frame and resolves with its ack, or rejects with its error. */
649
718
  send(type: string, data?: unknown, timeoutMs?: number): Promise<unknown>;
650
719
  /**
@@ -700,28 +769,6 @@ declare class ChatClient extends Emitter<ChatClientEvents> {
700
769
  }
701
770
  declare function createChatClient(options: ChatClientOptions): ChatClient;
702
771
 
703
- /**
704
- * Every failure this SDK raises, with the server's own code on it.
705
- *
706
- * The code matters more than the message. A consumer branches on
707
- * `rate_limited` versus `moderation_denied` versus `unauthorized`, and a
708
- * bare `Error` carrying prose forces them to match on strings the server
709
- * is free to reword.
710
- */
711
- declare class ChatError extends Error {
712
- readonly code: string;
713
- /** Present on rate limits, in milliseconds. */
714
- readonly retryAfterMs?: number;
715
- /** The consumer's own code, when a before_publish hook denied this. */
716
- readonly appCode?: string;
717
- /** The HTTP status, when this came from a REST call. */
718
- status?: number;
719
- constructor(code: string, message: string, extra?: {
720
- retryAfterMs?: number;
721
- appCode?: string;
722
- });
723
- }
724
-
725
772
  /**
726
773
  * The message list, in `seq` order, with its holes filled.
727
774
  *