@noverachat/sdk-web 0.5.1 → 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() {
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>;
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>;
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() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noverachat/sdk-web",
3
- "version": "0.5.1",
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",
@@ -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