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 workspace js-sdk/).
  • Build: tsup → ESM + CJS + .d.ts. sideEffects: false.
  • Re-exports: the entire @loopchat/protocol surface, 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 for status 403 && code 17: the connected user is not a member of an existing channel — the self-heal trigger (fix with the server SDK's idempotent addMembers).

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 first health.check event; a connection.error frame rejects with a 401 LoopChatNetworkError.
  • Keepalive: sends {"type":"health.check"} every 25s (configurable via healthCheckIntervalMs).
  • Auto-reconnect with exponential backoff + jitter (500ms base, 30s cap, 250ms jitter), passing last_event_id so the server replays missed events.
  • Tracks connectionStatus (Store) and connectionId; disconnect() stops reconnection, dispose() also clears listeners.
  • Runtime abstraction: uses the global WebSocket (browser, Node ≥ 22, RN) unless WebSocketImpl is injected. WebSocketLike/WebSocketConstructor describe 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.