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: idleTimeoutMs on the ask() call (shown above) overrides the client-level aiStreamIdleTimeoutMs passed to new ImcoreClient({ ... }); both default to 30000, <= 0 disables the timer. If no delta/done/error frame arrives before it fires, ask() delivers onError(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 reconnected event a still-in-flight ask() does a one-shot history read and, if the reply already landed while offline, delivers onDone(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 single ask()) 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 with onDone.
  • Before the first stream frame, use the original chat_dm_send ACK 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.