@kispi/chat 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +442 -0
- package/README.md +86 -14
- package/dist/index.cjs +243 -39
- package/dist/index.d.cts +153 -36
- package/dist/index.d.ts +153 -36
- package/dist/index.js +243 -39
- package/dist/server/index.cjs +2 -0
- package/dist/server/index.d.cts +39 -1
- package/dist/server/index.d.ts +39 -1
- package/dist/server/index.js +2 -0
- package/package.json +3 -2
package/README.en.md
ADDED
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
# @kispi/chat
|
|
2
|
+
|
|
3
|
+
Client SDK for the chat server. Many rooms over one socket, history in the right order.
|
|
4
|
+
**Zero runtime dependencies**, both ESM and CJS, type declarations included.
|
|
5
|
+
|
|
6
|
+
There are two entry points. `@kispi/chat` is the client for the browser and Node;
|
|
7
|
+
`@kispi/chat/server` is for the consumer backend that holds the `sk_`.
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install @kispi/chat
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
This document covers **the entire surface**. How to get started is here too.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## `@kispi/chat` — client
|
|
18
|
+
|
|
19
|
+
### `createChatClient(options): ChatClient`
|
|
20
|
+
|
|
21
|
+
| Option | Type | |
|
|
22
|
+
|---|---|---|
|
|
23
|
+
| `url` | `string` | **Required.** Pass the origin only. The SDK appends `/v1/ws?v=1` |
|
|
24
|
+
| `key` | `string` | **Required.** A `pk_`. It is public, so it is fine to put in the browser |
|
|
25
|
+
| `token` | `() => string \| Promise<string>` | **Required.** A user token signed by your backend. Called again on every connect and whenever REST gets a 401, so an expired one is never reused. A throw is retried with backoff; **throwing a `ChatError` means do not retry** (see below) |
|
|
26
|
+
| `restURL` | `string` | REST address. If omitted, it is derived from `url` (`ws://` → `http://`) |
|
|
27
|
+
| `externalToken` | `() => string \| Promise<string>` | External mode. If given, it **takes precedence over `token`** (same order the server checks in). **The server still rejects it, so there is no use for it today** |
|
|
28
|
+
| `WebSocket` | `new (url) => WebSocketLike` | Implementation to use instead of the global. Injected in tests |
|
|
29
|
+
| `fetch` | `typeof fetch` | Same as above |
|
|
30
|
+
|
|
31
|
+
Do not create `token` in the browser. Signing requires the `sk_`, and if the `sk_`
|
|
32
|
+
is in the browser, the whole app is open.
|
|
33
|
+
|
|
34
|
+
### `ChatClient`
|
|
35
|
+
|
|
36
|
+
| Property | |
|
|
37
|
+
|---|---|
|
|
38
|
+
| `state` | `'connecting' \| 'open' \| 'reconnecting' \| 'closed'` |
|
|
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
|
+
| `connectionId` | Identifier used for support requests and forced disconnects |
|
|
41
|
+
| `hello` | Everything the server sent on connect (limits, total unread, warnings) |
|
|
42
|
+
|
|
43
|
+
| Method | |
|
|
44
|
+
|---|---|
|
|
45
|
+
| `connect()` | Connects. If already connected or connecting, it joins that attempt. Rejects on failure, but **retries continue in the background** (see below) |
|
|
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) |
|
|
48
|
+
| `room(roomId)` | Room handle. Same id, same object |
|
|
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) |
|
|
50
|
+
| `rooms.list({cursor?, limit?})` | Rooms I'm a member of + unread + last message |
|
|
51
|
+
| `rooms.discover({type?, cursor?, limit?})` | Browse public rooms |
|
|
52
|
+
| `rooms.members(roomId).list/add/remove` | Member management |
|
|
53
|
+
| `unread()` | Total and per-room breakdown |
|
|
54
|
+
| `updateMe(meta)` | My `meta`. **`name` and `avatar` come from the token claims** |
|
|
55
|
+
| `send(type, data?, timeoutMs?)` | Raw frame. Only for calling things not on the surface |
|
|
56
|
+
|
|
57
|
+
| Event | |
|
|
58
|
+
|---|---|
|
|
59
|
+
| `state` | The four values above |
|
|
60
|
+
| `error` | The client stopped at `closed` on its own, and why (a `ChatError`): an authentication refusal, or a `ChatError` from `token()`. Not emitted for `close()` |
|
|
61
|
+
| `notification` | A new message arrived in a room I'm a member of, but I'm not viewing that room |
|
|
62
|
+
| `user.presence` | Presence changes of my other connections |
|
|
63
|
+
| `frame` | Every received frame. For debugging |
|
|
64
|
+
|
|
65
|
+
`on` returns an unsubscribe function.
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
const off = chat.on('state', s => setStatus(s))
|
|
69
|
+
off()
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### `Room`
|
|
73
|
+
|
|
74
|
+
| Property | |
|
|
75
|
+
|---|---|
|
|
76
|
+
| `messages` | Ascending by seq. **The SDK handles ordering and gap filling.** A new array on every change |
|
|
77
|
+
| `id` / `key` | Room identifiers. If opened by key, `id` is absent until you subscribe |
|
|
78
|
+
| `lastSeq` | The room's last seq as reported by the server |
|
|
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` |
|
|
81
|
+
|
|
82
|
+
| Method | |
|
|
83
|
+
|---|---|
|
|
84
|
+
| `subscribe()` | Turns on the live feed and reads the latest 100. Does nothing if already caught up. **Fine to call before connecting** — it waits until connected (rejects with `closed` on `close()`) |
|
|
85
|
+
| `unsubscribe()` | Stops viewing this room. It is not resubscribed on reconnect |
|
|
86
|
+
| `send({text, attachments?, entities?, replyTo?, threadId?, meta?}, {clientMessageId?})` | Publish. Acked with `{messageId, seq}`. If a subscription is in progress, it waits for it |
|
|
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 |
|
|
88
|
+
| `history({before?, after?, limit?, view?})` | Reads the past directly. **Independent of `messages`.** `view` is `'main'`, `'all'`, or `{thread: id}` |
|
|
89
|
+
| `reload()` | Re-reads the latest page |
|
|
90
|
+
| `react(messageId, emoji)` / `unreact(...)` | Reactions. Idempotent. The ack updates that row's `myReactions` |
|
|
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 |
|
|
92
|
+
| `markRead(seq, {threadId?})` | Read cursor |
|
|
93
|
+
| `typing()` | Typing indicator. Does not throw |
|
|
94
|
+
| `join()` / `leave()` | Membership |
|
|
95
|
+
| `presenceList()` | Who is in this room right now |
|
|
96
|
+
| `resume()` | Resubscribes after reconnecting. The SDK calls it for you |
|
|
97
|
+
|
|
98
|
+
Event names are **exactly the wire names**. `message.created`, `message.updated`,
|
|
99
|
+
`message.deleted`, `reaction.added`, `reaction.removed`, `thread.updated`,
|
|
100
|
+
`presence`, `typing`, `read`, `member.joined`, `member.left`, `room.updated`,
|
|
101
|
+
`room.deleted`, `custom`.
|
|
102
|
+
|
|
103
|
+
The SDK emits three more that have no wire counterpart.
|
|
104
|
+
|
|
105
|
+
| Event | When |
|
|
106
|
+
|---|---|
|
|
107
|
+
| `messages` | Every time the list changes. **A new array each time; an emitted array is never touched again** |
|
|
108
|
+
| `error` | Reading history or filling a gap failed. **The room resubscribes itself** (from 1 s, doubling, 5 tries while connected, again on every reconnect). The retry button for a person is `subscribe()` |
|
|
109
|
+
| `reset` | The cursor was older than the retention period, so the list was discarded and refilled. **You must discard what you rendered** |
|
|
110
|
+
|
|
111
|
+
### Things to know
|
|
112
|
+
|
|
113
|
+
**`messages` changes reference.** Every time the list changes a new array comes out, and
|
|
114
|
+
an array once emitted, along with the row objects in it, is never modified afterward. So you
|
|
115
|
+
can put it straight into state that compares by reference. Rows follow the same rule: only a
|
|
116
|
+
row an event changed (edit, delete, reaction, thread) is a new object, and the others keep
|
|
117
|
+
their reference — **same reference means unchanged**, so row-level memoization is safe. The
|
|
118
|
+
converse is not promised: `reload()` and reconnect catch-up fetch rows from the server again,
|
|
119
|
+
so a different reference can carry the same content.
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
// Svelte 5
|
|
123
|
+
let messages = $state.raw<Message[]>([])
|
|
124
|
+
room.on('messages', m => (messages = m))
|
|
125
|
+
|
|
126
|
+
// React
|
|
127
|
+
const [messages, setMessages] = useState<Message[]>([])
|
|
128
|
+
useEffect(() => room.on('messages', setMessages), [room])
|
|
129
|
+
|
|
130
|
+
// Vue
|
|
131
|
+
const messages = shallowRef<Message[]>([])
|
|
132
|
+
room.on('messages', m => (messages.value = m))
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Wrapping it in `$state` (a deep proxy) or `ref` works, but does unnecessary work. 0.1.x
|
|
136
|
+
modified the same array in place and emitted it again, so on the reference-comparing side
|
|
137
|
+
the screen froze.
|
|
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 the ack of 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
|
+
**Attach the past to the same list with `loadOlder()`.** `history()` is a direct read that
|
|
147
|
+
doesn't touch the list, so merging its results with the live list, filtering duplicates, and
|
|
148
|
+
handling `reset` and reconnects all fall to the consumer. `loadOlder()` puts those rows into
|
|
149
|
+
`messages`, so ordering, deduplication, deletion, reactions, and reconnects apply to them just
|
|
150
|
+
like any other row. Calling it several times concurrently still makes one request, and if
|
|
151
|
+
`hasMore` is `false` you're at the start of the room (or of the retention period).
|
|
152
|
+
`reset` or `reload()` discards the attached past as well.
|
|
153
|
+
|
|
154
|
+
```ts
|
|
155
|
+
showLoadMore(room.hasOlder)
|
|
156
|
+
const { hasMore } = await room.loadOlder()
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
**It's safe to send once `await room.subscribe()` has finished.** That said, you may
|
|
160
|
+
`send` without waiting — if a subscription is in progress, `send` (and `react`, `markRead`,
|
|
161
|
+
`loadOlder`, etc.) waits for it before going out. If the subscription fails, it rejects with
|
|
162
|
+
that error. A `roomByKey` room that was never subscribed doesn't know its id, so it rejects
|
|
163
|
+
immediately with `closed`. Publishing while `chat.state` is `reconnecting` also rejects with
|
|
164
|
+
`closed` — the SDK does not queue things to send.
|
|
165
|
+
|
|
166
|
+
**Opening by key does not create a room.** `roomByKey(key).subscribe()` only looks up an
|
|
167
|
+
existing room, and if there is none it's `not_found`. Rooms are created by the backend with
|
|
168
|
+
the `sk_` (`rooms.ensure({key, type})` in `@kispi/chat/server` — it's get-or-create, returning
|
|
169
|
+
the room as-is if it exists, so it's fine to call on every page load). If you turn on the app
|
|
170
|
+
setting `client_room_create`, user tokens can also call `PUT /v1/rooms` (allowed types, key
|
|
171
|
+
pattern, 50-per-day limit), but since the `pk_` is public, that becomes a door through which
|
|
172
|
+
anyone can stamp out rooms. It is off by default, and we recommend keeping it off.
|
|
173
|
+
|
|
174
|
+
**The connection comes back on its own.** If the socket drops or `token()` throws, it
|
|
175
|
+
reconnects with exponential backoff (0.5 s up to 30 s, with jitter). **The first `connect()`
|
|
176
|
+
is the same** — on failure the `await` rejects so the caller knows, and retries continue in
|
|
177
|
+
the background (`state` is `reconnecting`, then `open` once it connects). The server's
|
|
178
|
+
connection limit (`rate_limited`) and server failures (`internal`) take the same path (a
|
|
179
|
+
limit waits as long as the server says). So there's no need to write your own retry loop.
|
|
180
|
+
|
|
181
|
+
**Subscribe rooms first; don't wait for the connection.** `subscribe()` waits until the
|
|
182
|
+
client is connected and then goes out, so even if the first `connect()` fails, the room fills
|
|
183
|
+
the moment a background retry connects. Putting `subscribe()` after `await chat.connect()`
|
|
184
|
+
means a rejected connect never reaches the subscribe line.
|
|
185
|
+
|
|
186
|
+
```ts
|
|
187
|
+
const room = chat.roomByKey('lobby')
|
|
188
|
+
room.on('messages', render)
|
|
189
|
+
chat.connect().catch(showOffline) // retries continue even on failure
|
|
190
|
+
await room.subscribe() // settles once connected
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Three things stop it: `close()`, the server refusing authentication (`unauthorized`,
|
|
194
|
+
`forbidden` — repeating the same token would just be hammering the server), and **`token()`
|
|
195
|
+
throwing a `ChatError`**. That is how you say a failure is not worth retrying, like a ban or
|
|
196
|
+
an ended session:
|
|
197
|
+
|
|
198
|
+
```ts
|
|
199
|
+
token: async () => {
|
|
200
|
+
const res = await fetch('/api/chat-token')
|
|
201
|
+
if (res.status === 403) throw new ChatError('forbidden', 'banned') // not retried
|
|
202
|
+
if (!res.ok) throw new Error(`token ${res.status}`) // retried with backoff
|
|
203
|
+
return (await res.json()).token
|
|
204
|
+
}
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
The last two leave `state` at `closed` and report why on `chat.on('error')` — even mid-reconnect
|
|
208
|
+
or during a REST token refresh, where there is no caller to reject. Call `connect()` to come
|
|
209
|
+
back.
|
|
210
|
+
|
|
211
|
+
**After a nickname change, `reconnect()`.** The server reads the name and avatar from the
|
|
212
|
+
token's claims once, when the socket authenticates. So after your backend changes the name,
|
|
213
|
+
the next message on this connection still carries the old one. `reconnect()` calls `token()`
|
|
214
|
+
again, re-authenticates with the new claims, and the rooms carry on from what they hold.
|
|
215
|
+
`close()` + `connect()` works too, but passes through `closed` (your offline screen flashes)
|
|
216
|
+
and removes the page listeners. Frames still awaiting an ack on the old socket reject with
|
|
217
|
+
`closed`. When it resolves, `chat.user` is the new identity.
|
|
218
|
+
|
|
219
|
+
```ts
|
|
220
|
+
await api.updateNickname(name) // so your backend puts the new name in the next token()
|
|
221
|
+
await chat.reconnect()
|
|
222
|
+
chat.user?.name // the new name
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
**The SDK handles token expiry.** The socket authenticates only once on connect, so it stays
|
|
226
|
+
fine even when the token expires, but REST checks the token on every request. When REST gets a
|
|
227
|
+
401, the SDK calls `token()` again and retries **once**. A second 401 (origin, revoked token,
|
|
228
|
+
ban) is thrown as-is. There's no need to reconnect periodically.
|
|
229
|
+
|
|
230
|
+
The SDK neither caches the token nor hands it out. Cache inside `token()` (there's no need to
|
|
231
|
+
hit your backend on every call). **Don't reuse this token as the credential for your own
|
|
232
|
+
backend's API** — it is made to be presented to the chat engine, and mixing the two turns a
|
|
233
|
+
token leaked on one side into a key for the other. Use your own session for your own API.
|
|
234
|
+
|
|
235
|
+
**The SDK handles ordering and gap filling.** If frames arrive out of order or a reconnect
|
|
236
|
+
misses a range, it fills them in via REST. Gaps larger than one page (100) are filled
|
|
237
|
+
completely too.
|
|
238
|
+
|
|
239
|
+
**Reconnecting only recovers messages.** Because the history route covers messages only,
|
|
240
|
+
reactions on **older messages** or room renames that happened while disconnected are not
|
|
241
|
+
reflected. In that case, `await room.reload()`.
|
|
242
|
+
|
|
243
|
+
**`typing` is also delivered to the sender.** If you render "X is typing", skip your own
|
|
244
|
+
`userId`.
|
|
245
|
+
|
|
246
|
+
**Don't ignore `reset`.** If you keep appending to the old list, you're left with a gap that
|
|
247
|
+
nobody fills.
|
|
248
|
+
|
|
249
|
+
### Using it in the browser
|
|
250
|
+
|
|
251
|
+
You **must add the page's origin** to the `pk_`'s `allowed_origins`. That's the only thing.
|
|
252
|
+
The server emits CORS headers itself, so there's no need to touch nginx or any other proxy.
|
|
253
|
+
|
|
254
|
+
```sh
|
|
255
|
+
chat-server app create my-app --origins https://example.com
|
|
256
|
+
chat-server key create <app-id> pk --origins https://staging.example.com
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
Scheme, host, and port must match **exactly**. `https://example.com` and
|
|
260
|
+
`https://www.example.com` are different origins, and so are `http://localhost:3000` and
|
|
261
|
+
`http://127.0.0.1:3000`. Add development origins separately.
|
|
262
|
+
|
|
263
|
+
**In Node it works without this setting.** WHATWG `fetch` and `WebSocket` don't send
|
|
264
|
+
`Origin` outside the browser, and the engine lets requests without an `Origin` through. That's
|
|
265
|
+
how code that's green in Node can return 401 only in the browser — this section is the reason.
|
|
266
|
+
|
|
267
|
+
If the origin isn't on the list, REST rejects with 401, and the message states the cause:
|
|
268
|
+
|
|
269
|
+
```
|
|
270
|
+
origin not allowed for this publishable key: add it to the key's allowed_origins
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
**If it looks like the socket works but REST doesn't, look here.** WebSocket isn't subject to
|
|
274
|
+
CORS, so connecting and live messages run fine, while history, unread, and reconnect gap
|
|
275
|
+
filling are REST and fail silently. The symptom shows up as "messages arrive, but scrolling up
|
|
276
|
+
shows nothing".
|
|
277
|
+
|
|
278
|
+
### Errors
|
|
279
|
+
|
|
280
|
+
Failures are `ChatError`.
|
|
281
|
+
|
|
282
|
+
```ts
|
|
283
|
+
try {
|
|
284
|
+
await room.send({ text })
|
|
285
|
+
} catch (err) {
|
|
286
|
+
if (err instanceof ChatError && err.code === 'rate_limited') {
|
|
287
|
+
retryAfter(err.retryAfterMs)
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
| Field | |
|
|
293
|
+
|---|---|
|
|
294
|
+
| `code` | `unauthorized`, `rate_limited`, `not_found`, `invalid`, `timeout`, `closed`, ... |
|
|
295
|
+
| `retryAfterMs` | Wait time reported by the server when you hit a limit |
|
|
296
|
+
| `appCode` | Consumer-side code attached by a `before_publish` webhook when rejecting |
|
|
297
|
+
| `status` | HTTP status, if the error came from a REST call |
|
|
298
|
+
|
|
299
|
+
---
|
|
300
|
+
|
|
301
|
+
## `@kispi/chat/server` — backend
|
|
302
|
+
|
|
303
|
+
Uses the `sk_`. **Never put it in the browser.**
|
|
304
|
+
|
|
305
|
+
```ts
|
|
306
|
+
import { createChatServer } from '@kispi/chat/server'
|
|
307
|
+
|
|
308
|
+
const chat = createChatServer({
|
|
309
|
+
url: 'https://chat.example.com',
|
|
310
|
+
secretKey: process.env.CHAT_SECRET_KEY!,
|
|
311
|
+
keyId: process.env.CHAT_KEY_ID!,
|
|
312
|
+
webhookSecret: process.env.CHAT_WEBHOOK_SECRET,
|
|
313
|
+
guestSecret: process.env.CHAT_GUEST_SECRET, // only if you use guest()
|
|
314
|
+
})
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
### `chat.token(input): string`
|
|
318
|
+
|
|
319
|
+
A local signature that doesn't touch the network.
|
|
320
|
+
|
|
321
|
+
| Field | |
|
|
322
|
+
|---|---|
|
|
323
|
+
| `userId` | **Required.** Becomes the JWT `sub`. Your service's user id |
|
|
324
|
+
| `name` | **Required.** This value wins on every connect |
|
|
325
|
+
| `avatar`, `meta` | Optional |
|
|
326
|
+
| `ttlSeconds` | Default 1 hour, max 24 hours. Exceeding it is rejected, not truncated |
|
|
327
|
+
|
|
328
|
+
### `chat.guest({credential?, name, avatar?, meta?, ttlSeconds?})`
|
|
329
|
+
|
|
330
|
+
Identity for a visitor who isn't logged in. Returns `{userId, credential, token, created}`.
|
|
331
|
+
Uses neither the network nor storage.
|
|
332
|
+
|
|
333
|
+
```ts
|
|
334
|
+
app.post('/api/chat-token', (req, res) => {
|
|
335
|
+
if (req.user) return res.json({ token: chat.token({ userId: req.user.id, name: req.user.name }) })
|
|
336
|
+
const g = chat.guest({ credential: req.cookies.chat_guest, name: 'Guest' })
|
|
337
|
+
res.cookie('chat_guest', g.credential, { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 400 * 864e5 })
|
|
338
|
+
res.json({ token: g.token })
|
|
339
|
+
})
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
**Never sign a `userId` handed back by the browser as-is.** A guest id is not a secret — it
|
|
343
|
+
goes out as `sender.id` on every message in the room. A token endpoint that "re-signs the id
|
|
344
|
+
the browser kept" gives that guest's identity to anyone who copied the id from someone's
|
|
345
|
+
message. A format check (`g_` + UUID regex) doesn't prevent this. A UUID being hard to guess
|
|
346
|
+
means nothing once the value is public.
|
|
347
|
+
|
|
348
|
+
`guest()` has the browser hold a **credential** instead of the id. It is
|
|
349
|
+
`g1.<userId>.<MAC>`, with the MAC made from `guestSecret`. If the returned credential
|
|
350
|
+
verifies, it signs with the id inside it; if it's missing or invalid, it signs a new guest
|
|
351
|
+
(`g_` + 22 random characters, `created: true`). Where possible, keep it in an `httpOnly`
|
|
352
|
+
cookie so page scripts can't read it.
|
|
353
|
+
|
|
354
|
+
| | |
|
|
355
|
+
|---|---|
|
|
356
|
+
| `guestSecret` | At least 32 characters. Keep it on the backend only. **Don't derive it from the `sk_`** — the `sk_` is a key you rotate if it leaks, and rotating it must not turn every guest into a new person |
|
|
357
|
+
| Rotation | Pass an array: `[new, old]`. Signs with the first and verifies with all. A credential verified with an old one is re-signed with the same `userId` and returned, so it migrates on the next visit |
|
|
358
|
+
| Leak | If the secret leaks, anyone can become any guest. **Removing** the old one from the list turns every guest into a new person — losing identity is better than impersonation |
|
|
359
|
+
|
|
360
|
+
Guest ids start with `g_`. As long as logged-in users' ids don't start with `g_`, the two
|
|
361
|
+
spaces don't overlap.
|
|
362
|
+
|
|
363
|
+
### The rest
|
|
364
|
+
|
|
365
|
+
| Group | Methods |
|
|
366
|
+
|---|---|
|
|
367
|
+
| `rooms` | `ensure({key?, type, name?, meta?, members?})`, `ensureDM([a, b])`, `get`, `update`, `delete`, `custom(roomId, payload)`, `presence(roomId, {full?})`, `members.list/add/remove` |
|
|
368
|
+
| `messages` | `send(roomId, {sender, text?, kind?, attachments?, appMeta?, replyTo?, threadId?, clientMessageId?})`, `list`, `delete` |
|
|
369
|
+
| `users` | `list({cursor?, limit?})`, `update`, `delete({purgeMessages?})`, `purgeMessages(userId)`, `ban({until, reason?})`, `unban`, `revokeTokens`, `disconnect` |
|
|
370
|
+
| `events` | `list({after?, limit?})` — the outbox. Catches up on missed webhooks |
|
|
371
|
+
| `webhooks` | `verify(headers, rawBody, {now?, tolerance?})` |
|
|
372
|
+
|
|
373
|
+
`rooms.presence(roomId)` is `{count}`; with `{full: true}` it adds `users` (at most 1000,
|
|
374
|
+
`capped: true` when cut). An empty room is `{count: 0}`, not a 404.
|
|
375
|
+
|
|
376
|
+
**`users.purgeMessages(userId)` is moderation** — it deletes every message the user sent in the
|
|
377
|
+
app (tombstones: seq is untouched, and watching clients get `message.deleted`) **without
|
|
378
|
+
withdrawing them.** The name stays. For spam, **ban first**, then call it — a message posted
|
|
379
|
+
while it runs is not in its listing. It does a bounded amount per call, so call again while
|
|
380
|
+
`purgeCapped` is `true`. A withdrawn user is a 404 (use `delete(userId, {purgeMessages: true})`).
|
|
381
|
+
|
|
382
|
+
```ts
|
|
383
|
+
await chat.users.ban(userId, { until: Date.now() + 7 * 86_400_000, reason: 'spam' })
|
|
384
|
+
while ((await chat.users.purgeMessages(userId)).purgeCapped) {}
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
`users.ban`'s `until` is **unix milliseconds and required.** For an indefinite ban, pick a
|
|
388
|
+
far-future time yourself — it goes into the audit log as a decision, not an accident.
|
|
389
|
+
|
|
390
|
+
`users.*` works only **after the person has connected at least once**. Users come into
|
|
391
|
+
existence by connecting.
|
|
392
|
+
|
|
393
|
+
`users.list` is for the console. Reverse uid order, `limit` defaults to 50, max 100, `cursor`
|
|
394
|
+
is an opaque string and an empty string marks the last page. **Deleted users are included too,
|
|
395
|
+
and `deletedAt` marks them** — filter on it if you only want live users. This is the opposite of
|
|
396
|
+
the in-room lists (members, reactions, reads) hiding deleted users' ids, because those are lists
|
|
397
|
+
read by **other users**, whereas this one requires the `sk_`, so the reader is the very backend
|
|
398
|
+
that issued those ids.
|
|
399
|
+
|
|
400
|
+
`rooms.custom(roomId, payload)` is a **control signal that is not stored**. `payload` is a JSON
|
|
401
|
+
object and arrives as-is at `room.on('custom', ...)`. It leaves no history, no unread, and no
|
|
402
|
+
webhook, so anyone not connected at that moment never learns of it — for a signal that must be
|
|
403
|
+
received even after reconnecting, use `messages.send(roomId, {kind:'system', ...})`.
|
|
404
|
+
|
|
405
|
+
### Webhooks
|
|
406
|
+
|
|
407
|
+
```ts
|
|
408
|
+
app.post('/chat-hook', express.raw({ type: 'application/json' }), (req, res) => {
|
|
409
|
+
const { event, delivery, data } = chat.webhooks.verify(req.headers, req.body.toString('utf8'))
|
|
410
|
+
if (seen.has(delivery)) return res.sendStatus(200) // it's at-least-once
|
|
411
|
+
res.sendStatus(200) // acknowledge first
|
|
412
|
+
void handle(event, data) // process asynchronously
|
|
413
|
+
})
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
**It must be the raw body.** The MAC is over the bytes the server sent, and something parsed
|
|
417
|
+
and re-serialized is a different string.
|
|
418
|
+
|
|
419
|
+
**The signature covers the headers too.** The canonical string is
|
|
420
|
+
`` `${t}\n${event}\n${delivery}\n${rawBody}` `` and the order is fixed. So
|
|
421
|
+
`verify` reads `X-Chat-Event` and `X-Chat-Delivery` **before verifying**, and a replay with only
|
|
422
|
+
the headers swapped won't pass — `X-Chat-Delivery` is the deduplication key, so if it were
|
|
423
|
+
outside the signature, a single captured delivery could be counted as any number of new events.
|
|
424
|
+
|
|
425
|
+
Keep three things. `verify` checks the signature and the timestamp (default 5 minutes),
|
|
426
|
+
**deduplication is on you** (by `delivery`), and you must respond with a 2xx within 10 seconds.
|
|
427
|
+
Going over counts as a failure and backoff begins.
|
|
428
|
+
|
|
429
|
+
---
|
|
430
|
+
|
|
431
|
+
## Development
|
|
432
|
+
|
|
433
|
+
```sh
|
|
434
|
+
pnpm install
|
|
435
|
+
pnpm build # ESM + CJS + type declarations
|
|
436
|
+
pnpm exec vitest run
|
|
437
|
+
pnpm exec tsc --noEmit
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
Tests use **injection**, not mocks. Swapping in `WebSocket` and `fetch` means there's no need to
|
|
441
|
+
imitate the server, and the integration-style files connect to a **live server** outright.
|
|
442
|
+
Running them requires Postgres and Redis.
|
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>` | **필수.** 백엔드가 서명한 유저 토큰. 접속할 때마다, 그리고 REST가 401을 받을 때 다시 부르므로 만료된 것이 재사용되지
|
|
25
|
+
| `token` | `() => string \| Promise<string>` | **필수.** 백엔드가 서명한 유저 토큰. 접속할 때마다, 그리고 REST가 401을 받을 때 다시 부르므로 만료된 것이 재사용되지 않는다. 던지면 백오프로 다시 부르고, **`ChatError`를 던지면 다시 부르지 않는다**(아래) |
|
|
26
26
|
| `restURL` | `string` | REST 주소. 생략하면 `url`에서 유도한다(`ws://` → `http://`) |
|
|
27
27
|
| `externalToken` | `() => string \| Promise<string>` | external 모드. 주면 **`token`보다 우선한다**(서버의 판정 순서와 같다). **서버가 아직 거절하므로 오늘은 쓸 곳이 없다** |
|
|
28
28
|
| `WebSocket` | `new (url) => WebSocketLike` | 전역 대신 쓸 구현. 테스트에서 주입한다 |
|
|
@@ -36,14 +36,15 @@ 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
|
|
|
43
43
|
| 메서드 | |
|
|
44
44
|
|---|---|
|
|
45
45
|
| `connect()` | 붙는다. 이미 붙어 있거나 붙는 중이면 그 시도에 합류한다. 실패하면 거절하지만 **재시도는 백그라운드에서 계속된다**(아래) |
|
|
46
|
-
| `close()` | 끊는다. 이건 재접속하지
|
|
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 + 마지막 메시지 |
|
|
@@ -56,6 +57,7 @@ npm install @kispi/chat
|
|
|
56
57
|
| 이벤트 | |
|
|
57
58
|
|---|---|
|
|
58
59
|
| `state` | 위 네 값 |
|
|
60
|
+
| `error` | 클라이언트가 스스로 `closed`로 멈췄고 그 이유(`ChatError`). 인증 거절이나 `token()`의 `ChatError`. `close()`로 닫을 때는 없다 |
|
|
59
61
|
| `notification` | 멤버인 방에 새 메시지가 왔는데 그 방을 보고 있지 않을 때 |
|
|
60
62
|
| `user.presence` | 내 다른 접속의 presence 변화 |
|
|
61
63
|
| `frame` | 받은 프레임 전부. 디버깅용 |
|
|
@@ -75,16 +77,17 @@ off()
|
|
|
75
77
|
| `id` / `key` | 방 식별자. 키로 열었으면 구독 전까지 `id`가 없다 |
|
|
76
78
|
| `lastSeq` | 서버가 알려 준 방의 마지막 seq |
|
|
77
79
|
| `presence` | `{count, users?, capped?}` |
|
|
80
|
+
| `hasOlder` | `loadOlder()`로 더 읽을 과거가 있는지. 첫 `loadOlder()` 전에 "이전 메시지" 버튼을 그릴지 정할 때 쓴다. `loadOlder()`의 `hasMore`와 같은 판정(맨 위 행이 방의 보존 하한 위에 있는지)이고, 목록이 비었으면 `false`. `messages` 이벤트 때 다시 읽는다 |
|
|
78
81
|
|
|
79
82
|
| 메서드 | |
|
|
80
83
|
|---|---|
|
|
81
|
-
| `subscribe()` | 라이브 피드를 켜고 최근 100건을 읽는다. 이미 따라잡혔으면 아무것도 안
|
|
84
|
+
| `subscribe()` | 라이브 피드를 켜고 최근 100건을 읽는다. 이미 따라잡혔으면 아무것도 안 한다. **연결 전에 불러도 된다** — 붙을 때까지 기다린다(`close()`면 `closed`로 거절) |
|
|
82
85
|
| `unsubscribe()` | 이 방 보기를 그만둔다. 재접속해도 다시 구독하지 않는다 |
|
|
83
86
|
| `send({text, attachments?, entities?, replyTo?, threadId?, meta?}, {clientMessageId?})` | 발행. ack로 `{messageId, seq}`. 구독이 진행 중이면 그것을 기다린다 |
|
|
84
87
|
| `loadOlder({limit?})` | 가장 오래된 메시지 앞 페이지(기본 100)를 읽어 **`messages` 앞에 붙인다.** `{messages, hasMore}`. 스크롤을 올릴 때 쓴다. 목록이 비어 있으면(구독 전) 아무것도 읽지 않는다 |
|
|
85
88
|
| `history({before?, after?, limit?, view?})` | 과거를 직접 읽는다. **`messages`와 따로 논다.** `view`는 `'main'`, `'all'`, `{thread: id}` |
|
|
86
89
|
| `reload()` | 최근 페이지를 다시 읽는다 |
|
|
87
|
-
| `react(messageId, emoji)` / `unreact(...)` | 리액션.
|
|
90
|
+
| `react(messageId, emoji)` / `unreact(...)` | 리액션. 멱등. ack가 그 행의 `myReactions`를 고친다 |
|
|
88
91
|
| `reactionsOf(messageId, {emoji?, cursor?, limit?})` | 누가 눌렀는지. **한 행은 (유저, 이모지) 쌍**이라 한 사람이 여러 줄일 수 있다. `limit` 기본 50 최대 100, 마지막 페이지의 `cursor`는 빈 문자열 |
|
|
89
92
|
| `markRead(seq, {threadId?})` | 읽음 커서 |
|
|
90
93
|
| `typing()` | 입력 중. 던지지 않는다 |
|
|
@@ -102,14 +105,17 @@ off()
|
|
|
102
105
|
| 이벤트 | 언제 |
|
|
103
106
|
|---|---|
|
|
104
107
|
| `messages` | 목록이 바뀔 때마다. **매번 새 배열이고, 내보낸 배열은 다시 건드리지 않는다** |
|
|
105
|
-
| `error` | 히스토리를 읽거나 구멍을 메우는 데 실패했다.
|
|
108
|
+
| `error` | 히스토리를 읽거나 구멍을 메우는 데 실패했다. **방이 스스로 다시 구독한다**(1초부터 두 배씩, 연결된 동안 5번, 재접속마다 다시). 사람이 누를 재시도 버튼은 `subscribe()` |
|
|
106
109
|
| `reset` | 커서가 보존 기간보다 오래돼 목록을 버리고 다시 채웠다. **렌더한 것을 버려야 한다** |
|
|
107
110
|
|
|
108
111
|
### 알아 둘 것
|
|
109
112
|
|
|
110
113
|
**`messages`는 참조가 바뀐다.** 목록이 바뀔 때마다 새 배열이 나오고, 한 번 내보낸
|
|
111
|
-
배열과 그 안의 행 객체는 이후에 고쳐지지
|
|
112
|
-
|
|
114
|
+
배열과 그 안의 행 객체는 이후에 고쳐지지 않는다. 그래서 참조로 비교하는 상태에 그대로
|
|
115
|
+
넣으면 된다. 행도 마찬가지로, 이벤트(수정, 삭제, 리액션, 스레드)가 바꾼 행만 새 객체이고
|
|
116
|
+
나머지 행은 참조가 그대로다 — **참조가 같으면 바뀌지 않은 것**이니 행 단위 memo에 써도
|
|
117
|
+
된다. 반대는 약속하지 않는다: `reload()`와 재접속 따라잡기는 같은 내용의 행을 서버에서
|
|
118
|
+
새로 받아 오므로, 참조가 달라도 내용은 같을 수 있다.
|
|
113
119
|
|
|
114
120
|
```ts
|
|
115
121
|
// Svelte 5
|
|
@@ -128,6 +134,12 @@ room.on('messages', m => (messages.value = m))
|
|
|
128
134
|
`$state`(깊은 프록시)나 `ref`로 감싸도 동작하지만 필요 없는 일을 한다. 0.1.x는 같은
|
|
129
135
|
배열을 제자리에서 고쳐 다시 내보내서, 참조로 비교하는 쪽에서는 화면이 멈췄다.
|
|
130
136
|
|
|
137
|
+
**리액션: `reactions`는 모두의 개수, `myReactions`는 내가 누른 이모지다.** 둘 다
|
|
138
|
+
SDK가 라이브로 맞춘다 — 개수는 `reaction.*` 이벤트의 `count`로, `myReactions`는 그
|
|
139
|
+
이벤트의 `userId`가 `chat.user.id`일 때(다른 탭·기기에서 누른 것 포함)와 내
|
|
140
|
+
`react()`/`unreact()`의 ack로. 둘 다 더하기가 아니라 덮어쓰기라 이벤트와 ack가 모두
|
|
141
|
+
와도 두 번 세지 않는다. 바뀐 행만 새 객체다.
|
|
142
|
+
|
|
131
143
|
**과거는 `loadOlder()`로 같은 목록에 붙인다.** `history()`는 목록을 건드리지 않는
|
|
132
144
|
직접 읽기라, 그 결과를 라이브 목록과 합치고 중복을 거르고 `reset`과 재접속을
|
|
133
145
|
처리하는 일이 전부 소비자 몫이 된다. `loadOlder()`는 그 행들을 `messages`에 넣으므로
|
|
@@ -136,6 +148,7 @@ room.on('messages', m => (messages.value = m))
|
|
|
136
148
|
`reset`이나 `reload()`는 붙인 과거도 함께 버린다.
|
|
137
149
|
|
|
138
150
|
```ts
|
|
151
|
+
showLoadMore(room.hasOlder)
|
|
139
152
|
const { hasMore } = await room.loadOlder()
|
|
140
153
|
```
|
|
141
154
|
|
|
@@ -155,16 +168,61 @@ get-or-create라 페이지를 열 때마다 불러도 된다). 앱 설정 `clien
|
|
|
155
168
|
|
|
156
169
|
**연결은 알아서 돌아온다.** 소켓이 끊기거나 `token()`이 던지면 지수 백오프(0.5초에서
|
|
157
170
|
30초까지, 지터)로 다시 붙는다. **첫 `connect()`도 같다** — 실패하면 `await`는 거절해서
|
|
158
|
-
호출자가 알게 하고, 재시도는 백그라운드에서 이어진다(`state`가 `reconnecting
|
|
159
|
-
|
|
160
|
-
서버가
|
|
161
|
-
|
|
171
|
+
호출자가 알게 하고, 재시도는 백그라운드에서 이어진다(`state`가 `reconnecting`, 붙으면
|
|
172
|
+
`open`). 서버의 접속 한도(`rate_limited`)와 서버 장애(`internal`)도 같은 길로 다시
|
|
173
|
+
붙는다(한도는 서버가 말한 만큼 기다린다). 그래서 재시도 루프를 직접 짤 필요가 없다.
|
|
174
|
+
|
|
175
|
+
방은 **연결을 기다리지 말고 먼저 구독해 둔다.** `subscribe()`는 붙을 때까지 기다렸다가
|
|
176
|
+
나가므로, 첫 `connect()`가 실패해도 백그라운드 재시도가 붙는 순간 방이 채워진다.
|
|
177
|
+
`await chat.connect()` 뒤에 `subscribe()`를 두면 connect가 거절될 때 구독 줄에 닿지
|
|
178
|
+
않는다.
|
|
179
|
+
|
|
180
|
+
```ts
|
|
181
|
+
const room = chat.roomByKey('lobby')
|
|
182
|
+
room.on('messages', render)
|
|
183
|
+
chat.connect().catch(showOffline) // 실패해도 재시도는 계속된다
|
|
184
|
+
await room.subscribe() // 붙은 뒤 끝난다
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
멈추는 것은 셋이다. `close()`, 서버의 인증 거절(`unauthorized`, `forbidden` — 같은
|
|
188
|
+
토큰을 반복하는 것은 서버를 두드리는 일이다), 그리고 **`token()`이 `ChatError`를 던질
|
|
189
|
+
때**다. 밴이나 끝난 세션처럼 다시 해도 소용없는 실패는 그렇게 알린다:
|
|
190
|
+
|
|
191
|
+
```ts
|
|
192
|
+
token: async () => {
|
|
193
|
+
const res = await fetch('/api/chat-token')
|
|
194
|
+
if (res.status === 403) throw new ChatError('forbidden', 'banned') // 다시 하지 않는다
|
|
195
|
+
if (!res.ok) throw new Error(`token ${res.status}`) // 백오프로 다시 한다
|
|
196
|
+
return (await res.json()).token
|
|
197
|
+
}
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
뒤의 둘은 `state`를 `closed`로 두고 `chat.on('error')`로 이유를 낸다. 재접속 중이나
|
|
201
|
+
REST의 토큰 갱신 중이라 거절을 받을 호출자가 없어도 그렇다. 다시 붙으려면 `connect()`.
|
|
202
|
+
|
|
203
|
+
**닉네임을 바꾸면 `reconnect()`.** 서버는 이름·아바타를 접속할 때 토큰 claim에서
|
|
204
|
+
한 번 읽는다. 그래서 백엔드에서 이름을 바꿔도 이 접속의 다음 메시지는 옛 이름이다.
|
|
205
|
+
`reconnect()`는 `token()`을 다시 불러 새 claim으로 다시 인증하고, 방은 가진 것에서
|
|
206
|
+
이어서 따라잡는다. `close()` + `connect()`도 되지만 `closed`를 거쳐(오프라인 화면이
|
|
207
|
+
깜빡인다) 페이지 리스너까지 떼어 낸다. 옛 소켓에서 ack를 기다리던 프레임은 `closed`로
|
|
208
|
+
거절된다. 끝나면 `chat.user`가 새 신원이다.
|
|
209
|
+
|
|
210
|
+
```ts
|
|
211
|
+
await api.updateNickname(name) // 백엔드가 다음 token()에 새 이름을 싣게 한다
|
|
212
|
+
await chat.reconnect()
|
|
213
|
+
chat.user?.name // 새 이름
|
|
214
|
+
```
|
|
162
215
|
|
|
163
216
|
**토큰 만료는 SDK가 처리한다.** 소켓은 접속할 때 한 번만 인증하므로 토큰이 만료돼도
|
|
164
217
|
멀쩡하지만, REST는 요청마다 토큰을 본다. REST가 401을 받으면 SDK가 `token()`을 다시
|
|
165
218
|
불러 **한 번** 재시도한다. 두 번째 401(오리진, 폐기된 토큰, 밴)은 그대로 던진다.
|
|
166
219
|
주기적으로 재접속할 필요가 없다.
|
|
167
220
|
|
|
221
|
+
SDK는 토큰을 캐시하지도, 꺼내 주지도 않는다. 캐시는 `token()` 안에서 한다(부를
|
|
222
|
+
때마다 백엔드를 칠 필요는 없다). **이 토큰을 자기 백엔드 API의 인증으로 다시 쓰지
|
|
223
|
+
않는다** — 채팅 엔진에 내라고 만든 것이고, 둘을 섞으면 한쪽에 새어 나간 토큰이 다른
|
|
224
|
+
쪽의 열쇠가 된다. 자기 API에는 자기 세션을 쓴다.
|
|
225
|
+
|
|
168
226
|
**정렬과 구멍 메우기는 SDK가 한다.** 프레임이 순서 없이 오거나 재접속으로 구간을
|
|
169
227
|
놓치면 REST로 채운다. 한 페이지(100)를 넘는 구멍도 전부 채운다.
|
|
170
228
|
|
|
@@ -295,12 +353,26 @@ app.post('/api/chat-token', (req, res) => {
|
|
|
295
353
|
|
|
296
354
|
| 그룹 | 메서드 |
|
|
297
355
|
|---|---|
|
|
298
|
-
| `rooms` | `ensure({key?, type, name?, meta?, members?})`, `ensureDM([a, b])`, `get`, `update`, `delete`, `custom(roomId, payload)`, `members.list/add/remove` |
|
|
356
|
+
| `rooms` | `ensure({key?, type, name?, meta?, members?})`, `ensureDM([a, b])`, `get`, `update`, `delete`, `custom(roomId, payload)`, `presence(roomId, {full?})`, `members.list/add/remove` |
|
|
299
357
|
| `messages` | `send(roomId, {sender, text?, kind?, attachments?, appMeta?, replyTo?, threadId?, clientMessageId?})`, `list`, `delete` |
|
|
300
|
-
| `users` | `list({cursor?, limit?})`, `update`, `delete({purgeMessages?})`, `ban({until, reason?})`, `unban`, `revokeTokens`, `disconnect` |
|
|
358
|
+
| `users` | `list({cursor?, limit?})`, `update`, `delete({purgeMessages?})`, `purgeMessages(userId)`, `ban({until, reason?})`, `unban`, `revokeTokens`, `disconnect` |
|
|
301
359
|
| `events` | `list({after?, limit?})` — outbox. 놓친 웹훅을 따라잡는다 |
|
|
302
360
|
| `webhooks` | `verify(headers, rawBody, {now?, tolerance?})` |
|
|
303
361
|
|
|
362
|
+
`rooms.presence(roomId)`는 `{count}`이고, `{full: true}`면 `users`(최대 1000명, 잘리면
|
|
363
|
+
`capped: true`)가 붙는다. 빈 방은 404가 아니라 `{count: 0}`이다.
|
|
364
|
+
|
|
365
|
+
**`users.purgeMessages(userId)`는 모더레이션이다** — 그 유저가 앱에서 보낸 메시지를
|
|
366
|
+
전부 지우되(tombstone: seq는 그대로, 보고 있는 클라이언트에는 `message.deleted`가
|
|
367
|
+
간다) **탈퇴시키지 않는다.** 이름도 남는다. 스팸이면 **밴을 먼저** 하고 부른다 — 도는
|
|
368
|
+
사이에 올라온 메시지는 목록에 없다. 한 번에 상한만큼만 하므로 `purgeCapped`가
|
|
369
|
+
`true`인 동안 다시 부른다. 탈퇴한 유저는 404다(그때는 `delete(userId, {purgeMessages: true})`).
|
|
370
|
+
|
|
371
|
+
```ts
|
|
372
|
+
await chat.users.ban(userId, { until: Date.now() + 7 * 86_400_000, reason: 'spam' })
|
|
373
|
+
while ((await chat.users.purgeMessages(userId)).purgeCapped) {}
|
|
374
|
+
```
|
|
375
|
+
|
|
304
376
|
`users.ban`의 `until`은 **unix 밀리초이고 필수다.** 무기한 밴은 먼 미래 시각을
|
|
305
377
|
직접 고른다 — 감사 로그에 사고가 아니라 결정으로 남는다.
|
|
306
378
|
|