@noverachat/sdk-web 0.5.0 → 0.5.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
@@ -403,14 +403,26 @@ var ReconnectingWs = class _ReconnectingWs {
403
403
  // heartbeats and a stale typing event is worse than no event.
404
404
  outboundQueue = [];
405
405
  static OUTBOUND_QUEUE_CAP = 200;
406
+ /**
407
+ * 연결 시도의 세대 번호 — `connect()`/`close()` 마다 올린다.
408
+ *
409
+ * `openOnce()` 는 `urlBuilder()`(토큰 발급 왕복)를 await 하는 동안 아직
410
+ * 소켓이 없어서, 그 틈에 들어온 `close()` 는 닫을 대상을 찾지 못한다.
411
+ * 이어서 새 `connect()` 가 `explicitClose` 를 되돌리면, 뒤늦게 깨어난
412
+ * 앞선 시도가 **아무도 닫지 않는 소켓**을 하나 더 만든다(React StrictMode 의
413
+ * mount → cleanup → mount 에서 재현). 불리언 하나로는 "이미 취소됐다"를
414
+ * 알릴 수 없으므로 세대 번호로 판별한다.
415
+ */
416
+ gen = 0;
406
417
  get currentState() {
407
418
  return this.state;
408
419
  }
409
420
  async connect() {
410
421
  this.explicitClose = false;
411
- await this.openOnce();
422
+ await this.openOnce(++this.gen);
412
423
  }
413
424
  close() {
425
+ this.gen++;
414
426
  this.explicitClose = true;
415
427
  this.setState("closed");
416
428
  if (this.pingTimer) clearInterval(this.pingTimer);
@@ -443,7 +455,8 @@ var ReconnectingWs = class _ReconnectingWs {
443
455
  );
444
456
  return true;
445
457
  }
446
- async openOnce() {
458
+ async openOnce(gen) {
459
+ if (gen !== this.gen) return;
447
460
  this.setState(this.attempt === 0 ? "connecting" : "reconnecting");
448
461
  const WSImpl = this.opts.WebSocketImpl ?? globalThis.WebSocket;
449
462
  if (!WSImpl) throw new Error("No WebSocket implementation available");
@@ -452,7 +465,11 @@ var ReconnectingWs = class _ReconnectingWs {
452
465
  url = await this.opts.urlBuilder();
453
466
  } catch (err) {
454
467
  this.opts.logger?.("error", "url_builder_failed", err);
455
- this.scheduleReconnect();
468
+ if (gen === this.gen) this.scheduleReconnect();
469
+ return;
470
+ }
471
+ if (gen !== this.gen) {
472
+ this.opts.logger?.("info", "ws_open_aborted_stale", { gen, current: this.gen });
456
473
  return;
457
474
  }
458
475
  const ws = new WSImpl(url);
@@ -517,8 +534,9 @@ var ReconnectingWs = class _ReconnectingWs {
517
534
  const delay = exp + jitter;
518
535
  this.opts.logger?.("info", "ws_reconnect_scheduled", { attempt: this.attempt, delay });
519
536
  this.setState("reconnecting");
537
+ const gen = this.gen;
520
538
  setTimeout(() => {
521
- void this.openOnce();
539
+ void this.openOnce(gen);
522
540
  }, delay);
523
541
  }
524
542
  startPings() {
@@ -826,7 +844,15 @@ var MessageCollection = class {
826
844
  this._messages.splice(0, this.hydratedCount);
827
845
  this.hydratedCount = 0;
828
846
  }
829
- this._messages.unshift(...page.items.map(chatMessageFromHistory));
847
+ const indexById = /* @__PURE__ */ new Map();
848
+ this._messages.forEach((m, i) => indexById.set(m.id, i));
849
+ const fresh = [];
850
+ for (const item of page.items.map(chatMessageFromHistory)) {
851
+ const at = indexById.get(item.id);
852
+ if (at === void 0) fresh.push(item);
853
+ else this._messages[at] = item;
854
+ }
855
+ this._messages.unshift(...fresh);
830
856
  this.oldestCursor = page.nextCursor;
831
857
  this._hasMore = page.hasMore;
832
858
  this.emit("replaced");
@@ -997,8 +1023,26 @@ var MessageCollection = class {
997
1023
  this.bus.removeAll();
998
1024
  }
999
1025
  // ── event handlers ──────────────────────────────────────────────
1026
+ /**
1027
+ * 수신 프레임을 컬렉션에 넣는다.
1028
+ *
1029
+ * 두 가지를 방어한다 — backfill(`chat.backfill()`)은 재연결 시 앵커
1030
+ * 이후를 **다시** 실어오므로, 이미 들고 있는 메시지가 섞여 오고 화면보다
1031
+ * 오래된 메시지도 함께 온다:
1032
+ * - **중복**: 같은 id가 이미 있으면 무시한다.
1033
+ * - **순서**: 뒤에서부터 자리를 찾아 꽂는다. 실시간 프레임은 항상 마지막
1034
+ * 메시지보다 새로우므로 비교 1번으로 끝나고(빠른 경로), 옛 메시지가
1035
+ * 섞여 올 때만 탐색이 일어난다.
1036
+ */
1000
1037
  onIncoming(f) {
1001
- this._messages.push(chatMessageFromLive(f));
1038
+ const msg = chatMessageFromLive(f);
1039
+ let i = this._messages.length - 1;
1040
+ for (; i >= 0; i--) {
1041
+ const cur = this._messages[i];
1042
+ if (cur.id === msg.id) return;
1043
+ if (!idGreater(cur.id, msg.id)) break;
1044
+ }
1045
+ this._messages.splice(i + 1, 0, msg);
1002
1046
  this.emit("inserted");
1003
1047
  }
1004
1048
  onUpdated(f) {
@@ -2163,6 +2207,16 @@ var NoveraChat = class {
2163
2207
  readDebounceMs;
2164
2208
  logger;
2165
2209
  connectedOnce = false;
2210
+ /**
2211
+ * 방별 "실시간 파이프라인으로 본 최대 message_id" — 재연결 핸드셰이크의
2212
+ * `?since=`와 backfill 시작점.
2213
+ *
2214
+ * **의도적으로 낮게 유지한다.** 히스토리 로드(REST)는 이 값을 올리지
2215
+ * 않는다 — 앵커가 낮으면 backfill이 이미 가진 것까지 다시 주는 쪽으로
2216
+ * (안전), 높으면 못 받은 것을 영영 건너뛰는 쪽으로(치명) 실패하기
2217
+ * 때문이다. 중복은 수신 측(`MessageCollection.onIncoming`)의 id dedupe가
2218
+ * 흡수하므로, "빠뜨린 갱신"으로 보고 올리지 말 것.
2219
+ */
2166
2220
  lastMessageIdByRoom = /* @__PURE__ */ new Map();
2167
2221
  /** Most recently dispatched presence event key, `${userId}:${at}:${online}`.
2168
2222
  * Used to drop the N-1 shared-room copies of a single online/offline
package/dist/index.d.cts CHANGED
@@ -666,6 +666,17 @@ declare class ReconnectingWs {
666
666
  private explicitClose;
667
667
  private outboundQueue;
668
668
  private static readonly OUTBOUND_QUEUE_CAP;
669
+ /**
670
+ * 연결 시도의 세대 번호 — `connect()`/`close()` 마다 올린다.
671
+ *
672
+ * `openOnce()` 는 `urlBuilder()`(토큰 발급 왕복)를 await 하는 동안 아직
673
+ * 소켓이 없어서, 그 틈에 들어온 `close()` 는 닫을 대상을 찾지 못한다.
674
+ * 이어서 새 `connect()` 가 `explicitClose` 를 되돌리면, 뒤늦게 깨어난
675
+ * 앞선 시도가 **아무도 닫지 않는 소켓**을 하나 더 만든다(React StrictMode 의
676
+ * mount → cleanup → mount 에서 재현). 불리언 하나로는 "이미 취소됐다"를
677
+ * 알릴 수 없으므로 세대 번호로 판별한다.
678
+ */
679
+ private gen;
669
680
  constructor(opts: ReconnectingWsOptions);
670
681
  get currentState(): WsState;
671
682
  connect(): Promise<void>;
@@ -912,6 +923,17 @@ declare class MessageCollection {
912
923
  /** Unsubscribe from the room and flush the pending read watermark. The
913
924
  * collection must not be used afterwards. */
914
925
  dispose(): void;
926
+ /**
927
+ * 수신 프레임을 컬렉션에 넣는다.
928
+ *
929
+ * 두 가지를 방어한다 — backfill(`chat.backfill()`)은 재연결 시 앵커
930
+ * 이후를 **다시** 실어오므로, 이미 들고 있는 메시지가 섞여 오고 화면보다
931
+ * 오래된 메시지도 함께 온다:
932
+ * - **중복**: 같은 id가 이미 있으면 무시한다.
933
+ * - **순서**: 뒤에서부터 자리를 찾아 꽂는다. 실시간 프레임은 항상 마지막
934
+ * 메시지보다 새로우므로 비교 1번으로 끝나고(빠른 경로), 옛 메시지가
935
+ * 섞여 올 때만 탐색이 일어난다.
936
+ */
915
937
  private onIncoming;
916
938
  private onUpdated;
917
939
  private onDeleted;
@@ -1386,6 +1408,16 @@ declare class NoveraChat {
1386
1408
  private readDebounceMs;
1387
1409
  private logger;
1388
1410
  private connectedOnce;
1411
+ /**
1412
+ * 방별 "실시간 파이프라인으로 본 최대 message_id" — 재연결 핸드셰이크의
1413
+ * `?since=`와 backfill 시작점.
1414
+ *
1415
+ * **의도적으로 낮게 유지한다.** 히스토리 로드(REST)는 이 값을 올리지
1416
+ * 않는다 — 앵커가 낮으면 backfill이 이미 가진 것까지 다시 주는 쪽으로
1417
+ * (안전), 높으면 못 받은 것을 영영 건너뛰는 쪽으로(치명) 실패하기
1418
+ * 때문이다. 중복은 수신 측(`MessageCollection.onIncoming`)의 id dedupe가
1419
+ * 흡수하므로, "빠뜨린 갱신"으로 보고 올리지 말 것.
1420
+ */
1389
1421
  private lastMessageIdByRoom;
1390
1422
  /** Most recently dispatched presence event key, `${userId}:${at}:${online}`.
1391
1423
  * Used to drop the N-1 shared-room copies of a single online/offline
package/dist/index.d.ts CHANGED
@@ -666,6 +666,17 @@ declare class ReconnectingWs {
666
666
  private explicitClose;
667
667
  private outboundQueue;
668
668
  private static readonly OUTBOUND_QUEUE_CAP;
669
+ /**
670
+ * 연결 시도의 세대 번호 — `connect()`/`close()` 마다 올린다.
671
+ *
672
+ * `openOnce()` 는 `urlBuilder()`(토큰 발급 왕복)를 await 하는 동안 아직
673
+ * 소켓이 없어서, 그 틈에 들어온 `close()` 는 닫을 대상을 찾지 못한다.
674
+ * 이어서 새 `connect()` 가 `explicitClose` 를 되돌리면, 뒤늦게 깨어난
675
+ * 앞선 시도가 **아무도 닫지 않는 소켓**을 하나 더 만든다(React StrictMode 의
676
+ * mount → cleanup → mount 에서 재현). 불리언 하나로는 "이미 취소됐다"를
677
+ * 알릴 수 없으므로 세대 번호로 판별한다.
678
+ */
679
+ private gen;
669
680
  constructor(opts: ReconnectingWsOptions);
670
681
  get currentState(): WsState;
671
682
  connect(): Promise<void>;
@@ -912,6 +923,17 @@ declare class MessageCollection {
912
923
  /** Unsubscribe from the room and flush the pending read watermark. The
913
924
  * collection must not be used afterwards. */
914
925
  dispose(): void;
926
+ /**
927
+ * 수신 프레임을 컬렉션에 넣는다.
928
+ *
929
+ * 두 가지를 방어한다 — backfill(`chat.backfill()`)은 재연결 시 앵커
930
+ * 이후를 **다시** 실어오므로, 이미 들고 있는 메시지가 섞여 오고 화면보다
931
+ * 오래된 메시지도 함께 온다:
932
+ * - **중복**: 같은 id가 이미 있으면 무시한다.
933
+ * - **순서**: 뒤에서부터 자리를 찾아 꽂는다. 실시간 프레임은 항상 마지막
934
+ * 메시지보다 새로우므로 비교 1번으로 끝나고(빠른 경로), 옛 메시지가
935
+ * 섞여 올 때만 탐색이 일어난다.
936
+ */
915
937
  private onIncoming;
916
938
  private onUpdated;
917
939
  private onDeleted;
@@ -1386,6 +1408,16 @@ declare class NoveraChat {
1386
1408
  private readDebounceMs;
1387
1409
  private logger;
1388
1410
  private connectedOnce;
1411
+ /**
1412
+ * 방별 "실시간 파이프라인으로 본 최대 message_id" — 재연결 핸드셰이크의
1413
+ * `?since=`와 backfill 시작점.
1414
+ *
1415
+ * **의도적으로 낮게 유지한다.** 히스토리 로드(REST)는 이 값을 올리지
1416
+ * 않는다 — 앵커가 낮으면 backfill이 이미 가진 것까지 다시 주는 쪽으로
1417
+ * (안전), 높으면 못 받은 것을 영영 건너뛰는 쪽으로(치명) 실패하기
1418
+ * 때문이다. 중복은 수신 측(`MessageCollection.onIncoming`)의 id dedupe가
1419
+ * 흡수하므로, "빠뜨린 갱신"으로 보고 올리지 말 것.
1420
+ */
1389
1421
  private lastMessageIdByRoom;
1390
1422
  /** Most recently dispatched presence event key, `${userId}:${at}:${online}`.
1391
1423
  * Used to drop the N-1 shared-room copies of a single online/offline
package/dist/index.js CHANGED
@@ -368,14 +368,26 @@ var ReconnectingWs = class _ReconnectingWs {
368
368
  // heartbeats and a stale typing event is worse than no event.
369
369
  outboundQueue = [];
370
370
  static OUTBOUND_QUEUE_CAP = 200;
371
+ /**
372
+ * 연결 시도의 세대 번호 — `connect()`/`close()` 마다 올린다.
373
+ *
374
+ * `openOnce()` 는 `urlBuilder()`(토큰 발급 왕복)를 await 하는 동안 아직
375
+ * 소켓이 없어서, 그 틈에 들어온 `close()` 는 닫을 대상을 찾지 못한다.
376
+ * 이어서 새 `connect()` 가 `explicitClose` 를 되돌리면, 뒤늦게 깨어난
377
+ * 앞선 시도가 **아무도 닫지 않는 소켓**을 하나 더 만든다(React StrictMode 의
378
+ * mount → cleanup → mount 에서 재현). 불리언 하나로는 "이미 취소됐다"를
379
+ * 알릴 수 없으므로 세대 번호로 판별한다.
380
+ */
381
+ gen = 0;
371
382
  get currentState() {
372
383
  return this.state;
373
384
  }
374
385
  async connect() {
375
386
  this.explicitClose = false;
376
- await this.openOnce();
387
+ await this.openOnce(++this.gen);
377
388
  }
378
389
  close() {
390
+ this.gen++;
379
391
  this.explicitClose = true;
380
392
  this.setState("closed");
381
393
  if (this.pingTimer) clearInterval(this.pingTimer);
@@ -408,7 +420,8 @@ var ReconnectingWs = class _ReconnectingWs {
408
420
  );
409
421
  return true;
410
422
  }
411
- async openOnce() {
423
+ async openOnce(gen) {
424
+ if (gen !== this.gen) return;
412
425
  this.setState(this.attempt === 0 ? "connecting" : "reconnecting");
413
426
  const WSImpl = this.opts.WebSocketImpl ?? globalThis.WebSocket;
414
427
  if (!WSImpl) throw new Error("No WebSocket implementation available");
@@ -417,7 +430,11 @@ var ReconnectingWs = class _ReconnectingWs {
417
430
  url = await this.opts.urlBuilder();
418
431
  } catch (err) {
419
432
  this.opts.logger?.("error", "url_builder_failed", err);
420
- this.scheduleReconnect();
433
+ if (gen === this.gen) this.scheduleReconnect();
434
+ return;
435
+ }
436
+ if (gen !== this.gen) {
437
+ this.opts.logger?.("info", "ws_open_aborted_stale", { gen, current: this.gen });
421
438
  return;
422
439
  }
423
440
  const ws = new WSImpl(url);
@@ -482,8 +499,9 @@ var ReconnectingWs = class _ReconnectingWs {
482
499
  const delay = exp + jitter;
483
500
  this.opts.logger?.("info", "ws_reconnect_scheduled", { attempt: this.attempt, delay });
484
501
  this.setState("reconnecting");
502
+ const gen = this.gen;
485
503
  setTimeout(() => {
486
- void this.openOnce();
504
+ void this.openOnce(gen);
487
505
  }, delay);
488
506
  }
489
507
  startPings() {
@@ -791,7 +809,15 @@ var MessageCollection = class {
791
809
  this._messages.splice(0, this.hydratedCount);
792
810
  this.hydratedCount = 0;
793
811
  }
794
- this._messages.unshift(...page.items.map(chatMessageFromHistory));
812
+ const indexById = /* @__PURE__ */ new Map();
813
+ this._messages.forEach((m, i) => indexById.set(m.id, i));
814
+ const fresh = [];
815
+ for (const item of page.items.map(chatMessageFromHistory)) {
816
+ const at = indexById.get(item.id);
817
+ if (at === void 0) fresh.push(item);
818
+ else this._messages[at] = item;
819
+ }
820
+ this._messages.unshift(...fresh);
795
821
  this.oldestCursor = page.nextCursor;
796
822
  this._hasMore = page.hasMore;
797
823
  this.emit("replaced");
@@ -962,8 +988,26 @@ var MessageCollection = class {
962
988
  this.bus.removeAll();
963
989
  }
964
990
  // ── event handlers ──────────────────────────────────────────────
991
+ /**
992
+ * 수신 프레임을 컬렉션에 넣는다.
993
+ *
994
+ * 두 가지를 방어한다 — backfill(`chat.backfill()`)은 재연결 시 앵커
995
+ * 이후를 **다시** 실어오므로, 이미 들고 있는 메시지가 섞여 오고 화면보다
996
+ * 오래된 메시지도 함께 온다:
997
+ * - **중복**: 같은 id가 이미 있으면 무시한다.
998
+ * - **순서**: 뒤에서부터 자리를 찾아 꽂는다. 실시간 프레임은 항상 마지막
999
+ * 메시지보다 새로우므로 비교 1번으로 끝나고(빠른 경로), 옛 메시지가
1000
+ * 섞여 올 때만 탐색이 일어난다.
1001
+ */
965
1002
  onIncoming(f) {
966
- this._messages.push(chatMessageFromLive(f));
1003
+ const msg = chatMessageFromLive(f);
1004
+ let i = this._messages.length - 1;
1005
+ for (; i >= 0; i--) {
1006
+ const cur = this._messages[i];
1007
+ if (cur.id === msg.id) return;
1008
+ if (!idGreater(cur.id, msg.id)) break;
1009
+ }
1010
+ this._messages.splice(i + 1, 0, msg);
967
1011
  this.emit("inserted");
968
1012
  }
969
1013
  onUpdated(f) {
@@ -2128,6 +2172,16 @@ var NoveraChat = class {
2128
2172
  readDebounceMs;
2129
2173
  logger;
2130
2174
  connectedOnce = false;
2175
+ /**
2176
+ * 방별 "실시간 파이프라인으로 본 최대 message_id" — 재연결 핸드셰이크의
2177
+ * `?since=`와 backfill 시작점.
2178
+ *
2179
+ * **의도적으로 낮게 유지한다.** 히스토리 로드(REST)는 이 값을 올리지
2180
+ * 않는다 — 앵커가 낮으면 backfill이 이미 가진 것까지 다시 주는 쪽으로
2181
+ * (안전), 높으면 못 받은 것을 영영 건너뛰는 쪽으로(치명) 실패하기
2182
+ * 때문이다. 중복은 수신 측(`MessageCollection.onIncoming`)의 id dedupe가
2183
+ * 흡수하므로, "빠뜨린 갱신"으로 보고 올리지 말 것.
2184
+ */
2131
2185
  lastMessageIdByRoom = /* @__PURE__ */ new Map();
2132
2186
  /** Most recently dispatched presence event key, `${userId}:${at}:${online}`.
2133
2187
  * Used to drop the N-1 shared-room copies of a single online/offline
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noverachat/sdk-web",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "NoveraChat browser/Node SDK — WebSocket + REST client with optimistic UI, reconnection and gap-fill",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/client.ts CHANGED
@@ -57,6 +57,16 @@ export class NoveraChat {
57
57
  private readDebounceMs: number;
58
58
  private logger: ClientOptions["logger"];
59
59
  private connectedOnce = false;
60
+ /**
61
+ * 방별 "실시간 파이프라인으로 본 최대 message_id" — 재연결 핸드셰이크의
62
+ * `?since=`와 backfill 시작점.
63
+ *
64
+ * **의도적으로 낮게 유지한다.** 히스토리 로드(REST)는 이 값을 올리지
65
+ * 않는다 — 앵커가 낮으면 backfill이 이미 가진 것까지 다시 주는 쪽으로
66
+ * (안전), 높으면 못 받은 것을 영영 건너뛰는 쪽으로(치명) 실패하기
67
+ * 때문이다. 중복은 수신 측(`MessageCollection.onIncoming`)의 id dedupe가
68
+ * 흡수하므로, "빠뜨린 갱신"으로 보고 올리지 말 것.
69
+ */
60
70
  private lastMessageIdByRoom = new Map<string, MessageIdStr>();
61
71
  /** Most recently dispatched presence event key, `${userId}:${at}:${online}`.
62
72
  * Used to drop the N-1 shared-room copies of a single online/offline
@@ -223,7 +223,18 @@ export class MessageCollection {
223
223
  this._messages.splice(0, this.hydratedCount); // 스냅샷 통째 폐기 → 서버가 이긴다
224
224
  this.hydratedCount = 0;
225
225
  }
226
- this._messages.unshift(...page.items.map(chatMessageFromHistory));
226
+ // id 기준 upsert — 이미 들고 있는 메시지는 서버 버전으로 교체하고, 없는
227
+ // 것만 앞에 잇는다. `load()`가 두 번 불려도(React StrictMode의 mount →
228
+ // cleanup → mount 사이클처럼) 같은 페이지가 겹쳐 쌓이지 않는다.
229
+ const indexById = new Map<string, number>();
230
+ this._messages.forEach((m, i) => indexById.set(m.id, i));
231
+ const fresh: ChatMessage[] = [];
232
+ for (const item of page.items.map(chatMessageFromHistory)) {
233
+ const at = indexById.get(item.id);
234
+ if (at === undefined) fresh.push(item);
235
+ else this._messages[at] = item; // 겹치는 구간은 서버가 이긴다
236
+ }
237
+ this._messages.unshift(...fresh);
227
238
  this.oldestCursor = page.nextCursor;
228
239
  this._hasMore = page.hasMore;
229
240
  this.emit("replaced");
@@ -439,8 +450,26 @@ export class MessageCollection {
439
450
 
440
451
  // ── event handlers ──────────────────────────────────────────────
441
452
 
453
+ /**
454
+ * 수신 프레임을 컬렉션에 넣는다.
455
+ *
456
+ * 두 가지를 방어한다 — backfill(`chat.backfill()`)은 재연결 시 앵커
457
+ * 이후를 **다시** 실어오므로, 이미 들고 있는 메시지가 섞여 오고 화면보다
458
+ * 오래된 메시지도 함께 온다:
459
+ * - **중복**: 같은 id가 이미 있으면 무시한다.
460
+ * - **순서**: 뒤에서부터 자리를 찾아 꽂는다. 실시간 프레임은 항상 마지막
461
+ * 메시지보다 새로우므로 비교 1번으로 끝나고(빠른 경로), 옛 메시지가
462
+ * 섞여 올 때만 탐색이 일어난다.
463
+ */
442
464
  private onIncoming(f: WsChatReceive): void {
443
- this._messages.push(chatMessageFromLive(f));
465
+ const msg = chatMessageFromLive(f);
466
+ let i = this._messages.length - 1;
467
+ for (; i >= 0; i--) {
468
+ const cur = this._messages[i]!;
469
+ if (cur.id === msg.id) return; // 이미 있음 — 재전송분
470
+ if (!idGreater(cur.id, msg.id)) break; // 여기가 삽입 자리
471
+ }
472
+ this._messages.splice(i + 1, 0, msg);
444
473
  this.emit("inserted");
445
474
  }
446
475
 
@@ -41,6 +41,17 @@ export class ReconnectingWs {
41
41
  // heartbeats and a stale typing event is worse than no event.
42
42
  private outboundQueue: WsOutbound[] = [];
43
43
  private static readonly OUTBOUND_QUEUE_CAP = 200;
44
+ /**
45
+ * 연결 시도의 세대 번호 — `connect()`/`close()` 마다 올린다.
46
+ *
47
+ * `openOnce()` 는 `urlBuilder()`(토큰 발급 왕복)를 await 하는 동안 아직
48
+ * 소켓이 없어서, 그 틈에 들어온 `close()` 는 닫을 대상을 찾지 못한다.
49
+ * 이어서 새 `connect()` 가 `explicitClose` 를 되돌리면, 뒤늦게 깨어난
50
+ * 앞선 시도가 **아무도 닫지 않는 소켓**을 하나 더 만든다(React StrictMode 의
51
+ * mount → cleanup → mount 에서 재현). 불리언 하나로는 "이미 취소됐다"를
52
+ * 알릴 수 없으므로 세대 번호로 판별한다.
53
+ */
54
+ private gen = 0;
44
55
 
45
56
  constructor(private opts: ReconnectingWsOptions) {}
46
57
 
@@ -50,10 +61,11 @@ export class ReconnectingWs {
50
61
 
51
62
  async connect(): Promise<void> {
52
63
  this.explicitClose = false;
53
- await this.openOnce();
64
+ await this.openOnce(++this.gen);
54
65
  }
55
66
 
56
67
  close(): void {
68
+ this.gen++; // 진행 중인 연결 시도를 모두 무효화한다
57
69
  this.explicitClose = true;
58
70
  this.setState("closed");
59
71
  if (this.pingTimer) clearInterval(this.pingTimer);
@@ -91,7 +103,8 @@ export class ReconnectingWs {
91
103
  return true;
92
104
  }
93
105
 
94
- private async openOnce(): Promise<void> {
106
+ private async openOnce(gen: number): Promise<void> {
107
+ if (gen !== this.gen) return;
95
108
  this.setState(this.attempt === 0 ? "connecting" : "reconnecting");
96
109
  const WSImpl = this.opts.WebSocketImpl ?? (globalThis.WebSocket as typeof WebSocket);
97
110
  if (!WSImpl) throw new Error("No WebSocket implementation available");
@@ -101,7 +114,14 @@ export class ReconnectingWs {
101
114
  url = await this.opts.urlBuilder();
102
115
  } catch (err) {
103
116
  this.opts.logger?.("error", "url_builder_failed", err);
104
- this.scheduleReconnect();
117
+ if (gen === this.gen) this.scheduleReconnect();
118
+ return;
119
+ }
120
+
121
+ // await 사이에 close()/connect() 가 지나갔으면 이 시도는 이미 취소된
122
+ // 것이다 — 소켓을 만들지 않고 물러난다(누수 소켓 방지).
123
+ if (gen !== this.gen) {
124
+ this.opts.logger?.("info", "ws_open_aborted_stale", { gen, current: this.gen });
105
125
  return;
106
126
  }
107
127
 
@@ -175,8 +195,9 @@ export class ReconnectingWs {
175
195
  const delay = exp + jitter;
176
196
  this.opts.logger?.("info", "ws_reconnect_scheduled", { attempt: this.attempt, delay });
177
197
  this.setState("reconnecting");
198
+ const gen = this.gen; // 예약 시점의 세대 — 그사이 close/connect 가 오면 취소
178
199
  setTimeout(() => {
179
- void this.openOnce();
200
+ void this.openOnce(gen);
180
201
  }, delay);
181
202
  }
182
203