@noverachat/sdk-web 0.5.1 → 0.5.3

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() {
@@ -2048,6 +2066,16 @@ var Room = class _Room {
2048
2066
  this.pendingAcks.delete(tempId2);
2049
2067
  this.mySentTempIds.delete(tempId2);
2050
2068
  }
2069
+ /**
2070
+ * ack 를 기다리던 전송을 전부 실패 처리한다 — `chat.disconnect()` 처럼
2071
+ * 응답이 올 길이 사라졌을 때. 메시지 컬렉션은 건드리지 않는다(낙관적
2072
+ * 버블은 `failed` 로 바뀌어 재전송 UI 를 띄울 수 있다).
2073
+ */
2074
+ _failPendingSends(reason) {
2075
+ for (const tempId2 of [...this.pendingAcks.keys()]) {
2076
+ this._rejectPending(tempId2, new Error(reason));
2077
+ }
2078
+ }
2051
2079
  /**
2052
2080
  * Try to re-send a pending frame after a transient server reject
2053
2081
  * (currently only NC-RATE-001 — the tenant's per-second token bucket
@@ -2208,9 +2236,22 @@ var NoveraChat = class {
2208
2236
  async connect() {
2209
2237
  await this.ws.connect();
2210
2238
  }
2239
+ /**
2240
+ * 소켓을 닫는다. **Room 파사드와 그 메시지 상태는 유지한다** — `connect()`
2241
+ * 로 다시 붙으면 같은 파사드가 그대로 이어서 프레임을 받는다.
2242
+ *
2243
+ * 예전엔 여기서 `rooms.clear()` 를 했는데, 그러면 UI 가 들고 있던 컬렉션이
2244
+ * 다음 접근 때 빈 컬렉션으로 교체돼 이미 로드한 히스토리가 사라졌다
2245
+ * (React StrictMode 의 connect → disconnect → connect 에서 채팅방이 빈
2246
+ * 화면으로 뜨는 증상). 소켓은 재사용되는 `ReconnectingWs` 인스턴스라
2247
+ * 파사드가 참조를 유지해도 문제가 없다.
2248
+ *
2249
+ * 다만 ack 를 기다리던 전송은 응답 받을 길이 없으므로 여기서 실패로
2250
+ * 정리한다(낙관적 버블이 영원히 `sending` 으로 남지 않도록).
2251
+ */
2211
2252
  disconnect() {
2212
2253
  this.ws.close();
2213
- this.rooms.clear();
2254
+ for (const room of this.rooms.values()) room._failPendingSends("disconnected");
2214
2255
  }
2215
2256
  room(roomId) {
2216
2257
  let r = this.rooms.get(roomId);
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>;
@@ -1337,6 +1348,12 @@ declare class Room {
1337
1348
  * fail the matching outbound promise immediately instead of waiting
1338
1349
  * for the 15s ack timeout. */
1339
1350
  _rejectPending(tempId: string, err: Error): void;
1351
+ /**
1352
+ * ack 를 기다리던 전송을 전부 실패 처리한다 — `chat.disconnect()` 처럼
1353
+ * 응답이 올 길이 사라졌을 때. 메시지 컬렉션은 건드리지 않는다(낙관적
1354
+ * 버블은 `failed` 로 바뀌어 재전송 UI 를 띄울 수 있다).
1355
+ */
1356
+ _failPendingSends(reason: string): void;
1340
1357
  /**
1341
1358
  * Try to re-send a pending frame after a transient server reject
1342
1359
  * (currently only NC-RATE-001 — the tenant's per-second token bucket
@@ -1415,6 +1432,19 @@ declare class NoveraChat {
1415
1432
  private lastPresenceKey;
1416
1433
  constructor(opts: ClientOptions);
1417
1434
  connect(): Promise<void>;
1435
+ /**
1436
+ * 소켓을 닫는다. **Room 파사드와 그 메시지 상태는 유지한다** — `connect()`
1437
+ * 로 다시 붙으면 같은 파사드가 그대로 이어서 프레임을 받는다.
1438
+ *
1439
+ * 예전엔 여기서 `rooms.clear()` 를 했는데, 그러면 UI 가 들고 있던 컬렉션이
1440
+ * 다음 접근 때 빈 컬렉션으로 교체돼 이미 로드한 히스토리가 사라졌다
1441
+ * (React StrictMode 의 connect → disconnect → connect 에서 채팅방이 빈
1442
+ * 화면으로 뜨는 증상). 소켓은 재사용되는 `ReconnectingWs` 인스턴스라
1443
+ * 파사드가 참조를 유지해도 문제가 없다.
1444
+ *
1445
+ * 다만 ack 를 기다리던 전송은 응답 받을 길이 없으므로 여기서 실패로
1446
+ * 정리한다(낙관적 버블이 영원히 `sending` 으로 남지 않도록).
1447
+ */
1418
1448
  disconnect(): void;
1419
1449
  room(roomId: string): Room;
1420
1450
  /** Subscribe to client-level events. Currently `roomEvent` — membership /
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>;
@@ -1337,6 +1348,12 @@ declare class Room {
1337
1348
  * fail the matching outbound promise immediately instead of waiting
1338
1349
  * for the 15s ack timeout. */
1339
1350
  _rejectPending(tempId: string, err: Error): void;
1351
+ /**
1352
+ * ack 를 기다리던 전송을 전부 실패 처리한다 — `chat.disconnect()` 처럼
1353
+ * 응답이 올 길이 사라졌을 때. 메시지 컬렉션은 건드리지 않는다(낙관적
1354
+ * 버블은 `failed` 로 바뀌어 재전송 UI 를 띄울 수 있다).
1355
+ */
1356
+ _failPendingSends(reason: string): void;
1340
1357
  /**
1341
1358
  * Try to re-send a pending frame after a transient server reject
1342
1359
  * (currently only NC-RATE-001 — the tenant's per-second token bucket
@@ -1415,6 +1432,19 @@ declare class NoveraChat {
1415
1432
  private lastPresenceKey;
1416
1433
  constructor(opts: ClientOptions);
1417
1434
  connect(): Promise<void>;
1435
+ /**
1436
+ * 소켓을 닫는다. **Room 파사드와 그 메시지 상태는 유지한다** — `connect()`
1437
+ * 로 다시 붙으면 같은 파사드가 그대로 이어서 프레임을 받는다.
1438
+ *
1439
+ * 예전엔 여기서 `rooms.clear()` 를 했는데, 그러면 UI 가 들고 있던 컬렉션이
1440
+ * 다음 접근 때 빈 컬렉션으로 교체돼 이미 로드한 히스토리가 사라졌다
1441
+ * (React StrictMode 의 connect → disconnect → connect 에서 채팅방이 빈
1442
+ * 화면으로 뜨는 증상). 소켓은 재사용되는 `ReconnectingWs` 인스턴스라
1443
+ * 파사드가 참조를 유지해도 문제가 없다.
1444
+ *
1445
+ * 다만 ack 를 기다리던 전송은 응답 받을 길이 없으므로 여기서 실패로
1446
+ * 정리한다(낙관적 버블이 영원히 `sending` 으로 남지 않도록).
1447
+ */
1418
1448
  disconnect(): void;
1419
1449
  room(roomId: string): Room;
1420
1450
  /** Subscribe to client-level events. Currently `roomEvent` — membership /
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() {
@@ -2013,6 +2031,16 @@ var Room = class _Room {
2013
2031
  this.pendingAcks.delete(tempId2);
2014
2032
  this.mySentTempIds.delete(tempId2);
2015
2033
  }
2034
+ /**
2035
+ * ack 를 기다리던 전송을 전부 실패 처리한다 — `chat.disconnect()` 처럼
2036
+ * 응답이 올 길이 사라졌을 때. 메시지 컬렉션은 건드리지 않는다(낙관적
2037
+ * 버블은 `failed` 로 바뀌어 재전송 UI 를 띄울 수 있다).
2038
+ */
2039
+ _failPendingSends(reason) {
2040
+ for (const tempId2 of [...this.pendingAcks.keys()]) {
2041
+ this._rejectPending(tempId2, new Error(reason));
2042
+ }
2043
+ }
2016
2044
  /**
2017
2045
  * Try to re-send a pending frame after a transient server reject
2018
2046
  * (currently only NC-RATE-001 — the tenant's per-second token bucket
@@ -2173,9 +2201,22 @@ var NoveraChat = class {
2173
2201
  async connect() {
2174
2202
  await this.ws.connect();
2175
2203
  }
2204
+ /**
2205
+ * 소켓을 닫는다. **Room 파사드와 그 메시지 상태는 유지한다** — `connect()`
2206
+ * 로 다시 붙으면 같은 파사드가 그대로 이어서 프레임을 받는다.
2207
+ *
2208
+ * 예전엔 여기서 `rooms.clear()` 를 했는데, 그러면 UI 가 들고 있던 컬렉션이
2209
+ * 다음 접근 때 빈 컬렉션으로 교체돼 이미 로드한 히스토리가 사라졌다
2210
+ * (React StrictMode 의 connect → disconnect → connect 에서 채팅방이 빈
2211
+ * 화면으로 뜨는 증상). 소켓은 재사용되는 `ReconnectingWs` 인스턴스라
2212
+ * 파사드가 참조를 유지해도 문제가 없다.
2213
+ *
2214
+ * 다만 ack 를 기다리던 전송은 응답 받을 길이 없으므로 여기서 실패로
2215
+ * 정리한다(낙관적 버블이 영원히 `sending` 으로 남지 않도록).
2216
+ */
2176
2217
  disconnect() {
2177
2218
  this.ws.close();
2178
- this.rooms.clear();
2219
+ for (const room of this.rooms.values()) room._failPendingSends("disconnected");
2179
2220
  }
2180
2221
  room(roomId) {
2181
2222
  let r = this.rooms.get(roomId);
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.3",
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
@@ -149,9 +149,22 @@ export class NoveraChat {
149
149
  await this.ws.connect();
150
150
  }
151
151
 
152
+ /**
153
+ * 소켓을 닫는다. **Room 파사드와 그 메시지 상태는 유지한다** — `connect()`
154
+ * 로 다시 붙으면 같은 파사드가 그대로 이어서 프레임을 받는다.
155
+ *
156
+ * 예전엔 여기서 `rooms.clear()` 를 했는데, 그러면 UI 가 들고 있던 컬렉션이
157
+ * 다음 접근 때 빈 컬렉션으로 교체돼 이미 로드한 히스토리가 사라졌다
158
+ * (React StrictMode 의 connect → disconnect → connect 에서 채팅방이 빈
159
+ * 화면으로 뜨는 증상). 소켓은 재사용되는 `ReconnectingWs` 인스턴스라
160
+ * 파사드가 참조를 유지해도 문제가 없다.
161
+ *
162
+ * 다만 ack 를 기다리던 전송은 응답 받을 길이 없으므로 여기서 실패로
163
+ * 정리한다(낙관적 버블이 영원히 `sending` 으로 남지 않도록).
164
+ */
152
165
  disconnect(): void {
153
166
  this.ws.close();
154
- this.rooms.clear();
167
+ for (const room of this.rooms.values()) room._failPendingSends("disconnected");
155
168
  }
156
169
 
157
170
  room(roomId: string): Room {
@@ -1246,6 +1246,17 @@ export class Room {
1246
1246
  this.mySentTempIds.delete(tempId);
1247
1247
  }
1248
1248
 
1249
+ /**
1250
+ * ack 를 기다리던 전송을 전부 실패 처리한다 — `chat.disconnect()` 처럼
1251
+ * 응답이 올 길이 사라졌을 때. 메시지 컬렉션은 건드리지 않는다(낙관적
1252
+ * 버블은 `failed` 로 바뀌어 재전송 UI 를 띄울 수 있다).
1253
+ */
1254
+ _failPendingSends(reason: string): void {
1255
+ for (const tempId of [...this.pendingAcks.keys()]) {
1256
+ this._rejectPending(tempId, new Error(reason));
1257
+ }
1258
+ }
1259
+
1249
1260
  /**
1250
1261
  * Try to re-send a pending frame after a transient server reject
1251
1262
  * (currently only NC-RATE-001 — the tenant's per-second token bucket
@@ -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