@kispi/chat 0.1.2 → 0.2.1
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 +404 -0
- package/README.md +142 -11
- package/dist/{chunk-T3QOUFOM.js → chunk-5QQRM7BH.js} +2 -0
- package/dist/index.cjs +265 -38
- package/dist/index.d.cts +173 -36
- package/dist/index.d.ts +173 -36
- package/dist/index.js +264 -39
- package/dist/server/index.cjs +59 -10
- package/dist/server/index.d.cts +52 -1
- package/dist/server/index.d.ts +52 -1
- package/dist/server/index.js +57 -11
- package/package.json +3 -2
package/dist/index.cjs
CHANGED
|
@@ -67,6 +67,8 @@ var ChatError = class extends Error {
|
|
|
67
67
|
retryAfterMs;
|
|
68
68
|
/** The consumer's own code, when a before_publish hook denied this. */
|
|
69
69
|
appCode;
|
|
70
|
+
/** The HTTP status, when this came from a REST call. */
|
|
71
|
+
status;
|
|
70
72
|
constructor(code, message, extra) {
|
|
71
73
|
super(message);
|
|
72
74
|
this.name = "ChatError";
|
|
@@ -112,6 +114,16 @@ var Rest = class {
|
|
|
112
114
|
}
|
|
113
115
|
async call(method, path, body) {
|
|
114
116
|
const token = await this.options.token();
|
|
117
|
+
try {
|
|
118
|
+
return await this.callWith(token, method, path, body);
|
|
119
|
+
} catch (err) {
|
|
120
|
+
if (!(err instanceof ChatError) || err.status !== 401 || this.options.refreshToken === void 0) throw err;
|
|
121
|
+
const fresh = await this.options.refreshToken().catch(() => void 0);
|
|
122
|
+
if (fresh === void 0 || fresh === token) throw err;
|
|
123
|
+
return this.callWith(fresh, method, path, body);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
async callWith(token, method, path, body) {
|
|
115
127
|
if (token === void 0) {
|
|
116
128
|
throw new ChatError(
|
|
117
129
|
"unauthorized",
|
|
@@ -133,6 +145,7 @@ var Rest = class {
|
|
|
133
145
|
const parsed = text === "" ? void 0 : JSON.parse(text);
|
|
134
146
|
if (!res.ok) {
|
|
135
147
|
const err = errorFrom(parsed, `${method} ${path} failed with ${res.status}`);
|
|
148
|
+
err.status = res.status;
|
|
136
149
|
throw err;
|
|
137
150
|
}
|
|
138
151
|
return parsed;
|
|
@@ -179,10 +192,24 @@ var Timeline = class {
|
|
|
179
192
|
* rows into the new list.
|
|
180
193
|
*/
|
|
181
194
|
epoch = 0;
|
|
195
|
+
/**
|
|
196
|
+
* `items` has been handed out -- by the getter or by `onChange`.
|
|
197
|
+
*
|
|
198
|
+
* 한 번 내준 배열은 다시 건드리지 않는다. 같은 배열을 제자리에서 고쳐
|
|
199
|
+
* 다시 내주면 Svelte `$state.raw`, React `useState`, Vue `shallowRef`처럼
|
|
200
|
+
* 참조가 바뀌어야 다시 그리는 쪽은 변화를 보지 못하고 화면이 멈춘다. 그래서
|
|
201
|
+
* 내준 뒤의 첫 변경은 복사본에서 한다(copy-on-write). 행 객체도 같은 규칙이라
|
|
202
|
+
* 바뀐 행만 새 객체다.
|
|
203
|
+
*/
|
|
204
|
+
shared = false;
|
|
205
|
+
/** The `loadOlder` in flight, so a scroll handler firing twice asks once. */
|
|
206
|
+
older;
|
|
182
207
|
constructor(options) {
|
|
183
208
|
this.options = options;
|
|
184
209
|
}
|
|
210
|
+
/** A new array whenever the list changes; never mutated once returned. */
|
|
185
211
|
get messages() {
|
|
212
|
+
this.shared = true;
|
|
186
213
|
return this.items;
|
|
187
214
|
}
|
|
188
215
|
/** The highest seq this timeline holds, hole or no hole. */
|
|
@@ -229,6 +256,7 @@ var Timeline = class {
|
|
|
229
256
|
this.fills.clear();
|
|
230
257
|
this.pendingDeletes.clear();
|
|
231
258
|
this.items = [];
|
|
259
|
+
this.shared = false;
|
|
232
260
|
this.changed();
|
|
233
261
|
}
|
|
234
262
|
/**
|
|
@@ -255,6 +283,7 @@ var Timeline = class {
|
|
|
255
283
|
const page = [...messages].sort((a, b) => a.seq - b.seq);
|
|
256
284
|
const top = page.at(-1)?.seq ?? 0;
|
|
257
285
|
this.items = [...page, ...this.items.filter((m) => m.seq > top)];
|
|
286
|
+
this.shared = false;
|
|
258
287
|
for (const m of this.items) {
|
|
259
288
|
if (this.pendingDeletes.has(m.id)) this.applyDelete(m.id);
|
|
260
289
|
}
|
|
@@ -289,6 +318,7 @@ var Timeline = class {
|
|
|
289
318
|
if (at === -1) return;
|
|
290
319
|
const current = this.items[at];
|
|
291
320
|
if (current.deletedAt !== void 0) return;
|
|
321
|
+
this.own();
|
|
292
322
|
this.items[at] = { ...current, ...message };
|
|
293
323
|
this.changed();
|
|
294
324
|
}
|
|
@@ -315,6 +345,7 @@ var Timeline = class {
|
|
|
315
345
|
if (count <= 0) next = existing.filter((r) => r.emoji !== emoji);
|
|
316
346
|
else if (slot === -1) next = [...existing, { emoji, count }];
|
|
317
347
|
else next = existing.map((r, i) => i === slot ? { emoji, count } : r);
|
|
348
|
+
this.own();
|
|
318
349
|
this.items[at] = { ...current, reactions: next };
|
|
319
350
|
this.changed();
|
|
320
351
|
}
|
|
@@ -323,6 +354,7 @@ var Timeline = class {
|
|
|
323
354
|
const at = this.items.findIndex((m) => m.id === rootId);
|
|
324
355
|
if (at === -1) return;
|
|
325
356
|
const current = this.items[at];
|
|
357
|
+
this.own();
|
|
326
358
|
this.items[at] = { ...current, thread: lastSeq === void 0 ? { count } : { count, lastSeq } };
|
|
327
359
|
this.changed();
|
|
328
360
|
}
|
|
@@ -330,6 +362,36 @@ var Timeline = class {
|
|
|
330
362
|
remove(id) {
|
|
331
363
|
if (!this.applyDelete(id)) this.pendingDeletes.add(id);
|
|
332
364
|
}
|
|
365
|
+
/**
|
|
366
|
+
* Prepends the page just below the oldest message held.
|
|
367
|
+
*
|
|
368
|
+
* 스크롤을 올려 과거를 읽는 소비자가 `history()` 결과를 라이브 목록과 따로
|
|
369
|
+
* 들고 합치고, 중복을 걸러 내고, reset과 재접속을 따로 처리해야 했다. 같은
|
|
370
|
+
* 목록에 끼워 넣으면 그 일이 전부 이미 있는 규칙(seq 자리에 넣기, 같은 seq는
|
|
371
|
+
* 한 행, reset이면 epoch로 버리기)으로 끝난다.
|
|
372
|
+
*
|
|
373
|
+
* `hasMore`는 더 올라갈 것이 있는지다. 서버의 seq는 1부터 구멍 없이 붙으므로
|
|
374
|
+
* 맨 위가 1이거나 페이지가 덜 찼으면 끝이다(보존 기간이 지운 앞부분도 덜 찬
|
|
375
|
+
* 페이지로 드러난다). 한 번에 하나만 돈다 — 스크롤 핸들러가 두 번 불러도
|
|
376
|
+
* 요청은 하나다(진행 중인 호출과 같은 결과를 받으므로 뒤 호출의 `limit`은 쓰이지 않는다).
|
|
377
|
+
*/
|
|
378
|
+
loadOlder(limit = this.options.pageSize ?? MaxPageSize) {
|
|
379
|
+
this.older ??= this.readOlder(Math.min(limit, MaxPageSize)).finally(() => {
|
|
380
|
+
this.older = void 0;
|
|
381
|
+
});
|
|
382
|
+
return this.older;
|
|
383
|
+
}
|
|
384
|
+
async readOlder(limit) {
|
|
385
|
+
const first = this.items[0];
|
|
386
|
+
if (first === void 0 || first.seq <= 1) return { messages: [], hasMore: false };
|
|
387
|
+
const epoch = this.epoch;
|
|
388
|
+
const page = await this.options.fetchRange({ after: 0, before: first.seq, limit });
|
|
389
|
+
if (this.epoch !== epoch) return { messages: [], hasMore: true };
|
|
390
|
+
for (const m of page) this.insert(m, false);
|
|
391
|
+
if (page.length > 0) this.changed();
|
|
392
|
+
const top = page[0];
|
|
393
|
+
return { messages: page, hasMore: page.length >= limit && top !== void 0 && top.seq > 1 };
|
|
394
|
+
}
|
|
333
395
|
/**
|
|
334
396
|
* Fetches everything between what we have and `upTo`.
|
|
335
397
|
*
|
|
@@ -374,10 +436,11 @@ var Timeline = class {
|
|
|
374
436
|
return run;
|
|
375
437
|
}
|
|
376
438
|
/** Inserts at the seq position, replacing an existing row with that seq. */
|
|
377
|
-
insert(message) {
|
|
439
|
+
insert(message, notify = true) {
|
|
378
440
|
const pending = this.pendingDeletes.has(message.id);
|
|
379
441
|
if (pending) this.pendingDeletes.delete(message.id);
|
|
380
442
|
const withDelete = pending ? emptied(message) : message;
|
|
443
|
+
this.own();
|
|
381
444
|
const at = this.items.findIndex((m) => m.seq >= withDelete.seq);
|
|
382
445
|
if (at === -1) {
|
|
383
446
|
this.items.push(withDelete);
|
|
@@ -387,17 +450,25 @@ var Timeline = class {
|
|
|
387
450
|
} else {
|
|
388
451
|
this.items.splice(at, 0, withDelete);
|
|
389
452
|
}
|
|
390
|
-
this.changed();
|
|
453
|
+
if (notify) this.changed();
|
|
391
454
|
}
|
|
392
455
|
applyDelete(id) {
|
|
393
456
|
const at = this.items.findIndex((m) => m.id === id);
|
|
394
457
|
if (at === -1) return false;
|
|
395
458
|
this.pendingDeletes.delete(id);
|
|
459
|
+
this.own();
|
|
396
460
|
this.items[at] = emptied(this.items[at]);
|
|
397
461
|
this.changed();
|
|
398
462
|
return true;
|
|
399
463
|
}
|
|
464
|
+
/** Makes `items` safe to mutate: a copy, if the current one was handed out. */
|
|
465
|
+
own() {
|
|
466
|
+
if (!this.shared) return;
|
|
467
|
+
this.items = this.items.slice();
|
|
468
|
+
this.shared = false;
|
|
469
|
+
}
|
|
400
470
|
changed() {
|
|
471
|
+
this.shared = true;
|
|
401
472
|
this.options.onChange?.(this.items);
|
|
402
473
|
}
|
|
403
474
|
};
|
|
@@ -410,6 +481,8 @@ function createTimeline(options) {
|
|
|
410
481
|
|
|
411
482
|
// src/room.ts
|
|
412
483
|
var HistoryPageSize = 100;
|
|
484
|
+
var MaxRepairs = 5;
|
|
485
|
+
var RepairBaseMs = 1e3;
|
|
413
486
|
var Room = class extends Emitter {
|
|
414
487
|
/** Resolved on subscribe when the room was addressed by key. */
|
|
415
488
|
id;
|
|
@@ -447,6 +520,9 @@ var Room = class extends Emitter {
|
|
|
447
520
|
*/
|
|
448
521
|
wanted = false;
|
|
449
522
|
subscribing;
|
|
523
|
+
/** Self-repair: the armed retry, and how many have run since the last success. */
|
|
524
|
+
repairTimer;
|
|
525
|
+
repairs = 0;
|
|
450
526
|
constructor(chat, rest, address) {
|
|
451
527
|
super();
|
|
452
528
|
this.chat = chat;
|
|
@@ -462,10 +538,27 @@ var Room = class extends Emitter {
|
|
|
462
538
|
onChange: (messages) => this.emit("messages", messages)
|
|
463
539
|
});
|
|
464
540
|
}
|
|
465
|
-
/** Everything this client knows about the room, in seq order. */
|
|
541
|
+
/** Everything this client knows about the room, in seq order. A new array whenever it changes. */
|
|
466
542
|
get messages() {
|
|
467
543
|
return this.timeline.messages;
|
|
468
544
|
}
|
|
545
|
+
/**
|
|
546
|
+
* Reads the page just before the oldest message held and **prepends it to
|
|
547
|
+
* `messages`**.
|
|
548
|
+
*
|
|
549
|
+
* The scroll-back call. Unlike `history()`, the rows join the room's own
|
|
550
|
+
* list, so ordering, de-duplication, deletes, reactions and reconnects
|
|
551
|
+
* apply to them like any other row, and the `messages` event fires once.
|
|
552
|
+
* A `reset` (or `reload()`) drops them with everything else; a read that
|
|
553
|
+
* lands after one is discarded rather than stitched onto the new list.
|
|
554
|
+
*
|
|
555
|
+
* `hasMore` is false once the top of the room -- or of its retention --
|
|
556
|
+
* is reached. Concurrent calls share one request.
|
|
557
|
+
*/
|
|
558
|
+
async loadOlder(options = {}) {
|
|
559
|
+
await this.resolveId("loading older messages");
|
|
560
|
+
return this.timeline.loadOlder(options.limit);
|
|
561
|
+
}
|
|
469
562
|
/**
|
|
470
563
|
* Reloads recent history, discarding what is held.
|
|
471
564
|
*
|
|
@@ -496,7 +589,12 @@ var Room = class extends Emitter {
|
|
|
496
589
|
this.wanted = true;
|
|
497
590
|
if (this.subscribing !== void 0) return this.subscribing;
|
|
498
591
|
if (this.subscribed) return;
|
|
499
|
-
const attempt = this.doSubscribe().
|
|
592
|
+
const attempt = this.doSubscribe().then(() => {
|
|
593
|
+
const successor = this.subscribing;
|
|
594
|
+
if (successor !== void 0 && successor !== attempt) return successor;
|
|
595
|
+
if (this.subscribed) this.repairs = 0;
|
|
596
|
+
return void 0;
|
|
597
|
+
}).finally(() => {
|
|
500
598
|
if (this.subscribing === attempt) this.subscribing = void 0;
|
|
501
599
|
});
|
|
502
600
|
this.subscribing = attempt;
|
|
@@ -505,6 +603,10 @@ var Room = class extends Emitter {
|
|
|
505
603
|
async doSubscribe() {
|
|
506
604
|
const gen = this.generation;
|
|
507
605
|
const current = () => gen === this.generation;
|
|
606
|
+
if (this.chat.state !== "open") {
|
|
607
|
+
await this.chat.whenOpen();
|
|
608
|
+
if (!current()) return;
|
|
609
|
+
}
|
|
508
610
|
const data = this.id !== void 0 ? { roomId: this.id } : { key: this.key };
|
|
509
611
|
const since = this.timeline.contiguousSeq;
|
|
510
612
|
if (since > 0) data["since"] = since;
|
|
@@ -539,7 +641,8 @@ var Room = class extends Emitter {
|
|
|
539
641
|
*
|
|
540
642
|
* The list this room keeps is the live one; this is for a consumer
|
|
541
643
|
* scrolling back, which owns its own window and does not want the
|
|
542
|
-
* bottom of the room rearranged under it.
|
|
644
|
+
* bottom of the room rearranged under it. **Scrolling the room's own
|
|
645
|
+
* list back is `loadOlder()`**, which keeps one list instead of two.
|
|
543
646
|
*
|
|
544
647
|
* `view` defaults to the server's, which is every message including
|
|
545
648
|
* thread replies. **`view: 'main'` is a display filter, not a sync
|
|
@@ -548,7 +651,7 @@ var Room = class extends Emitter {
|
|
|
548
651
|
*/
|
|
549
652
|
async history(options = {}) {
|
|
550
653
|
const view = options.view === void 0 ? void 0 : typeof options.view === "string" ? options.view : `thread:${options.view.thread}`;
|
|
551
|
-
const page = await this.rest.get(`/v1/rooms/${this.
|
|
654
|
+
const page = await this.rest.get(`/v1/rooms/${await this.resolveId("reading history")}/messages`, {
|
|
552
655
|
before: options.before,
|
|
553
656
|
after: options.after,
|
|
554
657
|
limit: options.limit,
|
|
@@ -558,10 +661,10 @@ var Room = class extends Emitter {
|
|
|
558
661
|
}
|
|
559
662
|
/** Adds a reaction. Idempotent, like the frame. */
|
|
560
663
|
async react(messageId, emoji) {
|
|
561
|
-
await this.chat.send("react", { roomId: this.
|
|
664
|
+
await this.chat.send("react", { roomId: await this.resolveId("reacting"), messageId, emoji, op: "add" });
|
|
562
665
|
}
|
|
563
666
|
async unreact(messageId, emoji) {
|
|
564
|
-
await this.chat.send("react", { roomId: this.
|
|
667
|
+
await this.chat.send("react", { roomId: await this.resolveId("reacting"), messageId, emoji, op: "remove" });
|
|
565
668
|
}
|
|
566
669
|
/**
|
|
567
670
|
* Moves this user's read cursor.
|
|
@@ -571,7 +674,7 @@ var Room = class extends Emitter {
|
|
|
571
674
|
* way to know what happened. It is returned rather than swallowed.
|
|
572
675
|
*/
|
|
573
676
|
async markRead(seq, options = {}) {
|
|
574
|
-
const data = { roomId: this.
|
|
677
|
+
const data = { roomId: await this.resolveId("marking read"), seq };
|
|
575
678
|
if (options.threadId !== void 0) data["threadId"] = options.threadId;
|
|
576
679
|
return await this.chat.send("read", data);
|
|
577
680
|
}
|
|
@@ -598,15 +701,15 @@ var Room = class extends Emitter {
|
|
|
598
701
|
}
|
|
599
702
|
/** Joins a public or channel room. Idempotent. */
|
|
600
703
|
async join() {
|
|
601
|
-
await this.rest.post(`/v1/rooms/${this.
|
|
704
|
+
await this.rest.post(`/v1/rooms/${await this.resolveId("joining")}/join`);
|
|
602
705
|
}
|
|
603
706
|
/** Leaves. Idempotent from the caller's side. */
|
|
604
707
|
async leave() {
|
|
605
|
-
await this.rest.delete(`/v1/rooms/${this.
|
|
708
|
+
await this.rest.delete(`/v1/rooms/${await this.resolveId("leaving")}/members/me`);
|
|
606
709
|
}
|
|
607
710
|
/** The full presence list, for rooms too large to send it in the ack. */
|
|
608
711
|
async presenceList() {
|
|
609
|
-
return this.rest.get(`/v1/rooms/${this.
|
|
712
|
+
return this.rest.get(`/v1/rooms/${await this.resolveId("reading presence")}/presence`, { full: "true" });
|
|
610
713
|
}
|
|
611
714
|
/**
|
|
612
715
|
* Who reacted, one row per (user, emoji) pair.
|
|
@@ -618,7 +721,7 @@ var Room = class extends Emitter {
|
|
|
618
721
|
* most, and a full page still carries a cursor when more follows.
|
|
619
722
|
*/
|
|
620
723
|
async reactionsOf(messageId, options = {}) {
|
|
621
|
-
return this.rest.get(`/v1/rooms/${this.
|
|
724
|
+
return this.rest.get(`/v1/rooms/${await this.resolveId("reading reactions")}/messages/${messageId}/reactions`, {
|
|
622
725
|
emoji: options.emoji,
|
|
623
726
|
cursor: options.cursor,
|
|
624
727
|
limit: options.limit
|
|
@@ -633,13 +736,33 @@ var Room = class extends Emitter {
|
|
|
633
736
|
* transient 500 takes a room out of the live feed for the life of the
|
|
634
737
|
* page.
|
|
635
738
|
*/
|
|
636
|
-
async resume() {
|
|
739
|
+
async resume(restart = true) {
|
|
637
740
|
if (!this.wanted) return;
|
|
741
|
+
if (!restart) return this.subscribe();
|
|
638
742
|
this.generation++;
|
|
639
743
|
this.subscribing = void 0;
|
|
640
744
|
this.subscribed = false;
|
|
641
745
|
await this.subscribe();
|
|
642
746
|
}
|
|
747
|
+
/**
|
|
748
|
+
* Reports a failure no caller was waiting for, and repairs.
|
|
749
|
+
*
|
|
750
|
+
* 백그라운드 실패(재접속 뒤 다시 구독, 구멍 메우기)에는 거절을 받을 호출자가
|
|
751
|
+
* 없다. 이벤트만 내고 두면 방은 다음 재접속이나 탭 복귀까지 구멍을 안고 —
|
|
752
|
+
* 구독 프레임부터 실패했다면 라이브 피드 밖에서 — 머문다. 그래서 스스로 다시
|
|
753
|
+
* 구독한다. 횟수를 묶는 것은 고칠 수 없는 실패(권한, 없어진 방)가 서버를
|
|
754
|
+
* 계속 두드리지 않게 하려는 것이고, 그 뒤는 재접속과 `subscribe()`의 몫이다.
|
|
755
|
+
*/
|
|
756
|
+
failed(err) {
|
|
757
|
+
this.emit("error", err instanceof Error ? err : new Error(String(err)));
|
|
758
|
+
if (this.repairTimer !== void 0 || this.repairs >= MaxRepairs) return;
|
|
759
|
+
const delay = RepairBaseMs * 2 ** this.repairs++;
|
|
760
|
+
this.repairTimer = setTimeout(() => {
|
|
761
|
+
this.repairTimer = void 0;
|
|
762
|
+
if (!this.wanted || this.subscribed || this.chat.state !== "open") return;
|
|
763
|
+
this.subscribe().catch((e) => this.failed(e));
|
|
764
|
+
}, delay);
|
|
765
|
+
}
|
|
643
766
|
/**
|
|
644
767
|
* Sends `subscribe` and recovers from the one error it has an answer
|
|
645
768
|
* for.
|
|
@@ -684,9 +807,22 @@ var Room = class extends Emitter {
|
|
|
684
807
|
}
|
|
685
808
|
return this.id;
|
|
686
809
|
}
|
|
810
|
+
/**
|
|
811
|
+
* The room's id, waiting for a subscribe in flight to learn it.
|
|
812
|
+
*
|
|
813
|
+
* `roomByKey(k).subscribe()`를 await하지 않고 곧바로 `send`하는 것은 자연스러운
|
|
814
|
+
* 코드이고, 그때 id는 구독 ack가 와야 생긴다. 거절(`closed`)하면 소비자는
|
|
815
|
+
* "보내도 되는 때"를 알릴 신호를 따로 찾아야 한다 — 이미 날아가고 있는
|
|
816
|
+
* 구독을 기다리면 그 신호가 필요 없다. 구독이 실패하면 그 실패가 그대로
|
|
817
|
+
* 나간다. 구독한 적이 없으면 기다릴 것이 없으니 예전처럼 거절한다.
|
|
818
|
+
*/
|
|
819
|
+
async resolveId(what) {
|
|
820
|
+
while (this.id === void 0 && this.subscribing !== void 0) await this.subscribing;
|
|
821
|
+
return this.requireId(what);
|
|
822
|
+
}
|
|
687
823
|
/** Publishes and resolves when the server acks. */
|
|
688
824
|
async send(input, options = {}) {
|
|
689
|
-
const roomId = this.
|
|
825
|
+
const roomId = await this.resolveId("sending");
|
|
690
826
|
const body = {};
|
|
691
827
|
if (input.text !== void 0) body["text"] = input.text;
|
|
692
828
|
if (input.entities !== void 0) body["entities"] = input.entities;
|
|
@@ -708,7 +844,7 @@ var Room = class extends Emitter {
|
|
|
708
844
|
if (data.seq > this.lastSeq) this.lastSeq = data.seq;
|
|
709
845
|
this.timeline.add(data).catch((err) => {
|
|
710
846
|
this.subscribed = false;
|
|
711
|
-
this.
|
|
847
|
+
this.failed(err);
|
|
712
848
|
});
|
|
713
849
|
break;
|
|
714
850
|
case "message.updated":
|
|
@@ -777,8 +913,11 @@ var ChatClient = class extends Emitter {
|
|
|
777
913
|
heartbeat;
|
|
778
914
|
retryTimer;
|
|
779
915
|
attempt = 0;
|
|
780
|
-
/**
|
|
781
|
-
|
|
916
|
+
/**
|
|
917
|
+
* The reconnect loop is off: set by close(), and by a failure that
|
|
918
|
+
* retrying cannot fix (see `stop`). Cleared by connect().
|
|
919
|
+
*/
|
|
920
|
+
stopped = false;
|
|
782
921
|
nextFrameId = 0;
|
|
783
922
|
opening;
|
|
784
923
|
rest;
|
|
@@ -818,6 +957,7 @@ var ChatClient = class extends Emitter {
|
|
|
818
957
|
url: options.restURL ?? restURLFrom(options.url),
|
|
819
958
|
key: options.key,
|
|
820
959
|
token: async () => this.lastToken,
|
|
960
|
+
refreshToken: () => this.refreshToken(),
|
|
821
961
|
fetch: options.fetch ?? globalThis.fetch.bind(globalThis)
|
|
822
962
|
});
|
|
823
963
|
this.setupLifecycle();
|
|
@@ -855,7 +995,7 @@ var ChatClient = class extends Emitter {
|
|
|
855
995
|
* gap-filled via REST.
|
|
856
996
|
*/
|
|
857
997
|
async resume() {
|
|
858
|
-
if (this.
|
|
998
|
+
if (this.stopped) return;
|
|
859
999
|
if (this.state !== "open" || this.socket === void 0) {
|
|
860
1000
|
this.clearTimers();
|
|
861
1001
|
return this.connect();
|
|
@@ -867,9 +1007,7 @@ var ChatClient = class extends Emitter {
|
|
|
867
1007
|
return this.connect();
|
|
868
1008
|
}
|
|
869
1009
|
for (const room of this.handles.values()) {
|
|
870
|
-
void room.resume().catch((err) =>
|
|
871
|
-
room.emit("error", err instanceof Error ? err : new Error(String(err)));
|
|
872
|
-
});
|
|
1010
|
+
void room.resume().catch((err) => room.failed(err));
|
|
873
1011
|
}
|
|
874
1012
|
}
|
|
875
1013
|
/** The account-level routes: the rooms this user is in, and unread. */
|
|
@@ -961,7 +1099,7 @@ var ChatClient = class extends Emitter {
|
|
|
961
1099
|
}
|
|
962
1100
|
/** Opens the connection and resolves when `hello` arrives. */
|
|
963
1101
|
async connect() {
|
|
964
|
-
this.
|
|
1102
|
+
this.stopped = false;
|
|
965
1103
|
if (this.opening !== void 0) return this.opening;
|
|
966
1104
|
if (this.state === "open" && this.socket !== void 0) return;
|
|
967
1105
|
this.clearTimers();
|
|
@@ -974,11 +1112,12 @@ var ChatClient = class extends Emitter {
|
|
|
974
1112
|
* Closes for good.
|
|
975
1113
|
*
|
|
976
1114
|
* The distinction from a dropped socket is the whole point of the state
|
|
977
|
-
* machine:
|
|
978
|
-
*
|
|
1115
|
+
* machine: `closed` means nothing is coming back, and a consumer showing
|
|
1116
|
+
* "disconnected, retrying" versus "disconnected" needs it. The other
|
|
1117
|
+
* ways to reach it are refusals retrying cannot fix -- see `stop`.
|
|
979
1118
|
*/
|
|
980
1119
|
async close() {
|
|
981
|
-
this.
|
|
1120
|
+
this.stopped = true;
|
|
982
1121
|
this.teardowns.forEach((t) => {
|
|
983
1122
|
try {
|
|
984
1123
|
t();
|
|
@@ -995,8 +1134,54 @@ var ChatClient = class extends Emitter {
|
|
|
995
1134
|
} catch {
|
|
996
1135
|
}
|
|
997
1136
|
}
|
|
1137
|
+
this.settleOpenWaiters(new ChatError("closed", "the connection is closed"));
|
|
998
1138
|
this.setState("closed");
|
|
999
1139
|
}
|
|
1140
|
+
/**
|
|
1141
|
+
* Stops for a reason retrying cannot fix, and says which.
|
|
1142
|
+
*
|
|
1143
|
+
* `close()` without the teardown of the page listeners -- a consumer
|
|
1144
|
+
* that logs back in calls `connect()` on the same client -- and with an
|
|
1145
|
+
* `error` event, because on a reconnect there is no caller to reject.
|
|
1146
|
+
*/
|
|
1147
|
+
stop(err) {
|
|
1148
|
+
this.stopped = true;
|
|
1149
|
+
this.clearTimers();
|
|
1150
|
+
const socket = this.socket;
|
|
1151
|
+
this.socket = void 0;
|
|
1152
|
+
if (socket !== void 0) {
|
|
1153
|
+
try {
|
|
1154
|
+
socket.close(1e3, "client stopped");
|
|
1155
|
+
} catch {
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
this.failPending(err);
|
|
1159
|
+
this.settleOpenWaiters(new ChatError("closed", "the connection is closed"));
|
|
1160
|
+
this.setState("closed");
|
|
1161
|
+
this.emit("error", err);
|
|
1162
|
+
}
|
|
1163
|
+
/**
|
|
1164
|
+
* Resolves once the connection is open. Used by `Room.subscribe`.
|
|
1165
|
+
*
|
|
1166
|
+
* 연결 전에 부른 `subscribe()`를 `closed`로 거절하면 소비자는 "언제 다시
|
|
1167
|
+
* 부를지"를 스스로 알아내야 하고, 첫 connect가 실패했다면 그 때는 영영 오지
|
|
1168
|
+
* 않는다. 붙을 때까지 기다리면 그 신호가 필요 없다. 멈춘 클라이언트(close,
|
|
1169
|
+
* 인증 거절)는 붙을 일이 없으니 거절한다.
|
|
1170
|
+
*/
|
|
1171
|
+
whenOpen() {
|
|
1172
|
+
if (this.state === "open" && this.socket !== void 0) return Promise.resolve();
|
|
1173
|
+
if (this.stopped) return Promise.reject(new ChatError("closed", "the connection is closed"));
|
|
1174
|
+
return new Promise((resolve, reject) => void this.openWaiters.push({ resolve, reject }));
|
|
1175
|
+
}
|
|
1176
|
+
openWaiters = [];
|
|
1177
|
+
settleOpenWaiters(err) {
|
|
1178
|
+
const waiters = this.openWaiters;
|
|
1179
|
+
this.openWaiters = [];
|
|
1180
|
+
for (const w of waiters) {
|
|
1181
|
+
if (err === void 0) w.resolve();
|
|
1182
|
+
else w.reject(err);
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1000
1185
|
/** Sends a frame and resolves with its ack, or rejects with its error. */
|
|
1001
1186
|
send(type, data, timeoutMs = 15e3) {
|
|
1002
1187
|
const socket = this.socket;
|
|
@@ -1038,8 +1223,18 @@ var ChatClient = class extends Emitter {
|
|
|
1038
1223
|
}
|
|
1039
1224
|
async openOnce() {
|
|
1040
1225
|
this.setState(this.attempt === 0 ? "connecting" : "reconnecting");
|
|
1041
|
-
|
|
1042
|
-
|
|
1226
|
+
let authData;
|
|
1227
|
+
try {
|
|
1228
|
+
authData = await this.authData();
|
|
1229
|
+
} catch (err) {
|
|
1230
|
+
if (isChatError(err)) {
|
|
1231
|
+
this.stop(err);
|
|
1232
|
+
throw err;
|
|
1233
|
+
}
|
|
1234
|
+
this.scheduleReconnect();
|
|
1235
|
+
throw err;
|
|
1236
|
+
}
|
|
1237
|
+
if (this.stopped) {
|
|
1043
1238
|
throw new ChatError("closed", "the client was closed while connecting");
|
|
1044
1239
|
}
|
|
1045
1240
|
const socket = new this.makeSocket(`${this.options.url}/v1/ws?v=1`);
|
|
@@ -1060,9 +1255,14 @@ var ChatClient = class extends Emitter {
|
|
|
1060
1255
|
return;
|
|
1061
1256
|
}
|
|
1062
1257
|
if (frame.type === "error" && !settled) {
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1258
|
+
const err = errorFrom(frame.data);
|
|
1259
|
+
if (err.code === "rate_limited" || err.code === "internal") {
|
|
1260
|
+
if (err.retryAfterMs !== void 0 && err.retryAfterMs > 0) this.plannedDelayMs = err.retryAfterMs;
|
|
1261
|
+
finish(err);
|
|
1262
|
+
return;
|
|
1263
|
+
}
|
|
1264
|
+
finish(err);
|
|
1265
|
+
this.stop(err);
|
|
1066
1266
|
return;
|
|
1067
1267
|
}
|
|
1068
1268
|
this.onFrame(frame);
|
|
@@ -1095,12 +1295,11 @@ var ChatClient = class extends Emitter {
|
|
|
1095
1295
|
const reconnected = this.everOpened;
|
|
1096
1296
|
this.everOpened = true;
|
|
1097
1297
|
this.setState("open");
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
}
|
|
1298
|
+
this.settleOpenWaiters();
|
|
1299
|
+
for (const room of this.handles.values()) {
|
|
1300
|
+
void room.resume(reconnected).catch((err) => {
|
|
1301
|
+
room.failed(err);
|
|
1302
|
+
});
|
|
1104
1303
|
}
|
|
1105
1304
|
}
|
|
1106
1305
|
onFrame(frame) {
|
|
@@ -1170,7 +1369,7 @@ var ChatClient = class extends Emitter {
|
|
|
1170
1369
|
}, Math.max(1e3, intervalMs));
|
|
1171
1370
|
}
|
|
1172
1371
|
scheduleReconnect() {
|
|
1173
|
-
if (this.
|
|
1372
|
+
if (this.stopped) return;
|
|
1174
1373
|
this.setState("reconnecting");
|
|
1175
1374
|
const delay = this.nextDelay();
|
|
1176
1375
|
this.retryTimer = setTimeout(() => {
|
|
@@ -1207,6 +1406,31 @@ var ChatClient = class extends Emitter {
|
|
|
1207
1406
|
data["token"] = token;
|
|
1208
1407
|
return data;
|
|
1209
1408
|
}
|
|
1409
|
+
/**
|
|
1410
|
+
* REST가 401을 받았을 때 토큰을 새로 받는다.
|
|
1411
|
+
*
|
|
1412
|
+
* 소켓은 접속할 때 한 번 인증하고 그 뒤로는 토큰을 다시 보지 않지만, REST는
|
|
1413
|
+
* 요청마다 본다. 그래서 한 시간짜리 토큰이면 한 시간 뒤 라이브는 멀쩡한데
|
|
1414
|
+
* 히스토리·구멍 메우기만 401이 된다. 동시에 실패한 요청들이 `token()`을
|
|
1415
|
+
* 각자 부르지 않도록 진행 중인 것 하나를 나눠 쓴다.
|
|
1416
|
+
*/
|
|
1417
|
+
refreshToken() {
|
|
1418
|
+
if (this.options.externalToken !== void 0) return Promise.resolve(void 0);
|
|
1419
|
+
this.refreshing ??= (async () => {
|
|
1420
|
+
try {
|
|
1421
|
+
const token = await this.options.token();
|
|
1422
|
+
this.lastToken = token;
|
|
1423
|
+
return token;
|
|
1424
|
+
} catch (err) {
|
|
1425
|
+
if (isChatError(err)) this.stop(err);
|
|
1426
|
+
throw err;
|
|
1427
|
+
} finally {
|
|
1428
|
+
this.refreshing = void 0;
|
|
1429
|
+
}
|
|
1430
|
+
})();
|
|
1431
|
+
return this.refreshing;
|
|
1432
|
+
}
|
|
1433
|
+
refreshing;
|
|
1210
1434
|
failPending(err) {
|
|
1211
1435
|
for (const [, waiter] of this.pending) waiter.reject(err);
|
|
1212
1436
|
this.pending.clear();
|
|
@@ -1221,6 +1445,9 @@ var ChatClient = class extends Emitter {
|
|
|
1221
1445
|
this.retryTimer = void 0;
|
|
1222
1446
|
}
|
|
1223
1447
|
};
|
|
1448
|
+
function isChatError(err) {
|
|
1449
|
+
return err instanceof ChatError || err instanceof Error && err.name === "ChatError";
|
|
1450
|
+
}
|
|
1224
1451
|
function createChatClient(options) {
|
|
1225
1452
|
return new ChatClient(options);
|
|
1226
1453
|
}
|