@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.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  ChatError,
3
3
  errorFrom
4
- } from "./chunk-T3QOUFOM.js";
4
+ } from "./chunk-5QQRM7BH.js";
5
5
 
6
6
  // src/emitter.ts
7
7
  var Emitter = class {
@@ -57,6 +57,16 @@ var Rest = class {
57
57
  }
58
58
  async call(method, path, body) {
59
59
  const token = await this.options.token();
60
+ try {
61
+ return await this.callWith(token, method, path, body);
62
+ } catch (err) {
63
+ if (!(err instanceof ChatError) || err.status !== 401 || this.options.refreshToken === void 0) throw err;
64
+ const fresh = await this.options.refreshToken().catch(() => void 0);
65
+ if (fresh === void 0 || fresh === token) throw err;
66
+ return this.callWith(fresh, method, path, body);
67
+ }
68
+ }
69
+ async callWith(token, method, path, body) {
60
70
  if (token === void 0) {
61
71
  throw new ChatError(
62
72
  "unauthorized",
@@ -78,6 +88,7 @@ var Rest = class {
78
88
  const parsed = text === "" ? void 0 : JSON.parse(text);
79
89
  if (!res.ok) {
80
90
  const err = errorFrom(parsed, `${method} ${path} failed with ${res.status}`);
91
+ err.status = res.status;
81
92
  throw err;
82
93
  }
83
94
  return parsed;
@@ -124,10 +135,24 @@ var Timeline = class {
124
135
  * rows into the new list.
125
136
  */
126
137
  epoch = 0;
138
+ /**
139
+ * `items` has been handed out -- by the getter or by `onChange`.
140
+ *
141
+ * 한 번 내준 배열은 다시 건드리지 않는다. 같은 배열을 제자리에서 고쳐
142
+ * 다시 내주면 Svelte `$state.raw`, React `useState`, Vue `shallowRef`처럼
143
+ * 참조가 바뀌어야 다시 그리는 쪽은 변화를 보지 못하고 화면이 멈춘다. 그래서
144
+ * 내준 뒤의 첫 변경은 복사본에서 한다(copy-on-write). 행 객체도 같은 규칙이라
145
+ * 바뀐 행만 새 객체다.
146
+ */
147
+ shared = false;
148
+ /** The `loadOlder` in flight, so a scroll handler firing twice asks once. */
149
+ older;
127
150
  constructor(options) {
128
151
  this.options = options;
129
152
  }
153
+ /** A new array whenever the list changes; never mutated once returned. */
130
154
  get messages() {
155
+ this.shared = true;
131
156
  return this.items;
132
157
  }
133
158
  /** The highest seq this timeline holds, hole or no hole. */
@@ -174,6 +199,7 @@ var Timeline = class {
174
199
  this.fills.clear();
175
200
  this.pendingDeletes.clear();
176
201
  this.items = [];
202
+ this.shared = false;
177
203
  this.changed();
178
204
  }
179
205
  /**
@@ -200,6 +226,7 @@ var Timeline = class {
200
226
  const page = [...messages].sort((a, b) => a.seq - b.seq);
201
227
  const top = page.at(-1)?.seq ?? 0;
202
228
  this.items = [...page, ...this.items.filter((m) => m.seq > top)];
229
+ this.shared = false;
203
230
  for (const m of this.items) {
204
231
  if (this.pendingDeletes.has(m.id)) this.applyDelete(m.id);
205
232
  }
@@ -234,6 +261,7 @@ var Timeline = class {
234
261
  if (at === -1) return;
235
262
  const current = this.items[at];
236
263
  if (current.deletedAt !== void 0) return;
264
+ this.own();
237
265
  this.items[at] = { ...current, ...message };
238
266
  this.changed();
239
267
  }
@@ -260,6 +288,7 @@ var Timeline = class {
260
288
  if (count <= 0) next = existing.filter((r) => r.emoji !== emoji);
261
289
  else if (slot === -1) next = [...existing, { emoji, count }];
262
290
  else next = existing.map((r, i) => i === slot ? { emoji, count } : r);
291
+ this.own();
263
292
  this.items[at] = { ...current, reactions: next };
264
293
  this.changed();
265
294
  }
@@ -268,6 +297,7 @@ var Timeline = class {
268
297
  const at = this.items.findIndex((m) => m.id === rootId);
269
298
  if (at === -1) return;
270
299
  const current = this.items[at];
300
+ this.own();
271
301
  this.items[at] = { ...current, thread: lastSeq === void 0 ? { count } : { count, lastSeq } };
272
302
  this.changed();
273
303
  }
@@ -275,6 +305,36 @@ var Timeline = class {
275
305
  remove(id) {
276
306
  if (!this.applyDelete(id)) this.pendingDeletes.add(id);
277
307
  }
308
+ /**
309
+ * Prepends the page just below the oldest message held.
310
+ *
311
+ * 스크롤을 올려 과거를 읽는 소비자가 `history()` 결과를 라이브 목록과 따로
312
+ * 들고 합치고, 중복을 걸러 내고, reset과 재접속을 따로 처리해야 했다. 같은
313
+ * 목록에 끼워 넣으면 그 일이 전부 이미 있는 규칙(seq 자리에 넣기, 같은 seq는
314
+ * 한 행, reset이면 epoch로 버리기)으로 끝난다.
315
+ *
316
+ * `hasMore`는 더 올라갈 것이 있는지다. 서버의 seq는 1부터 구멍 없이 붙으므로
317
+ * 맨 위가 1이거나 페이지가 덜 찼으면 끝이다(보존 기간이 지운 앞부분도 덜 찬
318
+ * 페이지로 드러난다). 한 번에 하나만 돈다 — 스크롤 핸들러가 두 번 불러도
319
+ * 요청은 하나다(진행 중인 호출과 같은 결과를 받으므로 뒤 호출의 `limit`은 쓰이지 않는다).
320
+ */
321
+ loadOlder(limit = this.options.pageSize ?? MaxPageSize) {
322
+ this.older ??= this.readOlder(Math.min(limit, MaxPageSize)).finally(() => {
323
+ this.older = void 0;
324
+ });
325
+ return this.older;
326
+ }
327
+ async readOlder(limit) {
328
+ const first = this.items[0];
329
+ if (first === void 0 || first.seq <= 1) return { messages: [], hasMore: false };
330
+ const epoch = this.epoch;
331
+ const page = await this.options.fetchRange({ after: 0, before: first.seq, limit });
332
+ if (this.epoch !== epoch) return { messages: [], hasMore: true };
333
+ for (const m of page) this.insert(m, false);
334
+ if (page.length > 0) this.changed();
335
+ const top = page[0];
336
+ return { messages: page, hasMore: page.length >= limit && top !== void 0 && top.seq > 1 };
337
+ }
278
338
  /**
279
339
  * Fetches everything between what we have and `upTo`.
280
340
  *
@@ -319,10 +379,11 @@ var Timeline = class {
319
379
  return run;
320
380
  }
321
381
  /** Inserts at the seq position, replacing an existing row with that seq. */
322
- insert(message) {
382
+ insert(message, notify = true) {
323
383
  const pending = this.pendingDeletes.has(message.id);
324
384
  if (pending) this.pendingDeletes.delete(message.id);
325
385
  const withDelete = pending ? emptied(message) : message;
386
+ this.own();
326
387
  const at = this.items.findIndex((m) => m.seq >= withDelete.seq);
327
388
  if (at === -1) {
328
389
  this.items.push(withDelete);
@@ -332,17 +393,25 @@ var Timeline = class {
332
393
  } else {
333
394
  this.items.splice(at, 0, withDelete);
334
395
  }
335
- this.changed();
396
+ if (notify) this.changed();
336
397
  }
337
398
  applyDelete(id) {
338
399
  const at = this.items.findIndex((m) => m.id === id);
339
400
  if (at === -1) return false;
340
401
  this.pendingDeletes.delete(id);
402
+ this.own();
341
403
  this.items[at] = emptied(this.items[at]);
342
404
  this.changed();
343
405
  return true;
344
406
  }
407
+ /** Makes `items` safe to mutate: a copy, if the current one was handed out. */
408
+ own() {
409
+ if (!this.shared) return;
410
+ this.items = this.items.slice();
411
+ this.shared = false;
412
+ }
345
413
  changed() {
414
+ this.shared = true;
346
415
  this.options.onChange?.(this.items);
347
416
  }
348
417
  };
@@ -355,6 +424,8 @@ function createTimeline(options) {
355
424
 
356
425
  // src/room.ts
357
426
  var HistoryPageSize = 100;
427
+ var MaxRepairs = 5;
428
+ var RepairBaseMs = 1e3;
358
429
  var Room = class extends Emitter {
359
430
  /** Resolved on subscribe when the room was addressed by key. */
360
431
  id;
@@ -392,6 +463,9 @@ var Room = class extends Emitter {
392
463
  */
393
464
  wanted = false;
394
465
  subscribing;
466
+ /** Self-repair: the armed retry, and how many have run since the last success. */
467
+ repairTimer;
468
+ repairs = 0;
395
469
  constructor(chat, rest, address) {
396
470
  super();
397
471
  this.chat = chat;
@@ -407,10 +481,27 @@ var Room = class extends Emitter {
407
481
  onChange: (messages) => this.emit("messages", messages)
408
482
  });
409
483
  }
410
- /** Everything this client knows about the room, in seq order. */
484
+ /** Everything this client knows about the room, in seq order. A new array whenever it changes. */
411
485
  get messages() {
412
486
  return this.timeline.messages;
413
487
  }
488
+ /**
489
+ * Reads the page just before the oldest message held and **prepends it to
490
+ * `messages`**.
491
+ *
492
+ * The scroll-back call. Unlike `history()`, the rows join the room's own
493
+ * list, so ordering, de-duplication, deletes, reactions and reconnects
494
+ * apply to them like any other row, and the `messages` event fires once.
495
+ * A `reset` (or `reload()`) drops them with everything else; a read that
496
+ * lands after one is discarded rather than stitched onto the new list.
497
+ *
498
+ * `hasMore` is false once the top of the room -- or of its retention --
499
+ * is reached. Concurrent calls share one request.
500
+ */
501
+ async loadOlder(options = {}) {
502
+ await this.resolveId("loading older messages");
503
+ return this.timeline.loadOlder(options.limit);
504
+ }
414
505
  /**
415
506
  * Reloads recent history, discarding what is held.
416
507
  *
@@ -441,7 +532,12 @@ var Room = class extends Emitter {
441
532
  this.wanted = true;
442
533
  if (this.subscribing !== void 0) return this.subscribing;
443
534
  if (this.subscribed) return;
444
- 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(() => {
445
541
  if (this.subscribing === attempt) this.subscribing = void 0;
446
542
  });
447
543
  this.subscribing = attempt;
@@ -450,6 +546,10 @@ var Room = class extends Emitter {
450
546
  async doSubscribe() {
451
547
  const gen = this.generation;
452
548
  const current = () => gen === this.generation;
549
+ if (this.chat.state !== "open") {
550
+ await this.chat.whenOpen();
551
+ if (!current()) return;
552
+ }
453
553
  const data = this.id !== void 0 ? { roomId: this.id } : { key: this.key };
454
554
  const since = this.timeline.contiguousSeq;
455
555
  if (since > 0) data["since"] = since;
@@ -484,7 +584,8 @@ var Room = class extends Emitter {
484
584
  *
485
585
  * The list this room keeps is the live one; this is for a consumer
486
586
  * scrolling back, which owns its own window and does not want the
487
- * bottom of the room rearranged under it.
587
+ * bottom of the room rearranged under it. **Scrolling the room's own
588
+ * list back is `loadOlder()`**, which keeps one list instead of two.
488
589
  *
489
590
  * `view` defaults to the server's, which is every message including
490
591
  * thread replies. **`view: 'main'` is a display filter, not a sync
@@ -493,7 +594,7 @@ var Room = class extends Emitter {
493
594
  */
494
595
  async history(options = {}) {
495
596
  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`, {
597
+ const page = await this.rest.get(`/v1/rooms/${await this.resolveId("reading history")}/messages`, {
497
598
  before: options.before,
498
599
  after: options.after,
499
600
  limit: options.limit,
@@ -503,10 +604,10 @@ var Room = class extends Emitter {
503
604
  }
504
605
  /** Adds a reaction. Idempotent, like the frame. */
505
606
  async react(messageId, emoji) {
506
- await this.chat.send("react", { roomId: this.requireId("reacting"), messageId, emoji, op: "add" });
607
+ await this.chat.send("react", { roomId: await this.resolveId("reacting"), messageId, emoji, op: "add" });
507
608
  }
508
609
  async unreact(messageId, emoji) {
509
- await this.chat.send("react", { roomId: this.requireId("reacting"), messageId, emoji, op: "remove" });
610
+ await this.chat.send("react", { roomId: await this.resolveId("reacting"), messageId, emoji, op: "remove" });
510
611
  }
511
612
  /**
512
613
  * Moves this user's read cursor.
@@ -516,7 +617,7 @@ var Room = class extends Emitter {
516
617
  * way to know what happened. It is returned rather than swallowed.
517
618
  */
518
619
  async markRead(seq, options = {}) {
519
- const data = { roomId: this.requireId("marking read"), seq };
620
+ const data = { roomId: await this.resolveId("marking read"), seq };
520
621
  if (options.threadId !== void 0) data["threadId"] = options.threadId;
521
622
  return await this.chat.send("read", data);
522
623
  }
@@ -543,15 +644,15 @@ var Room = class extends Emitter {
543
644
  }
544
645
  /** Joins a public or channel room. Idempotent. */
545
646
  async join() {
546
- await this.rest.post(`/v1/rooms/${this.requireId("joining")}/join`);
647
+ await this.rest.post(`/v1/rooms/${await this.resolveId("joining")}/join`);
547
648
  }
548
649
  /** Leaves. Idempotent from the caller's side. */
549
650
  async leave() {
550
- await this.rest.delete(`/v1/rooms/${this.requireId("leaving")}/members/me`);
651
+ await this.rest.delete(`/v1/rooms/${await this.resolveId("leaving")}/members/me`);
551
652
  }
552
653
  /** The full presence list, for rooms too large to send it in the ack. */
553
654
  async presenceList() {
554
- return this.rest.get(`/v1/rooms/${this.requireId("reading presence")}/presence`, { full: "true" });
655
+ return this.rest.get(`/v1/rooms/${await this.resolveId("reading presence")}/presence`, { full: "true" });
555
656
  }
556
657
  /**
557
658
  * Who reacted, one row per (user, emoji) pair.
@@ -563,7 +664,7 @@ var Room = class extends Emitter {
563
664
  * most, and a full page still carries a cursor when more follows.
564
665
  */
565
666
  async reactionsOf(messageId, options = {}) {
566
- return this.rest.get(`/v1/rooms/${this.requireId("reading reactions")}/messages/${messageId}/reactions`, {
667
+ return this.rest.get(`/v1/rooms/${await this.resolveId("reading reactions")}/messages/${messageId}/reactions`, {
567
668
  emoji: options.emoji,
568
669
  cursor: options.cursor,
569
670
  limit: options.limit
@@ -578,13 +679,33 @@ var Room = class extends Emitter {
578
679
  * transient 500 takes a room out of the live feed for the life of the
579
680
  * page.
580
681
  */
581
- async resume() {
682
+ async resume(restart = true) {
582
683
  if (!this.wanted) return;
684
+ if (!restart) return this.subscribe();
583
685
  this.generation++;
584
686
  this.subscribing = void 0;
585
687
  this.subscribed = false;
586
688
  await this.subscribe();
587
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
+ }
588
709
  /**
589
710
  * Sends `subscribe` and recovers from the one error it has an answer
590
711
  * for.
@@ -629,9 +750,22 @@ var Room = class extends Emitter {
629
750
  }
630
751
  return this.id;
631
752
  }
753
+ /**
754
+ * The room's id, waiting for a subscribe in flight to learn it.
755
+ *
756
+ * `roomByKey(k).subscribe()`를 await하지 않고 곧바로 `send`하는 것은 자연스러운
757
+ * 코드이고, 그때 id는 구독 ack가 와야 생긴다. 거절(`closed`)하면 소비자는
758
+ * "보내도 되는 때"를 알릴 신호를 따로 찾아야 한다 — 이미 날아가고 있는
759
+ * 구독을 기다리면 그 신호가 필요 없다. 구독이 실패하면 그 실패가 그대로
760
+ * 나간다. 구독한 적이 없으면 기다릴 것이 없으니 예전처럼 거절한다.
761
+ */
762
+ async resolveId(what) {
763
+ while (this.id === void 0 && this.subscribing !== void 0) await this.subscribing;
764
+ return this.requireId(what);
765
+ }
632
766
  /** Publishes and resolves when the server acks. */
633
767
  async send(input, options = {}) {
634
- const roomId = this.requireId("sending");
768
+ const roomId = await this.resolveId("sending");
635
769
  const body = {};
636
770
  if (input.text !== void 0) body["text"] = input.text;
637
771
  if (input.entities !== void 0) body["entities"] = input.entities;
@@ -653,7 +787,7 @@ var Room = class extends Emitter {
653
787
  if (data.seq > this.lastSeq) this.lastSeq = data.seq;
654
788
  this.timeline.add(data).catch((err) => {
655
789
  this.subscribed = false;
656
- this.emit("error", err instanceof Error ? err : new Error(String(err)));
790
+ this.failed(err);
657
791
  });
658
792
  break;
659
793
  case "message.updated":
@@ -722,8 +856,11 @@ var ChatClient = class extends Emitter {
722
856
  heartbeat;
723
857
  retryTimer;
724
858
  attempt = 0;
725
- /** Set by close(), and the only thing that stops the reconnect loop. */
726
- 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;
727
864
  nextFrameId = 0;
728
865
  opening;
729
866
  rest;
@@ -763,6 +900,7 @@ var ChatClient = class extends Emitter {
763
900
  url: options.restURL ?? restURLFrom(options.url),
764
901
  key: options.key,
765
902
  token: async () => this.lastToken,
903
+ refreshToken: () => this.refreshToken(),
766
904
  fetch: options.fetch ?? globalThis.fetch.bind(globalThis)
767
905
  });
768
906
  this.setupLifecycle();
@@ -800,7 +938,7 @@ var ChatClient = class extends Emitter {
800
938
  * gap-filled via REST.
801
939
  */
802
940
  async resume() {
803
- if (this.closedByCaller) return;
941
+ if (this.stopped) return;
804
942
  if (this.state !== "open" || this.socket === void 0) {
805
943
  this.clearTimers();
806
944
  return this.connect();
@@ -812,9 +950,7 @@ var ChatClient = class extends Emitter {
812
950
  return this.connect();
813
951
  }
814
952
  for (const room of this.handles.values()) {
815
- void room.resume().catch((err) => {
816
- room.emit("error", err instanceof Error ? err : new Error(String(err)));
817
- });
953
+ void room.resume().catch((err) => room.failed(err));
818
954
  }
819
955
  }
820
956
  /** The account-level routes: the rooms this user is in, and unread. */
@@ -906,7 +1042,7 @@ var ChatClient = class extends Emitter {
906
1042
  }
907
1043
  /** Opens the connection and resolves when `hello` arrives. */
908
1044
  async connect() {
909
- this.closedByCaller = false;
1045
+ this.stopped = false;
910
1046
  if (this.opening !== void 0) return this.opening;
911
1047
  if (this.state === "open" && this.socket !== void 0) return;
912
1048
  this.clearTimers();
@@ -919,11 +1055,12 @@ var ChatClient = class extends Emitter {
919
1055
  * Closes for good.
920
1056
  *
921
1057
  * The distinction from a dropped socket is the whole point of the state
922
- * machine: this is the only path that reaches `closed`, and a consumer
923
- * 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`.
924
1061
  */
925
1062
  async close() {
926
- this.closedByCaller = true;
1063
+ this.stopped = true;
927
1064
  this.teardowns.forEach((t) => {
928
1065
  try {
929
1066
  t();
@@ -940,8 +1077,54 @@ var ChatClient = class extends Emitter {
940
1077
  } catch {
941
1078
  }
942
1079
  }
1080
+ this.settleOpenWaiters(new ChatError("closed", "the connection is closed"));
943
1081
  this.setState("closed");
944
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"));
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
+ }
1127
+ }
945
1128
  /** Sends a frame and resolves with its ack, or rejects with its error. */
946
1129
  send(type, data, timeoutMs = 15e3) {
947
1130
  const socket = this.socket;
@@ -983,8 +1166,18 @@ var ChatClient = class extends Emitter {
983
1166
  }
984
1167
  async openOnce() {
985
1168
  this.setState(this.attempt === 0 ? "connecting" : "reconnecting");
986
- const authData = await this.authData();
987
- if (this.closedByCaller) {
1169
+ let authData;
1170
+ try {
1171
+ authData = await this.authData();
1172
+ } catch (err) {
1173
+ if (isChatError(err)) {
1174
+ this.stop(err);
1175
+ throw err;
1176
+ }
1177
+ this.scheduleReconnect();
1178
+ throw err;
1179
+ }
1180
+ if (this.stopped) {
988
1181
  throw new ChatError("closed", "the client was closed while connecting");
989
1182
  }
990
1183
  const socket = new this.makeSocket(`${this.options.url}/v1/ws?v=1`);
@@ -1005,9 +1198,14 @@ var ChatClient = class extends Emitter {
1005
1198
  return;
1006
1199
  }
1007
1200
  if (frame.type === "error" && !settled) {
1008
- this.closedByCaller = true;
1009
- this.setState("closed");
1010
- 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);
1011
1209
  return;
1012
1210
  }
1013
1211
  this.onFrame(frame);
@@ -1040,12 +1238,11 @@ var ChatClient = class extends Emitter {
1040
1238
  const reconnected = this.everOpened;
1041
1239
  this.everOpened = true;
1042
1240
  this.setState("open");
1043
- if (reconnected) {
1044
- for (const room of this.handles.values()) {
1045
- void room.resume().catch((err) => {
1046
- room.emit("error", err instanceof Error ? err : new Error(String(err)));
1047
- });
1048
- }
1241
+ this.settleOpenWaiters();
1242
+ for (const room of this.handles.values()) {
1243
+ void room.resume(reconnected).catch((err) => {
1244
+ room.failed(err);
1245
+ });
1049
1246
  }
1050
1247
  }
1051
1248
  onFrame(frame) {
@@ -1115,7 +1312,7 @@ var ChatClient = class extends Emitter {
1115
1312
  }, Math.max(1e3, intervalMs));
1116
1313
  }
1117
1314
  scheduleReconnect() {
1118
- if (this.closedByCaller) return;
1315
+ if (this.stopped) return;
1119
1316
  this.setState("reconnecting");
1120
1317
  const delay = this.nextDelay();
1121
1318
  this.retryTimer = setTimeout(() => {
@@ -1152,6 +1349,31 @@ var ChatClient = class extends Emitter {
1152
1349
  data["token"] = token;
1153
1350
  return data;
1154
1351
  }
1352
+ /**
1353
+ * REST가 401을 받았을 때 토큰을 새로 받는다.
1354
+ *
1355
+ * 소켓은 접속할 때 한 번 인증하고 그 뒤로는 토큰을 다시 보지 않지만, REST는
1356
+ * 요청마다 본다. 그래서 한 시간짜리 토큰이면 한 시간 뒤 라이브는 멀쩡한데
1357
+ * 히스토리·구멍 메우기만 401이 된다. 동시에 실패한 요청들이 `token()`을
1358
+ * 각자 부르지 않도록 진행 중인 것 하나를 나눠 쓴다.
1359
+ */
1360
+ refreshToken() {
1361
+ if (this.options.externalToken !== void 0) return Promise.resolve(void 0);
1362
+ this.refreshing ??= (async () => {
1363
+ try {
1364
+ const token = await this.options.token();
1365
+ this.lastToken = token;
1366
+ return token;
1367
+ } catch (err) {
1368
+ if (isChatError(err)) this.stop(err);
1369
+ throw err;
1370
+ } finally {
1371
+ this.refreshing = void 0;
1372
+ }
1373
+ })();
1374
+ return this.refreshing;
1375
+ }
1376
+ refreshing;
1155
1377
  failPending(err) {
1156
1378
  for (const [, waiter] of this.pending) waiter.reject(err);
1157
1379
  this.pending.clear();
@@ -1166,6 +1388,9 @@ var ChatClient = class extends Emitter {
1166
1388
  this.retryTimer = void 0;
1167
1389
  }
1168
1390
  };
1391
+ function isChatError(err) {
1392
+ return err instanceof ChatError || err instanceof Error && err.name === "ChatError";
1393
+ }
1169
1394
  function createChatClient(options) {
1170
1395
  return new ChatClient(options);
1171
1396
  }