@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 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` | 히스토리를 읽거나 구멍을 메우는 데 실패했다. **다시 시도할 것은 `subscribe()`** |
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
- 그래서 재시도 루프를 직접 필요가 없다. 멈추는 것은 둘뿐이다: `close()`, 그리고
160
- 서버가 인증을 거절한 경우(`unauthorized` 같은 토큰을 반복하는 것은 서버를 두드리는
161
- 일이라 `closed`로 끝낸다).
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