@kispi/chat
v0.5.1
Published
Client SDK for the chat server: one WebSocket, many rooms, ordered history.
Readme
@kispi/chat
채팅 서버의 클라이언트 SDK. 소켓 하나에 방 여러 개, 순서가 맞는 히스토리. 런타임 의존성 0개, ESM과 CJS 둘 다, 타입 선언 포함.
진입점이 둘이다. @kispi/chat은 브라우저와 Node에서 쓰는 클라이언트,
@kispi/chat/server는 sk_를 들고 도는 소비자 백엔드용이다.
npm install @kispi/chat이 문서가 표면 전체를 적는다. 여기 없는 것은 내부 구현이고, 다음 버전에 말 없이 바뀐다.
@kispi/chat — 클라이언트
createChatClient(options): ChatClient
| 옵션 | 타입 | |
|---|---|---|
| key | string | 필수. pk_. 공개값이라 브라우저에 넣어도 된다 |
| token | () => string \| Promise<string> | 필수. 백엔드가 서명한 유저 토큰. 접속할 때마다, 그리고 REST가 401을 받을 때 다시 부르므로 만료된 것이 재사용되지 않는다. 던지면 백오프로 다시 부르고, ChatError를 던지면 다시 부르지 않는다(아래) |
| WebSocket | new (url) => WebSocketLike | 전역 대신 쓸 구현 |
| fetch | typeof fetch | 위와 같음 |
token을 브라우저에서 만들지 않는다. 서명에 sk_가 필요하고, sk_가
브라우저에 있으면 그 앱 전체가 열린다.
ChatClient
| 프로퍼티 | |
|---|---|
| state | 'connecting' \| 'open' \| 'reconnecting' \| 'closed' |
| user | 접속한 사람. {id, name, avatar?} — 서버가 토큰에서 받아들인 신원(hello가 준 것)이다. 토큰 응답을 따로 파싱하지 말고 이것을 쓴다. connect()/reconnect()가 끝나면 채워져 있다 |
| connectionId | 지원 문의와 강제 종료에 쓰는 식별자 |
| hello | 서버가 접속 때 준 것 전부. limits(실효 한도), unread({total, mentions, roomsCapped?}, 없는 것은 0이 아니라 '못 셌다'이니 unread()로 복구한다), warnings, serverTime, heartbeatMs |
| 메서드 | |
|---|---|
| connect() | 붙는다. 이미 붙어 있거나 붙는 중이면 그 시도에 합류한다. 실패하면 거절하지만 재시도는 백그라운드에서 계속된다(아래) |
| close() | 끊는다. 이건 재접속하지 않는다. 다시 붙으려면 connect() |
| reconnect() | 다시 인증한다: token()을 다시 불러 소켓을 바꾼다. 방 핸들과 구독, messages는 그대로이고 상태는 open → reconnecting → open이다(closed를 거치지 않는다). 닉네임·아바타를 바꾼 뒤에 쓴다(아래) |
| room(roomId) | 방 핸들. 같은 id면 같은 객체 |
| roomByKey(key) | 키로 여는 방 핸들. 구독할 때 서버가 id를 알려 준다. 방을 만들지 않는다 — 없는 키면 subscribe()가 not_found로 거절한다. 방은 백엔드가 rooms.ensure()로 만든다 |
| rooms.list({cursor?, limit?}) | 내가 멤버인 방 + unread + 마지막 메시지 |
| rooms.discover({type?, cursor?, limit?}) | 공개 방 탐색 |
| rooms.members(roomId).list/add/remove | 멤버 관리 |
| unread() | {total, mentions, roomsCapped?, rooms}. chat.hello?.unread와 같은 이름이다 |
| updateMe(meta) | 내 meta. name과 avatar는 토큰 claim에서 온다 |
| 이벤트 | |
|---|---|
| state | 위 네 값 |
| error | 클라이언트가 스스로 closed로 멈췄고 그 이유(ChatError). 인증 거절이나 token()의 ChatError. close()로 닫을 때는 없다 |
| notification | 멤버인 방에 새 메시지가 왔는데 그 방을 보고 있지 않을 때 |
| user.presence | 내 다른 접속의 presence 변화 |
| frame | 받은 프레임 전부. 디버깅용 |
on은 해지 함수를 돌려준다.
const off = chat.on('state', s => setStatus(s))
off()Room
| 프로퍼티 | |
|---|---|
| messages | seq 오름차순. 정렬과 구멍 메우기는 SDK가 한다. 바뀔 때마다 새 배열이다. 스레드 답글도 섞여 있다(threadId가 있는 행) — 본 타임라인만 그리려면 threadId가 없는 행만 고른다 |
| id / key | 방 식별자. 키로 열었으면 구독 전까지 id가 없다 |
| lastSeq | 서버가 알려 준 방의 마지막 seq |
| presence | {count, users?, capped?} — 지금 이 방에 누가 있나. 구독 ack와 presence 이벤트의 증분을 SDK가 합쳐 둔 값이다. users는 100명 이하 방에서만 있고, 그보다 크거나 모르면 없다 — 그때는 presenceList() |
| hasOlder | loadOlder()로 더 읽을 과거가 있는지. 첫 loadOlder() 전에 "이전 메시지" 버튼을 그릴지 정할 때 쓴다. loadOlder()의 hasMore와 같은 판정이고, 목록이 비었으면 false. messages 이벤트 때 다시 읽는다 |
| 메서드 | |
|---|---|
| subscribe() | 라이브 피드를 켜고 최근 100건을 읽는다. 이미 따라잡혔으면 아무것도 안 한다. 연결 전에 불러도 된다 — 붙을 때까지 기다린다(close()면 closed로 거절) |
| unsubscribe() | 이 방 보기를 그만둔다. 재접속해도 다시 구독하지 않는다 |
| send({text, attachments?, entities?, replyTo?, threadId?, meta?}, {clientMessageId?}) | 발행. ack로 {messageId, seq}. 구독이 진행 중이면 그것을 기다린다. 재접속 중이면 붙을 때까지 기다렸다가 보낸다(아래) |
| loadOlder({limit?}) | 가장 오래된 메시지 앞 페이지(기본 100)를 읽어 messages 앞에 붙인다. {messages, hasMore}. 스크롤을 올릴 때 쓴다. 목록이 비어 있으면(구독 전) 아무것도 읽지 않는다 |
| history({before?, after?, limit?, view?}) | 과거를 직접 읽는다. messages와 따로 논다. view는 'main', 'all', {thread: id} |
| reload() | 최근 페이지를 다시 읽는다 |
| react(messageId, emoji) / unreact(...) | 리액션. 멱등. 프레임보다 먼저 그 행의 myReactions를 고치고, 서버가 거절하면 되돌린다. 개수는 낙관적으로 올리지 않고 reaction.* 이벤트가 올 때 서버 값으로 고친다(아래) |
| reactionsOf(messageId, {emoji?, cursor?, limit?}) | 누가 눌렀는지. 한 행은 (유저, 이모지) 쌍이라 한 사람이 여러 줄일 수 있다. limit 기본 50 최대 100, 마지막 페이지의 cursor는 빈 문자열 |
| markRead(seq, {threadId?}) | 읽음 커서 |
| reads({userId?, cursor?, limit?}) | 읽음 커서 목록. {reads: [{userId?, seq, threadId?, withdrawn?}], cursor}. userId로 한 사람만 — 자기 스레드별 커서를 되찾을 때 reads({userId: chat.user.id}). cursor가 ''이면 끝 |
| typing({threadId?}) | 입력 중. 던지지 않는다. 스레드 답글을 쓰는 중이면 threadId |
| join() / leave() | 멤버십 |
| presenceList() | 지금 이 방에 있는 사람 |
이벤트 이름은 와이어의 이름 그대로이고, 페이로드는 타입 선언에 있다.
| 이벤트 | 페이로드 |
|---|---|
| message.created / message.updated | Message. created에는 thread가 없고 reactions가 비어 있다 |
| message.deleted | {id, seq}. seq는 그 메시지가 발행될 때 받은 값이다 |
| reaction.added / reaction.removed | {messageId, emoji, count, userId?}. SDK가 messages 안의 그 행의 reactions 개수를(내 것이면 myReactions도) 이미 고쳐 두었다. history()로 따로 읽은 행처럼 목록 밖의 행은 소비자가 고친다 |
| thread.updated | {rootId, count, lastSeq?, lastAt?} |
| presence | {count, joined?, left?, users?, capped?}. joined/left는 증분, users는 교체다 |
| typing | {userId, threadId?} — threadId는 스레드 루트 id, 본 타임라인이면 없다 |
| read | {userId, seq, threadId?} |
| member.joined / member.left | {userId, role?} |
| room.updated | 방 객체(unknown — 서버 표현 그대로다) |
| room.deleted | {roomId} |
| custom | rooms.custom(roomId, payload)이 보낸 객체 그대로 |
와이어에 대응물이 없는 셋을 SDK가 더 낸다.
| 이벤트 | 언제 |
|---|---|
| messages | 목록이 바뀔 때마다. 매번 새 배열이고, 내보낸 배열은 다시 건드리지 않는다 |
| error | 히스토리를 읽거나 구멍을 메우는 데 실패했다. 방이 스스로 다시 구독한다. 사람이 누를 재시도 버튼은 subscribe() |
| reset | 커서가 보존 기간보다 오래돼 목록을 버리고 다시 채웠다. 렌더한 것을 버려야 한다 |
알아 둘 것
messages는 매번 새 배열이고, 바뀐 행만 새 객체다. 참조로 비교하는 상태에 그대로 넣는다.
// Svelte 5
let messages = $state.raw<Message[]>([])
room.on('messages', m => (messages = m))
// React
const [messages, setMessages] = useState<Message[]>([])
useEffect(() => room.on('messages', setMessages), [room])
// Vue
const messages = shallowRef<Message[]>([])
room.on('messages', m => (messages.value = m))reset을 무시하면 안 된다. 옛 목록에 이어 붙이면 아무도 메우지 않는 구멍이 남는다.과거는
loadOlder()로 같은 목록에 붙인다.history()는 목록을 건드리지 않는 직접 읽기이므로 합치고 중복을 거르는 일이 소비자 몫이 된다.typing은 보낸 사람에게도 온다. "X가 입력 중"을 그린다면 자기userId를 건너뛴다.typing은 이름을 싣지 않는다. 이름은room.presence.users에서 찾는다 — 방을 보고 있는 사람이 거기 있다. 메시지 발신자에서 이름을 모으면 아직 말하지 않은 사람을 모른다.users가 없으면(100명 넘는 방)presenceList()로 한 번 받아 오고, 그래도 없으면 대체 이름을 쓴다.room.on('typing', ({ userId }) => { if (userId === chat.user?.id) return const name = room.presence?.users?.find(u => u.id === userId)?.name showTyping(userId, name ?? '누군가') // 몇 초 뒤 지우는 것은 소비자 몫이다 })연결은 알아서 돌아온다. 끊기면 지수 백오프로 다시 붙으니 재시도 루프를 짜지 않는다.
재접속 중에 보낸 것은 기다렸다가 나간다.
send·react·unreact·markRead는 상태가connecting/reconnecting이면 백오프를 건너뛰고 붙기를 기다려 보낸다. ack 전에 소켓이 끊기면 같은 프레임을 한 번 더 보낸다 — 서버가clientMessageId로 중복을 거르고 리액션·읽음은 멱등이라 두 번 반영되지 않는다. 대기와 ack를 합쳐 15초가 넘으면timeout이다.closed는 이제 멈춘 클라이언트(close(), 인증 거절, 한 번도connect()하지 않음)에서만 온다.typing()은 기다리지 않고 버린다.토큰 만료는 SDK가 처리한다. REST가 401을 받으면
token()을 다시 불러 한 번 재시도한다. 주기적으로 재접속할 필요가 없다.token()이ChatError를 던지면 멈춘다. 밴처럼 다시 해도 소용없는 실패를 그렇게 알린다.닉네임·아바타를 바꿨으면
reconnect(). 서버는 그 값을 접속할 때 토큰 claim에서 한 번 읽는다.
text는 신뢰할 수 없는 평문이다
메시지 text와 유저가 넣는 표시 이름·닉네임(name)은 엔진이 HTML 이스케이프하거나
정제하지 않은 평문이다. 의도적이다 — 이스케이프는 렌더링하는 출력 컨텍스트(HTML
텍스트 노드인지, 속성인지, 다른 포맷인지)에 달려 있어 엔진이 대신 정할 수 없다.
엔진은 크기·형식 같은 검증만 한다.
그래서 소비자가 렌더할 때 텍스트로 다뤄야 한다 — textContent나 프레임워크의 텍스트
바인딩(React의 {text}, Svelte의 {text}, Vue의 {{ text }}). innerHTML이나
v-html에 그대로 꽂으면 안 된다. 링크 자동 인식이나 마크업을 더하고 싶으면 문자열을
HTML로 만들어 정규식으로 치환하지 말고, 텍스트 노드를 만들어 DOM에 직접 붙인다.
// 하면 안 된다 — 정규식으로 HTML 문자열을 만든다
el.innerHTML = text.replace(urlRegex, '<a href="$1">$1</a>')
// 이렇게 — 텍스트 노드로 DOM을 만든다
for (const part of splitByUrl(text)) {
el.appendChild(part.isUrl ? makeAnchor(part.text) : document.createTextNode(part.text))
}욕설 같은 금칙어 처리는 이스케이프가 아니라 테넌트 정책이다 — 엔진은
before_publish 훅이라는 기전만 열어 주고, 판정은 소비자가 한다(docs/protocol.md의
before_publish 절).
브라우저에서 쓸 때
pk_의 허용 오리진에 페이지의 오리진을 넣어야 한다. 이것 하나다 — 서버가 CORS 헤더를
직접 내므로 프록시를 건드릴 일은 없다. 오리진은 콘솔의 키 화면에서 편집한다.
스킴·호스트·포트가 정확히 맞아야 한다. https://example.com과 https://www.example.com,
http://localhost:3000과 http://127.0.0.1:3000이 서로 다른 오리진이다. 개발용은 따로 넣는다.
목록에 없는 오리진에서는 REST만이 아니라 접속부터 401이다. 인증 거절은 SDK가 다시
시도하지 않으므로 클라이언트가 closed에서 멈추고, 이유는 chat.on('error')로 온다.
에러
실패는 ChatError다.
try {
await room.send({ text })
} catch (err) {
if (err instanceof ChatError && err.code === 'rate_limited') {
retryAfter(err.retryAfterMs)
}
}| 필드 | |
|---|---|
| code | 알려진 값의 유니온(ChatErrorCode)이라 오타가 컴파일에서 잡힌다. 서버: unauthorized, forbidden, not_found, invalid, too_large, rate_limited, banned, moderation_denied, cursor_too_old, internal. SDK: timeout, closed. 서버가 새 코드를 내도 좁혀 놓은 타입이 막지 않는다 |
| retryAfterMs | 한도에 걸렸을 때 서버가 알려 준 대기 시간 |
| appCode | before_publish 웹훅이 거절하며 붙인 소비자 쪽 코드 |
| status | REST 호출에서 난 에러면 HTTP 상태 |
@kispi/chat/server — 백엔드
sk_를 쓴다. 브라우저에 넣지 않는다.
import { createChatServer } from '@kispi/chat/server'
const chat = createChatServer({
secretKey: process.env.CHAT_SECRET_KEY!, // sk_…
webhookSecret: process.env.CHAT_WEBHOOK_SECRET,
guestSecret: process.env.CHAT_GUEST_SECRET, // guest()를 쓸 때만
})chat.token(input): string
네트워크를 타지 않는 로컬 서명이다.
| 필드 | |
|---|---|
| userId | 필수. JWT sub가 된다. 당신 서비스의 유저 id |
| name | 필수. 접속할 때마다 이 값이 이긴다 |
| avatar, meta | 선택 |
| ttlSeconds | 기본 1시간, 최대 24시간. 넘으면 자르지 않고 거절한다 |
chat.guest({credential?, name, avatar?, meta?, ttlSeconds?})
로그인하지 않은 방문자의 신원. {userId, credential, token, created}를 돌려준다.
네트워크도 저장소도 쓰지 않는다.
app.post('/api/chat-token', (req, res) => {
if (req.user) return res.json({ token: chat.token({ userId: req.user.id, name: req.user.name }) })
const g = chat.guest({ credential: req.cookies.chat_guest, name: '손님' })
res.cookie('chat_guest', g.credential, { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 400 * 864e5 })
res.json({ token: g.token })
})브라우저가 돌려준 userId를 그대로 서명하지 않는다. guest()는 브라우저가 id 대신
자격증명을 들게 한다 — 검증되면 그 안의 id로, 없거나 틀리면 새 게스트(created: true)로
서명한다. 가능하면 httpOnly 쿠키에 둔다.
| | |
|---|---|
| guestSecret | 32자 이상. 백엔드에만 둔다. sk_에서 유도하지 않는다 |
| 교체 | 배열로 준다: [새것, 옛것]. 첫 번째로 서명하고 전부로 검증한다. 옛것으로 검증된 자격증명은 같은 userId로 새로 서명해 돌려주므로 다음 방문에 옮겨 간다 |
게스트 id는 g_로 시작한다. 로그인 유저의 id를 g_로 시작하지 않게 하면 겹치지 않는다.
나머지
| 그룹 | 메서드 |
|---|---|
| rooms | list({type?, cursor?, limit?}), ensure({key?, type, name?, meta?, members?}), ensureDM([a, b]), get, update, delete, custom(roomId, payload), presence(roomId, {full?}), members.list/add/remove |
| messages | send(roomId, {sender, text?, kind?, attachments?, appMeta?, replyTo?, threadId?, clientMessageId?}), list, delete |
| users | list({cursor?, limit?}), update(userId, {name?, avatar?, meta?}), withdraw(userId, {purgeMessages?}), purgeMessages(userId), ban(userId, {until, reason?}), unban(userId), revokeTokens(userId), disconnect(userId, {connectionId?, reason?}) |
| events | list({after?, limit?}) — 놓친 웹훅 이벤트를 따라잡는다 |
| webhooks | verify(headers, rawBody, {now?, tolerance?}) — tolerance는 밀리초 |
rooms.ensure는 get-or-create다 — 있으면 그대로 돌려주므로 페이지를 열 때마다 불러도 된다.
rooms.list는 앱의 방을 최신순으로 준다. type이 없으면 공개 목록(public·channel)이고,
type: ['private', 'dm']처럼 주면 비공개 방과 DM도 나온다. limit 기본 50 최대 100, 빈 cursor가
마지막 페이지이고, 커서는 type을 싣지 않으므로 필터를 바꾸면 처음부터 받는다.
rooms.presence(roomId)는 {count}이고, {full: true}면 users(최대 1000명, 잘리면
capped: true)가 붙는다. 빈 방은 404가 아니라 {count: 0}이다.
rooms.custom(roomId, payload)은 저장되지 않는 제어 신호다. payload는 JSON 객체이고
그대로 room.on('custom', ...)에 도착한다. 히스토리도 unread도 웹훅도 남지 않으니, 재접속해도
받아야 하는 신호는 messages.send(roomId, {kind:'system', ...})으로 보낸다.
users.ban의 until은 unix 밀리초이고 필수다. 무기한 밴은 먼 미래 시각을 직접 고른다.
users.purgeMessages(userId)는 그 유저의 메시지를 거두되 탈퇴시키지 않는다. 한 번에
상한만큼만 하므로 purgeCapped가 true인 동안 다시 부르고, 스팸이면 밴을 먼저 한다.
await chat.users.ban(userId, { until: Date.now() + 7 * 86_400_000, reason: 'spam' })
while ((await chat.users.purgeMessages(userId)).purgeCapped) {}users.list는 최신순이고 limit 기본 50 최대 100, cursor는 불투명 문자열이며 빈
문자열이 마지막 페이지다. 탈퇴한 유저도 나오고 deletedAt이 그 표시다.
users.*는 그 사람이 한 번이라도 접속한 뒤에만 통한다 — 유저는 접속으로 생긴다.
웹훅
app.post('/chat-hook', express.raw({ type: 'application/json' }), (req, res) => {
const { event, delivery, data } = chat.webhooks.verify(req.headers, req.body.toString('utf8'))
if (seen.has(delivery)) return res.sendStatus(200) // at-least-once다
res.sendStatus(200) // 먼저 접수하고
void handle(event, data) // 처리는 비동기로
})raw 바디여야 한다. 파싱했다가 다시 직렬화한 것은 다른 문자열이라 검증에 실패한다.
세 가지를 지킨다. verify가 서명과 시각(기본 5분)을 보고, 중복 제거는 직접 해야 하며
(delivery로), 10초 안에 2xx로 답해야 한다. 넘기면 실패로 치고 백오프가 시작된다.
다른 언어로 검증하는 규격은 웹훅 문서에 있다.
