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-sdkvendor a prebuilt copy atvendor/protocol(refresh withpnpm sync-protocolfrom a sibling checkout).@loopchat/corere-exports the whole package, so app code never depends on@loopchat/protocoldirectly. - Build:
pnpm install && pnpm build(plaintsc).
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)