@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 +60 -6
- package/dist/index.d.cts +32 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.js +60 -6
- package/package.json +1 -1
- package/src/client.ts +10 -0
- package/src/features/message-collection.ts +31 -2
- 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() {
|
|
@@ -826,7 +844,15 @@ var MessageCollection = class {
|
|
|
826
844
|
this._messages.splice(0, this.hydratedCount);
|
|
827
845
|
this.hydratedCount = 0;
|
|
828
846
|
}
|
|
829
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|