@dotwire/client
@dotwire/client is the member-facing SDK for browsers and JavaScript runtimes. It connects to the dotwire hub over WebSockets, subscribes to rooms, sends messages, loads history and participants, and handles typing and presence. It requires Node.js 20 or later when you run it outside a browser.
For React, use @dotwire/react, which wraps this package.
Install
npm install @dotwire/clientQuickstart
import { DotwireClient } from '@dotwire/client';
const client = new DotwireClient({
baseUrl: 'https://chat.yourdomain.com',
tokenUrl: '/api/auth/token', // GET, credentials included, expects `{ token }`
automaticReconnect: true,
autoStopTypingMs: 3000,
typingThrottleMs: 1000
});
const room = client.room('a0000000-0000-0000-0000-000000000001');
await room.subscribe();
const unsubMessage = room.onMessage((message) => {
renderMessage(message);
});
const ack = await room.sendMessage('Hello, world!');
const history = await room.getHistory({ limit: 50 });
const participantIds = await room.getParticipants();
unsubMessage();
room.dispose();
await client.disconnect();
renderMessage stands for your own UI code. tokenUrl points at the token endpoint your host exposes. See Integrate a host app.
Options
Pass these to new DotwireClient(options). You must set either tokenUrl or getAccessToken.
| Option | Default | Description |
|---|---|---|
baseUrl | Required | dotwire's base URL. |
tokenUrl | — | Host token endpoint. Called with GET and credentials: 'include'; must return { token }. The SDK caches and refreshes the token. |
getAccessToken | — | Alternative to tokenUrl. Returns a token; you own caching and refresh. |
tokenRefreshMarginMs | 30000 | How long before expiry a cached token is refreshed. |
sendUrl | — | A host route for sends. When set, sendMessage posts { roomId, content } there with credentials: 'include' and no bearer header. See Moderation webhooks. |
hubPath | /hub/rooms | Hub path on baseUrl. |
transport | 'websockets' | 'websockets' skips negotiation. 'negotiate' uses SignalR negotiation. |
serverTimeoutMs | 90000 | SignalR server timeout. |
keepAliveIntervalMs | 15000 | SignalR client keep-alive interval. |
automaticReconnect | true | Reconnect automatically and resubscribe rooms. |
gapFillOnReconnect | true | After a reconnect, fetch missed messages by sequence. |
gapFillPageSize | 100 | Page size for gap-fill requests. |
requestTimeoutMs | 15000 | Timeout for HTTP requests. |
autoStopTypingMs | 3000 | How long after the last notifyTyping() the SDK sends "stopped typing". |
typingThrottleMs | 1000 | Minimum interval between typing notifications sent to the server. |
logLevel | 'none' | 'none', 'error', 'warning', 'information', or 'debug'. |
Room handles
Everything room-specific goes through client.room(roomId), which returns a DotwireRoomHandle.
const room = client.room('room-uuid');
// Connect & join group
await room.subscribe();
// Leave room
await room.unsubscribe();
// Clean up all room listeners and auto-unsubscribe
room.dispose();
subscribe({ history: n }) also fetches the latest n messages, delivers each one through onMessage, and returns that page.
History
// Fetch latest 50 messages
const latest = await room.getHistory({ limit: 50 });
// Paginate older messages before a sequence number
const older = await room.getHistory({
beforeSeq: latest.messages[0].seq,
limit: 50
});
// Replay missed messages after a reconnection
const missed = await room.getHistory({
afterSeq: lastKnownSeq,
limit: 100
});
You don't need to replay manually after an automatic reconnect: with gapFillOnReconnect on, the SDK pages getHistory({ afterSeq }) itself and delivers each missed message through onMessage with replayed: true. The SDK also deduplicates by seq, using a bounded set of the last 1,000 sequence numbers per room, so each message fires onMessage once.
Typing
function onUserKeystroke() {
room.notifyTyping();
}
function onUserSubmit() {
room.stopTyping();
}
const unsubTyping = room.onTypingWithExpiry((event) => {
if (event.isTyping) {
showTypingIndicator(event.userId);
} else {
hideTypingIndicator(event.userId);
}
}, 3500);
notifyTyping() throttles what it sends and stops automatically after a period of inactivity. onTypingWithExpiry treats a user as stopped if no update arrives within the given time.
Presence
const unsubPresence = room.onPresence((delta) => {
console.log('Joined:', delta.joined);
console.log('Left:', delta.left);
});
Presence arrives as coalesced deltas. There is no snapshot, so you see only changes after you subscribe. For current membership, use getParticipants(). See Participants and presence.
System messages
room.onSystemMessage((message) => {
showSystemBanner(message.content);
});
room.onUserMessage((message) => {
appendUserChatMessage(message);
});
A message counts as a system message when its sender id is system, or starts with system: or bot:.
API
DotwireClient
| Member | Returns | Description |
|---|---|---|
room(roomId) | DotwireRoomHandle | A handle for one room. |
connect() | Promise<void> | Opens the hub connection. |
disconnect() | Promise<void> | Closes the connection and leaves all rooms. |
getConnectionState() | ConnectionState | 'disconnected', 'connecting', 'connected', 'reconnecting', or 'disconnecting'. |
onMessage(handler) | () => void | Messages from every subscribed room. |
onSystemMessage(handler), onUserMessage(handler) | () => void | System messages only, or user messages only. |
onMessageRetracted(handler) | () => void | Redacted messages. |
onMembershipRevoked(handler) | () => void | Fires when the user is removed from a room. The room is unsubscribed automatically. |
onTyping(handler), onPresence(handler) | () => void | Typing and presence events from every subscribed room. |
onStateChange(handler) | () => void | Connection state changes. |
dispose() | void | Disposes every room handle and disconnects. |
Every on… method returns a function that removes the listener.
DotwireRoomHandle
| Member | Returns | Description |
|---|---|---|
subscribe(options?) | Promise<HistoryPage | undefined> | Joins the room. With { history: n }, also loads and delivers the latest n messages. |
unsubscribe() | Promise<void> | Leaves the room. |
sendMessage(content) | Promise<SendAck> | Sends a message. Resolves with { seq, time } once JetStream accepts it. |
getHistory(options) | Promise<HistoryPage> | One page of history: { messages, hasMore }. Takes afterSeq, beforeSeq, limit, and timeoutMs. |
getParticipants(timeoutMs?) | Promise<string[]> | Member user ids, sorted. |
notifyTyping() | Promise<void> | Throttled typing signal with automatic stop. |
startTyping(), stopTyping() | Promise<void> | Explicit typing state. |
onMessage, onSystemMessage, onUserMessage, onMessageRetracted, onMembershipRevoked, onTyping, onPresence | () => void | Room-scoped versions of the client listeners. |
onTypingWithExpiry(handler, ms) | () => void | Typing listener that resets a user after ms without updates. |
release() | void | Cancels timers and removes listeners without leaving the room, for handles that share a subscription. |
dispose() | void | release(), then leaves the room. |
Message shape
interface RoomMessage {
roomId: string;
seq: number;
senderId: string;
time: string;
content: string;
replayed?: boolean;
}
replayed is true only for messages delivered by reconnect gap-fill.
Errors
Failed requests reject with a DotwireClientError with status, kind, and optional code, reason, and retryAfterMs. kind is one of BadRequest, Unauthorized, Forbidden, NotFound, PresendRejected, RateLimited, Unavailable, or Unknown, the same kinds the host SDKs use. code and reason come from the error body.
Related
- Hub events: the wire protocol under this SDK.
@dotwire/react