Protocol (@loopchat/protocol)

The wire-protocol package: WebSocket event shapes, REST payloads, push/webhook envelopes, error codes, and cid helpers. It is the source of truth consumed by the server and all SDKs, and it is kept in lockstep with the Dart models in flutter-sdk.

  • Repo: protocol/ — TypeScript, zero runtime dependencies.
  • Consumption: the server and js-sdk vendor a prebuilt copy at vendor/protocol (refresh with pnpm sync-protocol from a sibling checkout). @loopchat/core re-exports the whole package, so app code never depends on @loopchat/protocol directly.
  • Build: pnpm install && pnpm build (plain tsc).

Core entities

Type Fields
UserObject id (required), name?, image?, role?, custom?, last_active_at?, created_at?, online?
ChannelObject cid ("${type}:${id}"), id, type, name?, created_by_id?, custom?, frozen, member_count?, last_message_at? (nullable), created_at
MemberObject user_id, user?, role ('member' | 'moderator' | 'owner'), created_at
MessageObject id (ULID — lexicographically sortable by creation time), cid, user_id, user?, text, attachments[], custom?, created_at, updated_at, deleted_at?
AttachmentObject type?, title?, image_url?, asset_url?, thumb_url?, mime_type?, file_size?, custom?
ReadStateObject user_id, last_read_message_id (nullable), unread_count
DeviceObject token, provider ('firebase'), created_at
ChannelStateObject channel, messages[], members[], read[] — the full channel state returned by watch / queryChannels with state=true

WebSocket events

EVENT_TYPES (and the EventType union): health.check, message.new, message.updated, message.deleted, member.added, member.removed, channel.created, channel.deleted, notification.message_new.

All events extend BaseEvent:

interface BaseEvent {
  type: EventType;
  event_id?: string;      // monotonic per-app ULID, used for reconnect replay
  cid?: string;
  channel_id?: string;
  channel_type?: string;
  user?: UserObject;
  created_at: string;
}

Concrete event shapes (WSEvent is the union of all four):

Event Extra fields
HealthCheckEvent connection_id; on the initial hello also me = UserObject & { total_unread_count, unread_channels, channel_reads[] }
MessageEvent (message.new / message.updated / message.deleted / notification.message_new) message, plus for new-message events the requesting user's unread_count? and total_unread_count?
MemberEvent (member.added / member.removed) member
ChannelEvent (channel.created / channel.deleted) channel?

See API — WebSocket for delivery semantics (who receives what, replay, keepalive).

REST payload types

Type Used by Shape
UpsertUserRequest POST /users { id, name?, image?, role?, custom? }
CreateChannelRequest POST /channels/:type { id, name?, members?, created_by_id, custom? }
AddMembersRequest POST /channels/:type/:id/members { members: string[], create_if_missing?: { name?, created_by_id, custom? } } — idempotent; create_if_missing creates the channel instead of failing
SendMessageRequest POST /channels/:type/:id/messages { message: { id?, text, attachments?, custom? } }
QueryChannelsRequest GET /channels { filter, sort?, limit?, offset?, state?, watch?, connection_id?, message_limit? }
QueryChannelsFilter { members?: { $in: string[] }, type?, id? (string | { $in }), cid? (string | { $in }) }
SortOption { field: 'last_message_at' | 'created_at' | 'member_count', direction: 1 | -1 }
WatchChannelRequest POST /channels/:type/:id/watch { connection_id?, data?: { name?, members?, custom? }, message_limit? }data enables lazy client-side channel creation
MarkReadRequest POST /channels/:type/:id/read { message_id? }
AddDeviceRequest POST /devices { token, provider: 'firebase' }

Push payload

PushPayload — the FCM data message shape (all values are sent as strings over FCM):

{
  type: 'message.new',
  cid: string,
  channel_id: string,
  channel_type: string,
  message_id: string,
  sender_id: string,
  sender_name: string,
  text_preview: string,   // first 140 chars
}

Webhook envelope

interface WebhookEnvelope {
  delivery_id: string;   // unique — use for idempotent handling on the receiver
  app_id: string;
  event: WSEvent;
  sent_at: string;
}

WEBHOOK_SIGNATURE_HEADER = x-loopchat-signature — an HMAC-SHA256 hex digest of the raw request body, keyed by the webhook's secret. See API — Push and Webhooks.

Error codes

ERROR_CODES (stable numeric values):

Constant Code Meaning
INTERNAL 1 Unhandled server error
VALIDATION_FAILED 4 Input failed validation
AUTH_FAILED 5 Token missing / invalid / expired
RATE_LIMITED 9 Rate limited
CHANNEL_NOT_FOUND 16 Channel does not exist
NOT_ALLOWED 17 Authenticated but not allowed (e.g. not a channel member)
USER_NOT_FOUND 18 User does not exist
MESSAGE_NOT_FOUND 19 Message does not exist
PLAN_LIMIT_EXCEEDED 97 Plan limit exceeded
OWNER_SUSPENDED 98 Project/account suspended (wire name kept for compatibility)
APP_SUSPENDED 99 App suspended

Errors are returned as APIError: { code, message, status } (where status is the HTTP status).

Helpers

makeCid(type, id)   // => "type:id"
parseCid(cid)       // => { type, id } (throws on malformed input)