internset-messaging-platform-sdk
v0.15.0
Published
TypeScript client SDK for the Messaging Platform — REST + WebSocket wrapper with auto-reconnect, auto-resync, and message dedupe.
Readme
internset-messaging-platform-sdk
TypeScript client SDK for the Messaging Platform — a thin, batteries-included wrapper over its REST API and realtime WebSocket. Handles auth token refresh, auto-reconnect, gap resync after sleep/offline, message dedupe, offline queueing, and attachment URL renewal so your app doesn't have to.
Works in the browser and in Node (WebSocket realtime requires a socket.io v4 compatible environment).
Install
npm install internset-messaging-platform-sdkQuick start
Your backend exchanges its API key for short-lived user tokens (server-side,
POST /v1/auth/user-tokens with X-Api-Key). The browser only ever sees user
tokens — never the API key.
import { MessagingClient } from 'internset-messaging-platform-sdk';
const client = new MessagingClient({
baseUrl: 'https://api.yourapp.com/chat', // platform origin, or a reverse-proxy path
getToken: async () => {
const res = await fetch('/api/messaging/token'); // your backend endpoint
const { token } = await res.json();
return token;
},
lifecycle: true, // reconnect + resync on tab focus / network regain
});
await client.connect();
client.on('message:created', (message) => {
console.log('new message', message);
});
const conversation = await client.createConversation({
type: 'direct',
memberIds: [otherMessagingUserId],
});
await client.sendMessage(conversation.id, {
content: { text: 'Hello!' },
});baseUrl may include a path prefix (e.g. the platform hidden behind
https://api.yourapp.com/chat) — the SDK derives the WebSocket engine path
from it automatically. Override with wsUrl / wsPath if your proxy layout
is unusual.
Client options
| Option | Default | Purpose |
| --- | --- | --- |
| baseUrl | — | Platform base URL (origin or origin + path prefix) |
| getToken | — | Returns a user token; called on connect and re-auth |
| refreshToken | — | Returns a fresh token (bypass caches); on a 401 the SDK calls it and retries the failed REST call / socket connect once |
| lifecycle | off | true (or options) wakes the client on focus/online |
| queueOfflineSends | true | Queue sends/edits/deletes/reactions made offline |
| dedupeCacheSize | 10000 | Seen-message-ID cache size |
| persistDedupe | browser: true | Persist the dedupe cache in storage |
| wakeMaxAttempts | 3 | Reconnect attempts per wake |
| onBeforeWake | — | Hook to clear cached tokens before a reconnect |
| wsUrl / wsPath | derived | Override WebSocket origin / engine path |
| attachmentUrlRenewSkewMs | 60s | Renew signed URLs this long before expiry |
Realtime events
client.on(event, handler) returns an unsubscribe function.
message:created,message:updated,message:deleted,message:restoredmessage:reaction— reactions / saved-by changes (metadata patch)message:delivered,message:read— peer receipt cursorsmessage:queued,message:sent,message:send-failed— offline outbox lifecycleoperation:queued,operation:failed— offline edit/delete/react/save lifecycletyping:started,typing:stopped,presence:changedconversation:created,conversation:updated,member:changedconnected,disconnected,connection:failed,sync:completed,error
Offline behaviour
With queueOfflineSends (default on):
sendMessagewhile offline returns a pending message (metadata.pending === true, id prefixedpending:) and emitsmessage:queued. On reconnect the queue flushes in order — each item emitsmessage:sent(with the real message) ormessage:send-failed.editMessage/deleteMessage/reactToMessage/toggleSaveMessagewhile offline throwOfflineQueuedError(catch it to keep your optimistic UI) and replay after reconnect.client.getQueuedMessages(conversationId?)returns pending messages so a thread can render unsent bubbles after a reload;client.queuedSendCountis the queue size.- The queue is persisted in
localStorage, so it survives page reloads, tab closes, and browser restarts; entries older than 24h are dropped. Flushing the same item from two tabs is safe — the platform is idempotent perclientMessageId. - Failed sends can be retried by calling
sendMessageagain with the sameclientMessageId— the platform is idempotent per(conversation, sender, clientMessageId).
After sleep or connection loss the client resyncs automatically: it asks the
platform for everything after its last-known sequence numbers and replays the
gap through message:created, then emits sync:completed.
In-app notifications
The platform doubles as a generic notification service — usable together with messaging or entirely on its own. Your backend sends; the browser lists and listens:
// Backend (API key) — recipients are YOUR user ids; unknown ones are provisioned
await server.sendNotification({
recipients: [userId],
type: 'ORDER_SHIPPED', // free string — you define your types
title: 'Your order has shipped',
body: 'Arriving Thursday.',
href: '/orders/123', // your app's route, navigated on click
metadata: { orderId: '123' },
});
// Browser (user token)
const page = await client.getNotifications({ limit: 20 }); // CursorPage
const { count } = await client.getUnreadNotificationCount();
await client.markNotificationRead(id);
await client.markAllNotificationsRead();
await client.deleteNotification(id);
const n = await client.getNotificationByMetadata('orderId', '123'); // or null
client.on('notification:created', (notification) => { /* toast + badge */ });
client.on('notification:sync', ({ unreadCount }) => { /* badge correction */ });Reliability matches messages: stored in the platform DB, delivered live over
the same socket, and after every reconnect (laptop wake, network regain) the
SDK automatically emits a notification:sync with the authoritative unread
count — no refresh needed. React: useUnreadNotificationCount(client).
Withdrawing server-side: server.deleteNotificationsByMetadata({ type?,
metadataKey, metadataValue }).
Inbox controller
InboxController maintains a live conversation list (ordering, unread counts,
last-message previews, presence) on top of a client — subscribe once and
render its snapshots.
import { InboxController } from 'internset-messaging-platform-sdk';
const inbox = new InboxController(client);
inbox.attach(); // wires client events
const unsubscribe = inbox.subscribe((snapshot) => {
render(snapshot.conversations, snapshot.lastMessagePreview);
});
await inbox.refreshInbox();Concurrent refreshInbox calls are coalesced; non-forced refreshes are
throttled (default 60s, inboxRefreshMinIntervalMs).
Server-side usage (Node / Next.js)
Both clients work server-side on Node >= 18 (global fetch).
User scope — the regular MessagingClient works in route handlers,
server actions, and server components exactly like in the browser (browser
conveniences like the offline queue and tab-lifecycle wake simply switch
off). Use it with a user token to prefetch conversations for SSR. Calling
REST methods does not require connect() — the WebSocket is only opened
when you ask for it.
API-key scope — MessagingServerClient is for the things only a server
may do, starting with minting user tokens. It refuses to run in a browser.
// app/api/messaging/token/route.ts (Next.js route handler)
import { MessagingServerClient } from 'internset-messaging-platform-sdk/server';
const server = new MessagingServerClient({
baseUrl: process.env.MESSAGING_PLATFORM_URL!, // e.g. http://127.0.0.1:6100
apiKey: process.env.MESSAGING_PLATFORM_API_KEY!,
});
export async function GET() {
const me = await getAuthenticatedUser(); // your own auth
const result = await server.issueUserToken({
externalUserId: me.id,
displayName: me.name,
avatarUrl: me.avatarUrl,
});
return Response.json(result); // { token, expiresIn, user }
}Also available: upsertUser, getUserByExternalId, and a generic
request(method, path, body) escape hatch for the rest of the API-key
surface (e.g. /v1/admin/* moderation endpoints). Keep the API key in
server env only — never expose it with a NEXT_PUBLIC_ prefix.
React
import {
useInbox,
useMessagingConnection,
useMessagingEvent,
useTotalUnread,
} from 'internset-messaging-platform-sdk/react';
function Badge({ controller }) {
const unread = useTotalUnread(controller);
return unread > 0 ? <span>{unread}</span> : null;
}Requires react >= 18 (optional peer dependency).
Attachments
One attachment per message, uploaded straight from the browser to storage via a presigned URL — the file never passes through your servers.
const attachment = await client.uploadAttachment(conversationId, file);
await client.sendAttachmentMessage(conversationId, attachment);
// Rendering: signed download URLs are cached and renewed automatically.
const url = await client.getAttachmentUrl({
objectKey: attachment.objectKey,
fileName: attachment.fileName,
});Validation helpers: validateAttachment(file),
ALLOWED_ATTACHMENT_EXTENSIONS, ATTACHMENT_MAX_BYTES, ATTACHMENT_ACCEPT.
Messages
await client.editMessage(messageId, { text: 'edited' }); // within the modify window
await client.deleteMessage(messageId); // soft delete
await client.reactToMessage(messageId, '👍'); // toggles per user
await client.toggleSaveMessage(messageId); // bookmark
await client.markAsRead(conversationId, upToSeq);
const results = await client.searchMessages('hello', { conversationId });
await client.reportMessage(messageId, { reason: 'harassment' });Contract helpers exported for UI logic: canEditMessage, canModifyMessage
(15-minute window), getMessageReactions, isMessageSavedByUser,
getMessageReceiptStatus, sortThreadMessages, maxMessageSeq,
REPORT_REASONS, and more — see dist/index.d.ts for the full surface.
Errors
ApiError— the platform rejected the request (hasstatusandcode).OfflineQueuedError— the request never reached the platform but was queued for replay (err.queued === true).formatMessagingError(err)— human-readable message for either.
