@kispi/chat 0.1.2 → 0.2.0
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.md +103 -8
- package/dist/{chunk-T3QOUFOM.js → chunk-5QQRM7BH.js} +2 -0
- package/dist/index.cjs +145 -14
- package/dist/index.d.cts +96 -6
- package/dist/index.d.ts +96 -6
- package/dist/index.js +144 -15
- 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 +1 -1
package/README.md
CHANGED
|
@@ -22,7 +22,7 @@ npm install @kispi/chat
|
|
|
22
22
|
|---|---|---|
|
|
23
23
|
| `url` | `string` | **필수.** 오리진만 준다. SDK가 `/v1/ws?v=1`을 붙인다 |
|
|
24
24
|
| `key` | `string` | **필수.** `pk_`. 공개값이라 브라우저에 넣어도 된다 |
|
|
25
|
-
| `token` | `() => string \| Promise<string>` | **필수.** 백엔드가 서명한 유저 토큰. 접속할
|
|
25
|
+
| `token` | `() => string \| Promise<string>` | **필수.** 백엔드가 서명한 유저 토큰. 접속할 때마다, 그리고 REST가 401을 받을 때 다시 부르므로 만료된 것이 재사용되지 않는다 |
|
|
26
26
|
| `restURL` | `string` | REST 주소. 생략하면 `url`에서 유도한다(`ws://` → `http://`) |
|
|
27
27
|
| `externalToken` | `() => string \| Promise<string>` | external 모드. 주면 **`token`보다 우선한다**(서버의 판정 순서와 같다). **서버가 아직 거절하므로 오늘은 쓸 곳이 없다** |
|
|
28
28
|
| `WebSocket` | `new (url) => WebSocketLike` | 전역 대신 쓸 구현. 테스트에서 주입한다 |
|
|
@@ -42,10 +42,10 @@ npm install @kispi/chat
|
|
|
42
42
|
|
|
43
43
|
| 메서드 | |
|
|
44
44
|
|---|---|
|
|
45
|
-
| `connect()` | 붙는다. 이미 붙어 있거나 붙는 중이면 그 시도에
|
|
45
|
+
| `connect()` | 붙는다. 이미 붙어 있거나 붙는 중이면 그 시도에 합류한다. 실패하면 거절하지만 **재시도는 백그라운드에서 계속된다**(아래) |
|
|
46
46
|
| `close()` | 끊는다. 이건 재접속하지 않는다 |
|
|
47
47
|
| `room(roomId)` | 방 핸들. 같은 id면 같은 객체 |
|
|
48
|
-
| `roomByKey(key)` | 키로 여는 방 핸들. 구독할 때 서버가 id를 알려
|
|
48
|
+
| `roomByKey(key)` | 키로 여는 방 핸들. 구독할 때 서버가 id를 알려 준다. **방을 만들지 않는다** — 없는 키면 `subscribe()`가 `not_found`로 거절한다(아래) |
|
|
49
49
|
| `rooms.list({cursor?, limit?})` | 내가 멤버인 방 + unread + 마지막 메시지 |
|
|
50
50
|
| `rooms.discover({type?, cursor?, limit?})` | 공개 방 탐색 |
|
|
51
51
|
| `rooms.members(roomId).list/add/remove` | 멤버 관리 |
|
|
@@ -71,7 +71,7 @@ off()
|
|
|
71
71
|
|
|
72
72
|
| 프로퍼티 | |
|
|
73
73
|
|---|---|
|
|
74
|
-
| `messages` | seq 오름차순. **정렬과 구멍 메우기는 SDK가
|
|
74
|
+
| `messages` | seq 오름차순. **정렬과 구멍 메우기는 SDK가 한다.** 바뀔 때마다 새 배열이다 |
|
|
75
75
|
| `id` / `key` | 방 식별자. 키로 열었으면 구독 전까지 `id`가 없다 |
|
|
76
76
|
| `lastSeq` | 서버가 알려 준 방의 마지막 seq |
|
|
77
77
|
| `presence` | `{count, users?, capped?}` |
|
|
@@ -80,8 +80,9 @@ off()
|
|
|
80
80
|
|---|---|
|
|
81
81
|
| `subscribe()` | 라이브 피드를 켜고 최근 100건을 읽는다. 이미 따라잡혔으면 아무것도 안 한다 |
|
|
82
82
|
| `unsubscribe()` | 이 방 보기를 그만둔다. 재접속해도 다시 구독하지 않는다 |
|
|
83
|
-
| `send({text, attachments?, entities?, replyTo?, threadId?, meta?}, {clientMessageId?})` | 발행. ack로 `{messageId, seq}
|
|
84
|
-
| `
|
|
83
|
+
| `send({text, attachments?, entities?, replyTo?, threadId?, meta?}, {clientMessageId?})` | 발행. ack로 `{messageId, seq}`. 구독이 진행 중이면 그것을 기다린다 |
|
|
84
|
+
| `loadOlder({limit?})` | 가장 오래된 메시지 앞 페이지(기본 100)를 읽어 **`messages` 앞에 붙인다.** `{messages, hasMore}`. 스크롤을 올릴 때 쓴다. 목록이 비어 있으면(구독 전) 아무것도 읽지 않는다 |
|
|
85
|
+
| `history({before?, after?, limit?, view?})` | 과거를 직접 읽는다. **`messages`와 따로 논다.** `view`는 `'main'`, `'all'`, `{thread: id}` |
|
|
85
86
|
| `reload()` | 최근 페이지를 다시 읽는다 |
|
|
86
87
|
| `react(messageId, emoji)` / `unreact(...)` | 리액션. 멱등 |
|
|
87
88
|
| `reactionsOf(messageId, {emoji?, cursor?, limit?})` | 누가 눌렀는지. **한 행은 (유저, 이모지) 쌍**이라 한 사람이 여러 줄일 수 있다. `limit` 기본 50 최대 100, 마지막 페이지의 `cursor`는 빈 문자열 |
|
|
@@ -100,11 +101,69 @@ off()
|
|
|
100
101
|
|
|
101
102
|
| 이벤트 | 언제 |
|
|
102
103
|
|---|---|
|
|
103
|
-
| `messages` | 목록이 바뀔 때마다.
|
|
104
|
+
| `messages` | 목록이 바뀔 때마다. **매번 새 배열이고, 내보낸 배열은 다시 건드리지 않는다** |
|
|
104
105
|
| `error` | 히스토리를 읽거나 구멍을 메우는 데 실패했다. **다시 시도할 것은 `subscribe()`** |
|
|
105
106
|
| `reset` | 커서가 보존 기간보다 오래돼 목록을 버리고 다시 채웠다. **렌더한 것을 버려야 한다** |
|
|
106
107
|
|
|
107
|
-
### 알아 둘 것
|
|
108
|
+
### 알아 둘 것
|
|
109
|
+
|
|
110
|
+
**`messages`는 참조가 바뀐다.** 목록이 바뀔 때마다 새 배열이 나오고, 한 번 내보낸
|
|
111
|
+
배열과 그 안의 행 객체는 이후에 고쳐지지 않는다(바뀐 행만 새 객체다). 그래서
|
|
112
|
+
참조로 비교하는 상태에 그대로 넣으면 된다.
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
// Svelte 5
|
|
116
|
+
let messages = $state.raw<Message[]>([])
|
|
117
|
+
room.on('messages', m => (messages = m))
|
|
118
|
+
|
|
119
|
+
// React
|
|
120
|
+
const [messages, setMessages] = useState<Message[]>([])
|
|
121
|
+
useEffect(() => room.on('messages', setMessages), [room])
|
|
122
|
+
|
|
123
|
+
// Vue
|
|
124
|
+
const messages = shallowRef<Message[]>([])
|
|
125
|
+
room.on('messages', m => (messages.value = m))
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
`$state`(깊은 프록시)나 `ref`로 감싸도 동작하지만 필요 없는 일을 한다. 0.1.x는 같은
|
|
129
|
+
배열을 제자리에서 고쳐 다시 내보내서, 참조로 비교하는 쪽에서는 화면이 멈췄다.
|
|
130
|
+
|
|
131
|
+
**과거는 `loadOlder()`로 같은 목록에 붙인다.** `history()`는 목록을 건드리지 않는
|
|
132
|
+
직접 읽기라, 그 결과를 라이브 목록과 합치고 중복을 거르고 `reset`과 재접속을
|
|
133
|
+
처리하는 일이 전부 소비자 몫이 된다. `loadOlder()`는 그 행들을 `messages`에 넣으므로
|
|
134
|
+
정렬·중복 제거·삭제·리액션·재접속이 다른 행과 똑같이 적용된다. 동시에 여러 번
|
|
135
|
+
불러도 요청은 하나이고, `hasMore`가 `false`면 방(또는 보존 기간)의 맨 앞이다.
|
|
136
|
+
`reset`이나 `reload()`는 붙인 과거도 함께 버린다.
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
const { hasMore } = await room.loadOlder()
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
**보내도 되는 때는 `await room.subscribe()`가 끝난 때다.** 다만 기다리지 않고
|
|
143
|
+
`send`해도 된다 — 구독이 진행 중이면 `send`(와 `react`, `markRead`, `loadOlder` 등)가
|
|
144
|
+
그것을 기다렸다가 나간다. 구독이 실패하면 그 에러로 거절한다. 구독한 적도 없는
|
|
145
|
+
`roomByKey` 방은 id를 모르므로 곧바로 `closed`로 거절한다. `chat.state`가
|
|
146
|
+
`reconnecting`인 동안의 발행도 `closed`로 거절한다 — SDK는 보낼 것을 쌓아 두지 않는다.
|
|
147
|
+
|
|
148
|
+
**키로 연다고 방이 생기지 않는다.** `roomByKey(key).subscribe()`는 있는 방을 찾기만
|
|
149
|
+
하고, 없으면 `not_found`다. 방은 백엔드가 `sk_`로 만든다
|
|
150
|
+
(`@kispi/chat/server`의 `rooms.ensure({key, type})` — 있으면 그대로 돌려주는
|
|
151
|
+
get-or-create라 페이지를 열 때마다 불러도 된다). 앱 설정 `client_room_create`를 켜면
|
|
152
|
+
유저 토큰으로도 `PUT /v1/rooms`를 부를 수 있지만(허용 타입·키 패턴·하루 50개
|
|
153
|
+
한도), `pk_`는 공개값이라 누구나 방을 찍어낼 수 있는 문이 된다. 기본은 꺼져 있고,
|
|
154
|
+
끄는 쪽을 권한다.
|
|
155
|
+
|
|
156
|
+
**연결은 알아서 돌아온다.** 소켓이 끊기거나 `token()`이 던지면 지수 백오프(0.5초에서
|
|
157
|
+
30초까지, 지터)로 다시 붙는다. **첫 `connect()`도 같다** — 실패하면 `await`는 거절해서
|
|
158
|
+
호출자가 알게 하고, 재시도는 백그라운드에서 이어진다(`state`가 `reconnecting`).
|
|
159
|
+
그래서 재시도 루프를 직접 짤 필요가 없다. 멈추는 것은 둘뿐이다: `close()`, 그리고
|
|
160
|
+
서버가 인증을 거절한 경우(`unauthorized` — 같은 토큰을 반복하는 것은 서버를 두드리는
|
|
161
|
+
일이라 `closed`로 끝낸다).
|
|
162
|
+
|
|
163
|
+
**토큰 만료는 SDK가 처리한다.** 소켓은 접속할 때 한 번만 인증하므로 토큰이 만료돼도
|
|
164
|
+
멀쩡하지만, REST는 요청마다 토큰을 본다. REST가 401을 받으면 SDK가 `token()`을 다시
|
|
165
|
+
불러 **한 번** 재시도한다. 두 번째 401(오리진, 폐기된 토큰, 밴)은 그대로 던진다.
|
|
166
|
+
주기적으로 재접속할 필요가 없다.
|
|
108
167
|
|
|
109
168
|
**정렬과 구멍 메우기는 SDK가 한다.** 프레임이 순서 없이 오거나 재접속으로 구간을
|
|
110
169
|
놓치면 REST로 채운다. 한 페이지(100)를 넘는 구멍도 전부 채운다.
|
|
@@ -167,6 +226,7 @@ try {
|
|
|
167
226
|
| `code` | `unauthorized`, `rate_limited`, `not_found`, `invalid`, `timeout`, `closed`, ... |
|
|
168
227
|
| `retryAfterMs` | 한도에 걸렸을 때 서버가 알려 준 대기 시간 |
|
|
169
228
|
| `appCode` | `before_publish` 웹훅이 거절하며 붙인 소비자 쪽 코드 |
|
|
229
|
+
| `status` | REST 호출에서 난 에러면 HTTP 상태 |
|
|
170
230
|
|
|
171
231
|
---
|
|
172
232
|
|
|
@@ -182,6 +242,7 @@ const chat = createChatServer({
|
|
|
182
242
|
secretKey: process.env.CHAT_SECRET_KEY!,
|
|
183
243
|
keyId: process.env.CHAT_KEY_ID!,
|
|
184
244
|
webhookSecret: process.env.CHAT_WEBHOOK_SECRET,
|
|
245
|
+
guestSecret: process.env.CHAT_GUEST_SECRET, // guest()를 쓸 때만
|
|
185
246
|
})
|
|
186
247
|
```
|
|
187
248
|
|
|
@@ -196,6 +257,40 @@ const chat = createChatServer({
|
|
|
196
257
|
| `avatar`, `meta` | 선택 |
|
|
197
258
|
| `ttlSeconds` | 기본 1시간, 최대 24시간. 넘으면 자르지 않고 거절한다 |
|
|
198
259
|
|
|
260
|
+
### `chat.guest({credential?, name, avatar?, meta?, ttlSeconds?})`
|
|
261
|
+
|
|
262
|
+
로그인하지 않은 방문자의 신원. `{userId, credential, token, created}`를 돌려준다.
|
|
263
|
+
네트워크도 저장소도 쓰지 않는다.
|
|
264
|
+
|
|
265
|
+
```ts
|
|
266
|
+
app.post('/api/chat-token', (req, res) => {
|
|
267
|
+
if (req.user) return res.json({ token: chat.token({ userId: req.user.id, name: req.user.name }) })
|
|
268
|
+
const g = chat.guest({ credential: req.cookies.chat_guest, name: '손님' })
|
|
269
|
+
res.cookie('chat_guest', g.credential, { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 400 * 864e5 })
|
|
270
|
+
res.json({ token: g.token })
|
|
271
|
+
})
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
**브라우저가 돌려준 `userId`를 그대로 서명하면 안 된다.** 게스트 id는 비밀이 아니다 —
|
|
275
|
+
방의 모든 메시지에 `sender.id`로 실려 나간다. "브라우저가 보관한 id를 다시 서명"하는
|
|
276
|
+
토큰 엔드포인트는 남의 메시지에서 id를 복사한 누구에게나 그 게스트의 신원을 준다.
|
|
277
|
+
형식 검사(`g_` + UUID 정규식)는 이것을 막지 못한다. UUID가 추측하기 어려운 것은
|
|
278
|
+
공개된 값 앞에서는 아무 의미가 없다.
|
|
279
|
+
|
|
280
|
+
`guest()`는 브라우저가 id 대신 **자격증명**을 들게 한다. `g1.<userId>.<MAC>`이고 MAC은
|
|
281
|
+
`guestSecret`으로 만든다. 돌아온 자격증명이 검증되면 그 안의 id로, 없거나 틀리면
|
|
282
|
+
새 게스트(`g_` + 무작위 22자, `created: true`)로 서명한다. 가능하면 `httpOnly` 쿠키에
|
|
283
|
+
두어 페이지의 스크립트가 읽지 못하게 한다.
|
|
284
|
+
|
|
285
|
+
| | |
|
|
286
|
+
|---|---|
|
|
287
|
+
| `guestSecret` | 32자 이상. 백엔드에만 둔다. **`sk_`에서 유도하지 않는다** — `sk_`는 유출되면 돌리는 키이고, 돌릴 때 모든 게스트가 새 사람이 되면 안 되기 때문이다 |
|
|
288
|
+
| 교체 | 배열로 준다: `[새것, 옛것]`. 첫 번째로 서명하고 전부로 검증한다. 옛것으로 검증된 자격증명은 같은 `userId`로 새로 서명해 돌려주므로 다음 방문에 옮겨 간다 |
|
|
289
|
+
| 유출 | 비밀이 새면 누구든 아무 게스트가 될 수 있다. 옛것을 목록에서 **빼면** 모든 게스트가 새 사람이 된다 — 신원을 잃는 것이 사칭보다 낫다 |
|
|
290
|
+
|
|
291
|
+
게스트 id는 `g_`로 시작한다. 로그인 유저의 id가 `g_`로 시작하지 않게 하면 두 공간이
|
|
292
|
+
겹치지 않는다.
|
|
293
|
+
|
|
199
294
|
### 나머지
|
|
200
295
|
|
|
201
296
|
| 그룹 | 메서드 |
|
|
@@ -5,6 +5,8 @@ var ChatError = class extends Error {
|
|
|
5
5
|
retryAfterMs;
|
|
6
6
|
/** The consumer's own code, when a before_publish hook denied this. */
|
|
7
7
|
appCode;
|
|
8
|
+
/** The HTTP status, when this came from a REST call. */
|
|
9
|
+
status;
|
|
8
10
|
constructor(code, message, extra) {
|
|
9
11
|
super(message);
|
|
10
12
|
this.name = "ChatError";
|
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
|
};
|
|
@@ -462,10 +533,27 @@ var Room = class extends Emitter {
|
|
|
462
533
|
onChange: (messages) => this.emit("messages", messages)
|
|
463
534
|
});
|
|
464
535
|
}
|
|
465
|
-
/** Everything this client knows about the room, in seq order. */
|
|
536
|
+
/** Everything this client knows about the room, in seq order. A new array whenever it changes. */
|
|
466
537
|
get messages() {
|
|
467
538
|
return this.timeline.messages;
|
|
468
539
|
}
|
|
540
|
+
/**
|
|
541
|
+
* Reads the page just before the oldest message held and **prepends it to
|
|
542
|
+
* `messages`**.
|
|
543
|
+
*
|
|
544
|
+
* The scroll-back call. Unlike `history()`, the rows join the room's own
|
|
545
|
+
* list, so ordering, de-duplication, deletes, reactions and reconnects
|
|
546
|
+
* apply to them like any other row, and the `messages` event fires once.
|
|
547
|
+
* A `reset` (or `reload()`) drops them with everything else; a read that
|
|
548
|
+
* lands after one is discarded rather than stitched onto the new list.
|
|
549
|
+
*
|
|
550
|
+
* `hasMore` is false once the top of the room -- or of its retention --
|
|
551
|
+
* is reached. Concurrent calls share one request.
|
|
552
|
+
*/
|
|
553
|
+
async loadOlder(options = {}) {
|
|
554
|
+
await this.resolveId("loading older messages");
|
|
555
|
+
return this.timeline.loadOlder(options.limit);
|
|
556
|
+
}
|
|
469
557
|
/**
|
|
470
558
|
* Reloads recent history, discarding what is held.
|
|
471
559
|
*
|
|
@@ -539,7 +627,8 @@ var Room = class extends Emitter {
|
|
|
539
627
|
*
|
|
540
628
|
* The list this room keeps is the live one; this is for a consumer
|
|
541
629
|
* scrolling back, which owns its own window and does not want the
|
|
542
|
-
* bottom of the room rearranged under it.
|
|
630
|
+
* bottom of the room rearranged under it. **Scrolling the room's own
|
|
631
|
+
* list back is `loadOlder()`**, which keeps one list instead of two.
|
|
543
632
|
*
|
|
544
633
|
* `view` defaults to the server's, which is every message including
|
|
545
634
|
* thread replies. **`view: 'main'` is a display filter, not a sync
|
|
@@ -548,7 +637,7 @@ var Room = class extends Emitter {
|
|
|
548
637
|
*/
|
|
549
638
|
async history(options = {}) {
|
|
550
639
|
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.
|
|
640
|
+
const page = await this.rest.get(`/v1/rooms/${await this.resolveId("reading history")}/messages`, {
|
|
552
641
|
before: options.before,
|
|
553
642
|
after: options.after,
|
|
554
643
|
limit: options.limit,
|
|
@@ -558,10 +647,10 @@ var Room = class extends Emitter {
|
|
|
558
647
|
}
|
|
559
648
|
/** Adds a reaction. Idempotent, like the frame. */
|
|
560
649
|
async react(messageId, emoji) {
|
|
561
|
-
await this.chat.send("react", { roomId: this.
|
|
650
|
+
await this.chat.send("react", { roomId: await this.resolveId("reacting"), messageId, emoji, op: "add" });
|
|
562
651
|
}
|
|
563
652
|
async unreact(messageId, emoji) {
|
|
564
|
-
await this.chat.send("react", { roomId: this.
|
|
653
|
+
await this.chat.send("react", { roomId: await this.resolveId("reacting"), messageId, emoji, op: "remove" });
|
|
565
654
|
}
|
|
566
655
|
/**
|
|
567
656
|
* Moves this user's read cursor.
|
|
@@ -571,7 +660,7 @@ var Room = class extends Emitter {
|
|
|
571
660
|
* way to know what happened. It is returned rather than swallowed.
|
|
572
661
|
*/
|
|
573
662
|
async markRead(seq, options = {}) {
|
|
574
|
-
const data = { roomId: this.
|
|
663
|
+
const data = { roomId: await this.resolveId("marking read"), seq };
|
|
575
664
|
if (options.threadId !== void 0) data["threadId"] = options.threadId;
|
|
576
665
|
return await this.chat.send("read", data);
|
|
577
666
|
}
|
|
@@ -598,15 +687,15 @@ var Room = class extends Emitter {
|
|
|
598
687
|
}
|
|
599
688
|
/** Joins a public or channel room. Idempotent. */
|
|
600
689
|
async join() {
|
|
601
|
-
await this.rest.post(`/v1/rooms/${this.
|
|
690
|
+
await this.rest.post(`/v1/rooms/${await this.resolveId("joining")}/join`);
|
|
602
691
|
}
|
|
603
692
|
/** Leaves. Idempotent from the caller's side. */
|
|
604
693
|
async leave() {
|
|
605
|
-
await this.rest.delete(`/v1/rooms/${this.
|
|
694
|
+
await this.rest.delete(`/v1/rooms/${await this.resolveId("leaving")}/members/me`);
|
|
606
695
|
}
|
|
607
696
|
/** The full presence list, for rooms too large to send it in the ack. */
|
|
608
697
|
async presenceList() {
|
|
609
|
-
return this.rest.get(`/v1/rooms/${this.
|
|
698
|
+
return this.rest.get(`/v1/rooms/${await this.resolveId("reading presence")}/presence`, { full: "true" });
|
|
610
699
|
}
|
|
611
700
|
/**
|
|
612
701
|
* Who reacted, one row per (user, emoji) pair.
|
|
@@ -618,7 +707,7 @@ var Room = class extends Emitter {
|
|
|
618
707
|
* most, and a full page still carries a cursor when more follows.
|
|
619
708
|
*/
|
|
620
709
|
async reactionsOf(messageId, options = {}) {
|
|
621
|
-
return this.rest.get(`/v1/rooms/${this.
|
|
710
|
+
return this.rest.get(`/v1/rooms/${await this.resolveId("reading reactions")}/messages/${messageId}/reactions`, {
|
|
622
711
|
emoji: options.emoji,
|
|
623
712
|
cursor: options.cursor,
|
|
624
713
|
limit: options.limit
|
|
@@ -684,9 +773,22 @@ var Room = class extends Emitter {
|
|
|
684
773
|
}
|
|
685
774
|
return this.id;
|
|
686
775
|
}
|
|
776
|
+
/**
|
|
777
|
+
* The room's id, waiting for a subscribe in flight to learn it.
|
|
778
|
+
*
|
|
779
|
+
* `roomByKey(k).subscribe()`를 await하지 않고 곧바로 `send`하는 것은 자연스러운
|
|
780
|
+
* 코드이고, 그때 id는 구독 ack가 와야 생긴다. 거절(`closed`)하면 소비자는
|
|
781
|
+
* "보내도 되는 때"를 알릴 신호를 따로 찾아야 한다 — 이미 날아가고 있는
|
|
782
|
+
* 구독을 기다리면 그 신호가 필요 없다. 구독이 실패하면 그 실패가 그대로
|
|
783
|
+
* 나간다. 구독한 적이 없으면 기다릴 것이 없으니 예전처럼 거절한다.
|
|
784
|
+
*/
|
|
785
|
+
async resolveId(what) {
|
|
786
|
+
while (this.id === void 0 && this.subscribing !== void 0) await this.subscribing;
|
|
787
|
+
return this.requireId(what);
|
|
788
|
+
}
|
|
687
789
|
/** Publishes and resolves when the server acks. */
|
|
688
790
|
async send(input, options = {}) {
|
|
689
|
-
const roomId = this.
|
|
791
|
+
const roomId = await this.resolveId("sending");
|
|
690
792
|
const body = {};
|
|
691
793
|
if (input.text !== void 0) body["text"] = input.text;
|
|
692
794
|
if (input.entities !== void 0) body["entities"] = input.entities;
|
|
@@ -818,6 +920,7 @@ var ChatClient = class extends Emitter {
|
|
|
818
920
|
url: options.restURL ?? restURLFrom(options.url),
|
|
819
921
|
key: options.key,
|
|
820
922
|
token: async () => this.lastToken,
|
|
923
|
+
refreshToken: () => this.refreshToken(),
|
|
821
924
|
fetch: options.fetch ?? globalThis.fetch.bind(globalThis)
|
|
822
925
|
});
|
|
823
926
|
this.setupLifecycle();
|
|
@@ -1038,7 +1141,13 @@ var ChatClient = class extends Emitter {
|
|
|
1038
1141
|
}
|
|
1039
1142
|
async openOnce() {
|
|
1040
1143
|
this.setState(this.attempt === 0 ? "connecting" : "reconnecting");
|
|
1041
|
-
|
|
1144
|
+
let authData;
|
|
1145
|
+
try {
|
|
1146
|
+
authData = await this.authData();
|
|
1147
|
+
} catch (err) {
|
|
1148
|
+
this.scheduleReconnect();
|
|
1149
|
+
throw err;
|
|
1150
|
+
}
|
|
1042
1151
|
if (this.closedByCaller) {
|
|
1043
1152
|
throw new ChatError("closed", "the client was closed while connecting");
|
|
1044
1153
|
}
|
|
@@ -1207,6 +1316,28 @@ var ChatClient = class extends Emitter {
|
|
|
1207
1316
|
data["token"] = token;
|
|
1208
1317
|
return data;
|
|
1209
1318
|
}
|
|
1319
|
+
/**
|
|
1320
|
+
* REST가 401을 받았을 때 토큰을 새로 받는다.
|
|
1321
|
+
*
|
|
1322
|
+
* 소켓은 접속할 때 한 번 인증하고 그 뒤로는 토큰을 다시 보지 않지만, REST는
|
|
1323
|
+
* 요청마다 본다. 그래서 한 시간짜리 토큰이면 한 시간 뒤 라이브는 멀쩡한데
|
|
1324
|
+
* 히스토리·구멍 메우기만 401이 된다. 동시에 실패한 요청들이 `token()`을
|
|
1325
|
+
* 각자 부르지 않도록 진행 중인 것 하나를 나눠 쓴다.
|
|
1326
|
+
*/
|
|
1327
|
+
refreshToken() {
|
|
1328
|
+
if (this.options.externalToken !== void 0) return Promise.resolve(void 0);
|
|
1329
|
+
this.refreshing ??= (async () => {
|
|
1330
|
+
try {
|
|
1331
|
+
const token = await this.options.token();
|
|
1332
|
+
this.lastToken = token;
|
|
1333
|
+
return token;
|
|
1334
|
+
} finally {
|
|
1335
|
+
this.refreshing = void 0;
|
|
1336
|
+
}
|
|
1337
|
+
})();
|
|
1338
|
+
return this.refreshing;
|
|
1339
|
+
}
|
|
1340
|
+
refreshing;
|
|
1210
1341
|
failPending(err) {
|
|
1211
1342
|
for (const [, waiter] of this.pending) waiter.reject(err);
|
|
1212
1343
|
this.pending.clear();
|