@kispi/chat 0.2.0 → 0.2.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/README.en.md +442 -0
- package/README.md +86 -14
- package/dist/index.cjs +243 -39
- package/dist/index.d.cts +153 -36
- package/dist/index.d.ts +153 -36
- package/dist/index.js +243 -39
- package/dist/server/index.cjs +2 -0
- package/dist/server/index.d.cts +39 -1
- package/dist/server/index.d.ts +39 -1
- package/dist/server/index.js +2 -0
- package/package.json +3 -2
package/dist/index.js
CHANGED
|
@@ -147,6 +147,13 @@ var Timeline = class {
|
|
|
147
147
|
shared = false;
|
|
148
148
|
/** The `loadOlder` in flight, so a scroll handler firing twice asks once. */
|
|
149
149
|
older;
|
|
150
|
+
/**
|
|
151
|
+
* The lowest seq the room still keeps (`minSeq` from the subscribe ack;
|
|
152
|
+
* 1 until told). Below it there is nothing to read, whatever seq says.
|
|
153
|
+
*/
|
|
154
|
+
floor = 1;
|
|
155
|
+
/** A `loadOlder` came back short: the top of what the server keeps was reached. */
|
|
156
|
+
exhausted = false;
|
|
150
157
|
constructor(options) {
|
|
151
158
|
this.options = options;
|
|
152
159
|
}
|
|
@@ -155,6 +162,22 @@ var Timeline = class {
|
|
|
155
162
|
this.shared = true;
|
|
156
163
|
return this.items;
|
|
157
164
|
}
|
|
165
|
+
/**
|
|
166
|
+
* Whether `loadOlder()` would find anything, answered without asking.
|
|
167
|
+
*
|
|
168
|
+
* `loadOlder()`의 `hasMore`와 같은 판정이다: 맨 위 행이 방의 보존 하한
|
|
169
|
+
* (`minSeq`, seq는 1부터 구멍 없이 붙는다)보다 위에 있고, 앞선 `loadOlder`가
|
|
170
|
+
* 덜 찬 페이지로 끝을 알린 적이 없으면 더 있다. 첫 `loadOlder()` 전에 "이전
|
|
171
|
+
* 메시지 더 보기"를 그릴지 정할 수 있게 한다. 목록이 비었으면 false다.
|
|
172
|
+
*/
|
|
173
|
+
get hasOlder() {
|
|
174
|
+
const first = this.items[0];
|
|
175
|
+
return !this.exhausted && first !== void 0 && first.seq > this.floor;
|
|
176
|
+
}
|
|
177
|
+
/** Records the room's retention floor (`minSeq`). */
|
|
178
|
+
setFloor(minSeq) {
|
|
179
|
+
this.floor = Math.max(1, minSeq);
|
|
180
|
+
}
|
|
158
181
|
/** The highest seq this timeline holds, hole or no hole. */
|
|
159
182
|
get highestSeq() {
|
|
160
183
|
return this.items.at(-1)?.seq ?? 0;
|
|
@@ -199,6 +222,7 @@ var Timeline = class {
|
|
|
199
222
|
this.fills.clear();
|
|
200
223
|
this.pendingDeletes.clear();
|
|
201
224
|
this.items = [];
|
|
225
|
+
this.exhausted = false;
|
|
202
226
|
this.shared = false;
|
|
203
227
|
this.changed();
|
|
204
228
|
}
|
|
@@ -226,6 +250,7 @@ var Timeline = class {
|
|
|
226
250
|
const page = [...messages].sort((a, b) => a.seq - b.seq);
|
|
227
251
|
const top = page.at(-1)?.seq ?? 0;
|
|
228
252
|
this.items = [...page, ...this.items.filter((m) => m.seq > top)];
|
|
253
|
+
this.exhausted = false;
|
|
229
254
|
this.shared = false;
|
|
230
255
|
for (const m of this.items) {
|
|
231
256
|
if (this.pendingDeletes.has(m.id)) this.applyDelete(m.id);
|
|
@@ -276,20 +301,35 @@ var Timeline = class {
|
|
|
276
301
|
*
|
|
277
302
|
* The server sends the new `count` with the event, so this is a
|
|
278
303
|
* replacement rather than an increment: two clients reacting at once
|
|
279
|
-
* cannot drift the way `+1`/`-1` would.
|
|
304
|
+
* cannot drift the way `+1`/`-1` would. `count` undefined leaves the
|
|
305
|
+
* aggregate alone (the react ack path); `mine` undefined leaves
|
|
306
|
+
* `myReactions` alone (somebody else's event).
|
|
280
307
|
*/
|
|
281
|
-
reaction(messageId, emoji, count) {
|
|
308
|
+
reaction(messageId, emoji, count, mine) {
|
|
282
309
|
const at = this.items.findIndex((m) => m.id === messageId);
|
|
283
310
|
if (at === -1) return;
|
|
284
311
|
const current = this.items[at];
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
312
|
+
let reactions = current.reactions;
|
|
313
|
+
if (count !== void 0) {
|
|
314
|
+
const existing = current.reactions ?? [];
|
|
315
|
+
const slot = existing.findIndex((r) => r.emoji === emoji);
|
|
316
|
+
if (count <= 0) reactions = existing.filter((r) => r.emoji !== emoji);
|
|
317
|
+
else if (slot === -1) reactions = [...existing, { emoji, count }];
|
|
318
|
+
else reactions = existing.map((r, i) => i === slot ? { emoji, count } : r);
|
|
319
|
+
}
|
|
320
|
+
let myReactions = current.myReactions;
|
|
321
|
+
if (mine !== void 0) {
|
|
322
|
+
const held = current.myReactions ?? [];
|
|
323
|
+
const has = held.includes(emoji);
|
|
324
|
+
if (mine && !has) myReactions = [...held, emoji];
|
|
325
|
+
else if (!mine && has) myReactions = held.filter((e) => e !== emoji);
|
|
326
|
+
}
|
|
327
|
+
if (reactions === current.reactions && myReactions === current.myReactions) return;
|
|
328
|
+
const next = { ...current };
|
|
329
|
+
if (reactions !== void 0) next.reactions = reactions;
|
|
330
|
+
if (myReactions !== void 0) next.myReactions = myReactions;
|
|
291
331
|
this.own();
|
|
292
|
-
this.items[at] =
|
|
332
|
+
this.items[at] = next;
|
|
293
333
|
this.changed();
|
|
294
334
|
}
|
|
295
335
|
/** Applies a `thread.updated` to the root message's aggregate. */
|
|
@@ -326,14 +366,16 @@ var Timeline = class {
|
|
|
326
366
|
}
|
|
327
367
|
async readOlder(limit) {
|
|
328
368
|
const first = this.items[0];
|
|
329
|
-
if (first === void 0 || first.seq <=
|
|
369
|
+
if (first === void 0 || first.seq <= this.floor) return { messages: [], hasMore: false };
|
|
330
370
|
const epoch = this.epoch;
|
|
331
371
|
const page = await this.options.fetchRange({ after: 0, before: first.seq, limit });
|
|
332
372
|
if (this.epoch !== epoch) return { messages: [], hasMore: true };
|
|
333
373
|
for (const m of page) this.insert(m, false);
|
|
334
374
|
if (page.length > 0) this.changed();
|
|
335
375
|
const top = page[0];
|
|
336
|
-
|
|
376
|
+
const hasMore = page.length >= limit && top !== void 0 && top.seq > this.floor;
|
|
377
|
+
this.exhausted = !hasMore;
|
|
378
|
+
return { messages: page, hasMore };
|
|
337
379
|
}
|
|
338
380
|
/**
|
|
339
381
|
* Fetches everything between what we have and `upTo`.
|
|
@@ -424,6 +466,8 @@ function createTimeline(options) {
|
|
|
424
466
|
|
|
425
467
|
// src/room.ts
|
|
426
468
|
var HistoryPageSize = 100;
|
|
469
|
+
var MaxRepairs = 5;
|
|
470
|
+
var RepairBaseMs = 1e3;
|
|
427
471
|
var Room = class extends Emitter {
|
|
428
472
|
/** Resolved on subscribe when the room was addressed by key. */
|
|
429
473
|
id;
|
|
@@ -461,6 +505,9 @@ var Room = class extends Emitter {
|
|
|
461
505
|
*/
|
|
462
506
|
wanted = false;
|
|
463
507
|
subscribing;
|
|
508
|
+
/** Self-repair: the armed retry, and how many have run since the last success. */
|
|
509
|
+
repairTimer;
|
|
510
|
+
repairs = 0;
|
|
464
511
|
constructor(chat, rest, address) {
|
|
465
512
|
super();
|
|
466
513
|
this.chat = chat;
|
|
@@ -476,6 +523,17 @@ var Room = class extends Emitter {
|
|
|
476
523
|
onChange: (messages) => this.emit("messages", messages)
|
|
477
524
|
});
|
|
478
525
|
}
|
|
526
|
+
/**
|
|
527
|
+
* Whether older history exists above the first message held -- what
|
|
528
|
+
* `loadOlder()` would report as `hasMore`, known before calling it.
|
|
529
|
+
*
|
|
530
|
+
* Derived from the first row's seq against the room's retention floor
|
|
531
|
+
* (the subscribe ack's `minSeq`), and false once a `loadOlder()` came
|
|
532
|
+
* back short. False while nothing is loaded. Re-read it on `messages`.
|
|
533
|
+
*/
|
|
534
|
+
get hasOlder() {
|
|
535
|
+
return this.timeline.hasOlder;
|
|
536
|
+
}
|
|
479
537
|
/** Everything this client knows about the room, in seq order. A new array whenever it changes. */
|
|
480
538
|
get messages() {
|
|
481
539
|
return this.timeline.messages;
|
|
@@ -527,7 +585,12 @@ var Room = class extends Emitter {
|
|
|
527
585
|
this.wanted = true;
|
|
528
586
|
if (this.subscribing !== void 0) return this.subscribing;
|
|
529
587
|
if (this.subscribed) return;
|
|
530
|
-
const attempt = this.doSubscribe().
|
|
588
|
+
const attempt = this.doSubscribe().then(() => {
|
|
589
|
+
const successor = this.subscribing;
|
|
590
|
+
if (successor !== void 0 && successor !== attempt) return successor;
|
|
591
|
+
if (this.subscribed) this.repairs = 0;
|
|
592
|
+
return void 0;
|
|
593
|
+
}).finally(() => {
|
|
531
594
|
if (this.subscribing === attempt) this.subscribing = void 0;
|
|
532
595
|
});
|
|
533
596
|
this.subscribing = attempt;
|
|
@@ -536,6 +599,10 @@ var Room = class extends Emitter {
|
|
|
536
599
|
async doSubscribe() {
|
|
537
600
|
const gen = this.generation;
|
|
538
601
|
const current = () => gen === this.generation;
|
|
602
|
+
if (this.chat.state !== "open") {
|
|
603
|
+
await this.chat.whenOpen();
|
|
604
|
+
if (!current()) return;
|
|
605
|
+
}
|
|
539
606
|
const data = this.id !== void 0 ? { roomId: this.id } : { key: this.key };
|
|
540
607
|
const since = this.timeline.contiguousSeq;
|
|
541
608
|
if (since > 0) data["since"] = since;
|
|
@@ -545,6 +612,7 @@ var Room = class extends Emitter {
|
|
|
545
612
|
this.id = ack.roomId;
|
|
546
613
|
this.chat.registerRoomId(ack.roomId, this);
|
|
547
614
|
this.lastSeq = ack.lastSeq;
|
|
615
|
+
if (typeof ack.minSeq === "number") this.timeline.setFloor(ack.minSeq);
|
|
548
616
|
if (ack.presence !== void 0) this.presence = ack.presence;
|
|
549
617
|
if (attempt.wasReset) {
|
|
550
618
|
await this.loadRecent(current);
|
|
@@ -588,12 +656,20 @@ var Room = class extends Emitter {
|
|
|
588
656
|
});
|
|
589
657
|
return page.messages;
|
|
590
658
|
}
|
|
591
|
-
/**
|
|
659
|
+
/**
|
|
660
|
+
* Adds a reaction. Idempotent, like the frame.
|
|
661
|
+
*
|
|
662
|
+
* The ack updates the row's `myReactions` (not its count -- see below),
|
|
663
|
+
* so the "I reacted" state is right even if the broadcast is missed.
|
|
664
|
+
*/
|
|
592
665
|
async react(messageId, emoji) {
|
|
593
666
|
await this.chat.send("react", { roomId: await this.resolveId("reacting"), messageId, emoji, op: "add" });
|
|
667
|
+
this.timeline.reaction(messageId, emoji, void 0, true);
|
|
594
668
|
}
|
|
669
|
+
/** Removes a reaction. Idempotent; the ack clears it from `myReactions`. */
|
|
595
670
|
async unreact(messageId, emoji) {
|
|
596
671
|
await this.chat.send("react", { roomId: await this.resolveId("reacting"), messageId, emoji, op: "remove" });
|
|
672
|
+
this.timeline.reaction(messageId, emoji, void 0, false);
|
|
597
673
|
}
|
|
598
674
|
/**
|
|
599
675
|
* Moves this user's read cursor.
|
|
@@ -665,13 +741,33 @@ var Room = class extends Emitter {
|
|
|
665
741
|
* transient 500 takes a room out of the live feed for the life of the
|
|
666
742
|
* page.
|
|
667
743
|
*/
|
|
668
|
-
async resume() {
|
|
744
|
+
async resume(restart = true) {
|
|
669
745
|
if (!this.wanted) return;
|
|
746
|
+
if (!restart) return this.subscribe();
|
|
670
747
|
this.generation++;
|
|
671
748
|
this.subscribing = void 0;
|
|
672
749
|
this.subscribed = false;
|
|
673
750
|
await this.subscribe();
|
|
674
751
|
}
|
|
752
|
+
/**
|
|
753
|
+
* Reports a failure no caller was waiting for, and repairs.
|
|
754
|
+
*
|
|
755
|
+
* 백그라운드 실패(재접속 뒤 다시 구독, 구멍 메우기)에는 거절을 받을 호출자가
|
|
756
|
+
* 없다. 이벤트만 내고 두면 방은 다음 재접속이나 탭 복귀까지 구멍을 안고 —
|
|
757
|
+
* 구독 프레임부터 실패했다면 라이브 피드 밖에서 — 머문다. 그래서 스스로 다시
|
|
758
|
+
* 구독한다. 횟수를 묶는 것은 고칠 수 없는 실패(권한, 없어진 방)가 서버를
|
|
759
|
+
* 계속 두드리지 않게 하려는 것이고, 그 뒤는 재접속과 `subscribe()`의 몫이다.
|
|
760
|
+
*/
|
|
761
|
+
failed(err) {
|
|
762
|
+
this.emit("error", err instanceof Error ? err : new Error(String(err)));
|
|
763
|
+
if (this.repairTimer !== void 0 || this.repairs >= MaxRepairs) return;
|
|
764
|
+
const delay = RepairBaseMs * 2 ** this.repairs++;
|
|
765
|
+
this.repairTimer = setTimeout(() => {
|
|
766
|
+
this.repairTimer = void 0;
|
|
767
|
+
if (!this.wanted || this.subscribed || this.chat.state !== "open") return;
|
|
768
|
+
this.subscribe().catch((e) => this.failed(e));
|
|
769
|
+
}, delay);
|
|
770
|
+
}
|
|
675
771
|
/**
|
|
676
772
|
* Sends `subscribe` and recovers from the one error it has an answer
|
|
677
773
|
* for.
|
|
@@ -753,7 +849,7 @@ var Room = class extends Emitter {
|
|
|
753
849
|
if (data.seq > this.lastSeq) this.lastSeq = data.seq;
|
|
754
850
|
this.timeline.add(data).catch((err) => {
|
|
755
851
|
this.subscribed = false;
|
|
756
|
-
this.
|
|
852
|
+
this.failed(err);
|
|
757
853
|
});
|
|
758
854
|
break;
|
|
759
855
|
case "message.updated":
|
|
@@ -768,7 +864,9 @@ var Room = class extends Emitter {
|
|
|
768
864
|
case "reaction.added":
|
|
769
865
|
case "reaction.removed": {
|
|
770
866
|
const r = data;
|
|
771
|
-
this.
|
|
867
|
+
const me = this.chat.user?.id;
|
|
868
|
+
const mine = me !== void 0 && r.userId === me ? type === "reaction.added" : void 0;
|
|
869
|
+
this.timeline.reaction(r.messageId, r.emoji, r.count, mine);
|
|
772
870
|
break;
|
|
773
871
|
}
|
|
774
872
|
case "thread.updated": {
|
|
@@ -822,8 +920,11 @@ var ChatClient = class extends Emitter {
|
|
|
822
920
|
heartbeat;
|
|
823
921
|
retryTimer;
|
|
824
922
|
attempt = 0;
|
|
825
|
-
/**
|
|
826
|
-
|
|
923
|
+
/**
|
|
924
|
+
* The reconnect loop is off: set by close(), and by a failure that
|
|
925
|
+
* retrying cannot fix (see `stop`). Cleared by connect().
|
|
926
|
+
*/
|
|
927
|
+
stopped = false;
|
|
827
928
|
nextFrameId = 0;
|
|
828
929
|
opening;
|
|
829
930
|
rest;
|
|
@@ -901,7 +1002,7 @@ var ChatClient = class extends Emitter {
|
|
|
901
1002
|
* gap-filled via REST.
|
|
902
1003
|
*/
|
|
903
1004
|
async resume() {
|
|
904
|
-
if (this.
|
|
1005
|
+
if (this.stopped) return;
|
|
905
1006
|
if (this.state !== "open" || this.socket === void 0) {
|
|
906
1007
|
this.clearTimers();
|
|
907
1008
|
return this.connect();
|
|
@@ -913,9 +1014,7 @@ var ChatClient = class extends Emitter {
|
|
|
913
1014
|
return this.connect();
|
|
914
1015
|
}
|
|
915
1016
|
for (const room of this.handles.values()) {
|
|
916
|
-
void room.resume().catch((err) =>
|
|
917
|
-
room.emit("error", err instanceof Error ? err : new Error(String(err)));
|
|
918
|
-
});
|
|
1017
|
+
void room.resume().catch((err) => room.failed(err));
|
|
919
1018
|
}
|
|
920
1019
|
}
|
|
921
1020
|
/** The account-level routes: the rooms this user is in, and unread. */
|
|
@@ -1007,7 +1106,7 @@ var ChatClient = class extends Emitter {
|
|
|
1007
1106
|
}
|
|
1008
1107
|
/** Opens the connection and resolves when `hello` arrives. */
|
|
1009
1108
|
async connect() {
|
|
1010
|
-
this.
|
|
1109
|
+
this.stopped = false;
|
|
1011
1110
|
if (this.opening !== void 0) return this.opening;
|
|
1012
1111
|
if (this.state === "open" && this.socket !== void 0) return;
|
|
1013
1112
|
this.clearTimers();
|
|
@@ -1016,15 +1115,58 @@ var ChatClient = class extends Emitter {
|
|
|
1016
1115
|
});
|
|
1017
1116
|
return this.opening;
|
|
1018
1117
|
}
|
|
1118
|
+
/**
|
|
1119
|
+
* Re-authenticates: calls `token()` again and replaces the socket, keeping
|
|
1120
|
+
* every room handle and every subscription.
|
|
1121
|
+
*
|
|
1122
|
+
* For when the identity the server holds has to change mid-session --
|
|
1123
|
+
* a nickname change, a new avatar, fresh claims. The server reads the
|
|
1124
|
+
* token once, at `auth`, so `sender.name` on the next message is the old
|
|
1125
|
+
* one until the connection is re-authenticated. `close()` + `connect()`
|
|
1126
|
+
* does that too, but passes through `closed` (a consumer's "you are
|
|
1127
|
+
* offline" screen) and tears down the page listeners.
|
|
1128
|
+
*
|
|
1129
|
+
* States: `open` → `reconnecting` → `open`, never `closed`. Rooms
|
|
1130
|
+
* resubscribe from what they hold and catch up the gap, as after any
|
|
1131
|
+
* drop; `messages` is kept. Frames awaiting an ack on the old socket are
|
|
1132
|
+
* rejected with `closed`. `chat.user` is the new identity once this
|
|
1133
|
+
* resolves. If `token()` fails the client keeps retrying in the
|
|
1134
|
+
* background like any reconnect (and this rejects); a `ChatError` from
|
|
1135
|
+
* `token()` stops it at `closed`, as on connect.
|
|
1136
|
+
*
|
|
1137
|
+
* On a client that is not connected (never connected, or `close()`d) it
|
|
1138
|
+
* is `connect()`.
|
|
1139
|
+
*/
|
|
1140
|
+
async reconnect() {
|
|
1141
|
+
if (this.opening !== void 0) await this.opening.catch(() => {
|
|
1142
|
+
});
|
|
1143
|
+
const socket = this.socket;
|
|
1144
|
+
if (socket === void 0 || this.state !== "open") return this.connect();
|
|
1145
|
+
this.clearTimers();
|
|
1146
|
+
this.retired.add(socket);
|
|
1147
|
+
this.socket = void 0;
|
|
1148
|
+
this.failPending(new ChatError("closed", "the connection was replaced by reconnect()"));
|
|
1149
|
+
try {
|
|
1150
|
+
socket.close(1e3, "client reconnecting");
|
|
1151
|
+
} catch {
|
|
1152
|
+
}
|
|
1153
|
+
this.opening = this.openOnce("reconnecting").finally(() => {
|
|
1154
|
+
this.opening = void 0;
|
|
1155
|
+
});
|
|
1156
|
+
return this.opening;
|
|
1157
|
+
}
|
|
1158
|
+
/** Sockets `reconnect()` replaced: their late events are not this client's any more. */
|
|
1159
|
+
retired = /* @__PURE__ */ new WeakSet();
|
|
1019
1160
|
/**
|
|
1020
1161
|
* Closes for good.
|
|
1021
1162
|
*
|
|
1022
1163
|
* The distinction from a dropped socket is the whole point of the state
|
|
1023
|
-
* machine:
|
|
1024
|
-
*
|
|
1164
|
+
* machine: `closed` means nothing is coming back, and a consumer showing
|
|
1165
|
+
* "disconnected, retrying" versus "disconnected" needs it. The other
|
|
1166
|
+
* ways to reach it are refusals retrying cannot fix -- see `stop`.
|
|
1025
1167
|
*/
|
|
1026
1168
|
async close() {
|
|
1027
|
-
this.
|
|
1169
|
+
this.stopped = true;
|
|
1028
1170
|
this.teardowns.forEach((t) => {
|
|
1029
1171
|
try {
|
|
1030
1172
|
t();
|
|
@@ -1041,7 +1183,53 @@ var ChatClient = class extends Emitter {
|
|
|
1041
1183
|
} catch {
|
|
1042
1184
|
}
|
|
1043
1185
|
}
|
|
1186
|
+
this.settleOpenWaiters(new ChatError("closed", "the connection is closed"));
|
|
1187
|
+
this.setState("closed");
|
|
1188
|
+
}
|
|
1189
|
+
/**
|
|
1190
|
+
* Stops for a reason retrying cannot fix, and says which.
|
|
1191
|
+
*
|
|
1192
|
+
* `close()` without the teardown of the page listeners -- a consumer
|
|
1193
|
+
* that logs back in calls `connect()` on the same client -- and with an
|
|
1194
|
+
* `error` event, because on a reconnect there is no caller to reject.
|
|
1195
|
+
*/
|
|
1196
|
+
stop(err) {
|
|
1197
|
+
this.stopped = true;
|
|
1198
|
+
this.clearTimers();
|
|
1199
|
+
const socket = this.socket;
|
|
1200
|
+
this.socket = void 0;
|
|
1201
|
+
if (socket !== void 0) {
|
|
1202
|
+
try {
|
|
1203
|
+
socket.close(1e3, "client stopped");
|
|
1204
|
+
} catch {
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
this.failPending(err);
|
|
1208
|
+
this.settleOpenWaiters(new ChatError("closed", "the connection is closed"));
|
|
1044
1209
|
this.setState("closed");
|
|
1210
|
+
this.emit("error", err);
|
|
1211
|
+
}
|
|
1212
|
+
/**
|
|
1213
|
+
* Resolves once the connection is open. Used by `Room.subscribe`.
|
|
1214
|
+
*
|
|
1215
|
+
* 연결 전에 부른 `subscribe()`를 `closed`로 거절하면 소비자는 "언제 다시
|
|
1216
|
+
* 부를지"를 스스로 알아내야 하고, 첫 connect가 실패했다면 그 때는 영영 오지
|
|
1217
|
+
* 않는다. 붙을 때까지 기다리면 그 신호가 필요 없다. 멈춘 클라이언트(close,
|
|
1218
|
+
* 인증 거절)는 붙을 일이 없으니 거절한다.
|
|
1219
|
+
*/
|
|
1220
|
+
whenOpen() {
|
|
1221
|
+
if (this.state === "open" && this.socket !== void 0) return Promise.resolve();
|
|
1222
|
+
if (this.stopped) return Promise.reject(new ChatError("closed", "the connection is closed"));
|
|
1223
|
+
return new Promise((resolve, reject) => void this.openWaiters.push({ resolve, reject }));
|
|
1224
|
+
}
|
|
1225
|
+
openWaiters = [];
|
|
1226
|
+
settleOpenWaiters(err) {
|
|
1227
|
+
const waiters = this.openWaiters;
|
|
1228
|
+
this.openWaiters = [];
|
|
1229
|
+
for (const w of waiters) {
|
|
1230
|
+
if (err === void 0) w.resolve();
|
|
1231
|
+
else w.reject(err);
|
|
1232
|
+
}
|
|
1045
1233
|
}
|
|
1046
1234
|
/** Sends a frame and resolves with its ack, or rejects with its error. */
|
|
1047
1235
|
send(type, data, timeoutMs = 15e3) {
|
|
@@ -1082,16 +1270,20 @@ var ChatClient = class extends Emitter {
|
|
|
1082
1270
|
this.state = next;
|
|
1083
1271
|
this.emit("state", next);
|
|
1084
1272
|
}
|
|
1085
|
-
async openOnce() {
|
|
1086
|
-
this.setState(
|
|
1273
|
+
async openOnce(state = this.attempt === 0 ? "connecting" : "reconnecting") {
|
|
1274
|
+
this.setState(state);
|
|
1087
1275
|
let authData;
|
|
1088
1276
|
try {
|
|
1089
1277
|
authData = await this.authData();
|
|
1090
1278
|
} catch (err) {
|
|
1279
|
+
if (isChatError(err)) {
|
|
1280
|
+
this.stop(err);
|
|
1281
|
+
throw err;
|
|
1282
|
+
}
|
|
1091
1283
|
this.scheduleReconnect();
|
|
1092
1284
|
throw err;
|
|
1093
1285
|
}
|
|
1094
|
-
if (this.
|
|
1286
|
+
if (this.stopped) {
|
|
1095
1287
|
throw new ChatError("closed", "the client was closed while connecting");
|
|
1096
1288
|
}
|
|
1097
1289
|
const socket = new this.makeSocket(`${this.options.url}/v1/ws?v=1`);
|
|
@@ -1105,6 +1297,7 @@ var ChatClient = class extends Emitter {
|
|
|
1105
1297
|
else reject(err);
|
|
1106
1298
|
};
|
|
1107
1299
|
socket.addEventListener("message", (ev) => {
|
|
1300
|
+
if (this.retired.has(socket)) return;
|
|
1108
1301
|
const frame = JSON.parse(String(ev.data));
|
|
1109
1302
|
if (frame.type === "hello") {
|
|
1110
1303
|
this.onHello(frame.data);
|
|
@@ -1112,14 +1305,20 @@ var ChatClient = class extends Emitter {
|
|
|
1112
1305
|
return;
|
|
1113
1306
|
}
|
|
1114
1307
|
if (frame.type === "error" && !settled) {
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1308
|
+
const err = errorFrom(frame.data);
|
|
1309
|
+
if (err.code === "rate_limited" || err.code === "internal") {
|
|
1310
|
+
if (err.retryAfterMs !== void 0 && err.retryAfterMs > 0) this.plannedDelayMs = err.retryAfterMs;
|
|
1311
|
+
finish(err);
|
|
1312
|
+
return;
|
|
1313
|
+
}
|
|
1314
|
+
finish(err);
|
|
1315
|
+
this.stop(err);
|
|
1118
1316
|
return;
|
|
1119
1317
|
}
|
|
1120
1318
|
this.onFrame(frame);
|
|
1121
1319
|
});
|
|
1122
1320
|
socket.addEventListener("close", () => {
|
|
1321
|
+
if (this.retired.has(socket)) return;
|
|
1123
1322
|
if (this.socket !== void 0 && this.socket !== socket) return;
|
|
1124
1323
|
this.clearTimers();
|
|
1125
1324
|
this.socket = void 0;
|
|
@@ -1147,12 +1346,11 @@ var ChatClient = class extends Emitter {
|
|
|
1147
1346
|
const reconnected = this.everOpened;
|
|
1148
1347
|
this.everOpened = true;
|
|
1149
1348
|
this.setState("open");
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
}
|
|
1349
|
+
this.settleOpenWaiters();
|
|
1350
|
+
for (const room of this.handles.values()) {
|
|
1351
|
+
void room.resume(reconnected).catch((err) => {
|
|
1352
|
+
room.failed(err);
|
|
1353
|
+
});
|
|
1156
1354
|
}
|
|
1157
1355
|
}
|
|
1158
1356
|
onFrame(frame) {
|
|
@@ -1222,7 +1420,7 @@ var ChatClient = class extends Emitter {
|
|
|
1222
1420
|
}, Math.max(1e3, intervalMs));
|
|
1223
1421
|
}
|
|
1224
1422
|
scheduleReconnect() {
|
|
1225
|
-
if (this.
|
|
1423
|
+
if (this.stopped) return;
|
|
1226
1424
|
this.setState("reconnecting");
|
|
1227
1425
|
const delay = this.nextDelay();
|
|
1228
1426
|
this.retryTimer = setTimeout(() => {
|
|
@@ -1274,6 +1472,9 @@ var ChatClient = class extends Emitter {
|
|
|
1274
1472
|
const token = await this.options.token();
|
|
1275
1473
|
this.lastToken = token;
|
|
1276
1474
|
return token;
|
|
1475
|
+
} catch (err) {
|
|
1476
|
+
if (isChatError(err)) this.stop(err);
|
|
1477
|
+
throw err;
|
|
1277
1478
|
} finally {
|
|
1278
1479
|
this.refreshing = void 0;
|
|
1279
1480
|
}
|
|
@@ -1295,6 +1496,9 @@ var ChatClient = class extends Emitter {
|
|
|
1295
1496
|
this.retryTimer = void 0;
|
|
1296
1497
|
}
|
|
1297
1498
|
};
|
|
1499
|
+
function isChatError(err) {
|
|
1500
|
+
return err instanceof ChatError || err instanceof Error && err.name === "ChatError";
|
|
1501
|
+
}
|
|
1298
1502
|
function createChatClient(options) {
|
|
1299
1503
|
return new ChatClient(options);
|
|
1300
1504
|
}
|
package/dist/server/index.cjs
CHANGED
|
@@ -274,6 +274,7 @@ function createChatServer(options) {
|
|
|
274
274
|
custom: async (roomId, payload) => {
|
|
275
275
|
await rest.call("POST", `/v1/rooms/${roomId}/custom`, { payload });
|
|
276
276
|
},
|
|
277
|
+
presence: (roomId, o) => rest.call("GET", `/v1/rooms/${roomId}/presence${rest.query({ full: o?.full ? "true" : void 0 })}`),
|
|
277
278
|
members: {
|
|
278
279
|
list: (roomId, o) => rest.call("GET", `/v1/rooms/${roomId}/members${rest.query({ cursor: o?.cursor, limit: o?.limit })}`),
|
|
279
280
|
add: async (roomId, userId, role) => {
|
|
@@ -320,6 +321,7 @@ function createChatServer(options) {
|
|
|
320
321
|
// the messages left standing. Nothing failed; the caller was told
|
|
321
322
|
// it had worked.
|
|
322
323
|
delete: (userId, o) => rest.call("DELETE", `/v1/users/${userId}${rest.query({ purge_messages: o?.purgeMessages ? "true" : void 0 })}`),
|
|
324
|
+
purgeMessages: (userId) => rest.call("DELETE", `/v1/users/${userId}/messages`),
|
|
323
325
|
revokeTokens: async (userId) => {
|
|
324
326
|
await rest.call("POST", `/v1/users/${userId}/revoke-tokens`);
|
|
325
327
|
},
|
package/dist/server/index.d.cts
CHANGED
|
@@ -109,6 +109,25 @@ type ServerSendInput = {
|
|
|
109
109
|
replyTo?: string;
|
|
110
110
|
threadId?: string;
|
|
111
111
|
};
|
|
112
|
+
type RoomPresence = {
|
|
113
|
+
count: number;
|
|
114
|
+
/** Only with `full: true`. */
|
|
115
|
+
users?: {
|
|
116
|
+
id: string;
|
|
117
|
+
name: string;
|
|
118
|
+
avatar?: string;
|
|
119
|
+
meta?: Record<string, unknown>;
|
|
120
|
+
}[];
|
|
121
|
+
capped?: boolean;
|
|
122
|
+
};
|
|
123
|
+
/** What `users.purgeMessages` and the withdrawal purge report. */
|
|
124
|
+
type PurgeMessagesResult = {
|
|
125
|
+
userId: string;
|
|
126
|
+
/** Messages this call turned into tombstones. 0 on a repeat call after a finished one. */
|
|
127
|
+
purgedMessages: number;
|
|
128
|
+
/** The per-call cap was hit: **call again** until this is false. */
|
|
129
|
+
purgeCapped: boolean;
|
|
130
|
+
};
|
|
112
131
|
type GuestInput = Omit<TokenInput, 'userId'> & {
|
|
113
132
|
/** What `guest()` returned last time, as the browser kept it. Missing or invalid means a new guest. */
|
|
114
133
|
credential?: string | null;
|
|
@@ -169,6 +188,14 @@ type ChatServer = {
|
|
|
169
188
|
* in one frame). `sk_` only, like every other route on this object.
|
|
170
189
|
*/
|
|
171
190
|
custom: (roomId: string, payload: Record<string, unknown>) => Promise<void>;
|
|
191
|
+
/**
|
|
192
|
+
* Who is in the room right now. `{count}` by default; `full: true` adds
|
|
193
|
+
* `users` (at most 1000, `capped: true` when cut). An empty room is
|
|
194
|
+
* `{count: 0}`, not a 404.
|
|
195
|
+
*/
|
|
196
|
+
presence: (roomId: string, options?: {
|
|
197
|
+
full?: boolean;
|
|
198
|
+
}) => Promise<RoomPresence>;
|
|
172
199
|
members: {
|
|
173
200
|
list: (roomId: string, options?: {
|
|
174
201
|
cursor?: string;
|
|
@@ -216,6 +243,17 @@ type ChatServer = {
|
|
|
216
243
|
avatar?: string;
|
|
217
244
|
meta?: Record<string, unknown>;
|
|
218
245
|
}) => Promise<unknown>;
|
|
246
|
+
/**
|
|
247
|
+
* Moderation: deletes every live message this user sent in the app
|
|
248
|
+
* (tombstones -- seq stays gap-free, clients get `message.deleted`),
|
|
249
|
+
* **without** withdrawing them. The name stays on the tombstones.
|
|
250
|
+
*
|
|
251
|
+
* Ban first (`ban`), then call this: a message posted while it runs is
|
|
252
|
+
* not in its listing. Bounded per call -- loop while `purgeCapped`.
|
|
253
|
+
* A withdrawn user is 404; their messages go with
|
|
254
|
+
* `delete(userId, { purgeMessages: true })`.
|
|
255
|
+
*/
|
|
256
|
+
purgeMessages: (userId: string) => Promise<PurgeMessagesResult>;
|
|
219
257
|
/** Withdrawal: anonymises rather than deleting rows. */
|
|
220
258
|
delete: (userId: string, options?: {
|
|
221
259
|
purgeMessages?: boolean;
|
|
@@ -256,4 +294,4 @@ type ChatServer = {
|
|
|
256
294
|
};
|
|
257
295
|
declare function createChatServer(options: ChatServerOptions): ChatServer;
|
|
258
296
|
|
|
259
|
-
export { type ChatServer, type ChatServerOptions, DefaultTokenTtlSeconds, type EnsureRoomInput, GuestIdPrefix, type GuestIdentity, type GuestInput, MaxTokenTtlSeconds, type ServerSendInput, SignatureToleranceMs, type TokenInput, type VerifiedDelivery, type VerifyOptions, createChatServer };
|
|
297
|
+
export { type ChatServer, type ChatServerOptions, DefaultTokenTtlSeconds, type EnsureRoomInput, GuestIdPrefix, type GuestIdentity, type GuestInput, MaxTokenTtlSeconds, type PurgeMessagesResult, type RoomPresence, type ServerSendInput, SignatureToleranceMs, type TokenInput, type VerifiedDelivery, type VerifyOptions, createChatServer };
|
package/dist/server/index.d.ts
CHANGED
|
@@ -109,6 +109,25 @@ type ServerSendInput = {
|
|
|
109
109
|
replyTo?: string;
|
|
110
110
|
threadId?: string;
|
|
111
111
|
};
|
|
112
|
+
type RoomPresence = {
|
|
113
|
+
count: number;
|
|
114
|
+
/** Only with `full: true`. */
|
|
115
|
+
users?: {
|
|
116
|
+
id: string;
|
|
117
|
+
name: string;
|
|
118
|
+
avatar?: string;
|
|
119
|
+
meta?: Record<string, unknown>;
|
|
120
|
+
}[];
|
|
121
|
+
capped?: boolean;
|
|
122
|
+
};
|
|
123
|
+
/** What `users.purgeMessages` and the withdrawal purge report. */
|
|
124
|
+
type PurgeMessagesResult = {
|
|
125
|
+
userId: string;
|
|
126
|
+
/** Messages this call turned into tombstones. 0 on a repeat call after a finished one. */
|
|
127
|
+
purgedMessages: number;
|
|
128
|
+
/** The per-call cap was hit: **call again** until this is false. */
|
|
129
|
+
purgeCapped: boolean;
|
|
130
|
+
};
|
|
112
131
|
type GuestInput = Omit<TokenInput, 'userId'> & {
|
|
113
132
|
/** What `guest()` returned last time, as the browser kept it. Missing or invalid means a new guest. */
|
|
114
133
|
credential?: string | null;
|
|
@@ -169,6 +188,14 @@ type ChatServer = {
|
|
|
169
188
|
* in one frame). `sk_` only, like every other route on this object.
|
|
170
189
|
*/
|
|
171
190
|
custom: (roomId: string, payload: Record<string, unknown>) => Promise<void>;
|
|
191
|
+
/**
|
|
192
|
+
* Who is in the room right now. `{count}` by default; `full: true` adds
|
|
193
|
+
* `users` (at most 1000, `capped: true` when cut). An empty room is
|
|
194
|
+
* `{count: 0}`, not a 404.
|
|
195
|
+
*/
|
|
196
|
+
presence: (roomId: string, options?: {
|
|
197
|
+
full?: boolean;
|
|
198
|
+
}) => Promise<RoomPresence>;
|
|
172
199
|
members: {
|
|
173
200
|
list: (roomId: string, options?: {
|
|
174
201
|
cursor?: string;
|
|
@@ -216,6 +243,17 @@ type ChatServer = {
|
|
|
216
243
|
avatar?: string;
|
|
217
244
|
meta?: Record<string, unknown>;
|
|
218
245
|
}) => Promise<unknown>;
|
|
246
|
+
/**
|
|
247
|
+
* Moderation: deletes every live message this user sent in the app
|
|
248
|
+
* (tombstones -- seq stays gap-free, clients get `message.deleted`),
|
|
249
|
+
* **without** withdrawing them. The name stays on the tombstones.
|
|
250
|
+
*
|
|
251
|
+
* Ban first (`ban`), then call this: a message posted while it runs is
|
|
252
|
+
* not in its listing. Bounded per call -- loop while `purgeCapped`.
|
|
253
|
+
* A withdrawn user is 404; their messages go with
|
|
254
|
+
* `delete(userId, { purgeMessages: true })`.
|
|
255
|
+
*/
|
|
256
|
+
purgeMessages: (userId: string) => Promise<PurgeMessagesResult>;
|
|
219
257
|
/** Withdrawal: anonymises rather than deleting rows. */
|
|
220
258
|
delete: (userId: string, options?: {
|
|
221
259
|
purgeMessages?: boolean;
|
|
@@ -256,4 +294,4 @@ type ChatServer = {
|
|
|
256
294
|
};
|
|
257
295
|
declare function createChatServer(options: ChatServerOptions): ChatServer;
|
|
258
296
|
|
|
259
|
-
export { type ChatServer, type ChatServerOptions, DefaultTokenTtlSeconds, type EnsureRoomInput, GuestIdPrefix, type GuestIdentity, type GuestInput, MaxTokenTtlSeconds, type ServerSendInput, SignatureToleranceMs, type TokenInput, type VerifiedDelivery, type VerifyOptions, createChatServer };
|
|
297
|
+
export { type ChatServer, type ChatServerOptions, DefaultTokenTtlSeconds, type EnsureRoomInput, GuestIdPrefix, type GuestIdentity, type GuestInput, MaxTokenTtlSeconds, type PurgeMessagesResult, type RoomPresence, type ServerSendInput, SignatureToleranceMs, type TokenInput, type VerifiedDelivery, type VerifyOptions, createChatServer };
|
package/dist/server/index.js
CHANGED
|
@@ -217,6 +217,7 @@ function createChatServer(options) {
|
|
|
217
217
|
custom: async (roomId, payload) => {
|
|
218
218
|
await rest.call("POST", `/v1/rooms/${roomId}/custom`, { payload });
|
|
219
219
|
},
|
|
220
|
+
presence: (roomId, o) => rest.call("GET", `/v1/rooms/${roomId}/presence${rest.query({ full: o?.full ? "true" : void 0 })}`),
|
|
220
221
|
members: {
|
|
221
222
|
list: (roomId, o) => rest.call("GET", `/v1/rooms/${roomId}/members${rest.query({ cursor: o?.cursor, limit: o?.limit })}`),
|
|
222
223
|
add: async (roomId, userId, role) => {
|
|
@@ -263,6 +264,7 @@ function createChatServer(options) {
|
|
|
263
264
|
// the messages left standing. Nothing failed; the caller was told
|
|
264
265
|
// it had worked.
|
|
265
266
|
delete: (userId, o) => rest.call("DELETE", `/v1/users/${userId}${rest.query({ purge_messages: o?.purgeMessages ? "true" : void 0 })}`),
|
|
267
|
+
purgeMessages: (userId) => rest.call("DELETE", `/v1/users/${userId}/messages`),
|
|
266
268
|
revokeTokens: async (userId) => {
|
|
267
269
|
await rest.call("POST", `/v1/users/${userId}/revoke-tokens`);
|
|
268
270
|
},
|