@kispi/chat 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +263 -0
- package/dist/chunk-T3QOUFOM.js +33 -0
- package/dist/index.cjs +1175 -0
- package/dist/index.d.cts +806 -0
- package/dist/index.d.ts +806 -0
- package/dist/index.js +1119 -0
- package/dist/server/index.cjs +311 -0
- package/dist/server/index.d.cts +208 -0
- package/dist/server/index.d.ts +208 -0
- package/dist/server/index.js +256 -0
- package/package.json +47 -0
package/README.md
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
# @kispi/chat
|
|
2
|
+
|
|
3
|
+
채팅 서버의 클라이언트 SDK. 소켓 하나에 방 여러 개, 순서가 맞는 히스토리.
|
|
4
|
+
**런타임 의존성 0개**, ESM과 CJS 둘 다, 타입 선언 포함.
|
|
5
|
+
|
|
6
|
+
진입점이 둘이다. `@kispi/chat`은 브라우저와 Node에서 쓰는 클라이언트,
|
|
7
|
+
`@kispi/chat/server`는 `sk_`를 들고 도는 소비자 백엔드용이다.
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install @kispi/chat
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
이 문서가 **표면 전체**를 적는다. 시작하는 법도 여기 있다.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## `@kispi/chat` — 클라이언트
|
|
18
|
+
|
|
19
|
+
### `createChatClient(options): ChatClient`
|
|
20
|
+
|
|
21
|
+
| 옵션 | 타입 | |
|
|
22
|
+
|---|---|---|
|
|
23
|
+
| `url` | `string` | **필수.** 오리진만 준다. SDK가 `/v1/ws?v=1`을 붙인다 |
|
|
24
|
+
| `key` | `string` | **필수.** `pk_`. 공개값이라 브라우저에 넣어도 된다 |
|
|
25
|
+
| `token` | `() => string \| Promise<string>` | **필수.** 백엔드가 서명한 유저 토큰. 접속할 때마다 다시 부르므로 만료된 것이 재사용되지 않는다 |
|
|
26
|
+
| `restURL` | `string` | REST 주소. 생략하면 `url`에서 유도한다(`ws://` → `http://`) |
|
|
27
|
+
| `externalToken` | `() => string \| Promise<string>` | external 모드. 주면 **`token`보다 우선한다**(서버의 판정 순서와 같다). **서버가 아직 거절하므로 오늘은 쓸 곳이 없다** |
|
|
28
|
+
| `WebSocket` | `new (url) => WebSocketLike` | 전역 대신 쓸 구현. 테스트에서 주입한다 |
|
|
29
|
+
| `fetch` | `typeof fetch` | 위와 같음 |
|
|
30
|
+
|
|
31
|
+
`token`을 브라우저에서 만들지 않는다. 서명에 `sk_`가 필요하고, `sk_`가
|
|
32
|
+
브라우저에 있으면 그 앱 전체가 열린다.
|
|
33
|
+
|
|
34
|
+
### `ChatClient`
|
|
35
|
+
|
|
36
|
+
| 프로퍼티 | |
|
|
37
|
+
|---|---|
|
|
38
|
+
| `state` | `'connecting' \| 'open' \| 'reconnecting' \| 'closed'` |
|
|
39
|
+
| `user` | 접속한 사람. `{id, name, avatar?}` |
|
|
40
|
+
| `connectionId` | 지원 문의와 강제 종료에 쓰는 식별자 |
|
|
41
|
+
| `hello` | 서버가 접속 때 준 것 전부(한도, 총 unread, 경고) |
|
|
42
|
+
|
|
43
|
+
| 메서드 | |
|
|
44
|
+
|---|---|
|
|
45
|
+
| `connect()` | 붙는다. 이미 붙어 있거나 붙는 중이면 그 시도에 합류한다 |
|
|
46
|
+
| `close()` | 끊는다. 이건 재접속하지 않는다 |
|
|
47
|
+
| `room(roomId)` | 방 핸들. 같은 id면 같은 객체 |
|
|
48
|
+
| `roomByKey(key)` | 키로 여는 방 핸들. 구독할 때 서버가 id를 알려 준다 |
|
|
49
|
+
| `rooms.list({cursor?, limit?})` | 내가 멤버인 방 + unread + 마지막 메시지 |
|
|
50
|
+
| `rooms.discover({type?, cursor?, limit?})` | 공개 방 탐색 |
|
|
51
|
+
| `rooms.members(roomId).list/add/remove` | 멤버 관리 |
|
|
52
|
+
| `unread()` | 총계와 방별 내역 |
|
|
53
|
+
| `updateMe(meta)` | 내 `meta`. **`name`과 `avatar`는 토큰 claim에서 온다** |
|
|
54
|
+
| `send(type, data?, timeoutMs?)` | 날 프레임. 표면에 없는 것을 부를 때만 |
|
|
55
|
+
|
|
56
|
+
| 이벤트 | |
|
|
57
|
+
|---|---|
|
|
58
|
+
| `state` | 위 네 값 |
|
|
59
|
+
| `notification` | 멤버인 방에 새 메시지가 왔는데 그 방을 보고 있지 않을 때 |
|
|
60
|
+
| `user.presence` | 내 다른 접속의 presence 변화 |
|
|
61
|
+
| `frame` | 받은 프레임 전부. 디버깅용 |
|
|
62
|
+
|
|
63
|
+
`on`은 해지 함수를 돌려준다.
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
const off = chat.on('state', s => setStatus(s))
|
|
67
|
+
off()
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### `Room`
|
|
71
|
+
|
|
72
|
+
| 프로퍼티 | |
|
|
73
|
+
|---|---|
|
|
74
|
+
| `messages` | seq 오름차순. **정렬과 구멍 메우기는 SDK가 한다** |
|
|
75
|
+
| `id` / `key` | 방 식별자. 키로 열었으면 구독 전까지 `id`가 없다 |
|
|
76
|
+
| `lastSeq` | 서버가 알려 준 방의 마지막 seq |
|
|
77
|
+
| `presence` | `{count, users?, capped?}` |
|
|
78
|
+
|
|
79
|
+
| 메서드 | |
|
|
80
|
+
|---|---|
|
|
81
|
+
| `subscribe()` | 라이브 피드를 켜고 최근 100건을 읽는다. 이미 따라잡혔으면 아무것도 안 한다 |
|
|
82
|
+
| `unsubscribe()` | 이 방 보기를 그만둔다. 재접속해도 다시 구독하지 않는다 |
|
|
83
|
+
| `send({text, attachments?, entities?, replyTo?, threadId?, meta?}, {clientMessageId?})` | 발행. ack로 `{messageId, seq}` |
|
|
84
|
+
| `history({before?, after?, limit?, view?})` | 과거를 직접 읽는다. `view`는 `'main'`, `'all'`, `{thread: id}` |
|
|
85
|
+
| `reload()` | 최근 페이지를 다시 읽는다 |
|
|
86
|
+
| `react(messageId, emoji)` / `unreact(...)` | 리액션. 멱등 |
|
|
87
|
+
| `reactionsOf(messageId, {emoji?, cursor?, limit?})` | 누가 눌렀는지. **한 행은 (유저, 이모지) 쌍**이라 한 사람이 여러 줄일 수 있다. `limit` 기본 50 최대 100, 마지막 페이지의 `cursor`는 빈 문자열 |
|
|
88
|
+
| `markRead(seq, {threadId?})` | 읽음 커서 |
|
|
89
|
+
| `typing()` | 입력 중. 던지지 않는다 |
|
|
90
|
+
| `join()` / `leave()` | 멤버십 |
|
|
91
|
+
| `presenceList()` | 지금 이 방에 있는 사람 |
|
|
92
|
+
| `resume()` | 재접속 후 다시 구독. SDK가 알아서 부른다 |
|
|
93
|
+
|
|
94
|
+
이벤트 이름은 **와이어의 이름 그대로**다. `message.created`, `message.updated`,
|
|
95
|
+
`message.deleted`, `reaction.added`, `reaction.removed`, `thread.updated`,
|
|
96
|
+
`presence`, `typing`, `read`, `member.joined`, `member.left`, `room.updated`,
|
|
97
|
+
`room.deleted`, `custom`.
|
|
98
|
+
|
|
99
|
+
와이어에 대응물이 없는 셋을 SDK가 더 낸다.
|
|
100
|
+
|
|
101
|
+
| 이벤트 | 언제 |
|
|
102
|
+
|---|---|
|
|
103
|
+
| `messages` | 목록이 바뀔 때마다. 통째로 다시 그리는 UI에 편하다 |
|
|
104
|
+
| `error` | 히스토리를 읽거나 구멍을 메우는 데 실패했다. **다시 시도할 것은 `subscribe()`** |
|
|
105
|
+
| `reset` | 커서가 보존 기간보다 오래돼 목록을 버리고 다시 채웠다. **렌더한 것을 버려야 한다** |
|
|
106
|
+
|
|
107
|
+
### 알아 둘 것 넷
|
|
108
|
+
|
|
109
|
+
**정렬과 구멍 메우기는 SDK가 한다.** 프레임이 순서 없이 오거나 재접속으로 구간을
|
|
110
|
+
놓치면 REST로 채운다. 한 페이지(100)를 넘는 구멍도 전부 채운다.
|
|
111
|
+
|
|
112
|
+
**재접속이 복구하는 것은 메시지뿐이다.** 히스토리 라우트가 메시지 하나라서,
|
|
113
|
+
끊긴 동안 **오래된 메시지**에 달린 리액션이나 바뀐 방 이름은 반영되지 않는다.
|
|
114
|
+
그럴 때는 `await room.reload()`.
|
|
115
|
+
|
|
116
|
+
**`typing`은 보낸 사람에게도 온다.** "X가 입력 중"을 그린다면 자기 `userId`를
|
|
117
|
+
건너뛴다.
|
|
118
|
+
|
|
119
|
+
**`reset`을 무시하면 안 된다.** 옛 목록에 이어 붙이면 아무도 메우지 않는 구멍이
|
|
120
|
+
남는다.
|
|
121
|
+
|
|
122
|
+
### 브라우저에서 쓸 때
|
|
123
|
+
|
|
124
|
+
`pk_`의 `allowed_origins`에 **페이지의 오리진을 넣어야 한다.** 이것 하나다. 서버가
|
|
125
|
+
CORS 헤더를 직접 내므로 nginx나 다른 프록시를 건드릴 일은 없다.
|
|
126
|
+
|
|
127
|
+
```sh
|
|
128
|
+
chat-server app create my-app --origins https://example.com
|
|
129
|
+
chat-server key create <app-id> pk --origins https://staging.example.com
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
스킴·호스트·포트가 **정확히** 맞아야 한다. `https://example.com`과
|
|
133
|
+
`https://www.example.com`은 다른 오리진이고, `http://localhost:3000`과
|
|
134
|
+
`http://127.0.0.1:3000`도 다른 오리진이다. 개발용 오리진은 따로 넣는다.
|
|
135
|
+
|
|
136
|
+
**Node에서는 이 설정 없이도 된다.** WHATWG `fetch`와 `WebSocket`은 브라우저 밖에서
|
|
137
|
+
`Origin`을 보내지 않고, 엔진은 `Origin`이 없는 요청을 통과시킨다. 그래서 Node에서
|
|
138
|
+
초록이던 코드가 브라우저에서만 401이 나는 일이 생긴다 — 이 절이 그 이유다.
|
|
139
|
+
|
|
140
|
+
목록에 없으면 REST가 401로 거절하고, 메시지가 원인을 말한다:
|
|
141
|
+
|
|
142
|
+
```
|
|
143
|
+
origin not allowed for this publishable key: add it to the key's allowed_origins
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
**소켓만 되고 REST만 안 되는 것처럼 보이면 여기를 본다.** WebSocket은 CORS 대상이
|
|
147
|
+
아니라서 접속과 라이브 메시지는 멀쩡히 도는데, 히스토리·unread·재접속 구멍 메우기가
|
|
148
|
+
REST라서 그것만 조용히 실패한다. 증상은 "메시지는 오는데 스크롤을 올리면 비어
|
|
149
|
+
있다"로 나타난다.
|
|
150
|
+
|
|
151
|
+
### 에러
|
|
152
|
+
|
|
153
|
+
실패는 `ChatError`다.
|
|
154
|
+
|
|
155
|
+
```ts
|
|
156
|
+
try {
|
|
157
|
+
await room.send({ text })
|
|
158
|
+
} catch (err) {
|
|
159
|
+
if (err instanceof ChatError && err.code === 'rate_limited') {
|
|
160
|
+
retryAfter(err.retryAfterMs)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
| 필드 | |
|
|
166
|
+
|---|---|
|
|
167
|
+
| `code` | `unauthorized`, `rate_limited`, `not_found`, `invalid`, `timeout`, `closed`, ... |
|
|
168
|
+
| `retryAfterMs` | 한도에 걸렸을 때 서버가 알려 준 대기 시간 |
|
|
169
|
+
| `appCode` | `before_publish` 웹훅이 거절하며 붙인 소비자 쪽 코드 |
|
|
170
|
+
|
|
171
|
+
---
|
|
172
|
+
|
|
173
|
+
## `@kispi/chat/server` — 백엔드
|
|
174
|
+
|
|
175
|
+
`sk_`를 쓴다. **브라우저에 넣지 않는다.**
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
import { createChatServer } from '@kispi/chat/server'
|
|
179
|
+
|
|
180
|
+
const chat = createChatServer({
|
|
181
|
+
url: 'https://chat.example.com',
|
|
182
|
+
secretKey: process.env.CHAT_SECRET_KEY!,
|
|
183
|
+
keyId: process.env.CHAT_KEY_ID!,
|
|
184
|
+
webhookSecret: process.env.CHAT_WEBHOOK_SECRET,
|
|
185
|
+
})
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
### `chat.token(input): string`
|
|
189
|
+
|
|
190
|
+
네트워크를 타지 않는 로컬 서명이다.
|
|
191
|
+
|
|
192
|
+
| 필드 | |
|
|
193
|
+
|---|---|
|
|
194
|
+
| `userId` | **필수.** JWT `sub`가 된다. 당신 서비스의 유저 id |
|
|
195
|
+
| `name` | **필수.** 접속할 때마다 이 값이 이긴다 |
|
|
196
|
+
| `avatar`, `meta` | 선택 |
|
|
197
|
+
| `ttlSeconds` | 기본 1시간, 최대 24시간. 넘으면 자르지 않고 거절한다 |
|
|
198
|
+
|
|
199
|
+
### 나머지
|
|
200
|
+
|
|
201
|
+
| 그룹 | 메서드 |
|
|
202
|
+
|---|---|
|
|
203
|
+
| `rooms` | `ensure({key?, type, name?, meta?, members?})`, `ensureDM([a, b])`, `get`, `update`, `delete`, `custom(roomId, payload)`, `members.list/add/remove` |
|
|
204
|
+
| `messages` | `send(roomId, {sender, text?, kind?, attachments?, appMeta?, replyTo?, threadId?, clientMessageId?})`, `list`, `delete` |
|
|
205
|
+
| `users` | `list({cursor?, limit?})`, `update`, `delete({purgeMessages?})`, `ban({until, reason?})`, `unban`, `revokeTokens`, `disconnect` |
|
|
206
|
+
| `events` | `list({after?, limit?})` — outbox. 놓친 웹훅을 따라잡는다 |
|
|
207
|
+
| `webhooks` | `verify(headers, rawBody, {now?, tolerance?})` |
|
|
208
|
+
|
|
209
|
+
`users.ban`의 `until`은 **unix 밀리초이고 필수다.** 무기한 밴은 먼 미래 시각을
|
|
210
|
+
직접 고른다 — 감사 로그에 사고가 아니라 결정으로 남는다.
|
|
211
|
+
|
|
212
|
+
`users.*`는 그 사람이 **한 번이라도 접속한 뒤에만** 통한다. 유저는 접속으로
|
|
213
|
+
생긴다.
|
|
214
|
+
|
|
215
|
+
`users.list`는 콘솔용이다. uid 역순, `limit` 기본 50 최대 100, `cursor`는 불투명
|
|
216
|
+
문자열이고 빈 문자열이 마지막 페이지다. **탈퇴한 유저도 나오고 `deletedAt`이 그
|
|
217
|
+
표시다** — 살아 있는 유저만 원하면 그것으로 거른다. 방 안의 목록들(멤버·리액션·읽음)이
|
|
218
|
+
탈퇴자의 id를 감추는 것과 반대인데, 그쪽은 **다른 유저**가 읽는 목록이고 이쪽은
|
|
219
|
+
`sk_`가 있어야 하므로 상대가 그 id를 발급한 백엔드 자신이기 때문이다.
|
|
220
|
+
|
|
221
|
+
`rooms.custom(roomId, payload)`은 **저장되지 않는 제어 신호**다. `payload`는 JSON
|
|
222
|
+
객체이고 그대로 `room.on('custom', ...)`에 도착한다. 히스토리도 unread도 웹훅도
|
|
223
|
+
남지 않으니, 그 순간 접속해 있지 않은 사람은 영영 모른다 — 재접속해도 받아야 하는
|
|
224
|
+
신호라면 `messages.send(roomId, {kind:'system', ...})`이다.
|
|
225
|
+
|
|
226
|
+
### 웹훅
|
|
227
|
+
|
|
228
|
+
```ts
|
|
229
|
+
app.post('/chat-hook', express.raw({ type: 'application/json' }), (req, res) => {
|
|
230
|
+
const { event, delivery, data } = chat.webhooks.verify(req.headers, req.body.toString('utf8'))
|
|
231
|
+
if (seen.has(delivery)) return res.sendStatus(200) // at-least-once다
|
|
232
|
+
res.sendStatus(200) // 먼저 접수하고
|
|
233
|
+
void handle(event, data) // 처리는 비동기로
|
|
234
|
+
})
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
**raw 바디여야 한다.** MAC은 서버가 보낸 바이트에 대한 것이고, 파싱했다가 다시
|
|
238
|
+
직렬화한 것은 다른 문자열이다.
|
|
239
|
+
|
|
240
|
+
**서명은 헤더도 덮는다.** 정규 문자열이
|
|
241
|
+
`` `${t}\n${event}\n${delivery}\n${rawBody}` ``이고 순서가 고정이다. 그래서
|
|
242
|
+
`verify`는 `X-Chat-Event`와 `X-Chat-Delivery`를 **검증 전에** 읽고, 헤더만
|
|
243
|
+
바꿔치기한 재전송은 통과하지 못한다 — `X-Chat-Delivery`가 중복 제거 키이므로
|
|
244
|
+
그것이 서명 밖에 있으면 캡처한 배달 하나가 얼마든지 새 이벤트로 셀 수 있다.
|
|
245
|
+
|
|
246
|
+
세 가지를 지킨다. `verify`가 서명과 시각(기본 5분)을 보고, **중복 제거는 직접**
|
|
247
|
+
해야 하며(`delivery`로), 10초 안에 2xx로 답해야 한다. 넘기면 실패로 치고 백오프가
|
|
248
|
+
시작된다.
|
|
249
|
+
|
|
250
|
+
---
|
|
251
|
+
|
|
252
|
+
## 개발
|
|
253
|
+
|
|
254
|
+
```sh
|
|
255
|
+
pnpm install
|
|
256
|
+
pnpm build # ESM + CJS + 타입 선언
|
|
257
|
+
pnpm exec vitest run
|
|
258
|
+
pnpm exec tsc --noEmit
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
테스트는 목이 아니라 **주입**을 쓴다. `WebSocket`과 `fetch`를 갈아 끼우면 서버를
|
|
262
|
+
흉내 낼 필요가 없고, 통합 계열 파일은 아예 **살아 있는 서버**에 붙는다. 실행에는
|
|
263
|
+
Postgres와 Redis가 필요하다.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// src/errors.ts
|
|
2
|
+
var ChatError = class extends Error {
|
|
3
|
+
code;
|
|
4
|
+
/** Present on rate limits, in milliseconds. */
|
|
5
|
+
retryAfterMs;
|
|
6
|
+
/** The consumer's own code, when a before_publish hook denied this. */
|
|
7
|
+
appCode;
|
|
8
|
+
constructor(code, message, extra) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = "ChatError";
|
|
11
|
+
this.code = code;
|
|
12
|
+
if (extra?.retryAfterMs !== void 0) this.retryAfterMs = extra.retryAfterMs;
|
|
13
|
+
if (extra?.appCode !== void 0) this.appCode = extra.appCode;
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
function errorFrom(data, fallback = "the server refused the request") {
|
|
17
|
+
if (typeof data === "object" && data !== null) {
|
|
18
|
+
const d = data;
|
|
19
|
+
const inner = d["error"] ?? d;
|
|
20
|
+
const code = typeof inner["code"] === "string" ? inner["code"] : "internal";
|
|
21
|
+
const message = typeof inner["message"] === "string" ? inner["message"] : fallback;
|
|
22
|
+
const extra = {};
|
|
23
|
+
if (typeof inner["retryAfterMs"] === "number") extra.retryAfterMs = inner["retryAfterMs"];
|
|
24
|
+
if (typeof inner["appCode"] === "string") extra.appCode = inner["appCode"];
|
|
25
|
+
return new ChatError(code, message, extra);
|
|
26
|
+
}
|
|
27
|
+
return new ChatError("internal", fallback);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export {
|
|
31
|
+
ChatError,
|
|
32
|
+
errorFrom
|
|
33
|
+
};
|