@kispi/chat 0.1.2 → 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 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>` | **필수.** 백엔드가 서명한 유저 토큰. 접속할 때마다 다시 부르므로 만료된 것이 재사용되지 않는다 |
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` | 전역 대신 쓸 구현. 테스트에서 주입한다 |
@@ -42,10 +42,10 @@ npm install @kispi/chat
42
42
 
43
43
  | 메서드 | |
44
44
  |---|---|
45
- | `connect()` | 붙는다. 이미 붙어 있거나 붙는 중이면 그 시도에 합류한다 |
46
- | `close()` | 끊는다. 이건 재접속하지 않는다 |
45
+ | `connect()` | 붙는다. 이미 붙어 있거나 붙는 중이면 그 시도에 합류한다. 실패하면 거절하지만 **재시도는 백그라운드에서 계속된다**(아래) |
46
+ | `close()` | 끊는다. 이건 재접속하지 않는다. 다시 붙으려면 `connect()` |
47
47
  | `room(roomId)` | 방 핸들. 같은 id면 같은 객체 |
48
- | `roomByKey(key)` | 키로 여는 방 핸들. 구독할 때 서버가 id를 알려 준다 |
48
+ | `roomByKey(key)` | 키로 여는 방 핸들. 구독할 때 서버가 id를 알려 준다. **방을 만들지 않는다** — 없는 키면 `subscribe()`가 `not_found`로 거절한다(아래) |
49
49
  | `rooms.list({cursor?, limit?})` | 내가 멤버인 방 + unread + 마지막 메시지 |
50
50
  | `rooms.discover({type?, cursor?, limit?})` | 공개 방 탐색 |
51
51
  | `rooms.members(roomId).list/add/remove` | 멤버 관리 |
@@ -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` | 받은 프레임 전부. 디버깅용 |
@@ -71,17 +72,18 @@ off()
71
72
 
72
73
  | 프로퍼티 | |
73
74
  |---|---|
74
- | `messages` | seq 오름차순. **정렬과 구멍 메우기는 SDK가 한다** |
75
+ | `messages` | seq 오름차순. **정렬과 구멍 메우기는 SDK가 한다.** 바뀔 때마다 새 배열이다 |
75
76
  | `id` / `key` | 방 식별자. 키로 열었으면 구독 전까지 `id`가 없다 |
76
77
  | `lastSeq` | 서버가 알려 준 방의 마지막 seq |
77
78
  | `presence` | `{count, users?, capped?}` |
78
79
 
79
80
  | 메서드 | |
80
81
  |---|---|
81
- | `subscribe()` | 라이브 피드를 켜고 최근 100건을 읽는다. 이미 따라잡혔으면 아무것도 안 한다 |
82
+ | `subscribe()` | 라이브 피드를 켜고 최근 100건을 읽는다. 이미 따라잡혔으면 아무것도 안 한다. **연결 전에 불러도 된다** — 붙을 때까지 기다린다(`close()`면 `closed`로 거절) |
82
83
  | `unsubscribe()` | 이 방 보기를 그만둔다. 재접속해도 다시 구독하지 않는다 |
83
- | `send({text, attachments?, entities?, replyTo?, threadId?, meta?}, {clientMessageId?})` | 발행. ack로 `{messageId, seq}` |
84
- | `history({before?, after?, limit?, view?})` | 과거를 직접 읽는다. `view`는 `'main'`, `'all'`, `{thread: id}` |
84
+ | `send({text, attachments?, entities?, replyTo?, threadId?, meta?}, {clientMessageId?})` | 발행. ack로 `{messageId, seq}`. 구독이 진행 중이면 그것을 기다린다 |
85
+ | `loadOlder({limit?})` | 가장 오래된 메시지 페이지(기본 100)를 읽어 **`messages` 앞에 붙인다.** `{messages, hasMore}`. 스크롤을 올릴 때 쓴다. 목록이 비어 있으면(구독 전) 아무것도 읽지 않는다 |
86
+ | `history({before?, after?, limit?, view?})` | 과거를 직접 읽는다. **`messages`와 따로 논다.** `view`는 `'main'`, `'all'`, `{thread: id}` |
85
87
  | `reload()` | 최근 페이지를 다시 읽는다 |
86
88
  | `react(messageId, emoji)` / `unreact(...)` | 리액션. 멱등 |
87
89
  | `reactionsOf(messageId, {emoji?, cursor?, limit?})` | 누가 눌렀는지. **한 행은 (유저, 이모지) 쌍**이라 한 사람이 여러 줄일 수 있다. `limit` 기본 50 최대 100, 마지막 페이지의 `cursor`는 빈 문자열 |
@@ -100,11 +102,104 @@ off()
100
102
 
101
103
  | 이벤트 | 언제 |
102
104
  |---|---|
103
- | `messages` | 목록이 바뀔 때마다. 통째로 다시 그리는 UI에 편하다 |
104
- | `error` | 히스토리를 읽거나 구멍을 메우는 데 실패했다. **다시 시도할 것은 `subscribe()`** |
105
+ | `messages` | 목록이 바뀔 때마다. **매번 새 배열이고, 내보낸 배열은 다시 건드리지 않는다** |
106
+ | `error` | 히스토리를 읽거나 구멍을 메우는 데 실패했다. **방이 스스로 다시 구독한다**(1초부터 두 배씩, 연결된 동안 5번, 재접속마다 다시). 사람이 누를 재시도 버튼은 `subscribe()` |
105
107
  | `reset` | 커서가 보존 기간보다 오래돼 목록을 버리고 다시 채웠다. **렌더한 것을 버려야 한다** |
106
108
 
107
- ### 알아 둘 것
109
+ ### 알아 둘 것
110
+
111
+ **`messages`는 참조가 바뀐다.** 목록이 바뀔 때마다 새 배열이 나오고, 한 번 내보낸
112
+ 배열과 그 안의 행 객체는 이후에 고쳐지지 않는다. 그래서 참조로 비교하는 상태에 그대로
113
+ 넣으면 된다. 행도 마찬가지로, 이벤트(수정, 삭제, 리액션, 스레드)가 바꾼 행만 새 객체이고
114
+ 나머지 행은 참조가 그대로다 — **참조가 같으면 바뀌지 않은 것**이니 행 단위 memo에 써도
115
+ 된다. 반대는 약속하지 않는다: `reload()`와 재접속 따라잡기는 같은 내용의 행을 서버에서
116
+ 새로 받아 오므로, 참조가 달라도 내용은 같을 수 있다.
117
+
118
+ ```ts
119
+ // Svelte 5
120
+ let messages = $state.raw<Message[]>([])
121
+ room.on('messages', m => (messages = m))
122
+
123
+ // React
124
+ const [messages, setMessages] = useState<Message[]>([])
125
+ useEffect(() => room.on('messages', setMessages), [room])
126
+
127
+ // Vue
128
+ const messages = shallowRef<Message[]>([])
129
+ room.on('messages', m => (messages.value = m))
130
+ ```
131
+
132
+ `$state`(깊은 프록시)나 `ref`로 감싸도 동작하지만 필요 없는 일을 한다. 0.1.x는 같은
133
+ 배열을 제자리에서 고쳐 다시 내보내서, 참조로 비교하는 쪽에서는 화면이 멈췄다.
134
+
135
+ **과거는 `loadOlder()`로 같은 목록에 붙인다.** `history()`는 목록을 건드리지 않는
136
+ 직접 읽기라, 그 결과를 라이브 목록과 합치고 중복을 거르고 `reset`과 재접속을
137
+ 처리하는 일이 전부 소비자 몫이 된다. `loadOlder()`는 그 행들을 `messages`에 넣으므로
138
+ 정렬·중복 제거·삭제·리액션·재접속이 다른 행과 똑같이 적용된다. 동시에 여러 번
139
+ 불러도 요청은 하나이고, `hasMore`가 `false`면 방(또는 보존 기간)의 맨 앞이다.
140
+ `reset`이나 `reload()`는 붙인 과거도 함께 버린다.
141
+
142
+ ```ts
143
+ const { hasMore } = await room.loadOlder()
144
+ ```
145
+
146
+ **보내도 되는 때는 `await room.subscribe()`가 끝난 때다.** 다만 기다리지 않고
147
+ `send`해도 된다 — 구독이 진행 중이면 `send`(와 `react`, `markRead`, `loadOlder` 등)가
148
+ 그것을 기다렸다가 나간다. 구독이 실패하면 그 에러로 거절한다. 구독한 적도 없는
149
+ `roomByKey` 방은 id를 모르므로 곧바로 `closed`로 거절한다. `chat.state`가
150
+ `reconnecting`인 동안의 발행도 `closed`로 거절한다 — SDK는 보낼 것을 쌓아 두지 않는다.
151
+
152
+ **키로 연다고 방이 생기지 않는다.** `roomByKey(key).subscribe()`는 있는 방을 찾기만
153
+ 하고, 없으면 `not_found`다. 방은 백엔드가 `sk_`로 만든다
154
+ (`@kispi/chat/server`의 `rooms.ensure({key, type})` — 있으면 그대로 돌려주는
155
+ get-or-create라 페이지를 열 때마다 불러도 된다). 앱 설정 `client_room_create`를 켜면
156
+ 유저 토큰으로도 `PUT /v1/rooms`를 부를 수 있지만(허용 타입·키 패턴·하루 50개
157
+ 한도), `pk_`는 공개값이라 누구나 방을 찍어낼 수 있는 문이 된다. 기본은 꺼져 있고,
158
+ 끄는 쪽을 권한다.
159
+
160
+ **연결은 알아서 돌아온다.** 소켓이 끊기거나 `token()`이 던지면 지수 백오프(0.5초에서
161
+ 30초까지, 지터)로 다시 붙는다. **첫 `connect()`도 같다** — 실패하면 `await`는 거절해서
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()`.
193
+
194
+ **토큰 만료는 SDK가 처리한다.** 소켓은 접속할 때 한 번만 인증하므로 토큰이 만료돼도
195
+ 멀쩡하지만, REST는 요청마다 토큰을 본다. REST가 401을 받으면 SDK가 `token()`을 다시
196
+ 불러 **한 번** 재시도한다. 두 번째 401(오리진, 폐기된 토큰, 밴)은 그대로 던진다.
197
+ 주기적으로 재접속할 필요가 없다.
198
+
199
+ SDK는 토큰을 캐시하지도, 꺼내 주지도 않는다. 캐시는 `token()` 안에서 한다(부를
200
+ 때마다 백엔드를 칠 필요는 없다). **이 토큰을 자기 백엔드 API의 인증으로 다시 쓰지
201
+ 않는다** — 채팅 엔진에 내라고 만든 것이고, 둘을 섞으면 한쪽에 새어 나간 토큰이 다른
202
+ 쪽의 열쇠가 된다. 자기 API에는 자기 세션을 쓴다.
108
203
 
109
204
  **정렬과 구멍 메우기는 SDK가 한다.** 프레임이 순서 없이 오거나 재접속으로 구간을
110
205
  놓치면 REST로 채운다. 한 페이지(100)를 넘는 구멍도 전부 채운다.
@@ -167,6 +262,7 @@ try {
167
262
  | `code` | `unauthorized`, `rate_limited`, `not_found`, `invalid`, `timeout`, `closed`, ... |
168
263
  | `retryAfterMs` | 한도에 걸렸을 때 서버가 알려 준 대기 시간 |
169
264
  | `appCode` | `before_publish` 웹훅이 거절하며 붙인 소비자 쪽 코드 |
265
+ | `status` | REST 호출에서 난 에러면 HTTP 상태 |
170
266
 
171
267
  ---
172
268
 
@@ -182,6 +278,7 @@ const chat = createChatServer({
182
278
  secretKey: process.env.CHAT_SECRET_KEY!,
183
279
  keyId: process.env.CHAT_KEY_ID!,
184
280
  webhookSecret: process.env.CHAT_WEBHOOK_SECRET,
281
+ guestSecret: process.env.CHAT_GUEST_SECRET, // guest()를 쓸 때만
185
282
  })
186
283
  ```
187
284
 
@@ -196,6 +293,40 @@ const chat = createChatServer({
196
293
  | `avatar`, `meta` | 선택 |
197
294
  | `ttlSeconds` | 기본 1시간, 최대 24시간. 넘으면 자르지 않고 거절한다 |
198
295
 
296
+ ### `chat.guest({credential?, name, avatar?, meta?, ttlSeconds?})`
297
+
298
+ 로그인하지 않은 방문자의 신원. `{userId, credential, token, created}`를 돌려준다.
299
+ 네트워크도 저장소도 쓰지 않는다.
300
+
301
+ ```ts
302
+ app.post('/api/chat-token', (req, res) => {
303
+ if (req.user) return res.json({ token: chat.token({ userId: req.user.id, name: req.user.name }) })
304
+ const g = chat.guest({ credential: req.cookies.chat_guest, name: '손님' })
305
+ res.cookie('chat_guest', g.credential, { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 400 * 864e5 })
306
+ res.json({ token: g.token })
307
+ })
308
+ ```
309
+
310
+ **브라우저가 돌려준 `userId`를 그대로 서명하면 안 된다.** 게스트 id는 비밀이 아니다 —
311
+ 방의 모든 메시지에 `sender.id`로 실려 나간다. "브라우저가 보관한 id를 다시 서명"하는
312
+ 토큰 엔드포인트는 남의 메시지에서 id를 복사한 누구에게나 그 게스트의 신원을 준다.
313
+ 형식 검사(`g_` + UUID 정규식)는 이것을 막지 못한다. UUID가 추측하기 어려운 것은
314
+ 공개된 값 앞에서는 아무 의미가 없다.
315
+
316
+ `guest()`는 브라우저가 id 대신 **자격증명**을 들게 한다. `g1.<userId>.<MAC>`이고 MAC은
317
+ `guestSecret`으로 만든다. 돌아온 자격증명이 검증되면 그 안의 id로, 없거나 틀리면
318
+ 새 게스트(`g_` + 무작위 22자, `created: true`)로 서명한다. 가능하면 `httpOnly` 쿠키에
319
+ 두어 페이지의 스크립트가 읽지 못하게 한다.
320
+
321
+ | | |
322
+ |---|---|
323
+ | `guestSecret` | 32자 이상. 백엔드에만 둔다. **`sk_`에서 유도하지 않는다** — `sk_`는 유출되면 돌리는 키이고, 돌릴 때 모든 게스트가 새 사람이 되면 안 되기 때문이다 |
324
+ | 교체 | 배열로 준다: `[새것, 옛것]`. 첫 번째로 서명하고 전부로 검증한다. 옛것으로 검증된 자격증명은 같은 `userId`로 새로 서명해 돌려주므로 다음 방문에 옮겨 간다 |
325
+ | 유출 | 비밀이 새면 누구든 아무 게스트가 될 수 있다. 옛것을 목록에서 **빼면** 모든 게스트가 새 사람이 된다 — 신원을 잃는 것이 사칭보다 낫다 |
326
+
327
+ 게스트 id는 `g_`로 시작한다. 로그인 유저의 id가 `g_`로 시작하지 않게 하면 두 공간이
328
+ 겹치지 않는다.
329
+
199
330
  ### 나머지
200
331
 
201
332
  | 그룹 | 메서드 |
@@ -5,6 +5,8 @@ var ChatError = class extends Error {
5
5
  retryAfterMs;
6
6
  /** The consumer's own code, when a before_publish hook denied this. */
7
7
  appCode;
8
+ /** The HTTP status, when this came from a REST call. */
9
+ status;
8
10
  constructor(code, message, extra) {
9
11
  super(message);
10
12
  this.name = "ChatError";