@kispi/chat 0.1.2 → 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
@@ -67,6 +67,8 @@ var ChatError = class extends Error {
67
67
  retryAfterMs;
68
68
  /** The consumer's own code, when a before_publish hook denied this. */
69
69
  appCode;
70
+ /** The HTTP status, when this came from a REST call. */
71
+ status;
70
72
  constructor(code, message, extra) {
71
73
  super(message);
72
74
  this.name = "ChatError";
@@ -112,6 +114,16 @@ var Rest = class {
112
114
  }
113
115
  async call(method, path, body) {
114
116
  const token = await this.options.token();
117
+ try {
118
+ return await this.callWith(token, method, path, body);
119
+ } catch (err) {
120
+ if (!(err instanceof ChatError) || err.status !== 401 || this.options.refreshToken === void 0) throw err;
121
+ const fresh = await this.options.refreshToken().catch(() => void 0);
122
+ if (fresh === void 0 || fresh === token) throw err;
123
+ return this.callWith(fresh, method, path, body);
124
+ }
125
+ }
126
+ async callWith(token, method, path, body) {
115
127
  if (token === void 0) {
116
128
  throw new ChatError(
117
129
  "unauthorized",
@@ -133,6 +145,7 @@ var Rest = class {
133
145
  const parsed = text === "" ? void 0 : JSON.parse(text);
134
146
  if (!res.ok) {
135
147
  const err = errorFrom(parsed, `${method} ${path} failed with ${res.status}`);
148
+ err.status = res.status;
136
149
  throw err;
137
150
  }
138
151
  return parsed;
@@ -179,10 +192,24 @@ var Timeline = class {
179
192
  * rows into the new list.
180
193
  */
181
194
  epoch = 0;
195
+ /**
196
+ * `items` has been handed out -- by the getter or by `onChange`.
197
+ *
198
+ * 한 번 내준 배열은 다시 건드리지 않는다. 같은 배열을 제자리에서 고쳐
199
+ * 다시 내주면 Svelte `$state.raw`, React `useState`, Vue `shallowRef`처럼
200
+ * 참조가 바뀌어야 다시 그리는 쪽은 변화를 보지 못하고 화면이 멈춘다. 그래서
201
+ * 내준 뒤의 첫 변경은 복사본에서 한다(copy-on-write). 행 객체도 같은 규칙이라
202
+ * 바뀐 행만 새 객체다.
203
+ */
204
+ shared = false;
205
+ /** The `loadOlder` in flight, so a scroll handler firing twice asks once. */
206
+ older;
182
207
  constructor(options) {
183
208
  this.options = options;
184
209
  }
210
+ /** A new array whenever the list changes; never mutated once returned. */
185
211
  get messages() {
212
+ this.shared = true;
186
213
  return this.items;
187
214
  }
188
215
  /** The highest seq this timeline holds, hole or no hole. */
@@ -229,6 +256,7 @@ var Timeline = class {
229
256
  this.fills.clear();
230
257
  this.pendingDeletes.clear();
231
258
  this.items = [];
259
+ this.shared = false;
232
260
  this.changed();
233
261
  }
234
262
  /**
@@ -255,6 +283,7 @@ var Timeline = class {
255
283
  const page = [...messages].sort((a, b) => a.seq - b.seq);
256
284
  const top = page.at(-1)?.seq ?? 0;
257
285
  this.items = [...page, ...this.items.filter((m) => m.seq > top)];
286
+ this.shared = false;
258
287
  for (const m of this.items) {
259
288
  if (this.pendingDeletes.has(m.id)) this.applyDelete(m.id);
260
289
  }
@@ -289,6 +318,7 @@ var Timeline = class {
289
318
  if (at === -1) return;
290
319
  const current = this.items[at];
291
320
  if (current.deletedAt !== void 0) return;
321
+ this.own();
292
322
  this.items[at] = { ...current, ...message };
293
323
  this.changed();
294
324
  }
@@ -315,6 +345,7 @@ var Timeline = class {
315
345
  if (count <= 0) next = existing.filter((r) => r.emoji !== emoji);
316
346
  else if (slot === -1) next = [...existing, { emoji, count }];
317
347
  else next = existing.map((r, i) => i === slot ? { emoji, count } : r);
348
+ this.own();
318
349
  this.items[at] = { ...current, reactions: next };
319
350
  this.changed();
320
351
  }
@@ -323,6 +354,7 @@ var Timeline = class {
323
354
  const at = this.items.findIndex((m) => m.id === rootId);
324
355
  if (at === -1) return;
325
356
  const current = this.items[at];
357
+ this.own();
326
358
  this.items[at] = { ...current, thread: lastSeq === void 0 ? { count } : { count, lastSeq } };
327
359
  this.changed();
328
360
  }
@@ -330,6 +362,36 @@ var Timeline = class {
330
362
  remove(id) {
331
363
  if (!this.applyDelete(id)) this.pendingDeletes.add(id);
332
364
  }
365
+ /**
366
+ * Prepends the page just below the oldest message held.
367
+ *
368
+ * 스크롤을 올려 과거를 읽는 소비자가 `history()` 결과를 라이브 목록과 따로
369
+ * 들고 합치고, 중복을 걸러 내고, reset과 재접속을 따로 처리해야 했다. 같은
370
+ * 목록에 끼워 넣으면 그 일이 전부 이미 있는 규칙(seq 자리에 넣기, 같은 seq는
371
+ * 한 행, reset이면 epoch로 버리기)으로 끝난다.
372
+ *
373
+ * `hasMore`는 더 올라갈 것이 있는지다. 서버의 seq는 1부터 구멍 없이 붙으므로
374
+ * 맨 위가 1이거나 페이지가 덜 찼으면 끝이다(보존 기간이 지운 앞부분도 덜 찬
375
+ * 페이지로 드러난다). 한 번에 하나만 돈다 — 스크롤 핸들러가 두 번 불러도
376
+ * 요청은 하나다(진행 중인 호출과 같은 결과를 받으므로 뒤 호출의 `limit`은 쓰이지 않는다).
377
+ */
378
+ loadOlder(limit = this.options.pageSize ?? MaxPageSize) {
379
+ this.older ??= this.readOlder(Math.min(limit, MaxPageSize)).finally(() => {
380
+ this.older = void 0;
381
+ });
382
+ return this.older;
383
+ }
384
+ async readOlder(limit) {
385
+ const first = this.items[0];
386
+ if (first === void 0 || first.seq <= 1) return { messages: [], hasMore: false };
387
+ const epoch = this.epoch;
388
+ const page = await this.options.fetchRange({ after: 0, before: first.seq, limit });
389
+ if (this.epoch !== epoch) return { messages: [], hasMore: true };
390
+ for (const m of page) this.insert(m, false);
391
+ if (page.length > 0) this.changed();
392
+ const top = page[0];
393
+ return { messages: page, hasMore: page.length >= limit && top !== void 0 && top.seq > 1 };
394
+ }
333
395
  /**
334
396
  * Fetches everything between what we have and `upTo`.
335
397
  *
@@ -374,10 +436,11 @@ var Timeline = class {
374
436
  return run;
375
437
  }
376
438
  /** Inserts at the seq position, replacing an existing row with that seq. */
377
- insert(message) {
439
+ insert(message, notify = true) {
378
440
  const pending = this.pendingDeletes.has(message.id);
379
441
  if (pending) this.pendingDeletes.delete(message.id);
380
442
  const withDelete = pending ? emptied(message) : message;
443
+ this.own();
381
444
  const at = this.items.findIndex((m) => m.seq >= withDelete.seq);
382
445
  if (at === -1) {
383
446
  this.items.push(withDelete);
@@ -387,17 +450,25 @@ var Timeline = class {
387
450
  } else {
388
451
  this.items.splice(at, 0, withDelete);
389
452
  }
390
- this.changed();
453
+ if (notify) this.changed();
391
454
  }
392
455
  applyDelete(id) {
393
456
  const at = this.items.findIndex((m) => m.id === id);
394
457
  if (at === -1) return false;
395
458
  this.pendingDeletes.delete(id);
459
+ this.own();
396
460
  this.items[at] = emptied(this.items[at]);
397
461
  this.changed();
398
462
  return true;
399
463
  }
464
+ /** Makes `items` safe to mutate: a copy, if the current one was handed out. */
465
+ own() {
466
+ if (!this.shared) return;
467
+ this.items = this.items.slice();
468
+ this.shared = false;
469
+ }
400
470
  changed() {
471
+ this.shared = true;
401
472
  this.options.onChange?.(this.items);
402
473
  }
403
474
  };
@@ -410,6 +481,8 @@ function createTimeline(options) {
410
481
 
411
482
  // src/room.ts
412
483
  var HistoryPageSize = 100;
484
+ var MaxRepairs = 5;
485
+ var RepairBaseMs = 1e3;
413
486
  var Room = class extends Emitter {
414
487
  /** Resolved on subscribe when the room was addressed by key. */
415
488
  id;
@@ -447,6 +520,9 @@ var Room = class extends Emitter {
447
520
  */
448
521
  wanted = false;
449
522
  subscribing;
523
+ /** Self-repair: the armed retry, and how many have run since the last success. */
524
+ repairTimer;
525
+ repairs = 0;
450
526
  constructor(chat, rest, address) {
451
527
  super();
452
528
  this.chat = chat;
@@ -462,10 +538,27 @@ var Room = class extends Emitter {
462
538
  onChange: (messages) => this.emit("messages", messages)
463
539
  });
464
540
  }
465
- /** Everything this client knows about the room, in seq order. */
541
+ /** Everything this client knows about the room, in seq order. A new array whenever it changes. */
466
542
  get messages() {
467
543
  return this.timeline.messages;
468
544
  }
545
+ /**
546
+ * Reads the page just before the oldest message held and **prepends it to
547
+ * `messages`**.
548
+ *
549
+ * The scroll-back call. Unlike `history()`, the rows join the room's own
550
+ * list, so ordering, de-duplication, deletes, reactions and reconnects
551
+ * apply to them like any other row, and the `messages` event fires once.
552
+ * A `reset` (or `reload()`) drops them with everything else; a read that
553
+ * lands after one is discarded rather than stitched onto the new list.
554
+ *
555
+ * `hasMore` is false once the top of the room -- or of its retention --
556
+ * is reached. Concurrent calls share one request.
557
+ */
558
+ async loadOlder(options = {}) {
559
+ await this.resolveId("loading older messages");
560
+ return this.timeline.loadOlder(options.limit);
561
+ }
469
562
  /**
470
563
  * Reloads recent history, discarding what is held.
471
564
  *
@@ -496,7 +589,12 @@ var Room = class extends Emitter {
496
589
  this.wanted = true;
497
590
  if (this.subscribing !== void 0) return this.subscribing;
498
591
  if (this.subscribed) return;
499
- 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(() => {
500
598
  if (this.subscribing === attempt) this.subscribing = void 0;
501
599
  });
502
600
  this.subscribing = attempt;
@@ -505,6 +603,10 @@ var Room = class extends Emitter {
505
603
  async doSubscribe() {
506
604
  const gen = this.generation;
507
605
  const current = () => gen === this.generation;
606
+ if (this.chat.state !== "open") {
607
+ await this.chat.whenOpen();
608
+ if (!current()) return;
609
+ }
508
610
  const data = this.id !== void 0 ? { roomId: this.id } : { key: this.key };
509
611
  const since = this.timeline.contiguousSeq;
510
612
  if (since > 0) data["since"] = since;
@@ -539,7 +641,8 @@ var Room = class extends Emitter {
539
641
  *
540
642
  * The list this room keeps is the live one; this is for a consumer
541
643
  * scrolling back, which owns its own window and does not want the
542
- * bottom of the room rearranged under it.
644
+ * bottom of the room rearranged under it. **Scrolling the room's own
645
+ * list back is `loadOlder()`**, which keeps one list instead of two.
543
646
  *
544
647
  * `view` defaults to the server's, which is every message including
545
648
  * thread replies. **`view: 'main'` is a display filter, not a sync
@@ -548,7 +651,7 @@ var Room = class extends Emitter {
548
651
  */
549
652
  async history(options = {}) {
550
653
  const view = options.view === void 0 ? void 0 : typeof options.view === "string" ? options.view : `thread:${options.view.thread}`;
551
- const page = await this.rest.get(`/v1/rooms/${this.requireId("reading history")}/messages`, {
654
+ const page = await this.rest.get(`/v1/rooms/${await this.resolveId("reading history")}/messages`, {
552
655
  before: options.before,
553
656
  after: options.after,
554
657
  limit: options.limit,
@@ -558,10 +661,10 @@ var Room = class extends Emitter {
558
661
  }
559
662
  /** Adds a reaction. Idempotent, like the frame. */
560
663
  async react(messageId, emoji) {
561
- await this.chat.send("react", { roomId: this.requireId("reacting"), messageId, emoji, op: "add" });
664
+ await this.chat.send("react", { roomId: await this.resolveId("reacting"), messageId, emoji, op: "add" });
562
665
  }
563
666
  async unreact(messageId, emoji) {
564
- await this.chat.send("react", { roomId: this.requireId("reacting"), messageId, emoji, op: "remove" });
667
+ await this.chat.send("react", { roomId: await this.resolveId("reacting"), messageId, emoji, op: "remove" });
565
668
  }
566
669
  /**
567
670
  * Moves this user's read cursor.
@@ -571,7 +674,7 @@ var Room = class extends Emitter {
571
674
  * way to know what happened. It is returned rather than swallowed.
572
675
  */
573
676
  async markRead(seq, options = {}) {
574
- const data = { roomId: this.requireId("marking read"), seq };
677
+ const data = { roomId: await this.resolveId("marking read"), seq };
575
678
  if (options.threadId !== void 0) data["threadId"] = options.threadId;
576
679
  return await this.chat.send("read", data);
577
680
  }
@@ -598,15 +701,15 @@ var Room = class extends Emitter {
598
701
  }
599
702
  /** Joins a public or channel room. Idempotent. */
600
703
  async join() {
601
- await this.rest.post(`/v1/rooms/${this.requireId("joining")}/join`);
704
+ await this.rest.post(`/v1/rooms/${await this.resolveId("joining")}/join`);
602
705
  }
603
706
  /** Leaves. Idempotent from the caller's side. */
604
707
  async leave() {
605
- await this.rest.delete(`/v1/rooms/${this.requireId("leaving")}/members/me`);
708
+ await this.rest.delete(`/v1/rooms/${await this.resolveId("leaving")}/members/me`);
606
709
  }
607
710
  /** The full presence list, for rooms too large to send it in the ack. */
608
711
  async presenceList() {
609
- return this.rest.get(`/v1/rooms/${this.requireId("reading presence")}/presence`, { full: "true" });
712
+ return this.rest.get(`/v1/rooms/${await this.resolveId("reading presence")}/presence`, { full: "true" });
610
713
  }
611
714
  /**
612
715
  * Who reacted, one row per (user, emoji) pair.
@@ -618,7 +721,7 @@ var Room = class extends Emitter {
618
721
  * most, and a full page still carries a cursor when more follows.
619
722
  */
620
723
  async reactionsOf(messageId, options = {}) {
621
- return this.rest.get(`/v1/rooms/${this.requireId("reading reactions")}/messages/${messageId}/reactions`, {
724
+ return this.rest.get(`/v1/rooms/${await this.resolveId("reading reactions")}/messages/${messageId}/reactions`, {
622
725
  emoji: options.emoji,
623
726
  cursor: options.cursor,
624
727
  limit: options.limit
@@ -633,13 +736,33 @@ var Room = class extends Emitter {
633
736
  * transient 500 takes a room out of the live feed for the life of the
634
737
  * page.
635
738
  */
636
- async resume() {
739
+ async resume(restart = true) {
637
740
  if (!this.wanted) return;
741
+ if (!restart) return this.subscribe();
638
742
  this.generation++;
639
743
  this.subscribing = void 0;
640
744
  this.subscribed = false;
641
745
  await this.subscribe();
642
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
+ }
643
766
  /**
644
767
  * Sends `subscribe` and recovers from the one error it has an answer
645
768
  * for.
@@ -684,9 +807,22 @@ var Room = class extends Emitter {
684
807
  }
685
808
  return this.id;
686
809
  }
810
+ /**
811
+ * The room's id, waiting for a subscribe in flight to learn it.
812
+ *
813
+ * `roomByKey(k).subscribe()`를 await하지 않고 곧바로 `send`하는 것은 자연스러운
814
+ * 코드이고, 그때 id는 구독 ack가 와야 생긴다. 거절(`closed`)하면 소비자는
815
+ * "보내도 되는 때"를 알릴 신호를 따로 찾아야 한다 — 이미 날아가고 있는
816
+ * 구독을 기다리면 그 신호가 필요 없다. 구독이 실패하면 그 실패가 그대로
817
+ * 나간다. 구독한 적이 없으면 기다릴 것이 없으니 예전처럼 거절한다.
818
+ */
819
+ async resolveId(what) {
820
+ while (this.id === void 0 && this.subscribing !== void 0) await this.subscribing;
821
+ return this.requireId(what);
822
+ }
687
823
  /** Publishes and resolves when the server acks. */
688
824
  async send(input, options = {}) {
689
- const roomId = this.requireId("sending");
825
+ const roomId = await this.resolveId("sending");
690
826
  const body = {};
691
827
  if (input.text !== void 0) body["text"] = input.text;
692
828
  if (input.entities !== void 0) body["entities"] = input.entities;
@@ -708,7 +844,7 @@ var Room = class extends Emitter {
708
844
  if (data.seq > this.lastSeq) this.lastSeq = data.seq;
709
845
  this.timeline.add(data).catch((err) => {
710
846
  this.subscribed = false;
711
- this.emit("error", err instanceof Error ? err : new Error(String(err)));
847
+ this.failed(err);
712
848
  });
713
849
  break;
714
850
  case "message.updated":
@@ -777,8 +913,11 @@ var ChatClient = class extends Emitter {
777
913
  heartbeat;
778
914
  retryTimer;
779
915
  attempt = 0;
780
- /** Set by close(), and the only thing that stops the reconnect loop. */
781
- 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;
782
921
  nextFrameId = 0;
783
922
  opening;
784
923
  rest;
@@ -818,6 +957,7 @@ var ChatClient = class extends Emitter {
818
957
  url: options.restURL ?? restURLFrom(options.url),
819
958
  key: options.key,
820
959
  token: async () => this.lastToken,
960
+ refreshToken: () => this.refreshToken(),
821
961
  fetch: options.fetch ?? globalThis.fetch.bind(globalThis)
822
962
  });
823
963
  this.setupLifecycle();
@@ -855,7 +995,7 @@ var ChatClient = class extends Emitter {
855
995
  * gap-filled via REST.
856
996
  */
857
997
  async resume() {
858
- if (this.closedByCaller) return;
998
+ if (this.stopped) return;
859
999
  if (this.state !== "open" || this.socket === void 0) {
860
1000
  this.clearTimers();
861
1001
  return this.connect();
@@ -867,9 +1007,7 @@ var ChatClient = class extends Emitter {
867
1007
  return this.connect();
868
1008
  }
869
1009
  for (const room of this.handles.values()) {
870
- void room.resume().catch((err) => {
871
- room.emit("error", err instanceof Error ? err : new Error(String(err)));
872
- });
1010
+ void room.resume().catch((err) => room.failed(err));
873
1011
  }
874
1012
  }
875
1013
  /** The account-level routes: the rooms this user is in, and unread. */
@@ -961,7 +1099,7 @@ var ChatClient = class extends Emitter {
961
1099
  }
962
1100
  /** Opens the connection and resolves when `hello` arrives. */
963
1101
  async connect() {
964
- this.closedByCaller = false;
1102
+ this.stopped = false;
965
1103
  if (this.opening !== void 0) return this.opening;
966
1104
  if (this.state === "open" && this.socket !== void 0) return;
967
1105
  this.clearTimers();
@@ -974,11 +1112,12 @@ var ChatClient = class extends Emitter {
974
1112
  * Closes for good.
975
1113
  *
976
1114
  * The distinction from a dropped socket is the whole point of the state
977
- * machine: this is the only path that reaches `closed`, and a consumer
978
- * 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`.
979
1118
  */
980
1119
  async close() {
981
- this.closedByCaller = true;
1120
+ this.stopped = true;
982
1121
  this.teardowns.forEach((t) => {
983
1122
  try {
984
1123
  t();
@@ -995,8 +1134,54 @@ var ChatClient = class extends Emitter {
995
1134
  } catch {
996
1135
  }
997
1136
  }
1137
+ this.settleOpenWaiters(new ChatError("closed", "the connection is closed"));
998
1138
  this.setState("closed");
999
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"));
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
+ }
1184
+ }
1000
1185
  /** Sends a frame and resolves with its ack, or rejects with its error. */
1001
1186
  send(type, data, timeoutMs = 15e3) {
1002
1187
  const socket = this.socket;
@@ -1038,8 +1223,18 @@ var ChatClient = class extends Emitter {
1038
1223
  }
1039
1224
  async openOnce() {
1040
1225
  this.setState(this.attempt === 0 ? "connecting" : "reconnecting");
1041
- const authData = await this.authData();
1042
- if (this.closedByCaller) {
1226
+ let authData;
1227
+ try {
1228
+ authData = await this.authData();
1229
+ } catch (err) {
1230
+ if (isChatError(err)) {
1231
+ this.stop(err);
1232
+ throw err;
1233
+ }
1234
+ this.scheduleReconnect();
1235
+ throw err;
1236
+ }
1237
+ if (this.stopped) {
1043
1238
  throw new ChatError("closed", "the client was closed while connecting");
1044
1239
  }
1045
1240
  const socket = new this.makeSocket(`${this.options.url}/v1/ws?v=1`);
@@ -1060,9 +1255,14 @@ var ChatClient = class extends Emitter {
1060
1255
  return;
1061
1256
  }
1062
1257
  if (frame.type === "error" && !settled) {
1063
- this.closedByCaller = true;
1064
- this.setState("closed");
1065
- 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);
1066
1266
  return;
1067
1267
  }
1068
1268
  this.onFrame(frame);
@@ -1095,12 +1295,11 @@ var ChatClient = class extends Emitter {
1095
1295
  const reconnected = this.everOpened;
1096
1296
  this.everOpened = true;
1097
1297
  this.setState("open");
1098
- if (reconnected) {
1099
- for (const room of this.handles.values()) {
1100
- void room.resume().catch((err) => {
1101
- room.emit("error", err instanceof Error ? err : new Error(String(err)));
1102
- });
1103
- }
1298
+ this.settleOpenWaiters();
1299
+ for (const room of this.handles.values()) {
1300
+ void room.resume(reconnected).catch((err) => {
1301
+ room.failed(err);
1302
+ });
1104
1303
  }
1105
1304
  }
1106
1305
  onFrame(frame) {
@@ -1170,7 +1369,7 @@ var ChatClient = class extends Emitter {
1170
1369
  }, Math.max(1e3, intervalMs));
1171
1370
  }
1172
1371
  scheduleReconnect() {
1173
- if (this.closedByCaller) return;
1372
+ if (this.stopped) return;
1174
1373
  this.setState("reconnecting");
1175
1374
  const delay = this.nextDelay();
1176
1375
  this.retryTimer = setTimeout(() => {
@@ -1207,6 +1406,31 @@ var ChatClient = class extends Emitter {
1207
1406
  data["token"] = token;
1208
1407
  return data;
1209
1408
  }
1409
+ /**
1410
+ * REST가 401을 받았을 때 토큰을 새로 받는다.
1411
+ *
1412
+ * 소켓은 접속할 때 한 번 인증하고 그 뒤로는 토큰을 다시 보지 않지만, REST는
1413
+ * 요청마다 본다. 그래서 한 시간짜리 토큰이면 한 시간 뒤 라이브는 멀쩡한데
1414
+ * 히스토리·구멍 메우기만 401이 된다. 동시에 실패한 요청들이 `token()`을
1415
+ * 각자 부르지 않도록 진행 중인 것 하나를 나눠 쓴다.
1416
+ */
1417
+ refreshToken() {
1418
+ if (this.options.externalToken !== void 0) return Promise.resolve(void 0);
1419
+ this.refreshing ??= (async () => {
1420
+ try {
1421
+ const token = await this.options.token();
1422
+ this.lastToken = token;
1423
+ return token;
1424
+ } catch (err) {
1425
+ if (isChatError(err)) this.stop(err);
1426
+ throw err;
1427
+ } finally {
1428
+ this.refreshing = void 0;
1429
+ }
1430
+ })();
1431
+ return this.refreshing;
1432
+ }
1433
+ refreshing;
1210
1434
  failPending(err) {
1211
1435
  for (const [, waiter] of this.pending) waiter.reject(err);
1212
1436
  this.pending.clear();
@@ -1221,6 +1445,9 @@ var ChatClient = class extends Emitter {
1221
1445
  this.retryTimer = void 0;
1222
1446
  }
1223
1447
  };
1448
+ function isChatError(err) {
1449
+ return err instanceof ChatError || err instanceof Error && err.name === "ChatError";
1450
+ }
1224
1451
  function createChatClient(options) {
1225
1452
  return new ChatClient(options);
1226
1453
  }