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