@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 +46 -5
- package/dist/index.d.cts +30 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.js +46 -5
- package/package.json +1 -1
- package/src/client.ts +14 -1
- package/src/features/room.ts +11 -0
- package/src/transport/ws.ts +25 -4
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.
|
|
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.
|
|
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
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.
|
|
167
|
+
for (const room of this.rooms.values()) room._failPendingSends("disconnected");
|
|
155
168
|
}
|
|
156
169
|
|
|
157
170
|
room(roomId: string): Room {
|
package/src/features/room.ts
CHANGED
|
@@ -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
|
package/src/transport/ws.ts
CHANGED
|
@@ -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
|
|