/** * 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` 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` 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:` (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[number] | (string & Record) export type WsResyncReason = OpenString export type WsErrorMessage = OpenString export type WsByeMessage = OpenString /** * 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 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) }