Skip to content
Clone
e33d6e5
431 lines 17.0 KB
/**
* Wire protocol of the (removed) chat WebSocket endpoint (`GET /ws/v1`, im gateway).
*
* 已过时:`app/im` 已从 KenApi 删除,实时通道归 `KenEvent`(Socket.IO)。
* 文件保留为旧协议的对照说明;按 KenEvent 重写时以
* `KenEvent/src/events/action/common.rs::payload` 为准(信封 = topic/tag/
* properties/body,没有 `subscribe` 帧)。
*
* This file is a 1:1 mirror of the Rust definitions in `gitdataai/app/im/src/frames.rs` and `lib/chat/src/share/realtime.rs`:
* src/ws/frames.rs -> ClientFrame / ServerFrame
* src/share/realtime.rs -> RoomEvent (`event` frame payloads)
* src/share/notify.rs + src/services/* -> the ad-hoc JSON on the user channel
* (`user_event` frame payloads)
*
* Serde rules this file encodes:
* - Client frames are internally tagged by `op` with `rename_all = "snake_case"`
* (frames.rs:6-7); server frames by `type` (frames.rs:29-30); the payloads
* carried by `event` / `user_event` by `kind` (realtime.rs:19-20).
* - Rust `Option<T>` fields are *always* serialised (`null` when absent) because
* the backend never uses `skip_serializing_if`, so those fields are
* `T | null` (required) here rather than `?: T`. The one exception is
* `mention.everyone`, which is built with `json!` and only appears on the
* @everyone fanout (notify.rs:231-237).
* - `Uuid` crosses as a string, `i64`/`u64` as a JSON number, `OffsetDateTime`
* as an RFC 3339 string.
* - `PresenceStatus` carries **no** `rename_all` (models/chat/presence_states.rs:7-31),
* so it is PascalCase on the wire, unlike every other chat enum. Reuse the
* generated type instead of hand-writing the variants.
*/
import type { MessageDto, NotificationType, PresenceStatus } from '@app/services/api'
/* --------------------------------------------------------------------------
* Connection lifecycle (client-side only; never on the wire)
* ------------------------------------------------------------------------ */
/**
* Connection lifecycle reported by `ChatWsClient.onStatus`.
* - `auth_required` / `failed` are terminal: the client stops reconnecting and
* needs an explicit `start()` (after re-login / after a manual retry).
*/
export type WsStatus = 'idle' | 'connecting' | 'open' | 'reconnecting' | 'closed' | 'auth_required' | 'failed'
/* --------------------------------------------------------------------------
* Server knobs that leak into the protocol
* (config/default.toml:143-163 + lib/config/src/chat.rs:1-23,60-101)
* ------------------------------------------------------------------------ */
/**
* Defaults of the `[chat]` / `[chat.ws]` config sections. They are *advisory*:
* a deployment can change them, so the client uses them as pre-flight limits and
* still treats the server's `error` / `resync_required` frames as authoritative.
*/
export const WS_SERVER_DEFAULTS = {
/** Lifetime of a `GET /auth/jwt-request` JWT; single-use (im gateway SETNX jti). */
jwtTtlSecs: 60,
/** Server tick interval; an idle socket gets `bye` after 3x (ws/mod.rs:220-224,310-315). */
heartbeatSecs: 30,
/** Max room subscriptions per socket (ws/mod.rs:228,410-419). */
maxSubscriptions: 64,
/** Inbound frame limit set on the socket (ws/mod.rs:141). */
maxFrameBytes: 262_144,
/** `history` / `resync` page size (ws/mod.rs:470,516). */
historyLimit: 200,
/** TTL carried by `typing` events, floor of 1s (ws/mod.rs:545). */
typingTtlSecs: 5,
/** Hard-coded per-socket typing throttle, not configurable (ws/mod.rs:534-538). */
typingThrottleSecs: 3,
} as const
/* --------------------------------------------------------------------------
* Client -> server frames (ws/frames.rs:6-27)
* ------------------------------------------------------------------------ */
export const WS_CLIENT_OPS = ['subscribe', 'unsubscribe', 'resync', 'typing', 'ping'] as const
/**
* `Subscribe { room_id, resync }`. `resync` is `Option<bool>` and the server
* does `resync.unwrap_or(true)` (frames.rs:9-13, ws/mod.rs:485), so omitting it
* asks for a `history` snapshot of the newest `history_limit` messages.
*/
export interface WsSubscribeFrame {
op: 'subscribe'
room_id: string
resync?: boolean
}
/** `Unsubscribe { room_id }` (frames.rs:14-16). */
export interface WsUnsubscribeFrame {
op: 'unsubscribe'
room_id: string
}
/**
* `Resync { room_id, after_seq }`. The reply is a `history` frame built with
* `seq > after_seq` ordered **ascending** (ws/mod.rs:505-531 +
* services/messages.rs:677-687), i.e. it walks forward from the cursor and is
* truncated at `history_limit`; `resync(room, 0)` therefore returns the *oldest*
* messages of the room, not the newest.
*/
export interface WsResyncFrame {
op: 'resync'
room_id: string
after_seq: number
}
/**
* `Typing { room_id, thread_id }`. Requires read access, is throttled to one
* broadcast per 3s per socket, and is re-broadcast to the room as
* `event{kind:'typing'}` (ws/mod.rs:533-549).
*/
export interface WsTypingFrame {
op: 'typing'
room_id: string
thread_id?: string | null
}
/** `Ping` -> the server answers `{type:'pong'}` (frames.rs:26, ws/mod.rs:550-552). */
export interface WsPingFrame {
op: 'ping'
}
export type ClientFrame = WsSubscribeFrame | WsUnsubscribeFrame | WsResyncFrame | WsTypingFrame | WsPingFrame
/* --------------------------------------------------------------------------
* Message / reaction payloads
* ------------------------------------------------------------------------ */
/**
* `MessageDto` (frames.rs:45 for `history`, realtime.rs:25/30 for events).
* Aliased to the generated type so it stays in sync with `bun run gen:api`.
*
* IMPORTANT: the backend serialises this DTO from the **actor's** point of view
* (`build_message_dto(.., caller)` — messages.rs:557-561 for publishes,
* :864-870 for edits, unfurl.rs:319-332 even uses `Uuid::nil()`), so per-viewer
* fields such as `reactions[].me` must not be trusted from a WS frame. The
* backend proves it knows this by stripping `me` from `reaction_updated`
* (services/reactions.rs:148-156).
*/
export type WsMessage = MessageDto
/** Viewer-neutral reaction aggregate sent by `reaction_updated`. */
export interface WsReactionSummary {
emoji: string
count: number
}
/* --------------------------------------------------------------------------
* Room broadcasts (`event` frames) (share/realtime.rs:19-74)
* ------------------------------------------------------------------------ */
export const WS_ROOM_EVENT_KINDS = [
'message_published',
'message_edited',
'message_deleted',
'messages_deleted',
'message_pinned',
'reaction_updated',
'typing',
'voice_presence',
'voice_hands',
'voice_layout',
'voice_moderation',
'room_updated',
'member_joined',
'member_left',
'thread_updated',
] as const
/**
* The 11 `RoomEvent` variants. `seq` exists on exactly the five `message*`
* variants (realtime.rs:103-110): `message_published` allocates a fresh room seq
* (`next_room_seq`, notify.rs:17-33) while edits/pins/deletes reuse the *current
* tip* (`current_room_seq`, notify.rs:37-45) so they never inflate unread
* counters or reorder listings. `reaction_updated` has no seq at all.
*/
export type RoomEvent =
| { kind: 'message_published'; room_id: string; seq: number; message: WsMessage }
| { kind: 'message_edited'; room_id: string; seq: number; message: WsMessage }
| { kind: 'message_deleted'; room_id: string; seq: number; message_id: string }
| { kind: 'messages_deleted'; room_id: string; seq: number; message_ids: string[] }
| {
kind: 'message_pinned'
room_id: string
seq: number
message_id: string
pinned: boolean
}
| {
kind: 'reaction_updated'
room_id: string
message_id: string
reactions: WsReactionSummary[]
}
| {
kind: 'typing'
room_id: string
user_id: string
thread_id: string | null
/** Discard the indicator this many seconds after it was seen. */
ttl_secs: number
}
| { kind: 'voice_presence'; room_id: string }
| { kind: 'voice_hands'; room_id: string }
| { kind: 'voice_layout'; room_id: string }
| { kind: 'voice_moderation'; room_id: string; user_id: string; action: string }
| { kind: 'room_updated'; room_id: string }
| { kind: 'member_joined'; room_id: string; user_id: string }
| { kind: 'member_left'; room_id: string; user_id: string }
| { kind: 'thread_updated'; room_id: string; thread_id: string }
/**
* Finite-number read for seq comparisons: a `seq` that is not a JSON number must
* never enter a cursor, because `NaN` would break every later comparison.
*/
export function asSeq(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value) ? value : undefined
}
/** Typing payload for the client-side registry (TTL already resolved to ms). */
export interface WsTypingIndicator {
room_id: string
user_id: string
thread_id: string | null
/** `Date.now()` based expiry stamp. */
expires_at: number
/** `Date.now()` stamp of the last refresh (indicators are refreshed, not re-added). */
updated_at: number
}
/** `RoomEvent::seq` when the variant carries one (realtime.rs:103-110). */
export function roomEventSeq(event: RoomEvent | null | undefined): number | undefined {
if (!event) return undefined
if (!('seq' in event)) return undefined
// Guard against drift: a non-numeric seq would poison every cursor comparison.
return asSeq(event.seq)
}
/* --------------------------------------------------------------------------
* Per-user pushes (`user_event` frames)
* ------------------------------------------------------------------------ */
export const WS_USER_EVENT_KINDS = [
'mention',
'presence',
'read_marker',
'reminder',
'room_added',
'notification',
] as const
/**
* The six payloads published on `chat:user:<uid>` (realtime.rs:15-17). They are
* hand-built `json!` objects, not a Rust enum, so this union is the only typed
* description of them:
* - `mention`: one-arg form per direct mention (messages.rs:591-596 and the edit
* path messages.rs:881-887); `everyone: true` only on the @everyone fanout
* (notify.rs:231-237).
* - `presence`: published to the acting user only (presence.rs:94-99), so it
* mirrors *your* status across tabs — a teammate's presence still has to come
* from `GET /chat/spaces/{space_name}/presences`.
* - `read_marker`: only when the marker actually advanced, and
* `last_read_message_id` is `null` when room-level state was advanced without a
* message id (read_state.rs:86-98 + notify.rs:301-326).
* - `reminder`: fired by the reminder worker (reminders.rs:251-258).
* - `room_added`: pushed to the user who just joined a room, next to the room's
* `member_joined` broadcast (rooms.rs:656-666).
* - `notification`: one row of `user_notifications` written by any crate
* (chat mentions/reminders/invites, issue replies/assignments, space invites);
* `export::notify::notification_event_json` wraps `Model::to_event_value()`,
* which mirrors the `GET /me/notifications` DTO field-for-field.
*/
export type UserEvent =
| { kind: 'mention'; room_id: string; message_id: string; everyone?: boolean }
| { kind: 'presence'; status: PresenceStatus }
| {
kind: 'read_marker'
room_id: string
last_read_message_id: string | null
last_read_seq: number
}
| { kind: 'reminder'; room_id: string; message_id: string }
| { kind: 'room_added'; room_id: string }
| { kind: 'notification'; notification: WsNotification }
/** The embedded payload of a `notification` user event (`Model::to_event_value`). */
export interface WsNotification {
id: string
notification_type: NotificationType
title: string
body: string | null
data: unknown
actor_id: string | null
read_at: string | null
created_at: string
}
export function isNotificationUserEvent(event: UserEvent): event is {
kind: 'notification'
notification: WsNotification
} {
return event.kind === 'notification'
}
/* --------------------------------------------------------------------------
* Server -> client frames (ws/frames.rs:29-63)
* ------------------------------------------------------------------------ */
export const WS_SERVER_FRAME_TYPES = [
'ready',
'event',
'user_event',
'history',
'gap',
'resync_required',
'error',
'pong',
'bye',
] as const
/** `resync_required.reason`; the backend uses a plain `String` (frames.rs:52-55). */
export const WS_RESYNC_REASONS = ['lagged', 'access revoked'] as const
/** `error.message`; plain `String` with four fixed producers (see below). */
export const WS_ERROR_MESSAGES = [
'invalid frame', // ws/mod.rs:256-260 - JSON did not deserialize as ClientFrame
'subscription limit reached', // ws/mod.rs:410-419 - max_subscriptions per socket
'forbidden', // ws/mod.rs:420-429 - require_room_readable failed
'subscribe failed', // ws/mod.rs:430-442 - redis pubsub subscribe failed
] as const
/** `bye.message`; sent right before a server-initiated close (ws/mod.rs:577-587). */
export const WS_BYE_MESSAGES = [
'internal error', // ws/mod.rs:183-188 - user channel subscribe failed
'idle timeout', // ws/mod.rs:310-315 - silent for heartbeat_secs * 3
] as const
/**
* Literal union kept for autocomplete, widened so a backend that adds a new
* reason/message cannot break the client's types.
*/
type OpenString<T extends readonly string[]> = T[number] | (string & Record<never, never>)
export type WsResyncReason = OpenString<typeof WS_RESYNC_REASONS>
export type WsErrorMessage = OpenString<typeof WS_ERROR_MESSAGES>
export type WsByeMessage = OpenString<typeof WS_BYE_MESSAGES>
/**
* The 9 `ServerFrame` variants. `event` is typed `| null` on purpose: the server
* holds it as `serde_json::Value` and substitutes `Value::Null` when the redis
* payload cannot be parsed (frames.rs:36-41, ws/mod.rs:279-283,299-305).
*/
export type ServerFrame =
/** First frame after the upgrade; `session_id` is a fresh v7 uuid per socket. */
| { type: 'ready'; user_id: string; session_id: string }
| { type: 'event'; room_id: string; event: RoomEvent | null }
| { type: 'user_event'; event: UserEvent | null }
| { type: 'history'; room_id: string; messages: WsMessage[] }
/** Stream order broke: messages `from..to` were dropped (ws/mod.rs:366-387). */
| { type: 'gap'; room_id: string; from: number; to: number }
| { type: 'resync_required'; room_id: string; reason: WsResyncReason }
| { type: 'error'; message: WsErrorMessage }
| { type: 'pong' }
| { type: 'bye'; message: WsByeMessage }
/** Coarse classification of `error.message` (the frame carries no room_id). */
export type WsServerErrorKind = 'invalid_frame' | 'subscription_limit' | 'forbidden' | 'subscribe_failed' | 'unknown'
export function classifyWsError(message: string): WsServerErrorKind {
switch (message) {
case 'invalid frame':
return 'invalid_frame'
case 'subscription limit reached':
return 'subscription_limit'
case 'forbidden':
return 'forbidden'
case 'subscribe failed':
return 'subscribe_failed'
default:
return 'unknown'
}
}
/** Errors that can only be produced by a `subscribe` frame (best-effort attribution). */
export function isSubscribeError(kind: WsServerErrorKind): boolean {
return kind === 'forbidden' || kind === 'subscription_limit' || kind === 'subscribe_failed'
}
/* --------------------------------------------------------------------------
* Runtime parsing
* ------------------------------------------------------------------------ */
/**
* Validate a parsed JSON value as a `ServerFrame`.
*
* Unknown `type` discriminants return `null` so callers can surface drift
* instead of casting it into the union. `event` / `user_event` payloads that are
* `null` or lack a string `kind` are normalised to `event: null`, which is the
* same state the backend produces for an unparseable redis payload.
*/
export function asServerFrame(value: unknown): ServerFrame | null {
if (typeof value !== 'object' || value === null) return null
const candidate = value as Record<string, unknown>
if (typeof candidate.type !== 'string') return null
if (!WS_SERVER_FRAME_TYPES.includes(candidate.type as (typeof WS_SERVER_FRAME_TYPES)[number])) {
return null
}
if (candidate.type === 'history' && !Array.isArray(candidate.messages)) return null
if (candidate.type === 'event' || candidate.type === 'user_event') {
const event = candidate.event
if (typeof event !== 'object' || event === null || typeof (event as { kind?: unknown }).kind !== 'string') {
return { ...candidate, type: candidate.type, event: null } as unknown as ServerFrame
}
}
return value as ServerFrame
}
/**
* Parse one inbound frame. The backend only ever writes text frames
* (`Message::Text`, ws/mod.rs:566-575), so non-string payloads are rejected.
*/
export function parseServerFrame(data: unknown): ServerFrame | null {
if (typeof data !== 'string') return null
let parsed: unknown
try {
parsed = JSON.parse(data)
} catch {
return null
}
return asServerFrame(parsed)
}