JS SDK — Core (@loopchat/core)
The framework-agnostic TypeScript client SDK — the vanilla-JS foundation the React and React Native packages wrap. Runs in browsers, Node ≥ 22, and React Native. Zero runtime dependencies (the protocol package is inlined at build time).
- Repo:
js-sdk/packages/core(pnpm workspacejs-sdk/). - Build: tsup → ESM + CJS +
.d.ts.sideEffects: false. - Re-exports: the entire
@loopchat/protocolsurface, so app code imports every type from@loopchat/core.
import { LoopChatClient, Filter } from '@loopchat/core';
const client = new LoopChatClient('lck_yourApiKey', { baseUrl: 'https://chat.example.com' });
// Token comes from YOUR backend (@loopchat/node's createToken).
// client.devToken('user-id') works for apps with dev mode enabled.
await client.connectUser({ id: 'thierry', name: 'Thierry' }, token);
const channel = client.channel('messaging', 'pool-abc', { name: 'Pool ABC' });
await channel.watch(); // state + live events; 403/code 17 => not a member
await channel.sendMessage({ text: 'hello!' });
channel.state.messages.subscribe(() => render(channel.state.messages.get()));
const channels = await client.queryChannels(
Filter.in('members', [client.state.currentUser.get()!.id]),
[{ field: 'last_message_at' }],
{ limit: 30, watch: true },
);
LoopChatClient
new LoopChatClient(apiKey: string, opts?: LoopChatClientOptions)
LoopChatClientOptions: baseUrl? (default http://localhost:8080), logger?, fetchImpl? (injectable fetch for tests/exotic runtimes), WebSocketImpl? (injectable WebSocket — Node < 22 needs one).
Connection
| Member | Description |
|---|---|
connectUser(user, token): Promise<UserObject> |
Opens the WebSocket and resolves on the first health.check (which carries connection_id + unread state). Also best-effort syncs the profile via PATCH /users/me when name/image/custom are provided. |
disconnectUser(): Promise<void> |
Closes the socket, clears token, channels, and state. |
close(): void |
Tears the client down (socket + subscriptions). |
devToken(userId): string |
Dev token (JWT shape, signature devtoken). Server accepts it only for apps with dev mode enabled. |
connectionStatus: Store<ConnectionStatus> |
Reactive store: 'connecting' | 'connected' | 'disconnected'. |
wsConnectionStatus / connectionId |
Current status / current WS connection id (or null). |
Channels
| Member | Description |
|---|---|
channel(type, id, extraData?): Channel |
Returns a cached Channel reference. extraData ({ name?, members?, ...custom }) is used as creation data when watch() lazily creates the channel. |
queryChannels(filter, sort?, opts?): Promise<Channel[]> |
GET /channels. opts: limit (default 30), offset, state (default true), watch (subscribe the current connection to each result), messageLimit (default 25). Returned channels have their state applied. |
Users and devices
| Member | Description |
|---|---|
updateUser(partialUser): Promise<UserObject> |
PATCH /users/me — update own name/image/custom. |
addDevice(token, provider = 'firebase') |
Register an FCM device token. |
removeDevice(token) / getDevices() |
Unregister / list this user's devices. |
Events
client.on(event => ...) // all events
client.on('message.new', event => ...) // one type
// both return { unsubscribe }
Incoming events update client state (unread counts), get routed to the matching cached Channel, and channel.deleted evicts the channel from the cache.
client.state: ClientState
Reactive client-level state, all fields are Stores: currentUser (UserObject | null), totalUnreadCount (number), unreadByCid (record keyed by cid), plus unreadCountFor(cid). Unread counts are seeded from the connect-time health.check me payload and updated live from message.new / notification.message_new events (unread_count field) and channel.deleted.
Channel
Obtained via client.channel(type, id). Properties: type, id, cid ("type:id"), name, extraData, state.
| Method | Description |
|---|---|
watch({ messageLimit? }) |
POST …/watch — loads full channel state and subscribes this WS connection to the channel's events. Lazily creates the channel from extraData when it doesn't exist. Throws LoopChatNetworkError with status 403 / code 17 when the user is not a member of an existing channel. Default message limit 50. |
stopWatching() |
Unsubscribes this connection (no-op when not connected). |
sendMessage({ text, id?, attachments?, custom? }) |
POST …/messages. The server assigns a ULID id and echoes a message.new event; the REST response and WS echo are deduped by id. |
updateMessage(messageId, partial) |
PUT …/messages/:id — own messages only (server-enforced). |
deleteMessage(messageId, { hard? }) |
DELETE …/messages/:id (soft by default). |
query({ before?, limit? }) |
Loads older history (before the oldest loaded message by default; limit default 50) and prepends it. |
markRead() |
POST …/read — marks read up to the newest message and zeroes local unread state. |
on(handler) / on(type, handler) |
Channel-scoped event subscription; returns { unsubscribe }. |
channel.state: ChannelState
Reactive channel state, each field a Store: channel (ChannelObject | null), messages (readonly array, ULID-ordered), members, unreadCount. Mutators (addMessage, updateMessage, removeMessage, prependMessages) dedupe by message id and keep ULID order. Live events (message.*, member.*) are applied automatically; own messages never bump the unread count.
Store<T>
The minimal reactive primitive that keeps the React/RN packages thin:
store.get(): T // stable snapshot until next set
store.set(next) / store.update(fn) // notify subscribers (Object.is no-op check)
store.subscribe(listener): () => void
The (subscribe, get) pair is directly compatible with React's useSyncExternalStore(store.subscribe, store.get).
Filter and sorting
Filter.in('members', [userId]) // { members: { $in: [userId] } }
Filter.equal('type', 'messaging') // { type: 'messaging' }
Filter.and(a, b) // shallow merge (v1 filters are conjunctive by shape)
Filter.empty()
SortOption: { field: 'last_message_at' | 'created_at' | 'member_count', direction?: 1 | -1 } — direction defaults to -1 (descending); serializeSort fills the default before sending.
Errors — LoopChatNetworkError
Thrown for non-2xx REST responses and WS auth failures:
status— HTTP status,code— TheLoopChat protocol error code (see API Reference).isNotMember— convenience getter forstatus 403 && code 17: the connected user is not a member of an existing channel — the self-heal trigger (fix with the server SDK's idempotentaddMembers).
WebSocket internals — LoopChatWebSocket
Exported for advanced use; LoopChatClient manages one internally.
- Connects to
<baseUrl with ws(s) scheme>/connect?api_key=…&token=…[&last_event_id=…]. connect(token)resolves with the firsthealth.checkevent; aconnection.errorframe rejects with a 401LoopChatNetworkError.- Keepalive: sends
{"type":"health.check"}every 25s (configurable viahealthCheckIntervalMs). - Auto-reconnect with exponential backoff + jitter (500ms base, 30s cap, 250ms jitter), passing
last_event_idso the server replays missed events. - Tracks
connectionStatus(Store) andconnectionId;disconnect()stops reconnection,dispose()also clears listeners. - Runtime abstraction: uses the global
WebSocket(browser, Node ≥ 22, RN) unlessWebSocketImplis injected.WebSocketLike/WebSocketConstructordescribe the required WHATWG subset.
HTTP internals — HttpClient
Thin REST client used by LoopChatClient and Channel. All chat calls are client-authenticated: x-api-key + authorization: Bearer <user token> headers, JSON bodies, query-string helpers, and non-2xx responses become LoopChatNetworkError.fromBody(status, json). fetchImpl is injectable (FetchLike).
Exports summary
LoopChatClient, ClientState, Channel, ChannelState, Filter, serializeSort, LoopChatNetworkError, Store, devToken, LoopChatWebSocket, plus types (LoopChatClientOptions, QueryChannelsOptions, ChannelExtraData, SendMessageInput, FilterObject, SortOption, SortField, ClientEvent, ConnectionErrorEvent, ConnectionStatus, WebSocketConstructor, WebSocketLike, FetchLike) and everything from @loopchat/protocol.