@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.js CHANGED
@@ -424,6 +424,8 @@ function createTimeline(options) {
424
424
 
425
425
  // src/room.ts
426
426
  var HistoryPageSize = 100;
427
+ var MaxRepairs = 5;
428
+ var RepairBaseMs = 1e3;
427
429
  var Room = class extends Emitter {
428
430
  /** Resolved on subscribe when the room was addressed by key. */
429
431
  id;
@@ -461,6 +463,9 @@ var Room = class extends Emitter {
461
463
  */
462
464
  wanted = false;
463
465
  subscribing;
466
+ /** Self-repair: the armed retry, and how many have run since the last success. */
467
+ repairTimer;
468
+ repairs = 0;
464
469
  constructor(chat, rest, address) {
465
470
  super();
466
471
  this.chat = chat;
@@ -527,7 +532,12 @@ var Room = class extends Emitter {
527
532
  this.wanted = true;
528
533
  if (this.subscribing !== void 0) return this.subscribing;
529
534
  if (this.subscribed) return;
530
- const attempt = this.doSubscribe().finally(() => {
535
+ const attempt = this.doSubscribe().then(() => {
536
+ const successor = this.subscribing;
537
+ if (successor !== void 0 && successor !== attempt) return successor;
538
+ if (this.subscribed) this.repairs = 0;
539
+ return void 0;
540
+ }).finally(() => {
531
541
  if (this.subscribing === attempt) this.subscribing = void 0;
532
542
  });
533
543
  this.subscribing = attempt;
@@ -536,6 +546,10 @@ var Room = class extends Emitter {
536
546
  async doSubscribe() {
537
547
  const gen = this.generation;
538
548
  const current = () => gen === this.generation;
549
+ if (this.chat.state !== "open") {
550
+ await this.chat.whenOpen();
551
+ if (!current()) return;
552
+ }
539
553
  const data = this.id !== void 0 ? { roomId: this.id } : { key: this.key };
540
554
  const since = this.timeline.contiguousSeq;
541
555
  if (since > 0) data["since"] = since;
@@ -665,13 +679,33 @@ var Room = class extends Emitter {
665
679
  * transient 500 takes a room out of the live feed for the life of the
666
680
  * page.
667
681
  */
668
- async resume() {
682
+ async resume(restart = true) {
669
683
  if (!this.wanted) return;
684
+ if (!restart) return this.subscribe();
670
685
  this.generation++;
671
686
  this.subscribing = void 0;
672
687
  this.subscribed = false;
673
688
  await this.subscribe();
674
689
  }
690
+ /**
691
+ * Reports a failure no caller was waiting for, and repairs.
692
+ *
693
+ * 백그라운드 실패(재접속 뒤 다시 구독, 구멍 메우기)에는 거절을 받을 호출자가
694
+ * 없다. 이벤트만 내고 두면 방은 다음 재접속이나 탭 복귀까지 구멍을 안고 —
695
+ * 구독 프레임부터 실패했다면 라이브 피드 밖에서 — 머문다. 그래서 스스로 다시
696
+ * 구독한다. 횟수를 묶는 것은 고칠 수 없는 실패(권한, 없어진 방)가 서버를
697
+ * 계속 두드리지 않게 하려는 것이고, 그 뒤는 재접속과 `subscribe()`의 몫이다.
698
+ */
699
+ failed(err) {
700
+ this.emit("error", err instanceof Error ? err : new Error(String(err)));
701
+ if (this.repairTimer !== void 0 || this.repairs >= MaxRepairs) return;
702
+ const delay = RepairBaseMs * 2 ** this.repairs++;
703
+ this.repairTimer = setTimeout(() => {
704
+ this.repairTimer = void 0;
705
+ if (!this.wanted || this.subscribed || this.chat.state !== "open") return;
706
+ this.subscribe().catch((e) => this.failed(e));
707
+ }, delay);
708
+ }
675
709
  /**
676
710
  * Sends `subscribe` and recovers from the one error it has an answer
677
711
  * for.
@@ -753,7 +787,7 @@ var Room = class extends Emitter {
753
787
  if (data.seq > this.lastSeq) this.lastSeq = data.seq;
754
788
  this.timeline.add(data).catch((err) => {
755
789
  this.subscribed = false;
756
- this.emit("error", err instanceof Error ? err : new Error(String(err)));
790
+ this.failed(err);
757
791
  });
758
792
  break;
759
793
  case "message.updated":
@@ -822,8 +856,11 @@ var ChatClient = class extends Emitter {
822
856
  heartbeat;
823
857
  retryTimer;
824
858
  attempt = 0;
825
- /** Set by close(), and the only thing that stops the reconnect loop. */
826
- closedByCaller = false;
859
+ /**
860
+ * The reconnect loop is off: set by close(), and by a failure that
861
+ * retrying cannot fix (see `stop`). Cleared by connect().
862
+ */
863
+ stopped = false;
827
864
  nextFrameId = 0;
828
865
  opening;
829
866
  rest;
@@ -901,7 +938,7 @@ var ChatClient = class extends Emitter {
901
938
  * gap-filled via REST.
902
939
  */
903
940
  async resume() {
904
- if (this.closedByCaller) return;
941
+ if (this.stopped) return;
905
942
  if (this.state !== "open" || this.socket === void 0) {
906
943
  this.clearTimers();
907
944
  return this.connect();
@@ -913,9 +950,7 @@ var ChatClient = class extends Emitter {
913
950
  return this.connect();
914
951
  }
915
952
  for (const room of this.handles.values()) {
916
- void room.resume().catch((err) => {
917
- room.emit("error", err instanceof Error ? err : new Error(String(err)));
918
- });
953
+ void room.resume().catch((err) => room.failed(err));
919
954
  }
920
955
  }
921
956
  /** The account-level routes: the rooms this user is in, and unread. */
@@ -1007,7 +1042,7 @@ var ChatClient = class extends Emitter {
1007
1042
  }
1008
1043
  /** Opens the connection and resolves when `hello` arrives. */
1009
1044
  async connect() {
1010
- this.closedByCaller = false;
1045
+ this.stopped = false;
1011
1046
  if (this.opening !== void 0) return this.opening;
1012
1047
  if (this.state === "open" && this.socket !== void 0) return;
1013
1048
  this.clearTimers();
@@ -1020,11 +1055,12 @@ var ChatClient = class extends Emitter {
1020
1055
  * Closes for good.
1021
1056
  *
1022
1057
  * The distinction from a dropped socket is the whole point of the state
1023
- * machine: this is the only path that reaches `closed`, and a consumer
1024
- * showing "disconnected, retrying" versus "disconnected" needs it.
1058
+ * machine: `closed` means nothing is coming back, and a consumer showing
1059
+ * "disconnected, retrying" versus "disconnected" needs it. The other
1060
+ * ways to reach it are refusals retrying cannot fix -- see `stop`.
1025
1061
  */
1026
1062
  async close() {
1027
- this.closedByCaller = true;
1063
+ this.stopped = true;
1028
1064
  this.teardowns.forEach((t) => {
1029
1065
  try {
1030
1066
  t();
@@ -1041,7 +1077,53 @@ var ChatClient = class extends Emitter {
1041
1077
  } catch {
1042
1078
  }
1043
1079
  }
1080
+ this.settleOpenWaiters(new ChatError("closed", "the connection is closed"));
1081
+ this.setState("closed");
1082
+ }
1083
+ /**
1084
+ * Stops for a reason retrying cannot fix, and says which.
1085
+ *
1086
+ * `close()` without the teardown of the page listeners -- a consumer
1087
+ * that logs back in calls `connect()` on the same client -- and with an
1088
+ * `error` event, because on a reconnect there is no caller to reject.
1089
+ */
1090
+ stop(err) {
1091
+ this.stopped = true;
1092
+ this.clearTimers();
1093
+ const socket = this.socket;
1094
+ this.socket = void 0;
1095
+ if (socket !== void 0) {
1096
+ try {
1097
+ socket.close(1e3, "client stopped");
1098
+ } catch {
1099
+ }
1100
+ }
1101
+ this.failPending(err);
1102
+ this.settleOpenWaiters(new ChatError("closed", "the connection is closed"));
1044
1103
  this.setState("closed");
1104
+ this.emit("error", err);
1105
+ }
1106
+ /**
1107
+ * Resolves once the connection is open. Used by `Room.subscribe`.
1108
+ *
1109
+ * 연결 전에 부른 `subscribe()`를 `closed`로 거절하면 소비자는 "언제 다시
1110
+ * 부를지"를 스스로 알아내야 하고, 첫 connect가 실패했다면 그 때는 영영 오지
1111
+ * 않는다. 붙을 때까지 기다리면 그 신호가 필요 없다. 멈춘 클라이언트(close,
1112
+ * 인증 거절)는 붙을 일이 없으니 거절한다.
1113
+ */
1114
+ whenOpen() {
1115
+ if (this.state === "open" && this.socket !== void 0) return Promise.resolve();
1116
+ if (this.stopped) return Promise.reject(new ChatError("closed", "the connection is closed"));
1117
+ return new Promise((resolve, reject) => void this.openWaiters.push({ resolve, reject }));
1118
+ }
1119
+ openWaiters = [];
1120
+ settleOpenWaiters(err) {
1121
+ const waiters = this.openWaiters;
1122
+ this.openWaiters = [];
1123
+ for (const w of waiters) {
1124
+ if (err === void 0) w.resolve();
1125
+ else w.reject(err);
1126
+ }
1045
1127
  }
1046
1128
  /** Sends a frame and resolves with its ack, or rejects with its error. */
1047
1129
  send(type, data, timeoutMs = 15e3) {
@@ -1088,10 +1170,14 @@ var ChatClient = class extends Emitter {
1088
1170
  try {
1089
1171
  authData = await this.authData();
1090
1172
  } catch (err) {
1173
+ if (isChatError(err)) {
1174
+ this.stop(err);
1175
+ throw err;
1176
+ }
1091
1177
  this.scheduleReconnect();
1092
1178
  throw err;
1093
1179
  }
1094
- if (this.closedByCaller) {
1180
+ if (this.stopped) {
1095
1181
  throw new ChatError("closed", "the client was closed while connecting");
1096
1182
  }
1097
1183
  const socket = new this.makeSocket(`${this.options.url}/v1/ws?v=1`);
@@ -1112,9 +1198,14 @@ var ChatClient = class extends Emitter {
1112
1198
  return;
1113
1199
  }
1114
1200
  if (frame.type === "error" && !settled) {
1115
- this.closedByCaller = true;
1116
- this.setState("closed");
1117
- finish(errorFrom(frame.data));
1201
+ const err = errorFrom(frame.data);
1202
+ if (err.code === "rate_limited" || err.code === "internal") {
1203
+ if (err.retryAfterMs !== void 0 && err.retryAfterMs > 0) this.plannedDelayMs = err.retryAfterMs;
1204
+ finish(err);
1205
+ return;
1206
+ }
1207
+ finish(err);
1208
+ this.stop(err);
1118
1209
  return;
1119
1210
  }
1120
1211
  this.onFrame(frame);
@@ -1147,12 +1238,11 @@ var ChatClient = class extends Emitter {
1147
1238
  const reconnected = this.everOpened;
1148
1239
  this.everOpened = true;
1149
1240
  this.setState("open");
1150
- if (reconnected) {
1151
- for (const room of this.handles.values()) {
1152
- void room.resume().catch((err) => {
1153
- room.emit("error", err instanceof Error ? err : new Error(String(err)));
1154
- });
1155
- }
1241
+ this.settleOpenWaiters();
1242
+ for (const room of this.handles.values()) {
1243
+ void room.resume(reconnected).catch((err) => {
1244
+ room.failed(err);
1245
+ });
1156
1246
  }
1157
1247
  }
1158
1248
  onFrame(frame) {
@@ -1222,7 +1312,7 @@ var ChatClient = class extends Emitter {
1222
1312
  }, Math.max(1e3, intervalMs));
1223
1313
  }
1224
1314
  scheduleReconnect() {
1225
- if (this.closedByCaller) return;
1315
+ if (this.stopped) return;
1226
1316
  this.setState("reconnecting");
1227
1317
  const delay = this.nextDelay();
1228
1318
  this.retryTimer = setTimeout(() => {
@@ -1274,6 +1364,9 @@ var ChatClient = class extends Emitter {
1274
1364
  const token = await this.options.token();
1275
1365
  this.lastToken = token;
1276
1366
  return token;
1367
+ } catch (err) {
1368
+ if (isChatError(err)) this.stop(err);
1369
+ throw err;
1277
1370
  } finally {
1278
1371
  this.refreshing = void 0;
1279
1372
  }
@@ -1295,6 +1388,9 @@ var ChatClient = class extends Emitter {
1295
1388
  this.retryTimer = void 0;
1296
1389
  }
1297
1390
  };
1391
+ function isChatError(err) {
1392
+ return err instanceof ChatError || err instanceof Error && err.name === "ChatError";
1393
+ }
1298
1394
  function createChatClient(options) {
1299
1395
  return new ChatClient(options);
1300
1396
  }
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@kispi/chat",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Client SDK for the chat server: one WebSocket, many rooms, ordered history.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "sideEffects": false,
8
8
  "files": [
9
- "dist"
9
+ "dist",
10
+ "README.en.md"
10
11
  ],
11
12
  "exports": {
12
13
  ".": {