@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.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
ChatError,
|
|
3
3
|
errorFrom
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-5QQRM7BH.js";
|
|
5
5
|
|
|
6
6
|
// src/emitter.ts
|
|
7
7
|
var Emitter = class {
|
|
@@ -57,6 +57,16 @@ var Rest = class {
|
|
|
57
57
|
}
|
|
58
58
|
async call(method, path, body) {
|
|
59
59
|
const token = await this.options.token();
|
|
60
|
+
try {
|
|
61
|
+
return await this.callWith(token, method, path, body);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
if (!(err instanceof ChatError) || err.status !== 401 || this.options.refreshToken === void 0) throw err;
|
|
64
|
+
const fresh = await this.options.refreshToken().catch(() => void 0);
|
|
65
|
+
if (fresh === void 0 || fresh === token) throw err;
|
|
66
|
+
return this.callWith(fresh, method, path, body);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
async callWith(token, method, path, body) {
|
|
60
70
|
if (token === void 0) {
|
|
61
71
|
throw new ChatError(
|
|
62
72
|
"unauthorized",
|
|
@@ -78,6 +88,7 @@ var Rest = class {
|
|
|
78
88
|
const parsed = text === "" ? void 0 : JSON.parse(text);
|
|
79
89
|
if (!res.ok) {
|
|
80
90
|
const err = errorFrom(parsed, `${method} ${path} failed with ${res.status}`);
|
|
91
|
+
err.status = res.status;
|
|
81
92
|
throw err;
|
|
82
93
|
}
|
|
83
94
|
return parsed;
|
|
@@ -124,10 +135,24 @@ var Timeline = class {
|
|
|
124
135
|
* rows into the new list.
|
|
125
136
|
*/
|
|
126
137
|
epoch = 0;
|
|
138
|
+
/**
|
|
139
|
+
* `items` has been handed out -- by the getter or by `onChange`.
|
|
140
|
+
*
|
|
141
|
+
* 한 번 내준 배열은 다시 건드리지 않는다. 같은 배열을 제자리에서 고쳐
|
|
142
|
+
* 다시 내주면 Svelte `$state.raw`, React `useState`, Vue `shallowRef`처럼
|
|
143
|
+
* 참조가 바뀌어야 다시 그리는 쪽은 변화를 보지 못하고 화면이 멈춘다. 그래서
|
|
144
|
+
* 내준 뒤의 첫 변경은 복사본에서 한다(copy-on-write). 행 객체도 같은 규칙이라
|
|
145
|
+
* 바뀐 행만 새 객체다.
|
|
146
|
+
*/
|
|
147
|
+
shared = false;
|
|
148
|
+
/** The `loadOlder` in flight, so a scroll handler firing twice asks once. */
|
|
149
|
+
older;
|
|
127
150
|
constructor(options) {
|
|
128
151
|
this.options = options;
|
|
129
152
|
}
|
|
153
|
+
/** A new array whenever the list changes; never mutated once returned. */
|
|
130
154
|
get messages() {
|
|
155
|
+
this.shared = true;
|
|
131
156
|
return this.items;
|
|
132
157
|
}
|
|
133
158
|
/** The highest seq this timeline holds, hole or no hole. */
|
|
@@ -174,6 +199,7 @@ var Timeline = class {
|
|
|
174
199
|
this.fills.clear();
|
|
175
200
|
this.pendingDeletes.clear();
|
|
176
201
|
this.items = [];
|
|
202
|
+
this.shared = false;
|
|
177
203
|
this.changed();
|
|
178
204
|
}
|
|
179
205
|
/**
|
|
@@ -200,6 +226,7 @@ var Timeline = class {
|
|
|
200
226
|
const page = [...messages].sort((a, b) => a.seq - b.seq);
|
|
201
227
|
const top = page.at(-1)?.seq ?? 0;
|
|
202
228
|
this.items = [...page, ...this.items.filter((m) => m.seq > top)];
|
|
229
|
+
this.shared = false;
|
|
203
230
|
for (const m of this.items) {
|
|
204
231
|
if (this.pendingDeletes.has(m.id)) this.applyDelete(m.id);
|
|
205
232
|
}
|
|
@@ -234,6 +261,7 @@ var Timeline = class {
|
|
|
234
261
|
if (at === -1) return;
|
|
235
262
|
const current = this.items[at];
|
|
236
263
|
if (current.deletedAt !== void 0) return;
|
|
264
|
+
this.own();
|
|
237
265
|
this.items[at] = { ...current, ...message };
|
|
238
266
|
this.changed();
|
|
239
267
|
}
|
|
@@ -260,6 +288,7 @@ var Timeline = class {
|
|
|
260
288
|
if (count <= 0) next = existing.filter((r) => r.emoji !== emoji);
|
|
261
289
|
else if (slot === -1) next = [...existing, { emoji, count }];
|
|
262
290
|
else next = existing.map((r, i) => i === slot ? { emoji, count } : r);
|
|
291
|
+
this.own();
|
|
263
292
|
this.items[at] = { ...current, reactions: next };
|
|
264
293
|
this.changed();
|
|
265
294
|
}
|
|
@@ -268,6 +297,7 @@ var Timeline = class {
|
|
|
268
297
|
const at = this.items.findIndex((m) => m.id === rootId);
|
|
269
298
|
if (at === -1) return;
|
|
270
299
|
const current = this.items[at];
|
|
300
|
+
this.own();
|
|
271
301
|
this.items[at] = { ...current, thread: lastSeq === void 0 ? { count } : { count, lastSeq } };
|
|
272
302
|
this.changed();
|
|
273
303
|
}
|
|
@@ -275,6 +305,36 @@ var Timeline = class {
|
|
|
275
305
|
remove(id) {
|
|
276
306
|
if (!this.applyDelete(id)) this.pendingDeletes.add(id);
|
|
277
307
|
}
|
|
308
|
+
/**
|
|
309
|
+
* Prepends the page just below the oldest message held.
|
|
310
|
+
*
|
|
311
|
+
* 스크롤을 올려 과거를 읽는 소비자가 `history()` 결과를 라이브 목록과 따로
|
|
312
|
+
* 들고 합치고, 중복을 걸러 내고, reset과 재접속을 따로 처리해야 했다. 같은
|
|
313
|
+
* 목록에 끼워 넣으면 그 일이 전부 이미 있는 규칙(seq 자리에 넣기, 같은 seq는
|
|
314
|
+
* 한 행, reset이면 epoch로 버리기)으로 끝난다.
|
|
315
|
+
*
|
|
316
|
+
* `hasMore`는 더 올라갈 것이 있는지다. 서버의 seq는 1부터 구멍 없이 붙으므로
|
|
317
|
+
* 맨 위가 1이거나 페이지가 덜 찼으면 끝이다(보존 기간이 지운 앞부분도 덜 찬
|
|
318
|
+
* 페이지로 드러난다). 한 번에 하나만 돈다 — 스크롤 핸들러가 두 번 불러도
|
|
319
|
+
* 요청은 하나다(진행 중인 호출과 같은 결과를 받으므로 뒤 호출의 `limit`은 쓰이지 않는다).
|
|
320
|
+
*/
|
|
321
|
+
loadOlder(limit = this.options.pageSize ?? MaxPageSize) {
|
|
322
|
+
this.older ??= this.readOlder(Math.min(limit, MaxPageSize)).finally(() => {
|
|
323
|
+
this.older = void 0;
|
|
324
|
+
});
|
|
325
|
+
return this.older;
|
|
326
|
+
}
|
|
327
|
+
async readOlder(limit) {
|
|
328
|
+
const first = this.items[0];
|
|
329
|
+
if (first === void 0 || first.seq <= 1) return { messages: [], hasMore: false };
|
|
330
|
+
const epoch = this.epoch;
|
|
331
|
+
const page = await this.options.fetchRange({ after: 0, before: first.seq, limit });
|
|
332
|
+
if (this.epoch !== epoch) return { messages: [], hasMore: true };
|
|
333
|
+
for (const m of page) this.insert(m, false);
|
|
334
|
+
if (page.length > 0) this.changed();
|
|
335
|
+
const top = page[0];
|
|
336
|
+
return { messages: page, hasMore: page.length >= limit && top !== void 0 && top.seq > 1 };
|
|
337
|
+
}
|
|
278
338
|
/**
|
|
279
339
|
* Fetches everything between what we have and `upTo`.
|
|
280
340
|
*
|
|
@@ -319,10 +379,11 @@ var Timeline = class {
|
|
|
319
379
|
return run;
|
|
320
380
|
}
|
|
321
381
|
/** Inserts at the seq position, replacing an existing row with that seq. */
|
|
322
|
-
insert(message) {
|
|
382
|
+
insert(message, notify = true) {
|
|
323
383
|
const pending = this.pendingDeletes.has(message.id);
|
|
324
384
|
if (pending) this.pendingDeletes.delete(message.id);
|
|
325
385
|
const withDelete = pending ? emptied(message) : message;
|
|
386
|
+
this.own();
|
|
326
387
|
const at = this.items.findIndex((m) => m.seq >= withDelete.seq);
|
|
327
388
|
if (at === -1) {
|
|
328
389
|
this.items.push(withDelete);
|
|
@@ -332,17 +393,25 @@ var Timeline = class {
|
|
|
332
393
|
} else {
|
|
333
394
|
this.items.splice(at, 0, withDelete);
|
|
334
395
|
}
|
|
335
|
-
this.changed();
|
|
396
|
+
if (notify) this.changed();
|
|
336
397
|
}
|
|
337
398
|
applyDelete(id) {
|
|
338
399
|
const at = this.items.findIndex((m) => m.id === id);
|
|
339
400
|
if (at === -1) return false;
|
|
340
401
|
this.pendingDeletes.delete(id);
|
|
402
|
+
this.own();
|
|
341
403
|
this.items[at] = emptied(this.items[at]);
|
|
342
404
|
this.changed();
|
|
343
405
|
return true;
|
|
344
406
|
}
|
|
407
|
+
/** Makes `items` safe to mutate: a copy, if the current one was handed out. */
|
|
408
|
+
own() {
|
|
409
|
+
if (!this.shared) return;
|
|
410
|
+
this.items = this.items.slice();
|
|
411
|
+
this.shared = false;
|
|
412
|
+
}
|
|
345
413
|
changed() {
|
|
414
|
+
this.shared = true;
|
|
346
415
|
this.options.onChange?.(this.items);
|
|
347
416
|
}
|
|
348
417
|
};
|
|
@@ -355,6 +424,8 @@ function createTimeline(options) {
|
|
|
355
424
|
|
|
356
425
|
// src/room.ts
|
|
357
426
|
var HistoryPageSize = 100;
|
|
427
|
+
var MaxRepairs = 5;
|
|
428
|
+
var RepairBaseMs = 1e3;
|
|
358
429
|
var Room = class extends Emitter {
|
|
359
430
|
/** Resolved on subscribe when the room was addressed by key. */
|
|
360
431
|
id;
|
|
@@ -392,6 +463,9 @@ var Room = class extends Emitter {
|
|
|
392
463
|
*/
|
|
393
464
|
wanted = false;
|
|
394
465
|
subscribing;
|
|
466
|
+
/** Self-repair: the armed retry, and how many have run since the last success. */
|
|
467
|
+
repairTimer;
|
|
468
|
+
repairs = 0;
|
|
395
469
|
constructor(chat, rest, address) {
|
|
396
470
|
super();
|
|
397
471
|
this.chat = chat;
|
|
@@ -407,10 +481,27 @@ var Room = class extends Emitter {
|
|
|
407
481
|
onChange: (messages) => this.emit("messages", messages)
|
|
408
482
|
});
|
|
409
483
|
}
|
|
410
|
-
/** Everything this client knows about the room, in seq order. */
|
|
484
|
+
/** Everything this client knows about the room, in seq order. A new array whenever it changes. */
|
|
411
485
|
get messages() {
|
|
412
486
|
return this.timeline.messages;
|
|
413
487
|
}
|
|
488
|
+
/**
|
|
489
|
+
* Reads the page just before the oldest message held and **prepends it to
|
|
490
|
+
* `messages`**.
|
|
491
|
+
*
|
|
492
|
+
* The scroll-back call. Unlike `history()`, the rows join the room's own
|
|
493
|
+
* list, so ordering, de-duplication, deletes, reactions and reconnects
|
|
494
|
+
* apply to them like any other row, and the `messages` event fires once.
|
|
495
|
+
* A `reset` (or `reload()`) drops them with everything else; a read that
|
|
496
|
+
* lands after one is discarded rather than stitched onto the new list.
|
|
497
|
+
*
|
|
498
|
+
* `hasMore` is false once the top of the room -- or of its retention --
|
|
499
|
+
* is reached. Concurrent calls share one request.
|
|
500
|
+
*/
|
|
501
|
+
async loadOlder(options = {}) {
|
|
502
|
+
await this.resolveId("loading older messages");
|
|
503
|
+
return this.timeline.loadOlder(options.limit);
|
|
504
|
+
}
|
|
414
505
|
/**
|
|
415
506
|
* Reloads recent history, discarding what is held.
|
|
416
507
|
*
|
|
@@ -441,7 +532,12 @@ var Room = class extends Emitter {
|
|
|
441
532
|
this.wanted = true;
|
|
442
533
|
if (this.subscribing !== void 0) return this.subscribing;
|
|
443
534
|
if (this.subscribed) return;
|
|
444
|
-
const attempt = this.doSubscribe().
|
|
535
|
+
const attempt = this.doSubscribe().then(() => {
|
|
536
|
+
const successor = this.subscribing;
|
|
537
|
+
if (successor !== void 0 && successor !== attempt) return successor;
|
|
538
|
+
if (this.subscribed) this.repairs = 0;
|
|
539
|
+
return void 0;
|
|
540
|
+
}).finally(() => {
|
|
445
541
|
if (this.subscribing === attempt) this.subscribing = void 0;
|
|
446
542
|
});
|
|
447
543
|
this.subscribing = attempt;
|
|
@@ -450,6 +546,10 @@ var Room = class extends Emitter {
|
|
|
450
546
|
async doSubscribe() {
|
|
451
547
|
const gen = this.generation;
|
|
452
548
|
const current = () => gen === this.generation;
|
|
549
|
+
if (this.chat.state !== "open") {
|
|
550
|
+
await this.chat.whenOpen();
|
|
551
|
+
if (!current()) return;
|
|
552
|
+
}
|
|
453
553
|
const data = this.id !== void 0 ? { roomId: this.id } : { key: this.key };
|
|
454
554
|
const since = this.timeline.contiguousSeq;
|
|
455
555
|
if (since > 0) data["since"] = since;
|
|
@@ -484,7 +584,8 @@ var Room = class extends Emitter {
|
|
|
484
584
|
*
|
|
485
585
|
* The list this room keeps is the live one; this is for a consumer
|
|
486
586
|
* scrolling back, which owns its own window and does not want the
|
|
487
|
-
* bottom of the room rearranged under it.
|
|
587
|
+
* bottom of the room rearranged under it. **Scrolling the room's own
|
|
588
|
+
* list back is `loadOlder()`**, which keeps one list instead of two.
|
|
488
589
|
*
|
|
489
590
|
* `view` defaults to the server's, which is every message including
|
|
490
591
|
* thread replies. **`view: 'main'` is a display filter, not a sync
|
|
@@ -493,7 +594,7 @@ var Room = class extends Emitter {
|
|
|
493
594
|
*/
|
|
494
595
|
async history(options = {}) {
|
|
495
596
|
const view = options.view === void 0 ? void 0 : typeof options.view === "string" ? options.view : `thread:${options.view.thread}`;
|
|
496
|
-
const page = await this.rest.get(`/v1/rooms/${this.
|
|
597
|
+
const page = await this.rest.get(`/v1/rooms/${await this.resolveId("reading history")}/messages`, {
|
|
497
598
|
before: options.before,
|
|
498
599
|
after: options.after,
|
|
499
600
|
limit: options.limit,
|
|
@@ -503,10 +604,10 @@ var Room = class extends Emitter {
|
|
|
503
604
|
}
|
|
504
605
|
/** Adds a reaction. Idempotent, like the frame. */
|
|
505
606
|
async react(messageId, emoji) {
|
|
506
|
-
await this.chat.send("react", { roomId: this.
|
|
607
|
+
await this.chat.send("react", { roomId: await this.resolveId("reacting"), messageId, emoji, op: "add" });
|
|
507
608
|
}
|
|
508
609
|
async unreact(messageId, emoji) {
|
|
509
|
-
await this.chat.send("react", { roomId: this.
|
|
610
|
+
await this.chat.send("react", { roomId: await this.resolveId("reacting"), messageId, emoji, op: "remove" });
|
|
510
611
|
}
|
|
511
612
|
/**
|
|
512
613
|
* Moves this user's read cursor.
|
|
@@ -516,7 +617,7 @@ var Room = class extends Emitter {
|
|
|
516
617
|
* way to know what happened. It is returned rather than swallowed.
|
|
517
618
|
*/
|
|
518
619
|
async markRead(seq, options = {}) {
|
|
519
|
-
const data = { roomId: this.
|
|
620
|
+
const data = { roomId: await this.resolveId("marking read"), seq };
|
|
520
621
|
if (options.threadId !== void 0) data["threadId"] = options.threadId;
|
|
521
622
|
return await this.chat.send("read", data);
|
|
522
623
|
}
|
|
@@ -543,15 +644,15 @@ var Room = class extends Emitter {
|
|
|
543
644
|
}
|
|
544
645
|
/** Joins a public or channel room. Idempotent. */
|
|
545
646
|
async join() {
|
|
546
|
-
await this.rest.post(`/v1/rooms/${this.
|
|
647
|
+
await this.rest.post(`/v1/rooms/${await this.resolveId("joining")}/join`);
|
|
547
648
|
}
|
|
548
649
|
/** Leaves. Idempotent from the caller's side. */
|
|
549
650
|
async leave() {
|
|
550
|
-
await this.rest.delete(`/v1/rooms/${this.
|
|
651
|
+
await this.rest.delete(`/v1/rooms/${await this.resolveId("leaving")}/members/me`);
|
|
551
652
|
}
|
|
552
653
|
/** The full presence list, for rooms too large to send it in the ack. */
|
|
553
654
|
async presenceList() {
|
|
554
|
-
return this.rest.get(`/v1/rooms/${this.
|
|
655
|
+
return this.rest.get(`/v1/rooms/${await this.resolveId("reading presence")}/presence`, { full: "true" });
|
|
555
656
|
}
|
|
556
657
|
/**
|
|
557
658
|
* Who reacted, one row per (user, emoji) pair.
|
|
@@ -563,7 +664,7 @@ var Room = class extends Emitter {
|
|
|
563
664
|
* most, and a full page still carries a cursor when more follows.
|
|
564
665
|
*/
|
|
565
666
|
async reactionsOf(messageId, options = {}) {
|
|
566
|
-
return this.rest.get(`/v1/rooms/${this.
|
|
667
|
+
return this.rest.get(`/v1/rooms/${await this.resolveId("reading reactions")}/messages/${messageId}/reactions`, {
|
|
567
668
|
emoji: options.emoji,
|
|
568
669
|
cursor: options.cursor,
|
|
569
670
|
limit: options.limit
|
|
@@ -578,13 +679,33 @@ var Room = class extends Emitter {
|
|
|
578
679
|
* transient 500 takes a room out of the live feed for the life of the
|
|
579
680
|
* page.
|
|
580
681
|
*/
|
|
581
|
-
async resume() {
|
|
682
|
+
async resume(restart = true) {
|
|
582
683
|
if (!this.wanted) return;
|
|
684
|
+
if (!restart) return this.subscribe();
|
|
583
685
|
this.generation++;
|
|
584
686
|
this.subscribing = void 0;
|
|
585
687
|
this.subscribed = false;
|
|
586
688
|
await this.subscribe();
|
|
587
689
|
}
|
|
690
|
+
/**
|
|
691
|
+
* Reports a failure no caller was waiting for, and repairs.
|
|
692
|
+
*
|
|
693
|
+
* 백그라운드 실패(재접속 뒤 다시 구독, 구멍 메우기)에는 거절을 받을 호출자가
|
|
694
|
+
* 없다. 이벤트만 내고 두면 방은 다음 재접속이나 탭 복귀까지 구멍을 안고 —
|
|
695
|
+
* 구독 프레임부터 실패했다면 라이브 피드 밖에서 — 머문다. 그래서 스스로 다시
|
|
696
|
+
* 구독한다. 횟수를 묶는 것은 고칠 수 없는 실패(권한, 없어진 방)가 서버를
|
|
697
|
+
* 계속 두드리지 않게 하려는 것이고, 그 뒤는 재접속과 `subscribe()`의 몫이다.
|
|
698
|
+
*/
|
|
699
|
+
failed(err) {
|
|
700
|
+
this.emit("error", err instanceof Error ? err : new Error(String(err)));
|
|
701
|
+
if (this.repairTimer !== void 0 || this.repairs >= MaxRepairs) return;
|
|
702
|
+
const delay = RepairBaseMs * 2 ** this.repairs++;
|
|
703
|
+
this.repairTimer = setTimeout(() => {
|
|
704
|
+
this.repairTimer = void 0;
|
|
705
|
+
if (!this.wanted || this.subscribed || this.chat.state !== "open") return;
|
|
706
|
+
this.subscribe().catch((e) => this.failed(e));
|
|
707
|
+
}, delay);
|
|
708
|
+
}
|
|
588
709
|
/**
|
|
589
710
|
* Sends `subscribe` and recovers from the one error it has an answer
|
|
590
711
|
* for.
|
|
@@ -629,9 +750,22 @@ var Room = class extends Emitter {
|
|
|
629
750
|
}
|
|
630
751
|
return this.id;
|
|
631
752
|
}
|
|
753
|
+
/**
|
|
754
|
+
* The room's id, waiting for a subscribe in flight to learn it.
|
|
755
|
+
*
|
|
756
|
+
* `roomByKey(k).subscribe()`를 await하지 않고 곧바로 `send`하는 것은 자연스러운
|
|
757
|
+
* 코드이고, 그때 id는 구독 ack가 와야 생긴다. 거절(`closed`)하면 소비자는
|
|
758
|
+
* "보내도 되는 때"를 알릴 신호를 따로 찾아야 한다 — 이미 날아가고 있는
|
|
759
|
+
* 구독을 기다리면 그 신호가 필요 없다. 구독이 실패하면 그 실패가 그대로
|
|
760
|
+
* 나간다. 구독한 적이 없으면 기다릴 것이 없으니 예전처럼 거절한다.
|
|
761
|
+
*/
|
|
762
|
+
async resolveId(what) {
|
|
763
|
+
while (this.id === void 0 && this.subscribing !== void 0) await this.subscribing;
|
|
764
|
+
return this.requireId(what);
|
|
765
|
+
}
|
|
632
766
|
/** Publishes and resolves when the server acks. */
|
|
633
767
|
async send(input, options = {}) {
|
|
634
|
-
const roomId = this.
|
|
768
|
+
const roomId = await this.resolveId("sending");
|
|
635
769
|
const body = {};
|
|
636
770
|
if (input.text !== void 0) body["text"] = input.text;
|
|
637
771
|
if (input.entities !== void 0) body["entities"] = input.entities;
|
|
@@ -653,7 +787,7 @@ var Room = class extends Emitter {
|
|
|
653
787
|
if (data.seq > this.lastSeq) this.lastSeq = data.seq;
|
|
654
788
|
this.timeline.add(data).catch((err) => {
|
|
655
789
|
this.subscribed = false;
|
|
656
|
-
this.
|
|
790
|
+
this.failed(err);
|
|
657
791
|
});
|
|
658
792
|
break;
|
|
659
793
|
case "message.updated":
|
|
@@ -722,8 +856,11 @@ var ChatClient = class extends Emitter {
|
|
|
722
856
|
heartbeat;
|
|
723
857
|
retryTimer;
|
|
724
858
|
attempt = 0;
|
|
725
|
-
/**
|
|
726
|
-
|
|
859
|
+
/**
|
|
860
|
+
* The reconnect loop is off: set by close(), and by a failure that
|
|
861
|
+
* retrying cannot fix (see `stop`). Cleared by connect().
|
|
862
|
+
*/
|
|
863
|
+
stopped = false;
|
|
727
864
|
nextFrameId = 0;
|
|
728
865
|
opening;
|
|
729
866
|
rest;
|
|
@@ -763,6 +900,7 @@ var ChatClient = class extends Emitter {
|
|
|
763
900
|
url: options.restURL ?? restURLFrom(options.url),
|
|
764
901
|
key: options.key,
|
|
765
902
|
token: async () => this.lastToken,
|
|
903
|
+
refreshToken: () => this.refreshToken(),
|
|
766
904
|
fetch: options.fetch ?? globalThis.fetch.bind(globalThis)
|
|
767
905
|
});
|
|
768
906
|
this.setupLifecycle();
|
|
@@ -800,7 +938,7 @@ var ChatClient = class extends Emitter {
|
|
|
800
938
|
* gap-filled via REST.
|
|
801
939
|
*/
|
|
802
940
|
async resume() {
|
|
803
|
-
if (this.
|
|
941
|
+
if (this.stopped) return;
|
|
804
942
|
if (this.state !== "open" || this.socket === void 0) {
|
|
805
943
|
this.clearTimers();
|
|
806
944
|
return this.connect();
|
|
@@ -812,9 +950,7 @@ var ChatClient = class extends Emitter {
|
|
|
812
950
|
return this.connect();
|
|
813
951
|
}
|
|
814
952
|
for (const room of this.handles.values()) {
|
|
815
|
-
void room.resume().catch((err) =>
|
|
816
|
-
room.emit("error", err instanceof Error ? err : new Error(String(err)));
|
|
817
|
-
});
|
|
953
|
+
void room.resume().catch((err) => room.failed(err));
|
|
818
954
|
}
|
|
819
955
|
}
|
|
820
956
|
/** The account-level routes: the rooms this user is in, and unread. */
|
|
@@ -906,7 +1042,7 @@ var ChatClient = class extends Emitter {
|
|
|
906
1042
|
}
|
|
907
1043
|
/** Opens the connection and resolves when `hello` arrives. */
|
|
908
1044
|
async connect() {
|
|
909
|
-
this.
|
|
1045
|
+
this.stopped = false;
|
|
910
1046
|
if (this.opening !== void 0) return this.opening;
|
|
911
1047
|
if (this.state === "open" && this.socket !== void 0) return;
|
|
912
1048
|
this.clearTimers();
|
|
@@ -919,11 +1055,12 @@ var ChatClient = class extends Emitter {
|
|
|
919
1055
|
* Closes for good.
|
|
920
1056
|
*
|
|
921
1057
|
* The distinction from a dropped socket is the whole point of the state
|
|
922
|
-
* machine:
|
|
923
|
-
*
|
|
1058
|
+
* machine: `closed` means nothing is coming back, and a consumer showing
|
|
1059
|
+
* "disconnected, retrying" versus "disconnected" needs it. The other
|
|
1060
|
+
* ways to reach it are refusals retrying cannot fix -- see `stop`.
|
|
924
1061
|
*/
|
|
925
1062
|
async close() {
|
|
926
|
-
this.
|
|
1063
|
+
this.stopped = true;
|
|
927
1064
|
this.teardowns.forEach((t) => {
|
|
928
1065
|
try {
|
|
929
1066
|
t();
|
|
@@ -940,8 +1077,54 @@ var ChatClient = class extends Emitter {
|
|
|
940
1077
|
} catch {
|
|
941
1078
|
}
|
|
942
1079
|
}
|
|
1080
|
+
this.settleOpenWaiters(new ChatError("closed", "the connection is closed"));
|
|
943
1081
|
this.setState("closed");
|
|
944
1082
|
}
|
|
1083
|
+
/**
|
|
1084
|
+
* Stops for a reason retrying cannot fix, and says which.
|
|
1085
|
+
*
|
|
1086
|
+
* `close()` without the teardown of the page listeners -- a consumer
|
|
1087
|
+
* that logs back in calls `connect()` on the same client -- and with an
|
|
1088
|
+
* `error` event, because on a reconnect there is no caller to reject.
|
|
1089
|
+
*/
|
|
1090
|
+
stop(err) {
|
|
1091
|
+
this.stopped = true;
|
|
1092
|
+
this.clearTimers();
|
|
1093
|
+
const socket = this.socket;
|
|
1094
|
+
this.socket = void 0;
|
|
1095
|
+
if (socket !== void 0) {
|
|
1096
|
+
try {
|
|
1097
|
+
socket.close(1e3, "client stopped");
|
|
1098
|
+
} catch {
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
this.failPending(err);
|
|
1102
|
+
this.settleOpenWaiters(new ChatError("closed", "the connection is closed"));
|
|
1103
|
+
this.setState("closed");
|
|
1104
|
+
this.emit("error", err);
|
|
1105
|
+
}
|
|
1106
|
+
/**
|
|
1107
|
+
* Resolves once the connection is open. Used by `Room.subscribe`.
|
|
1108
|
+
*
|
|
1109
|
+
* 연결 전에 부른 `subscribe()`를 `closed`로 거절하면 소비자는 "언제 다시
|
|
1110
|
+
* 부를지"를 스스로 알아내야 하고, 첫 connect가 실패했다면 그 때는 영영 오지
|
|
1111
|
+
* 않는다. 붙을 때까지 기다리면 그 신호가 필요 없다. 멈춘 클라이언트(close,
|
|
1112
|
+
* 인증 거절)는 붙을 일이 없으니 거절한다.
|
|
1113
|
+
*/
|
|
1114
|
+
whenOpen() {
|
|
1115
|
+
if (this.state === "open" && this.socket !== void 0) return Promise.resolve();
|
|
1116
|
+
if (this.stopped) return Promise.reject(new ChatError("closed", "the connection is closed"));
|
|
1117
|
+
return new Promise((resolve, reject) => void this.openWaiters.push({ resolve, reject }));
|
|
1118
|
+
}
|
|
1119
|
+
openWaiters = [];
|
|
1120
|
+
settleOpenWaiters(err) {
|
|
1121
|
+
const waiters = this.openWaiters;
|
|
1122
|
+
this.openWaiters = [];
|
|
1123
|
+
for (const w of waiters) {
|
|
1124
|
+
if (err === void 0) w.resolve();
|
|
1125
|
+
else w.reject(err);
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
945
1128
|
/** Sends a frame and resolves with its ack, or rejects with its error. */
|
|
946
1129
|
send(type, data, timeoutMs = 15e3) {
|
|
947
1130
|
const socket = this.socket;
|
|
@@ -983,8 +1166,18 @@ var ChatClient = class extends Emitter {
|
|
|
983
1166
|
}
|
|
984
1167
|
async openOnce() {
|
|
985
1168
|
this.setState(this.attempt === 0 ? "connecting" : "reconnecting");
|
|
986
|
-
|
|
987
|
-
|
|
1169
|
+
let authData;
|
|
1170
|
+
try {
|
|
1171
|
+
authData = await this.authData();
|
|
1172
|
+
} catch (err) {
|
|
1173
|
+
if (isChatError(err)) {
|
|
1174
|
+
this.stop(err);
|
|
1175
|
+
throw err;
|
|
1176
|
+
}
|
|
1177
|
+
this.scheduleReconnect();
|
|
1178
|
+
throw err;
|
|
1179
|
+
}
|
|
1180
|
+
if (this.stopped) {
|
|
988
1181
|
throw new ChatError("closed", "the client was closed while connecting");
|
|
989
1182
|
}
|
|
990
1183
|
const socket = new this.makeSocket(`${this.options.url}/v1/ws?v=1`);
|
|
@@ -1005,9 +1198,14 @@ var ChatClient = class extends Emitter {
|
|
|
1005
1198
|
return;
|
|
1006
1199
|
}
|
|
1007
1200
|
if (frame.type === "error" && !settled) {
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1201
|
+
const err = errorFrom(frame.data);
|
|
1202
|
+
if (err.code === "rate_limited" || err.code === "internal") {
|
|
1203
|
+
if (err.retryAfterMs !== void 0 && err.retryAfterMs > 0) this.plannedDelayMs = err.retryAfterMs;
|
|
1204
|
+
finish(err);
|
|
1205
|
+
return;
|
|
1206
|
+
}
|
|
1207
|
+
finish(err);
|
|
1208
|
+
this.stop(err);
|
|
1011
1209
|
return;
|
|
1012
1210
|
}
|
|
1013
1211
|
this.onFrame(frame);
|
|
@@ -1040,12 +1238,11 @@ var ChatClient = class extends Emitter {
|
|
|
1040
1238
|
const reconnected = this.everOpened;
|
|
1041
1239
|
this.everOpened = true;
|
|
1042
1240
|
this.setState("open");
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
}
|
|
1241
|
+
this.settleOpenWaiters();
|
|
1242
|
+
for (const room of this.handles.values()) {
|
|
1243
|
+
void room.resume(reconnected).catch((err) => {
|
|
1244
|
+
room.failed(err);
|
|
1245
|
+
});
|
|
1049
1246
|
}
|
|
1050
1247
|
}
|
|
1051
1248
|
onFrame(frame) {
|
|
@@ -1115,7 +1312,7 @@ var ChatClient = class extends Emitter {
|
|
|
1115
1312
|
}, Math.max(1e3, intervalMs));
|
|
1116
1313
|
}
|
|
1117
1314
|
scheduleReconnect() {
|
|
1118
|
-
if (this.
|
|
1315
|
+
if (this.stopped) return;
|
|
1119
1316
|
this.setState("reconnecting");
|
|
1120
1317
|
const delay = this.nextDelay();
|
|
1121
1318
|
this.retryTimer = setTimeout(() => {
|
|
@@ -1152,6 +1349,31 @@ var ChatClient = class extends Emitter {
|
|
|
1152
1349
|
data["token"] = token;
|
|
1153
1350
|
return data;
|
|
1154
1351
|
}
|
|
1352
|
+
/**
|
|
1353
|
+
* REST가 401을 받았을 때 토큰을 새로 받는다.
|
|
1354
|
+
*
|
|
1355
|
+
* 소켓은 접속할 때 한 번 인증하고 그 뒤로는 토큰을 다시 보지 않지만, REST는
|
|
1356
|
+
* 요청마다 본다. 그래서 한 시간짜리 토큰이면 한 시간 뒤 라이브는 멀쩡한데
|
|
1357
|
+
* 히스토리·구멍 메우기만 401이 된다. 동시에 실패한 요청들이 `token()`을
|
|
1358
|
+
* 각자 부르지 않도록 진행 중인 것 하나를 나눠 쓴다.
|
|
1359
|
+
*/
|
|
1360
|
+
refreshToken() {
|
|
1361
|
+
if (this.options.externalToken !== void 0) return Promise.resolve(void 0);
|
|
1362
|
+
this.refreshing ??= (async () => {
|
|
1363
|
+
try {
|
|
1364
|
+
const token = await this.options.token();
|
|
1365
|
+
this.lastToken = token;
|
|
1366
|
+
return token;
|
|
1367
|
+
} catch (err) {
|
|
1368
|
+
if (isChatError(err)) this.stop(err);
|
|
1369
|
+
throw err;
|
|
1370
|
+
} finally {
|
|
1371
|
+
this.refreshing = void 0;
|
|
1372
|
+
}
|
|
1373
|
+
})();
|
|
1374
|
+
return this.refreshing;
|
|
1375
|
+
}
|
|
1376
|
+
refreshing;
|
|
1155
1377
|
failPending(err) {
|
|
1156
1378
|
for (const [, waiter] of this.pending) waiter.reject(err);
|
|
1157
1379
|
this.pending.clear();
|
|
@@ -1166,6 +1388,9 @@ var ChatClient = class extends Emitter {
|
|
|
1166
1388
|
this.retryTimer = void 0;
|
|
1167
1389
|
}
|
|
1168
1390
|
};
|
|
1391
|
+
function isChatError(err) {
|
|
1392
|
+
return err instanceof ChatError || err instanceof Error && err.name === "ChatError";
|
|
1393
|
+
}
|
|
1169
1394
|
function createChatClient(options) {
|
|
1170
1395
|
return new ChatClient(options);
|
|
1171
1396
|
}
|