@kispi/chat 0.2.1 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +68 -4
- package/README.md +65 -4
- package/dist/index.cjs +239 -17
- package/dist/index.d.cts +165 -4
- package/dist/index.d.ts +165 -4
- package/dist/index.js +239 -17
- 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 +1 -1
package/README.en.md
CHANGED
|
@@ -36,7 +36,7 @@ is in the browser, the whole app is open.
|
|
|
36
36
|
| Property | |
|
|
37
37
|
|---|---|
|
|
38
38
|
| `state` | `'connecting' \| 'open' \| 'reconnecting' \| 'closed'` |
|
|
39
|
-
| `user` | The connected user. `{id, name, avatar?}` |
|
|
39
|
+
| `user` | The connected user. `{id, name, avatar?}` — **the identity the server accepted from the token** (what `hello` carried). Use this instead of parsing your token response. Set once `connect()`/`reconnect()` resolves |
|
|
40
40
|
| `connectionId` | Identifier used for support requests and forced disconnects |
|
|
41
41
|
| `hello` | Everything the server sent on connect (limits, total unread, warnings) |
|
|
42
42
|
|
|
@@ -44,6 +44,7 @@ is in the browser, the whole app is open.
|
|
|
44
44
|
|---|---|
|
|
45
45
|
| `connect()` | Connects. If already connected or connecting, it joins that attempt. Rejects on failure, but **retries continue in the background** (see below) |
|
|
46
46
|
| `close()` | Disconnects. This one does not reconnect. Call `connect()` to come back |
|
|
47
|
+
| `reconnect()` | **Re-authenticates**: calls `token()` again and replaces the socket. Room handles, subscriptions and `messages` are kept, and the state goes `open → reconnecting → open` (never `closed`). Use it after a nickname or avatar change (below) |
|
|
47
48
|
| `room(roomId)` | Room handle. Same id, same object |
|
|
48
49
|
| `roomByKey(key)` | Handle for a room opened by key. The server tells you the id when you subscribe. **Does not create the room** — for a key that doesn't exist, `subscribe()` rejects with `not_found` (see below) |
|
|
49
50
|
| `rooms.list({cursor?, limit?})` | Rooms I'm a member of + unread + last message |
|
|
@@ -76,6 +77,7 @@ off()
|
|
|
76
77
|
| `id` / `key` | Room identifiers. If opened by key, `id` is absent until you subscribe |
|
|
77
78
|
| `lastSeq` | The room's last seq as reported by the server |
|
|
78
79
|
| `presence` | `{count, users?, capped?}` |
|
|
80
|
+
| `hasOlder` | Whether `loadOlder()` has more past to read. Lets you decide whether to draw "load earlier" before the first `loadOlder()`. The same test as `loadOlder()`'s `hasMore` (the first row is above the room's retention floor); `false` while the list is empty. Re-read it on `messages` |
|
|
79
81
|
|
|
80
82
|
| Method | |
|
|
81
83
|
|---|---|
|
|
@@ -85,7 +87,7 @@ off()
|
|
|
85
87
|
| `loadOlder({limit?})` | Reads the page before the oldest message (default 100) and **prepends it to `messages`.** Returns `{messages, hasMore}`. Use it when scrolling up. If the list is empty (before subscribing), it reads nothing |
|
|
86
88
|
| `history({before?, after?, limit?, view?})` | Reads the past directly. **Independent of `messages`.** `view` is `'main'`, `'all'`, or `{thread: id}` |
|
|
87
89
|
| `reload()` | Re-reads the latest page |
|
|
88
|
-
| `react(messageId, emoji)` / `unreact(...)` | Reactions. Idempotent |
|
|
90
|
+
| `react(messageId, emoji)` / `unreact(...)` | Reactions. Idempotent. Updates that row's `myReactions` **before the frame goes out**, and rolls it back if the server refuses. Never touches the count |
|
|
89
91
|
| `reactionsOf(messageId, {emoji?, cursor?, limit?})` | Who reacted. **Each row is a (user, emoji) pair**, so one person can appear on several rows. `limit` defaults to 50, max 100; the last page's `cursor` is an empty string |
|
|
90
92
|
| `markRead(seq, {threadId?})` | Read cursor |
|
|
91
93
|
| `typing()` | Typing indicator. Does not throw |
|
|
@@ -134,6 +136,33 @@ Wrapping it in `$state` (a deep proxy) or `ref` works, but does unnecessary work
|
|
|
134
136
|
modified the same array in place and emitted it again, so on the reference-comparing side
|
|
135
137
|
the screen froze.
|
|
136
138
|
|
|
139
|
+
**Reactions: `reactions` is everyone's counts, `myReactions` is the emoji you pressed.**
|
|
140
|
+
The SDK keeps both live — counts from the `count` on `reaction.*` events, `myReactions` from
|
|
141
|
+
those events when their `userId` is `chat.user.id` (including presses from your other tabs and
|
|
142
|
+
devices) and from your own `react()`/`unreact()`. Both are overwrites, not
|
|
143
|
+
increments, so getting the event and the ack never counts twice. Only the changed row is a new
|
|
144
|
+
object.
|
|
145
|
+
|
|
146
|
+
**`myReactions` moves the moment you press.** `react()`/`unreact()` update that row's
|
|
147
|
+
`myReactions` and emit `messages` **before** the frame goes out, so the press does not feel a
|
|
148
|
+
round trip late — the value is already determined by (you, emoji) and the server is idempotent,
|
|
149
|
+
so the ack has nothing new to say. If the server refuses (a limit, permissions) or the ack never
|
|
150
|
+
arrives (timeout, a dropped connection), the SDK rolls it back — unless something newer has
|
|
151
|
+
taken that spot in the meantime. A later press, a `reaction.*` event carrying your own `userId`,
|
|
152
|
+
and a page from `reload()` or a reconnect all beat a pending optimistic value, and a refusal
|
|
153
|
+
never resurrects a stale one. **The SDK owns this field**: an optimistic layer of your own on
|
|
154
|
+
top of it would give it two owners.
|
|
155
|
+
|
|
156
|
+
**The count (`reactions[].count`) is always the server's.** It is never moved locally. If
|
|
157
|
+
somebody else presses the same emoji while your ack is in flight, a local ±1 disagrees with the
|
|
158
|
+
number the server sends, and the event that would have corrected it has already gone by. Counts
|
|
159
|
+
move on `reaction.*` events only.
|
|
160
|
+
|
|
161
|
+
**Publishing (`send()`) is deliberately not optimistic.** A `before_publish` hook may rewrite
|
|
162
|
+
the body (an emoji filter, a link rewrite), so there is no value on the client to draw ahead of
|
|
163
|
+
time. A message joins the list when its `message.created` arrives. A "sending…" indicator is
|
|
164
|
+
your own view state, and unlike a reaction the SDK cannot decide it for you.
|
|
165
|
+
|
|
137
166
|
**Attach the past to the same list with `loadOlder()`.** `history()` is a direct read that
|
|
138
167
|
doesn't touch the list, so merging its results with the live list, filtering duplicates, and
|
|
139
168
|
handling `reset` and reconnects all fall to the consumer. `loadOlder()` puts those rows into
|
|
@@ -142,7 +171,14 @@ like any other row. Calling it several times concurrently still makes one reques
|
|
|
142
171
|
`hasMore` is `false` you're at the start of the room (or of the retention period).
|
|
143
172
|
`reset` or `reload()` discards the attached past as well.
|
|
144
173
|
|
|
174
|
+
**`hasOlder` and `loadOlder()` describe the SDK's list, not your window.** The SDK never trims
|
|
175
|
+
`room.messages` — if it holds 500 rows, all 500 are there. So if you render a 100-row slice of
|
|
176
|
+
the array you were handed, the other 400 are in `room.messages`, not on the server, and
|
|
177
|
+
`hasOlder === false` is the right answer (it means you are at the start of the room). Widening
|
|
178
|
+
the window is a re-read of `room.messages`; asking the server comes after that.
|
|
179
|
+
|
|
145
180
|
```ts
|
|
181
|
+
showLoadMore(room.hasOlder)
|
|
146
182
|
const { hasMore } = await room.loadOlder()
|
|
147
183
|
```
|
|
148
184
|
|
|
@@ -198,6 +234,20 @@ The last two leave `state` at `closed` and report why on `chat.on('error')` —
|
|
|
198
234
|
or during a REST token refresh, where there is no caller to reject. Call `connect()` to come
|
|
199
235
|
back.
|
|
200
236
|
|
|
237
|
+
**After a nickname change, `reconnect()`.** The server reads the name and avatar from the
|
|
238
|
+
token's claims once, when the socket authenticates. So after your backend changes the name,
|
|
239
|
+
the next message on this connection still carries the old one. `reconnect()` calls `token()`
|
|
240
|
+
again, re-authenticates with the new claims, and the rooms carry on from what they hold.
|
|
241
|
+
`close()` + `connect()` works too, but passes through `closed` (your offline screen flashes)
|
|
242
|
+
and removes the page listeners. Frames still awaiting an ack on the old socket reject with
|
|
243
|
+
`closed`. When it resolves, `chat.user` is the new identity.
|
|
244
|
+
|
|
245
|
+
```ts
|
|
246
|
+
await api.updateNickname(name) // so your backend puts the new name in the next token()
|
|
247
|
+
await chat.reconnect()
|
|
248
|
+
chat.user?.name // the new name
|
|
249
|
+
```
|
|
250
|
+
|
|
201
251
|
**The SDK handles token expiry.** The socket authenticates only once on connect, so it stays
|
|
202
252
|
fine even when the token expires, but REST checks the token on every request. When REST gets a
|
|
203
253
|
401, the SDK calls `token()` again and retries **once**. A second 401 (origin, revoked token,
|
|
@@ -340,12 +390,26 @@ spaces don't overlap.
|
|
|
340
390
|
|
|
341
391
|
| Group | Methods |
|
|
342
392
|
|---|---|
|
|
343
|
-
| `rooms` | `ensure({key?, type, name?, meta?, members?})`, `ensureDM([a, b])`, `get`, `update`, `delete`, `custom(roomId, payload)`, `members.list/add/remove` |
|
|
393
|
+
| `rooms` | `ensure({key?, type, name?, meta?, members?})`, `ensureDM([a, b])`, `get`, `update`, `delete`, `custom(roomId, payload)`, `presence(roomId, {full?})`, `members.list/add/remove` |
|
|
344
394
|
| `messages` | `send(roomId, {sender, text?, kind?, attachments?, appMeta?, replyTo?, threadId?, clientMessageId?})`, `list`, `delete` |
|
|
345
|
-
| `users` | `list({cursor?, limit?})`, `update`, `delete({purgeMessages?})`, `ban({until, reason?})`, `unban`, `revokeTokens`, `disconnect` |
|
|
395
|
+
| `users` | `list({cursor?, limit?})`, `update`, `delete({purgeMessages?})`, `purgeMessages(userId)`, `ban({until, reason?})`, `unban`, `revokeTokens`, `disconnect` |
|
|
346
396
|
| `events` | `list({after?, limit?})` — the outbox. Catches up on missed webhooks |
|
|
347
397
|
| `webhooks` | `verify(headers, rawBody, {now?, tolerance?})` |
|
|
348
398
|
|
|
399
|
+
`rooms.presence(roomId)` is `{count}`; with `{full: true}` it adds `users` (at most 1000,
|
|
400
|
+
`capped: true` when cut). An empty room is `{count: 0}`, not a 404.
|
|
401
|
+
|
|
402
|
+
**`users.purgeMessages(userId)` is moderation** — it deletes every message the user sent in the
|
|
403
|
+
app (tombstones: seq is untouched, and watching clients get `message.deleted`) **without
|
|
404
|
+
withdrawing them.** The name stays. For spam, **ban first**, then call it — a message posted
|
|
405
|
+
while it runs is not in its listing. It does a bounded amount per call, so call again while
|
|
406
|
+
`purgeCapped` is `true`. A withdrawn user is a 404 (use `delete(userId, {purgeMessages: true})`).
|
|
407
|
+
|
|
408
|
+
```ts
|
|
409
|
+
await chat.users.ban(userId, { until: Date.now() + 7 * 86_400_000, reason: 'spam' })
|
|
410
|
+
while ((await chat.users.purgeMessages(userId)).purgeCapped) {}
|
|
411
|
+
```
|
|
412
|
+
|
|
349
413
|
`users.ban`'s `until` is **unix milliseconds and required.** For an indefinite ban, pick a
|
|
350
414
|
far-future time yourself — it goes into the audit log as a decision, not an accident.
|
|
351
415
|
|
package/README.md
CHANGED
|
@@ -36,7 +36,7 @@ npm install @kispi/chat
|
|
|
36
36
|
| 프로퍼티 | |
|
|
37
37
|
|---|---|
|
|
38
38
|
| `state` | `'connecting' \| 'open' \| 'reconnecting' \| 'closed'` |
|
|
39
|
-
| `user` | 접속한 사람. `{id, name, avatar?}` |
|
|
39
|
+
| `user` | 접속한 사람. `{id, name, avatar?}` — **서버가 토큰에서 받아들인 신원**(hello가 준 것)이다. 토큰 응답을 따로 파싱하지 말고 이것을 쓴다. `connect()`/`reconnect()`가 끝나면 채워져 있다 |
|
|
40
40
|
| `connectionId` | 지원 문의와 강제 종료에 쓰는 식별자 |
|
|
41
41
|
| `hello` | 서버가 접속 때 준 것 전부(한도, 총 unread, 경고) |
|
|
42
42
|
|
|
@@ -44,6 +44,7 @@ npm install @kispi/chat
|
|
|
44
44
|
|---|---|
|
|
45
45
|
| `connect()` | 붙는다. 이미 붙어 있거나 붙는 중이면 그 시도에 합류한다. 실패하면 거절하지만 **재시도는 백그라운드에서 계속된다**(아래) |
|
|
46
46
|
| `close()` | 끊는다. 이건 재접속하지 않는다. 다시 붙으려면 `connect()` |
|
|
47
|
+
| `reconnect()` | **다시 인증한다**: `token()`을 다시 불러 소켓을 바꾼다. 방 핸들과 구독, `messages`는 그대로이고 상태는 `open → reconnecting → open`이다(`closed`를 거치지 않는다). 닉네임·아바타를 바꾼 뒤에 쓴다(아래) |
|
|
47
48
|
| `room(roomId)` | 방 핸들. 같은 id면 같은 객체 |
|
|
48
49
|
| `roomByKey(key)` | 키로 여는 방 핸들. 구독할 때 서버가 id를 알려 준다. **방을 만들지 않는다** — 없는 키면 `subscribe()`가 `not_found`로 거절한다(아래) |
|
|
49
50
|
| `rooms.list({cursor?, limit?})` | 내가 멤버인 방 + unread + 마지막 메시지 |
|
|
@@ -76,6 +77,7 @@ off()
|
|
|
76
77
|
| `id` / `key` | 방 식별자. 키로 열었으면 구독 전까지 `id`가 없다 |
|
|
77
78
|
| `lastSeq` | 서버가 알려 준 방의 마지막 seq |
|
|
78
79
|
| `presence` | `{count, users?, capped?}` |
|
|
80
|
+
| `hasOlder` | `loadOlder()`로 더 읽을 과거가 있는지. 첫 `loadOlder()` 전에 "이전 메시지" 버튼을 그릴지 정할 때 쓴다. `loadOlder()`의 `hasMore`와 같은 판정(맨 위 행이 방의 보존 하한 위에 있는지)이고, 목록이 비었으면 `false`. `messages` 이벤트 때 다시 읽는다 |
|
|
79
81
|
|
|
80
82
|
| 메서드 | |
|
|
81
83
|
|---|---|
|
|
@@ -85,7 +87,7 @@ off()
|
|
|
85
87
|
| `loadOlder({limit?})` | 가장 오래된 메시지 앞 페이지(기본 100)를 읽어 **`messages` 앞에 붙인다.** `{messages, hasMore}`. 스크롤을 올릴 때 쓴다. 목록이 비어 있으면(구독 전) 아무것도 읽지 않는다 |
|
|
86
88
|
| `history({before?, after?, limit?, view?})` | 과거를 직접 읽는다. **`messages`와 따로 논다.** `view`는 `'main'`, `'all'`, `{thread: id}` |
|
|
87
89
|
| `reload()` | 최근 페이지를 다시 읽는다 |
|
|
88
|
-
| `react(messageId, emoji)` / `unreact(...)` | 리액션.
|
|
90
|
+
| `react(messageId, emoji)` / `unreact(...)` | 리액션. 멱등. **프레임보다 먼저** 그 행의 `myReactions`를 고치고, 서버가 거절하면 되돌린다. 개수는 건드리지 않는다 |
|
|
89
91
|
| `reactionsOf(messageId, {emoji?, cursor?, limit?})` | 누가 눌렀는지. **한 행은 (유저, 이모지) 쌍**이라 한 사람이 여러 줄일 수 있다. `limit` 기본 50 최대 100, 마지막 페이지의 `cursor`는 빈 문자열 |
|
|
90
92
|
| `markRead(seq, {threadId?})` | 읽음 커서 |
|
|
91
93
|
| `typing()` | 입력 중. 던지지 않는다 |
|
|
@@ -132,6 +134,30 @@ room.on('messages', m => (messages.value = m))
|
|
|
132
134
|
`$state`(깊은 프록시)나 `ref`로 감싸도 동작하지만 필요 없는 일을 한다. 0.1.x는 같은
|
|
133
135
|
배열을 제자리에서 고쳐 다시 내보내서, 참조로 비교하는 쪽에서는 화면이 멈췄다.
|
|
134
136
|
|
|
137
|
+
**리액션: `reactions`는 모두의 개수, `myReactions`는 내가 누른 이모지다.** 둘 다
|
|
138
|
+
SDK가 라이브로 맞춘다 — 개수는 `reaction.*` 이벤트의 `count`로, `myReactions`는 그
|
|
139
|
+
이벤트의 `userId`가 `chat.user.id`일 때(다른 탭·기기에서 누른 것 포함)와 내
|
|
140
|
+
`react()`/`unreact()`로. 둘 다 더하기가 아니라 덮어쓰기라 이벤트와 ack가 모두
|
|
141
|
+
와도 두 번 세지 않는다. 바뀐 행만 새 객체다.
|
|
142
|
+
|
|
143
|
+
**`myReactions`는 누른 즉시 바뀐다.** `react()`/`unreact()`는 프레임을 보내기 **전에**
|
|
144
|
+
그 행의 `myReactions`를 고치고 `messages`를 내보낸다. 그래서 누른 느낌이 왕복만큼
|
|
145
|
+
늦지 않는다 — 누른 값은 (나, 이모지)로 이미 정해져 있고 서버는 멱등이라, ack가
|
|
146
|
+
새로 알려 줄 것이 없다. 서버가 거절하거나(한도, 권한) ack가 못 오면(타임아웃,
|
|
147
|
+
끊김) 되돌린다. 단, 그 사이 더 새로운 값이 자리를 차지했으면 그대로 둔다: 뒤에 누른
|
|
148
|
+
값, 내 `userId`가 실린 `reaction.*` 이벤트, `reload()`나 재접속이 가져온 페이지가
|
|
149
|
+
모두 대기 중인 낙관값을 이긴다. 거절이 낡은 값을 되살리는 일은 없다.
|
|
150
|
+
**이 필드의 주인은 SDK다** — 위에 자기 낙관 계층을 덧대면 주인이 둘이 된다.
|
|
151
|
+
|
|
152
|
+
**개수(`reactions[].count`)는 늘 서버 값이다.** 로컬에서 ±1하지 않는다. ack를
|
|
153
|
+
기다리는 사이 남이 같은 이모지를 누르면 로컬 계산은 서버가 보낼 수와 어긋나고,
|
|
154
|
+
그 차이를 고쳐 줄 이벤트는 이미 지나갔다. 개수는 `reaction.*` 이벤트가 옮긴다.
|
|
155
|
+
|
|
156
|
+
**발행(`send()`)은 일부러 낙관적이지 않다.** `before_publish` 훅이 본문을 바꿀 수
|
|
157
|
+
있어서(이모지 필터, 링크 치환) 미리 그릴 값이 클라이언트에 없다. 메시지는
|
|
158
|
+
`message.created`가 올 때 목록에 들어간다. 보내는 중 표시가 필요하면 그것은 소비자의
|
|
159
|
+
화면 상태이고, 리액션과 달리 SDK가 대신 정해 줄 수 없다.
|
|
160
|
+
|
|
135
161
|
**과거는 `loadOlder()`로 같은 목록에 붙인다.** `history()`는 목록을 건드리지 않는
|
|
136
162
|
직접 읽기라, 그 결과를 라이브 목록과 합치고 중복을 거르고 `reset`과 재접속을
|
|
137
163
|
처리하는 일이 전부 소비자 몫이 된다. `loadOlder()`는 그 행들을 `messages`에 넣으므로
|
|
@@ -139,7 +165,15 @@ room.on('messages', m => (messages.value = m))
|
|
|
139
165
|
불러도 요청은 하나이고, `hasMore`가 `false`면 방(또는 보존 기간)의 맨 앞이다.
|
|
140
166
|
`reset`이나 `reload()`는 붙인 과거도 함께 버린다.
|
|
141
167
|
|
|
168
|
+
**`hasOlder`와 `loadOlder()`는 SDK의 목록을 말한다, 당신의 창이 아니라.** SDK는
|
|
169
|
+
`room.messages`를 줄이지 않는다 — 500건을 들고 있으면 500건이 다 거기 있다. 화면
|
|
170
|
+
성능 때문에 받은 배열을 100건으로 잘라 그리고 있다면, 더 보여 줄 400건은 서버가
|
|
171
|
+
아니라 `room.messages`에 있다. 그때 `hasOlder`가 `false`인 것은 맞는 답이다(방의
|
|
172
|
+
맨 앞이라는 뜻이다). 창을 넓히는 일은 `room.messages`를 다시 읽는 것이고, 서버에
|
|
173
|
+
묻는 것은 그 다음이다.
|
|
174
|
+
|
|
142
175
|
```ts
|
|
176
|
+
showLoadMore(room.hasOlder)
|
|
143
177
|
const { hasMore } = await room.loadOlder()
|
|
144
178
|
```
|
|
145
179
|
|
|
@@ -191,6 +225,19 @@ token: async () => {
|
|
|
191
225
|
뒤의 둘은 `state`를 `closed`로 두고 `chat.on('error')`로 이유를 낸다. 재접속 중이나
|
|
192
226
|
REST의 토큰 갱신 중이라 거절을 받을 호출자가 없어도 그렇다. 다시 붙으려면 `connect()`.
|
|
193
227
|
|
|
228
|
+
**닉네임을 바꾸면 `reconnect()`.** 서버는 이름·아바타를 접속할 때 토큰 claim에서
|
|
229
|
+
한 번 읽는다. 그래서 백엔드에서 이름을 바꿔도 이 접속의 다음 메시지는 옛 이름이다.
|
|
230
|
+
`reconnect()`는 `token()`을 다시 불러 새 claim으로 다시 인증하고, 방은 가진 것에서
|
|
231
|
+
이어서 따라잡는다. `close()` + `connect()`도 되지만 `closed`를 거쳐(오프라인 화면이
|
|
232
|
+
깜빡인다) 페이지 리스너까지 떼어 낸다. 옛 소켓에서 ack를 기다리던 프레임은 `closed`로
|
|
233
|
+
거절된다. 끝나면 `chat.user`가 새 신원이다.
|
|
234
|
+
|
|
235
|
+
```ts
|
|
236
|
+
await api.updateNickname(name) // 백엔드가 다음 token()에 새 이름을 싣게 한다
|
|
237
|
+
await chat.reconnect()
|
|
238
|
+
chat.user?.name // 새 이름
|
|
239
|
+
```
|
|
240
|
+
|
|
194
241
|
**토큰 만료는 SDK가 처리한다.** 소켓은 접속할 때 한 번만 인증하므로 토큰이 만료돼도
|
|
195
242
|
멀쩡하지만, REST는 요청마다 토큰을 본다. REST가 401을 받으면 SDK가 `token()`을 다시
|
|
196
243
|
불러 **한 번** 재시도한다. 두 번째 401(오리진, 폐기된 토큰, 밴)은 그대로 던진다.
|
|
@@ -331,12 +378,26 @@ app.post('/api/chat-token', (req, res) => {
|
|
|
331
378
|
|
|
332
379
|
| 그룹 | 메서드 |
|
|
333
380
|
|---|---|
|
|
334
|
-
| `rooms` | `ensure({key?, type, name?, meta?, members?})`, `ensureDM([a, b])`, `get`, `update`, `delete`, `custom(roomId, payload)`, `members.list/add/remove` |
|
|
381
|
+
| `rooms` | `ensure({key?, type, name?, meta?, members?})`, `ensureDM([a, b])`, `get`, `update`, `delete`, `custom(roomId, payload)`, `presence(roomId, {full?})`, `members.list/add/remove` |
|
|
335
382
|
| `messages` | `send(roomId, {sender, text?, kind?, attachments?, appMeta?, replyTo?, threadId?, clientMessageId?})`, `list`, `delete` |
|
|
336
|
-
| `users` | `list({cursor?, limit?})`, `update`, `delete({purgeMessages?})`, `ban({until, reason?})`, `unban`, `revokeTokens`, `disconnect` |
|
|
383
|
+
| `users` | `list({cursor?, limit?})`, `update`, `delete({purgeMessages?})`, `purgeMessages(userId)`, `ban({until, reason?})`, `unban`, `revokeTokens`, `disconnect` |
|
|
337
384
|
| `events` | `list({after?, limit?})` — outbox. 놓친 웹훅을 따라잡는다 |
|
|
338
385
|
| `webhooks` | `verify(headers, rawBody, {now?, tolerance?})` |
|
|
339
386
|
|
|
387
|
+
`rooms.presence(roomId)`는 `{count}`이고, `{full: true}`면 `users`(최대 1000명, 잘리면
|
|
388
|
+
`capped: true`)가 붙는다. 빈 방은 404가 아니라 `{count: 0}`이다.
|
|
389
|
+
|
|
390
|
+
**`users.purgeMessages(userId)`는 모더레이션이다** — 그 유저가 앱에서 보낸 메시지를
|
|
391
|
+
전부 지우되(tombstone: seq는 그대로, 보고 있는 클라이언트에는 `message.deleted`가
|
|
392
|
+
간다) **탈퇴시키지 않는다.** 이름도 남는다. 스팸이면 **밴을 먼저** 하고 부른다 — 도는
|
|
393
|
+
사이에 올라온 메시지는 목록에 없다. 한 번에 상한만큼만 하므로 `purgeCapped`가
|
|
394
|
+
`true`인 동안 다시 부른다. 탈퇴한 유저는 404다(그때는 `delete(userId, {purgeMessages: true})`).
|
|
395
|
+
|
|
396
|
+
```ts
|
|
397
|
+
await chat.users.ban(userId, { until: Date.now() + 7 * 86_400_000, reason: 'spam' })
|
|
398
|
+
while ((await chat.users.purgeMessages(userId)).purgeCapped) {}
|
|
399
|
+
```
|
|
400
|
+
|
|
340
401
|
`users.ban`의 `until`은 **unix 밀리초이고 필수다.** 무기한 밴은 먼 미래 시각을
|
|
341
402
|
직접 고른다 — 감사 로그에 사고가 아니라 결정으로 남는다.
|
|
342
403
|
|
package/dist/index.cjs
CHANGED
|
@@ -204,6 +204,25 @@ var Timeline = class {
|
|
|
204
204
|
shared = false;
|
|
205
205
|
/** The `loadOlder` in flight, so a scroll handler firing twice asks once. */
|
|
206
206
|
older;
|
|
207
|
+
/**
|
|
208
|
+
* The lowest seq the room still keeps (`minSeq` from the subscribe ack;
|
|
209
|
+
* 1 until told). Below it there is nothing to read, whatever seq says.
|
|
210
|
+
*/
|
|
211
|
+
floor = 1;
|
|
212
|
+
/** A `loadOlder` came back short: the top of what the server keeps was reached. */
|
|
213
|
+
exhausted = false;
|
|
214
|
+
/**
|
|
215
|
+
* Presses this client has made and the server has not answered yet, one
|
|
216
|
+
* entry per (message, emoji) pair, each remembering the last value the
|
|
217
|
+
* **server** gave for that pair.
|
|
218
|
+
*
|
|
219
|
+
* 되돌릴 자리로 "누르기 직전 화면값"을 쓰면 안 된다. 눌렀다 곧바로 취소하고
|
|
220
|
+
* 두 거절이 차례로 오면, 두 번째 거절이 첫 번째의 낙관값 — 서버가 한 번도
|
|
221
|
+
* 가진 적 없는 값 — 을 화면에 되살린다. 마지막으로 서버가 말한 값만이
|
|
222
|
+
* 되돌릴 자리다.
|
|
223
|
+
*/
|
|
224
|
+
presses = /* @__PURE__ */ new Map();
|
|
225
|
+
pressSeq = 0;
|
|
207
226
|
constructor(options) {
|
|
208
227
|
this.options = options;
|
|
209
228
|
}
|
|
@@ -212,6 +231,22 @@ var Timeline = class {
|
|
|
212
231
|
this.shared = true;
|
|
213
232
|
return this.items;
|
|
214
233
|
}
|
|
234
|
+
/**
|
|
235
|
+
* Whether `loadOlder()` would find anything, answered without asking.
|
|
236
|
+
*
|
|
237
|
+
* `loadOlder()`의 `hasMore`와 같은 판정이다: 맨 위 행이 방의 보존 하한
|
|
238
|
+
* (`minSeq`, seq는 1부터 구멍 없이 붙는다)보다 위에 있고, 앞선 `loadOlder`가
|
|
239
|
+
* 덜 찬 페이지로 끝을 알린 적이 없으면 더 있다. 첫 `loadOlder()` 전에 "이전
|
|
240
|
+
* 메시지 더 보기"를 그릴지 정할 수 있게 한다. 목록이 비었으면 false다.
|
|
241
|
+
*/
|
|
242
|
+
get hasOlder() {
|
|
243
|
+
const first = this.items[0];
|
|
244
|
+
return !this.exhausted && first !== void 0 && first.seq > this.floor;
|
|
245
|
+
}
|
|
246
|
+
/** Records the room's retention floor (`minSeq`). */
|
|
247
|
+
setFloor(minSeq) {
|
|
248
|
+
this.floor = Math.max(1, minSeq);
|
|
249
|
+
}
|
|
215
250
|
/** The highest seq this timeline holds, hole or no hole. */
|
|
216
251
|
get highestSeq() {
|
|
217
252
|
return this.items.at(-1)?.seq ?? 0;
|
|
@@ -255,7 +290,9 @@ var Timeline = class {
|
|
|
255
290
|
this.epoch++;
|
|
256
291
|
this.fills.clear();
|
|
257
292
|
this.pendingDeletes.clear();
|
|
293
|
+
this.presses.clear();
|
|
258
294
|
this.items = [];
|
|
295
|
+
this.exhausted = false;
|
|
259
296
|
this.shared = false;
|
|
260
297
|
this.changed();
|
|
261
298
|
}
|
|
@@ -281,8 +318,10 @@ var Timeline = class {
|
|
|
281
318
|
this.epoch++;
|
|
282
319
|
this.fills.clear();
|
|
283
320
|
const page = [...messages].sort((a, b) => a.seq - b.seq);
|
|
321
|
+
for (const m of page) this.serverRendered(m.id);
|
|
284
322
|
const top = page.at(-1)?.seq ?? 0;
|
|
285
323
|
this.items = [...page, ...this.items.filter((m) => m.seq > top)];
|
|
324
|
+
this.exhausted = false;
|
|
286
325
|
this.shared = false;
|
|
287
326
|
for (const m of this.items) {
|
|
288
327
|
if (this.pendingDeletes.has(m.id)) this.applyDelete(m.id);
|
|
@@ -333,20 +372,84 @@ var Timeline = class {
|
|
|
333
372
|
*
|
|
334
373
|
* The server sends the new `count` with the event, so this is a
|
|
335
374
|
* replacement rather than an increment: two clients reacting at once
|
|
336
|
-
* cannot drift the way `+1`/`-1` would.
|
|
375
|
+
* cannot drift the way `+1`/`-1` would. `count` undefined leaves the
|
|
376
|
+
* aggregate alone (the react ack path); `mine` undefined leaves
|
|
377
|
+
* `myReactions` alone (somebody else's event).
|
|
378
|
+
*
|
|
379
|
+
* This is the **authoritative** entry point: a value the server sent.
|
|
380
|
+
* So a press still waiting on an answer for the same pair is finished
|
|
381
|
+
* here -- a refusal landing afterwards must not push this value back
|
|
382
|
+
* to the older one it was going to restore.
|
|
383
|
+
*/
|
|
384
|
+
reaction(messageId, emoji, count, mine) {
|
|
385
|
+
if (mine !== void 0) this.presses.delete(pairKey(messageId, emoji));
|
|
386
|
+
this.applyReaction(messageId, emoji, count, mine);
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* Applies this client's own react/unreact to `myReactions` **before the
|
|
390
|
+
* frame goes out**, and returns the press so its answer can settle it.
|
|
391
|
+
*
|
|
392
|
+
* 누른 값은 (사용자, 이모지)로 로컬에서 정해지고 엔진은 멱등이므로, 미리
|
|
393
|
+
* 적용해도 ack가 새로 알려 줄 것이 없다. `count`는 일부러 건드리지 않는다 —
|
|
394
|
+
* 그 사이 남이 같은 이모지를 누르면 로컬 +1/-1은 서버가 보낼 수와 다르고,
|
|
395
|
+
* 그 차이를 고칠 이벤트는 이미 지나갔다.
|
|
396
|
+
*
|
|
397
|
+
* Undefined when the row is not held: there is nothing to show now and
|
|
398
|
+
* nothing to put back later.
|
|
399
|
+
*/
|
|
400
|
+
pressReaction(messageId, emoji, mine) {
|
|
401
|
+
const at = this.items.findIndex((m) => m.id === messageId);
|
|
402
|
+
if (at === -1) return void 0;
|
|
403
|
+
const key = pairKey(messageId, emoji);
|
|
404
|
+
const base = this.presses.get(key)?.base ?? (this.items[at].myReactions ?? []).includes(emoji);
|
|
405
|
+
const press = { messageId, emoji, mine, seq: ++this.pressSeq };
|
|
406
|
+
this.presses.set(key, { seq: press.seq, base });
|
|
407
|
+
this.applyReaction(messageId, emoji, void 0, mine);
|
|
408
|
+
return press;
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Settles a press with what the server said.
|
|
412
|
+
*
|
|
413
|
+
* `accepted` re-asserts the value the press asked for; a refusal puts
|
|
414
|
+
* the last server-given value back. Either way it does nothing once
|
|
415
|
+
* this press no longer owns the pair -- a newer press took it over
|
|
416
|
+
* (and that one's answer decides), or something authoritative already
|
|
417
|
+
* landed on it (a `reaction.*` event carrying this user's id, a
|
|
418
|
+
* `reload()`, a reconnect's catch-up page). Reverting there would
|
|
419
|
+
* resurrect a value older than what is on screen.
|
|
337
420
|
*/
|
|
338
|
-
|
|
421
|
+
settleReaction(press, accepted) {
|
|
422
|
+
const key = pairKey(press.messageId, press.emoji);
|
|
423
|
+
const held = this.presses.get(key);
|
|
424
|
+
if (held === void 0 || held.seq !== press.seq) return;
|
|
425
|
+
this.presses.delete(key);
|
|
426
|
+
this.applyReaction(press.messageId, press.emoji, void 0, accepted ? press.mine : held.base);
|
|
427
|
+
}
|
|
428
|
+
applyReaction(messageId, emoji, count, mine) {
|
|
339
429
|
const at = this.items.findIndex((m) => m.id === messageId);
|
|
340
430
|
if (at === -1) return;
|
|
341
431
|
const current = this.items[at];
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
432
|
+
let reactions = current.reactions;
|
|
433
|
+
if (count !== void 0) {
|
|
434
|
+
const existing = current.reactions ?? [];
|
|
435
|
+
const slot = existing.findIndex((r) => r.emoji === emoji);
|
|
436
|
+
if (count <= 0) reactions = existing.filter((r) => r.emoji !== emoji);
|
|
437
|
+
else if (slot === -1) reactions = [...existing, { emoji, count }];
|
|
438
|
+
else reactions = existing.map((r, i) => i === slot ? { emoji, count } : r);
|
|
439
|
+
}
|
|
440
|
+
let myReactions = current.myReactions;
|
|
441
|
+
if (mine !== void 0) {
|
|
442
|
+
const held = current.myReactions ?? [];
|
|
443
|
+
const has = held.includes(emoji);
|
|
444
|
+
if (mine && !has) myReactions = [...held, emoji];
|
|
445
|
+
else if (!mine && has) myReactions = held.filter((e) => e !== emoji);
|
|
446
|
+
}
|
|
447
|
+
if (reactions === current.reactions && myReactions === current.myReactions) return;
|
|
448
|
+
const next = { ...current };
|
|
449
|
+
if (reactions !== void 0) next.reactions = reactions;
|
|
450
|
+
if (myReactions !== void 0) next.myReactions = myReactions;
|
|
348
451
|
this.own();
|
|
349
|
-
this.items[at] =
|
|
452
|
+
this.items[at] = next;
|
|
350
453
|
this.changed();
|
|
351
454
|
}
|
|
352
455
|
/** Applies a `thread.updated` to the root message's aggregate. */
|
|
@@ -383,14 +486,16 @@ var Timeline = class {
|
|
|
383
486
|
}
|
|
384
487
|
async readOlder(limit) {
|
|
385
488
|
const first = this.items[0];
|
|
386
|
-
if (first === void 0 || first.seq <=
|
|
489
|
+
if (first === void 0 || first.seq <= this.floor) return { messages: [], hasMore: false };
|
|
387
490
|
const epoch = this.epoch;
|
|
388
491
|
const page = await this.options.fetchRange({ after: 0, before: first.seq, limit });
|
|
389
492
|
if (this.epoch !== epoch) return { messages: [], hasMore: true };
|
|
390
493
|
for (const m of page) this.insert(m, false);
|
|
391
494
|
if (page.length > 0) this.changed();
|
|
392
495
|
const top = page[0];
|
|
393
|
-
|
|
496
|
+
const hasMore = page.length >= limit && top !== void 0 && top.seq > this.floor;
|
|
497
|
+
this.exhausted = !hasMore;
|
|
498
|
+
return { messages: page, hasMore };
|
|
394
499
|
}
|
|
395
500
|
/**
|
|
396
501
|
* Fetches everything between what we have and `upTo`.
|
|
@@ -437,6 +542,7 @@ var Timeline = class {
|
|
|
437
542
|
}
|
|
438
543
|
/** Inserts at the seq position, replacing an existing row with that seq. */
|
|
439
544
|
insert(message, notify = true) {
|
|
545
|
+
this.serverRendered(message.id);
|
|
440
546
|
const pending = this.pendingDeletes.has(message.id);
|
|
441
547
|
if (pending) this.pendingDeletes.delete(message.id);
|
|
442
548
|
const withDelete = pending ? emptied(message) : message;
|
|
@@ -461,6 +567,21 @@ var Timeline = class {
|
|
|
461
567
|
this.changed();
|
|
462
568
|
return true;
|
|
463
569
|
}
|
|
570
|
+
/**
|
|
571
|
+
* Forgets the presses on a row the server has just re-rendered.
|
|
572
|
+
*
|
|
573
|
+
* 히스토리 응답의 행은 뷰어별 `myReactions`를 싣고 오므로 그 값이 서버의
|
|
574
|
+
* 값이다. 누름을 남겨 두면 그 뒤에 온 거절이 더 새로운 진실 위에 옛 값을
|
|
575
|
+
* 덮어쓴다. 행 전체 단위인 것은 페이지가 그 행의 이모지 전부를 다시 그려
|
|
576
|
+
* 주기 때문이다.
|
|
577
|
+
*/
|
|
578
|
+
serverRendered(messageId) {
|
|
579
|
+
if (this.presses.size === 0) return;
|
|
580
|
+
const prefix = `${messageId}\0`;
|
|
581
|
+
for (const key of this.presses.keys()) {
|
|
582
|
+
if (key.startsWith(prefix)) this.presses.delete(key);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
464
585
|
/** Makes `items` safe to mutate: a copy, if the current one was handed out. */
|
|
465
586
|
own() {
|
|
466
587
|
if (!this.shared) return;
|
|
@@ -475,6 +596,9 @@ var Timeline = class {
|
|
|
475
596
|
function emptied(message) {
|
|
476
597
|
return { ...message, body: {}, deletedAt: message.deletedAt ?? Date.now() };
|
|
477
598
|
}
|
|
599
|
+
function pairKey(messageId, emoji) {
|
|
600
|
+
return `${messageId}\0${emoji}`;
|
|
601
|
+
}
|
|
478
602
|
function createTimeline(options) {
|
|
479
603
|
return new Timeline(options);
|
|
480
604
|
}
|
|
@@ -538,6 +662,17 @@ var Room = class extends Emitter {
|
|
|
538
662
|
onChange: (messages) => this.emit("messages", messages)
|
|
539
663
|
});
|
|
540
664
|
}
|
|
665
|
+
/**
|
|
666
|
+
* Whether older history exists above the first message held -- what
|
|
667
|
+
* `loadOlder()` would report as `hasMore`, known before calling it.
|
|
668
|
+
*
|
|
669
|
+
* Derived from the first row's seq against the room's retention floor
|
|
670
|
+
* (the subscribe ack's `minSeq`), and false once a `loadOlder()` came
|
|
671
|
+
* back short. False while nothing is loaded. Re-read it on `messages`.
|
|
672
|
+
*/
|
|
673
|
+
get hasOlder() {
|
|
674
|
+
return this.timeline.hasOlder;
|
|
675
|
+
}
|
|
541
676
|
/** Everything this client knows about the room, in seq order. A new array whenever it changes. */
|
|
542
677
|
get messages() {
|
|
543
678
|
return this.timeline.messages;
|
|
@@ -616,6 +751,7 @@ var Room = class extends Emitter {
|
|
|
616
751
|
this.id = ack.roomId;
|
|
617
752
|
this.chat.registerRoomId(ack.roomId, this);
|
|
618
753
|
this.lastSeq = ack.lastSeq;
|
|
754
|
+
if (typeof ack.minSeq === "number") this.timeline.setFloor(ack.minSeq);
|
|
619
755
|
if (ack.presence !== void 0) this.presence = ack.presence;
|
|
620
756
|
if (attempt.wasReset) {
|
|
621
757
|
await this.loadRecent(current);
|
|
@@ -659,12 +795,52 @@ var Room = class extends Emitter {
|
|
|
659
795
|
});
|
|
660
796
|
return page.messages;
|
|
661
797
|
}
|
|
662
|
-
/**
|
|
798
|
+
/**
|
|
799
|
+
* Adds a reaction. Idempotent, like the frame.
|
|
800
|
+
*
|
|
801
|
+
* The row's `myReactions` moves **before the frame goes out** and rolls
|
|
802
|
+
* back if the server refuses; its count stays the server's. See
|
|
803
|
+
* `toggleReaction`.
|
|
804
|
+
*/
|
|
663
805
|
async react(messageId, emoji) {
|
|
664
|
-
await this.
|
|
806
|
+
await this.toggleReaction(messageId, emoji, true);
|
|
665
807
|
}
|
|
808
|
+
/** Removes a reaction. Idempotent, and optimistic the same way `react` is. */
|
|
666
809
|
async unreact(messageId, emoji) {
|
|
667
|
-
await this.
|
|
810
|
+
await this.toggleReaction(messageId, emoji, false);
|
|
811
|
+
}
|
|
812
|
+
/**
|
|
813
|
+
* Moves `myReactions` now, sends the frame after.
|
|
814
|
+
*
|
|
815
|
+
* 누른 값은 (사용자, 이모지)로 로컬에서 정해지고 엔진은 멱등이므로 ack를
|
|
816
|
+
* 기다릴 이유가 없다 — 기다리면 누른 느낌이 왕복 시간만큼 늦는다. 소비자가
|
|
817
|
+
* 자기 낙관 계층을 덧대면 이 필드의 주인이 둘이 되므로, SDK가 한다.
|
|
818
|
+
* **발행은 다르다**: `before_publish` 훅이 본문을 바꿀 수 있어 미리 그릴 값이
|
|
819
|
+
* 로컬에 없다. 그래서 `send()`는 낙관적이지 않다.
|
|
820
|
+
*
|
|
821
|
+
* 개수는 옮기지 않는다. ack가 오기 전에 남이 같은 이모지를 누르면 로컬
|
|
822
|
+
* +1/-1은 서버가 보낸 수와 다른 값이 되고, 그것을 고칠 이벤트는 이미
|
|
823
|
+
* 지나갔다. 또 ack와 방 프레임은 다른 길로 와 순서가 없으므로, 늦게 온 내
|
|
824
|
+
* ack(count 1)가 앞서 온 남의 이벤트(count 2)를 되돌린다.
|
|
825
|
+
*
|
|
826
|
+
* 거절·타임아웃·끊김이면 되돌리지만, 그 사이 더 새로운 값이 자리를 차지했다면
|
|
827
|
+
* 두고 나온다(`Timeline.settleReaction`).
|
|
828
|
+
*/
|
|
829
|
+
async toggleReaction(messageId, emoji, mine) {
|
|
830
|
+
const press = this.timeline.pressReaction(messageId, emoji, mine);
|
|
831
|
+
try {
|
|
832
|
+
await this.chat.send("react", {
|
|
833
|
+
roomId: await this.resolveId("reacting"),
|
|
834
|
+
messageId,
|
|
835
|
+
emoji,
|
|
836
|
+
op: mine ? "add" : "remove"
|
|
837
|
+
});
|
|
838
|
+
} catch (err) {
|
|
839
|
+
if (press !== void 0) this.timeline.settleReaction(press, false);
|
|
840
|
+
throw err;
|
|
841
|
+
}
|
|
842
|
+
if (press !== void 0) this.timeline.settleReaction(press, true);
|
|
843
|
+
else this.timeline.reaction(messageId, emoji, void 0, mine);
|
|
668
844
|
}
|
|
669
845
|
/**
|
|
670
846
|
* Moves this user's read cursor.
|
|
@@ -859,7 +1035,9 @@ var Room = class extends Emitter {
|
|
|
859
1035
|
case "reaction.added":
|
|
860
1036
|
case "reaction.removed": {
|
|
861
1037
|
const r = data;
|
|
862
|
-
this.
|
|
1038
|
+
const me = this.chat.user?.id;
|
|
1039
|
+
const mine = me !== void 0 && r.userId === me ? type === "reaction.added" : void 0;
|
|
1040
|
+
this.timeline.reaction(r.messageId, r.emoji, r.count, mine);
|
|
863
1041
|
break;
|
|
864
1042
|
}
|
|
865
1043
|
case "thread.updated": {
|
|
@@ -1108,6 +1286,48 @@ var ChatClient = class extends Emitter {
|
|
|
1108
1286
|
});
|
|
1109
1287
|
return this.opening;
|
|
1110
1288
|
}
|
|
1289
|
+
/**
|
|
1290
|
+
* Re-authenticates: calls `token()` again and replaces the socket, keeping
|
|
1291
|
+
* every room handle and every subscription.
|
|
1292
|
+
*
|
|
1293
|
+
* For when the identity the server holds has to change mid-session --
|
|
1294
|
+
* a nickname change, a new avatar, fresh claims. The server reads the
|
|
1295
|
+
* token once, at `auth`, so `sender.name` on the next message is the old
|
|
1296
|
+
* one until the connection is re-authenticated. `close()` + `connect()`
|
|
1297
|
+
* does that too, but passes through `closed` (a consumer's "you are
|
|
1298
|
+
* offline" screen) and tears down the page listeners.
|
|
1299
|
+
*
|
|
1300
|
+
* States: `open` → `reconnecting` → `open`, never `closed`. Rooms
|
|
1301
|
+
* resubscribe from what they hold and catch up the gap, as after any
|
|
1302
|
+
* drop; `messages` is kept. Frames awaiting an ack on the old socket are
|
|
1303
|
+
* rejected with `closed`. `chat.user` is the new identity once this
|
|
1304
|
+
* resolves. If `token()` fails the client keeps retrying in the
|
|
1305
|
+
* background like any reconnect (and this rejects); a `ChatError` from
|
|
1306
|
+
* `token()` stops it at `closed`, as on connect.
|
|
1307
|
+
*
|
|
1308
|
+
* On a client that is not connected (never connected, or `close()`d) it
|
|
1309
|
+
* is `connect()`.
|
|
1310
|
+
*/
|
|
1311
|
+
async reconnect() {
|
|
1312
|
+
if (this.opening !== void 0) await this.opening.catch(() => {
|
|
1313
|
+
});
|
|
1314
|
+
const socket = this.socket;
|
|
1315
|
+
if (socket === void 0 || this.state !== "open") return this.connect();
|
|
1316
|
+
this.clearTimers();
|
|
1317
|
+
this.retired.add(socket);
|
|
1318
|
+
this.socket = void 0;
|
|
1319
|
+
this.failPending(new ChatError("closed", "the connection was replaced by reconnect()"));
|
|
1320
|
+
try {
|
|
1321
|
+
socket.close(1e3, "client reconnecting");
|
|
1322
|
+
} catch {
|
|
1323
|
+
}
|
|
1324
|
+
this.opening = this.openOnce("reconnecting").finally(() => {
|
|
1325
|
+
this.opening = void 0;
|
|
1326
|
+
});
|
|
1327
|
+
return this.opening;
|
|
1328
|
+
}
|
|
1329
|
+
/** Sockets `reconnect()` replaced: their late events are not this client's any more. */
|
|
1330
|
+
retired = /* @__PURE__ */ new WeakSet();
|
|
1111
1331
|
/**
|
|
1112
1332
|
* Closes for good.
|
|
1113
1333
|
*
|
|
@@ -1221,8 +1441,8 @@ var ChatClient = class extends Emitter {
|
|
|
1221
1441
|
this.state = next;
|
|
1222
1442
|
this.emit("state", next);
|
|
1223
1443
|
}
|
|
1224
|
-
async openOnce() {
|
|
1225
|
-
this.setState(
|
|
1444
|
+
async openOnce(state = this.attempt === 0 ? "connecting" : "reconnecting") {
|
|
1445
|
+
this.setState(state);
|
|
1226
1446
|
let authData;
|
|
1227
1447
|
try {
|
|
1228
1448
|
authData = await this.authData();
|
|
@@ -1248,6 +1468,7 @@ var ChatClient = class extends Emitter {
|
|
|
1248
1468
|
else reject(err);
|
|
1249
1469
|
};
|
|
1250
1470
|
socket.addEventListener("message", (ev) => {
|
|
1471
|
+
if (this.retired.has(socket)) return;
|
|
1251
1472
|
const frame = JSON.parse(String(ev.data));
|
|
1252
1473
|
if (frame.type === "hello") {
|
|
1253
1474
|
this.onHello(frame.data);
|
|
@@ -1268,6 +1489,7 @@ var ChatClient = class extends Emitter {
|
|
|
1268
1489
|
this.onFrame(frame);
|
|
1269
1490
|
});
|
|
1270
1491
|
socket.addEventListener("close", () => {
|
|
1492
|
+
if (this.retired.has(socket)) return;
|
|
1271
1493
|
if (this.socket !== void 0 && this.socket !== socket) return;
|
|
1272
1494
|
this.clearTimers();
|
|
1273
1495
|
this.socket = void 0;
|