RTM (signaling) client for Node.js wrapping the Agora RTM C SDK via koffi FFI — no native compilation required.
- Pure-JS FFI binding via koffi — no
node-gyp, no build toolchain - Works on macOS and Linux
- Includes SDK auto-install flow for native libs
- Supports multiple clients per process via
worker_threads
- macOS arm64
- Linux x64
- Node.js 18+
No C++ compiler or build tools needed.
npm installCommonJS:
const { createRtmClient, RtmConfig } = require('rtm_nodejs');ESM:
import { createRtmClient, RtmConfig } from 'rtm_nodejs';import { createRtmClient, RtmConfig, SubscribeOptions } from 'rtm_nodejs';
const client = await createRtmClient(
new RtmConfig({ appId: 'YOUR_APP_ID', userId: '1001' })
);
client.on('message', (e) => {
console.log('incoming', e.channelName, e.publisher, e.message);
});
client.on('linkState', (e) => {
console.log('link state', e.previousState, '→', e.currentState);
});
await client.login('');
await client.subscribe('test-channel', new SubscribeOptions({ withMessage: true }));
await client.sendChannelMessage('test-channel', 'hello from node');The native SDK limits each process to one reliable client. To run multiple clients in a single process, use createWorkerRtmClient which runs each additional client in its own worker_thread:
import { createRtmClient, createWorkerRtmClient } from 'rtm_nodejs';
// First client — runs in the main thread (as before)
const client1 = await createRtmClient({ appId: APP_ID, userId: 'user1' });
// Second client — runs in a worker thread
const client2 = await createWorkerRtmClient({ appId: APP_ID, userId: 'user2' });
// Same API from here
client2.on('message', (e) => console.log(e));
await client2.login('');The WorkerRTMClient API mirrors RTMClient with two differences:
createStreamChannel(name)is async (returnsPromise<WorkerStreamChannel>)getVersion()/getErrorReason(code)are async (returnsPromise<string>)
See examples/rtm_multi_client.js for a full working example.
Set your app ID, then run:
export AGORA_APP_ID=your_app_id_here
npm run exampleCustom user IDs:
AGORA_APP_ID=your_app_id_here node examples/rtm_basic.js 1001 1002The demo:
- logs in
- subscribes
test-channel - sends one channel message
- sends one peer message
- waits and prints incoming messages
- cleanly logs out on
Ctrl+C
This package wraps Agora RTM native SDK 2.2.8.
The native libs are auto-assembled on first use:
- The Agora RTM SDK (
libAgoraRtmKit.dylib/libagora_rtm_sdk.sopluslibaosl) is downloaded from Agora's CDN. - The matching C ABI shim (
libagora_rtm_sdk_c.{dylib,so}) for your platform is copied from the npm package'sprebuilds/directory. The shim is built from the C++ source innative/and translates the koffi-friendly C ABI used by this package into calls to Agora's C++ SDK.
Default behavior:
- Native libs land in
agora_rtm/agora/agora_sdk - A signature is stored in
agora_rtm/agora/version.txtso re-installs don't re-download
Overrides:
AGORA_SDK_LIBRARY_DIR: use an existing local SDK directory (skip auto-download)AGORA_SDK_URL: force a specific SDK zip URL
Files placed in agora_sdk/:
- macOS:
libagora_rtm_sdk_c.dylib(shim) +libAgoraRtmKit.dylib+libaosl.dylib - Linux:
libagora_rtm_sdk_c.so(shim) +libagora_rtm_sdk.so+libaosl.so
If you're cloning the repo for development, prebuilt shims live under prebuilds/<platform>-<arch>/. To rebuild for your host:
# macOS — build from the host
cd native && cmake -S . -B build && cmake --build build
# Linux x64 — uses Docker
bash native/scripts/build-linux.sh x64CI (.github/workflows/build-shim.yml) produces all three platform shims on tagged releases and uploads them as the prebuilds-all artifact.
config:RtmConfigoptions.libraryDir?: explicit SDK lib directory- returns:
Promise<RTMClient>
config:RtmConfig(plain object —eventHandleris ignored)options.libraryDir?: explicit SDK lib directoryoptions.timeout?: RPC timeout in ms (default10000)- returns:
Promise<WorkerRTMClient>
Main fields:
appId,userIduseStringUserId(truefor string IDs,falsefor numeric IDs; defaulttrue)areaCode:RtmAreaCodevalue (defaultRTM_AREA_CODE_GLOB)logConfig,proxyConfig,encryptionConfig,privateConfig
RTMClient extends EventEmitter. Listen with client.on(event, handler).
| Event | Handler signature | Description |
|---|---|---|
'message' |
(event) |
Incoming message on a subscribed channel or from a user |
'presence' |
(event) |
Presence change in a subscribed channel |
'topic' |
(event) |
Topic publisher change in a stream channel |
'lock' |
(event) |
Lock state change in a subscribed channel |
'storage' |
(event) |
Metadata change in a subscribed channel or user |
'linkState' |
(event) |
SDK connection state changed |
All async methods return a Promise that resolves on success or rejects with an RtmError on failure.
| Method | Resolves with | Description |
|---|---|---|
login(token) |
void |
Log in to RTM |
logout() |
void |
Log out |
renewToken(token) |
{ serviceType, channelName } |
Renew auth token |
publish(target, message, options?) |
void |
Publish to a channel or user |
sendChannelMessage(channel, message) |
void |
Publish string message to a message channel |
sendUserMessage(userId, message) |
void |
Send string message to a specific user |
subscribe(channel, options?) |
void |
Subscribe to a channel |
unsubscribe(channel) |
void |
Unsubscribe from a channel |
getPresence() |
RTMPresence | null |
Get the presence interface |
getStorage() |
RTMStorage | null |
Get the storage interface |
getLock() |
RTMLock | null |
Get the lock interface |
getHistory() |
RTMHistory | null |
Get the history interface |
createStreamChannel(name) |
RTMStreamChannel | null |
Create a stream channel instance |
getVersion() |
string |
SDK version string |
getErrorReason(code) |
string |
Human-readable error description |
setParameters(json) |
number (sync) |
Apply a private SDK parameter (JSON string). Returns 0 on success. Must be called before login() for parameters that affect the login handshake. |
release() |
— | Destroy the client and free resources |
Synchronous escape hatch for SDK parameters that don't have a typed wrapper. Pass a JSON string. Returns 0 on success, negative on failure. Must be called before login() if the parameter affects the login handshake.
Currently known opt-in flag, both verified end-to-end:
// Allow '.' in channel names (default SDK behavior rejects them).
client.setParameters('{"rtm.extend_channel_name": true}');
await client.login('');
await client.subscribe('any.dotted.channel', new SubscribeOptions({ withMessage: true }));Notes:
- The flag relaxes only the dot rule for channel names.
*,/,\,\0, non-printable ASCII, and leading underscore are still rejected. - The flag has no effect on user IDs — those continue to follow the default character ruleset.
setParameterskeys are private SDK parameters: not part of the public Agora API and may change in future SDK releases.- Multiple keys can be combined in one JSON object:
'{"rtm.extend_channel_name": true, "rtm.channel.join_limit": [5000, 20]}'.
See docs/DOTTED_NAMES_REPORT.md for the empirical test matrix.
Rejected promises throw RtmError (extends Error) with a .code property containing the numeric SDK error code.
try {
await client.login('');
} catch (err) {
if (err instanceof RtmError) {
console.error(err.code, err.message);
}
}Obtained via client.getPresence(). All methods return a Promise.
| Method | Resolves with | Description |
|---|---|---|
whoNow(channelName, channelType, options?) |
{ userStateList, nextPage } |
Query users currently in a channel |
whereNow(userId) |
{ channels } |
Query channels a user is in |
getOnlineUsers(channelName, channelType, options?) |
{ userStateList, nextPage } |
Get paginated list of online users |
getUserChannels(userId) |
{ channels } |
Get all channels a user has joined |
setState(channelName, channelType, items) |
void |
Set user state key/value pairs |
removeState(channelName, channelType, keys) |
void |
Remove user state keys |
getState(userId, channelName, channelType) |
{ state } |
Get state of a specific user |
channelType defaults to RtmChannelType.RTM_CHANNEL_TYPE_MESSAGE.
options for whoNow / getOnlineUsers (PresenceOptions / GetOnlineUsersOptions):
includeUserId— boolean (defaulttrue)includeState— boolean (defaultfalse)page— string pagination token (default'')
items for setState: [{ key: string, value: string }]
keys for removeState: string[]
Classes / config:
RtmConfig, RtmLogConfig, RtmProxyConfig, RtmPrivateConfig, RtmEncryptionConfig,
PublishOptions, SubscribeOptions, PresenceOptions, GetOnlineUsersOptions,
MetadataOptions, MetadataItem, JoinChannelOptions, JoinTopicOptions, TopicMessageOptions,
GetHistoryMessagesOptions, IRtmEventHandler
Client classes:
RTMClient, WorkerRTMClient, RTMPresence, RTMStorage, RTMLock, RTMHistory, RTMStreamChannel, RtmError
Enums:
RtmAreaCode, RtmLogLevel, RtmEncryptionMode, RtmServiceType,
RtmChannelType, RtmMessageType, RtmStorageType, RtmStorageEventType,
RtmLockEventType, RtmProxyType, RtmTopicEventType, RtmPresenceEventType,
RtmLinkState, RtmLinkOperation, RtmLinkStateChangeReason, RtmErrorCode
Helper:
enumNameByValue(enumObj, value, fallback?)
- Verify
appId,userId, and token policy - Make sure both clients use the same channel string
- Ensure network is available to Agora endpoints
Pre-create the log file path you configured, or disable file logging:
logConfig: new RtmLogConfig({ filePath: '' })export AGORA_SDK_LIBRARY_DIR=/absolute/path/to/libsMIT