SDK · JavaScript/TypeScript
Isomorphic TypeScript client SDK for the imcore IM server. Standard WebSocket + JSON envelope; zero runtime dependencies.
Versioning & compatibility: this SDK (current release v1.10.0) is versioned on its own line (
clients/SDK_VERSION), independent of the imcore server release tag. It targets the imcore wire protocol as of server v1.2.0; because the protocol is additive/forward-compatible, it works with any server ≥ v1.2.0.
Install
npm install @imcore/sdk
# Node also needs a WebSocket implementation:
npm install ws
Electron apps use the same package through the isolated
@imcore/sdk/electron/main and @imcore/sdk/electron/renderer entry points.
See the Electron guide for the secure main/preload/renderer setup.
React Native apps use @imcore/sdk/react-native for AsyncStorage persistence,
AppState/NetInfo lifecycle handling, and native file uploads. See the
React Native guide.
微信小程序使用 @imcore/sdk/wechat-miniprogram,内置 wx.connectSocket、
异步存储、前后台/网络生命周期及 wx.uploadFile 适配。参见
微信小程序接入指南。
Usage
import { ImcoreClient } from '@imcore/sdk';
// Browser: native WebSocket is used automatically. React Native apps should
// use the dedicated @imcore/sdk/react-native entry documented below.
// Node: pass a WebSocket implementation.
// import WebSocket from 'ws';
const im = new ImcoreClient({
url: 'wss://your-host/acc',
auth: { token: '<jwt>', login: { userID: '1001', userName: 'alice' } },
// webSocketImpl: WebSocket, // Node: pass a WebSocket constructor
// requestTimeoutMs: 10000, // default: 10 000 ms per request
// successCode: 200, // default: 200 (server success code)
// reconnect: { initialDelayMs: 500, maxDelayMs: 15000, jitter: true },
// heartbeatIntervalMs: 25000, // default: 25 000 ms between heartbeats
// connectTimeoutMs: 10000, // DNS/TCP/TLS/upgrade budget
// heartbeatTimeoutMs: 75000, // reconnect after no inbound frames
// timers: ..., // injectable timer API (useful in tests)
// logger: console, // default: console; pass a custom logger
// maxRequestBytes: 262144, // local pre-send size check → RequestError 1011; <=0 disables
// fetchImpl: fetch, // HTTP adapter for limits/media in non-browser runtimes
});
im.onStatus((s) => console.log('status', s)); // idle|connecting|open|authenticated|closed|reauth_required
im.on('chat_message', (m) => console.log('msg', m));
im.on('reconnected', () => {
// re-pull conversation lists + cursor history here (SDK does not auto-reconcile in v1)
});
// connect() resolves only after the server acknowledges login (code 200).
// It REJECTS if the server refuses login (e.g. bad token), so use try/catch:
try {
await im.connect();
} catch (err) {
console.error('login refused', err); // err is a RequestError with .code set
}
// Explicitly refresh the server's effective limits when the deployment or
// tenant configuration may differ from SDK defaults. If maxRequestBytes was
// omitted above, this also updates the local pre-send frame guard.
const limits = await im.refreshMessageLimits();
await im.dm.send({ targetUserID: '1002', text: 'hi' });
const history = await im.dm.history({ targetUserID: '1002', limit: 20 });
const page = await im.dm.historyPage({ targetUserID: '1002', limit: 20 });
// Older: historyPage({ targetUserID: '1002', beforeID: page.nextCursor })
// Newer/reconnect reconciliation: historyPage({ targetUserID: '1002', afterID: page.prevCursor })
// Offline-safe send: queues while disconnected, adds tempID for server-side
// idempotency, and can be flushed after reconnect/auth.
const queued = await im.dm.sendQueued({ targetUserID: '1002', text: 'works offline' });
if (queued.status === 'queued') {
console.log('queued tempID', queued.tempID);
}
await im.offline.flush();
// Local cache is updated from live pushes and successful send acks.
const messages = await im.cache.getMessages('dm:1001:1002');
const conversations = await im.cache.getConversations();
// Register platform push tokens obtained from APNs/FCM/vendor SDKs.
await im.push.register({
deviceID: 'ios-001',
platform: 'ios',
vendor: 'apns',
pushToken: '<device-token>',
appID: 'com.example.app',
});
// Escape hatch for any command not yet wrapped:
await im.request('chat_group_create', { name: 'team' });
im.send('chat_dm_typing', { targetUserID: '1002' });
Reconnect catch-up(重连补拉)
reconnected fires on every successful reconnect (not the first connect()).
The SDK does not auto-reconcile state across a reconnect gap — a typical
handler re-pulls what changed while offline:
im.on('reconnected', async () => {
// 1. Re-pull conversation lists — labels/mute/unread may have changed while offline.
const [dms, groups, rooms] = await Promise.all([
im.dm.conversations(),
im.group.conversations(),
im.room.conversations(),
]);
// 2. For conversations still open in the UI, backfill with the cursor you
// remembered from the last rendered page — afterID, not beforeID.
const page = await im.dm.historyPage({ targetUserID: '1002', afterID: lastKnownCursor });
// 3. Multi-device: pull the cross-device conversation-state snapshot too.
await im.operations.deviceSyncState();
});
historyPage results do not get written into im.cache — reconciling
paged history against the local cache is the app’s responsibility.
Conversations you’ve been removed from (e.g. kicked from a group) are already
purged from the cache by the removed push as it arrives, and the server’s
own conversation list won’t include them again after reconnect either — there
is nothing left to reconcile for those.
Customer-service workspace
Customer-service commands have typed request/response DTOs under
im.customerService:
const workspace = await im.customerService.workspaceBootstrap();
const routing = await im.customerService.transfer({
targetUserID: 'customer-1001',
toAgentUserID: 'agent-2002',
note: 'Billing specialist requested',
});
const profile = await im.customerService.profileGet({ targetUserID: 'customer-1001' });
await im.customerService.noteAdd({ targetUserID: 'customer-1001', body: 'Requested a refund' });
await im.customerService.claimGroup({ groupID: 7, customerUserID: 'customer-1001' });
const off = im.customerService.onDMRoutingChanged((event) => {
console.log('routing changed', event.conversation_id, event.routing_mode);
});
off();
AI streaming (customer-service bots)
Send a DM to a bot user (bot:<id>) and aggregate its streamed reply:
const handle = im.ai.ask(
{ targetUserID: 'bot:cs', text: '退款怎么弄' },
{
onDelta: (messageID, fullSoFar) => render(fullSoFar),
onDone: (messageID, fullText) => finalize(fullText),
onError: (messageID, code, detail) => showError(code, detail),
},
);
// handle.cancel() stops local aggregation (server generation is unaffected).
// After the first stream frame exposes messageID, request server cancellation:
await im.ai.cancel(messageID);
// Or observe every bot reply (e.g. handover agent seat), optionally per conversation:
const off = im.ai.onReply({
conversationID: 'dm:1001:bot:cs',
onDelta: (m, full) => render(full),
onDone: (m, full) => finalize(full),
onError: (m, code) => showError(code),
});
off();
onDone carries the authoritative fullText; onDelta is for progressive
rendering. Error code is the server’s enum (rate_limited, bot_unavailable,
blocked, …).
ask() is disconnect-resilient: on reconnected it does one history-based
recovery check for the in-flight reply, and if no matching stream frame
arrives within the idle timeout it terminates instead of hanging forever.
ask() correlates stream frames with the original question through
request_message_id. Frames arriving before the chat_dm_send ACK are held
and replayed only after the ACK identifies this request. Older servers without
the field remain compatible through first-frame binding.
const handle = im.ai.ask(
{ targetUserID: 'bot:cs', text: '退款怎么弄', idleTimeoutMs: 15_000 },
{
onDone: (messageID, fullText) => finalize(fullText),
onError: (messageID, code, detail) => {
if (code === 'stream_timeout') return retry();
showError(code, detail);
},
},
);
- Idle timeout:
idleTimeoutMson theask()call (shown above) overrides the client-levelaiStreamIdleTimeoutMspassed tonew ImcoreClient({ ... }); both default to 30000,<= 0disables the timer. If nodelta/done/errorframe arrives before it fires,ask()deliversonError(messageID, 'stream_timeout')and unsubscribes — it no longer hangs indefinitely on a dropped stream. - Reconnect recovery: the bot’s reply is persisted unconditionally
server-side, so on the client’s
reconnectedevent a still-in-flightask()does a one-shot history read and, if the reply already landed while offline, deliversonDone(messageID, fullText, createdAt)with the persisted full text from history — no re-send needed. A miss just resets the idle window and keeps waiting. onReply(the open observer, not tied to a singleask()) gets neither of these — it stays a passive, unbounded subscription.handle.cancel()is local unsubscribe only.im.ai.cancel(messageID)requests server-side cancellation. Acceptance is best-effort:onError(..., 'canceled')is the expected terminal, but a final DM already crossing the persistence boundary remains authoritative and can still finish withonDone.- Before the first stream frame, use the original
chat_dm_sendACK message ID:await im.ai.cancelRequest(requestMessageID). This also covers a queued job.
Relations (friends / blocks / whitelist)
await im.relations.friendAdd('1002', '你好,加个好友'); // may auto-accept → status 'accepted'
const list = await im.relations.friendList(); // { items, userIDs, count }
await im.relations.friendAccept('1002');
await im.relations.blockAdd('1003');
await im.relations.whitelistAdd('1004');
const off = im.relations.onFriendRequestUpdate((req) => {
// req.direction: 'incoming' | 'outgoing'; req.status: 'pending' | 'accepted' | 'rejected' | …
});
off();
Message lifecycle (edit / recall / delete, typing, receipts)
await im.dm.edit({ targetUserID: '1002', messageID: 'm1', text: 'fixed typo' });
await im.dm.recall({ targetUserID: '1002', messageID: 'm1' });
await im.group.delete({ messageID: 'm2' }); // active group
await im.dm.setTyping({ targetUserID: '1002', isTyping: true });
const summaries = await im.group.receipts({ groupID: 7, messageIDs: ['m1'] });
const off = im.dm.onTyping((t) => { /* t.userID, t.isTyping */ });
im.dm.onMutation((m) => { /* m.operation: edit|recall|delete, m.messageID */ });
im.dm.onReadReceipt((r) => { /* r.readerUserID, r.lastReadMessageID */ });
off();
group/room 的 edit/recall/delete/setTyping 作用于当前活跃会话(先 join);发”已读”用既有 markRead。
Message expansion KV (polls / sign-up chains / task status)
给任意消息附加可增改删的 Key/Value:u:<userID>: 前缀的 key 只有本人能写删(投票/接龙的防篡改槽位),其余 key 只有消息发送者能写删(任务状态等)。value 是字符串,结构化数据自行 JSON 编码。
// 投票:发一条“投票”消息,各人把票写进自己的私有槽位
const poll = await im.group.send({ groupID: 7, text: '午饭吃什么?A 火锅 / B 烧烤' });
await im.group.setExpansion({
messageID: String(poll.messageID),
entries: { [`u:${myUserID}:vote`]: 'A' }, // 每人只能写自己的 u:<uid>:*
});
// 计票:客户端遍历 expansions 聚合(服务端不做统计)
im.group.onMutation((m) => {
if (m.operation !== 'expansion') return;
const votes = Object.entries(m.message?.expansions ?? {})
.filter(([k]) => k.endsWith(':vote'))
.map(([, e]) => e.value);
// votes => ['A', 'B', 'A', ...]
});
// 改票 = 再 set(每 key last-write-wins);弃票 = 删除自己的 key
await im.group.setExpansion({ messageID: id, entries: { [`u:${myUserID}:vote`]: 'B' } });
await im.group.deleteExpansion({ messageID: id, keys: [`u:${myUserID}:vote`] });
// 任务状态:公共 key(无 u: 前缀)仅消息发送者可写
await im.dm.setExpansion({ targetUserID: '1002', messageID: id, entries: { 'task.status': 'done' } });
离线端上线后从历史里直接拿到最新 message.expansions(无需补事件);单条消息默认上限 100 个 key、单次最多 20 个。
Group governance
Owner/admin-only calls to manage a group, plus directed-visibility send params.
// Governance (owner/admin)
await im.group.muteAll({ groupID: 7, muted: true });
await im.group.setRole({ groupID: 7, targetUserID: '1002', role: 'admin' });
await im.group.transferOwner({ groupID: 7, targetUserID: '1002' });
await im.group.disband({ groupID: 7 });
// Join requests: apply to join, list pending requests, approve/reject
await im.group.apply({ groupID: 7, message: '想加入' });
const requests = await im.group.joinRequests();
// raw ChatGroupRequestList: { incomingInvites, outgoingInvites, incomingApplies, outgoingApplies }
await im.group.approveRequest({ requestID: 123 });
await im.group.rejectRequest({ requestID: 123 });
await im.group.setHistoryPolicy({ groupID: 7, allowHistoryBeforeJoin: false });
// send()/sendQueued() also accept mention/target params:
await im.group.send({ groupID: 7, text: '@all 通知', mentionAll: true });
await im.group.send({ groupID: 7, text: '@bob', mentionUserIDs: ['1002'] });
await im.group.send({ groupID: 7, text: '仅这几位可见', targetUserIDs: ['1002', '1003'] });
Realtime signaling (RTC)
im.rtc is a signaling-only wrapper around WebRTC call setup: typed send
methods (1-1 call invite / accept / reject / hangup / offer / answer / ICE,
plus multi-party mesh join / signal / leave) and typed observers for the
matching inbound pushes. The SDK never creates an RTCPeerConnection or
touches media — your app owns the peer connection and wires the SDK’s
signals to it.
// Caller
const { callID } = await im.rtc.invite({ targetUserID: '1002', callType: 'video' });
const pc = new RTCPeerConnection(/* your ICE config */);
pc.onicecandidate = (e) => {
if (e.candidate) {
im.rtc.sendIce({
callID: callID!,
targetUserID: '1002',
candidate: e.candidate.candidate,
sdpMid: e.candidate.sdpMid ?? undefined,
sdpMLineIndex: e.candidate.sdpMLineIndex ?? undefined,
});
}
};
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
await im.rtc.sendOffer({ callID: callID!, targetUserID: '1002', callType: 'video', sdp: offer.sdp! });
// Callee / peer
im.rtc.onInvite((s) => { /* ring UI, using s.callID / s.fromUserID */ });
im.rtc.onOffer(async (s) => {
await pc.setRemoteDescription({ type: 'offer', sdp: s.sdp! });
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
await im.rtc.sendAnswer({ callID: s.callID!, targetUserID: s.fromUserID!, callType: 'video', sdp: answer.sdp! });
});
im.rtc.onIceCandidate((s) => pc.addIceCandidate({ candidate: s.candidate, sdpMid: s.sdpMid, sdpMLineIndex: s.sdpMLineIndex }));
await im.rtc.hangup({ callID: callID!, targetUserID: '1002', reason: 'normal' });
Multi-party mesh (room/group) uses the same shape: im.rtc.multiJoin(...) /
im.rtc.multiSignal(...) / im.rtc.multiLeave(...) plus
im.rtc.onMultiState(...) / im.rtc.onMultiSignal(...) — each participant
still runs its own RTCPeerConnection; the SDK only relays the mesh signaling.
Operations (the complete command surface)
im.operations wraps the final 26 server commands that previously required the
generic request() escape hatch: conversation removal/labels, DM policy and AI
memory deletion, multi-device snapshots, presence/status, room governance and
the compatibility room-history command.
await im.operations.setDMPolicy('friends_only');
await im.operations.subscribePresence({ targetUserIDs: ['1002', '1003'] });
await im.operations.setConversationLabels({ scene: 'dm', targetUserID: '1002', labels: ['vip'] });
await im.operations.setRoomAttributes(7, { topic: 'launch' });
await im.operations.setRoomMuteAll(7, true);
// Manual mark-unread (conversation UX trio, feature 1): flip a conversation's
// unread flag without a new message arriving.
await im.operations.markUnread({ scene: 'dm', targetUserID: '1002', unread: true });
Drafts (cross-device compose sync)
im.drafts persists an in-progress compose (text/segments/reply reference)
per conversation server-side, so it follows the user across devices.
await im.drafts.set({ scene: 'dm', targetUserID: '1002', text: '还没发送的草稿' });
await im.drafts.clear({ scene: 'dm', targetUserID: '1002' });
Scheduled do-not-disturb (push quiet hours)
im.push.getDnd() / im.push.setDnd() wrap the server’s daily DND window for
push notifications. The SDK maps to/from the friendly field names shown below;
the wire payload uses dndEnabled/dndStartMinutes/etc.
const dnd = await im.push.getDnd();
await im.push.setDnd({
enabled: true,
startMinutes: 22 * 60, // 22:00
endMinutes: 8 * 60, // 08:00 next day
timezone: 'Asia/Shanghai',
days: 0b1111111, // bitmask, all 7 days
});
Runtime message limits
im.refreshMessageLimits() calls authenticated GET /user/message-limits and
returns the message-limits.v1 snapshot, including frame, text, segment,
attachment, encrypted-message and expansion limits. The call is explicit so
applications control when HTTP is available; it is not part of connect().
When maxRequestBytes is omitted, a successful refresh updates the local
pre-send frame limit. An explicitly configured value always wins, including
<= 0 to disable the local check. HTTP, authentication, business-code or
schema failures reject the refresh and leave the existing local limit intact.
Media upload
im.media.upload sends the file over HTTP while chat operations use WebSocket.
It POSTs to the server’s /user/chat/upload (server-proxied to object
storage) and hands back a ready-to-send segment. The HTTP base URL is derived
from the WebSocket url (ws→http, wss→https); override with httpBaseUrl if
uploads are served elsewhere.
const { segment, asset } = await im.media.upload({
file, // a browser File/Blob
type: 'image',
scene: 'room',
roomID: 42,
});
await im.room.send({ roomID: 42, segments: [segment] }); // segment drops straight in
// DM: pass conversationID, or targetUserID to let the server derive it.
Scope (v1)
Transport core + seq-correlated request/response + typed DM/group/room
send/receive/history, local message/conversation cache, tempID-backed offline
send queue, push device registration, and media upload (im.media).
Not included: E2EE key management, service workers / APNs / FCM token
acquisition, framework bindings, or UI components.
Development
npm install
npm test # unit tests (mock WebSocket, no server needed)
npm run build # ESM + CJS + d.ts
npm run typecheck
# Integration test against a live server:
IMCORE_TEST_WS_URL=wss://host/acc IMCORE_TEST_WS_TOKEN=<jwt> \
IMCORE_TEST_USER_ID=1001 IMCORE_TEST_PEER_ID=1002 npm test
Mint the test token with the repo’s token CLI (same HS256 signing path as the server):
go run ./cmd/demo_token -user 1001 # secret from $IMCORE_AUTH_JWT_SECRET or -secret; issuer/audience default to the demo config
The session-consistency E2E suite (session-consistency.integration.test.ts —
kick eviction, reconnect catch-up recipe) needs a second live user acting
at the same time, so it additionally reads IMCORE_TEST_PEER_TOKEN (a real
token for IMCORE_TEST_PEER_ID, minted the same way: go run ./cmd/demo_token -user 1002); without all five vars (URL/TOKEN/USER_ID/PEER_ID/
PEER_TOKEN) it skips cleanly like the rest of this section.