One package covers both transports: Realtime holds a WebSocket for live subscriptions, Rest makes stateless HTTPS calls. Both ship TypeScript types.
npm install @foony/realtime
Realtime constructor
import { Realtime } from '@foony/realtime';
const realtime = new Realtime({
authCallback: async () => fetchTokenFromYourBackend(),
});
| Option | Type | Default | Meaning |
|---|---|---|---|
key |
string |
API key, for servers and trusted contexts | |
token |
string |
Static JWT, for scripts and local dev | |
authCallback |
() => Promise<string> | string |
Returns a fresh JWT, called on connect and re-auth | |
endpoint |
string |
realtime.foony.io |
Host or full wss:// URL |
clientId |
string |
Identity attached to key-auth connections | |
autoReconnect |
boolean |
true |
Reconnect with exponential backoff, 1 s to 30 s |
queueMessages |
boolean |
true |
Buffer publishes while disconnected |
batch |
BatchOptions |
Auto-batching defaults for all channels |
Exactly one of key, token, or authCallback is required.
Connection
realtime.connection.on((state, reason) => {
console.log(state, reason?.message);
});
await realtime.connection.once('connected');
console.log(realtime.getConnectionId(), realtime.getClientId());
await realtime.close();
States move through initialized, connecting, connected, disconnected, closing, closed, and failed. On reconnect the SDK re-attaches subscribed channels, re-enters presence it had entered, flushes queued publishes, and backfills any missed messages. Resent publishes carry stable ids, so the service deduplicates them.
Channels
const channel = realtime.channels.get('chat:lobby');
realtime.channels.release('chat:lobby');
get returns a stable instance per name and accepts { cipher, batch } options. release detaches and forgets the channel. Names are 1 to 255 characters from A-Z a-z 0-9 : - _, colons for hierarchy, no leading colon.
Messages
channel.subscribe(listener);
channel.subscribe('typing', listener);
channel.subscribe(['added', 'removed'], listener);
channel.unsubscribe();
await channel.publish('chat', { text: 'hello' });
await channel.publish([{ name: 'a', data: 1 }, { name: 'b', data: 2 }]);
await channel.publish('cursor', { x, y }, { ephemeral: true });
channel.flush();
See Publish and subscribe for batching, ephemeral semantics, and the message object fields.
History
const { messages, more } = await channel.history({ limit: 50, before: oldestSerial });
Oldest first, with before as an exclusive serial cursor for paging backward. Details in History.
Presence
channel.presence.on(listener);
channel.presence.on('leave', listener);
await channel.presence.enter({ name: 'Acorn' });
await channel.presence.update({ name: 'Acorn', busy: true });
await channel.presence.leave();
Listeners receive the current members as enter events first, then live transitions. Details in Presence.
Channel lifecycle
await channel.attach();
await channel.detach();
console.log(channel.state);
channel.on((change) => {
console.log(change.current);
});
Subscribing attaches implicitly, so attach is only for warming a channel early. States are initialized, attaching, attached, detaching, detached, suspended, and failed.
End-to-end encryption
import { generateRandomKey } from '@foony/realtime';
const key = await generateRandomKey();
const channel = realtime.channels.get('secure:room', { cipher: { key } });
await channel.publish('chat', { text: 'only we can read this' });
Payloads are AES-GCM encrypted before they leave your process and the service only ever sees ciphertext. See Encryption for keys, what stays plaintext, and reading encrypted history.
Rest client
import { Rest } from '@foony/realtime';
const rest = new Rest({ key: process.env.FOONY_API_KEY! });
await rest.channels.get('chat:lobby').publish('chat', { text: 'from a function' });
const history = await rest.channels.get('chat:lobby').history({ limit: 100 });
const presence = await rest.channels.get('chat:lobby').presence.get();
const details = await rest.auth.requestToken({ clientId: 'user-123' });
const serverTime = await rest.time();
Read methods return paginated results with items, next(), hasNext(), and isLast(). Failures throw RestError with the numeric code and HTTP statusCode from the error table.
Errors
Error codes are exported as the ErrorCode object, so handlers can compare against named constants instead of magic numbers:
import { ErrorCode } from '@foony/realtime';
channel.publish('chat', data).catch((error) => {
if (error.code === ErrorCode.Capability) {
showUpgradePrompt();
}
});