@wemany/chat-client
v0.3.0
Published
Wemany chat client SDK — WebSocket real-time messaging, E2EE for private channels, multi-device sync. Replaces stream-chat.
Readme
@wemany/chat-client
Wemany chat client SDK — WebSocket real-time messaging + REST history + E2EE
for private channels + multi-device sync. Drop-in replacement for stream-chat
in any Wemany frontend (Next.js web app, Expo mobile app, browser POC, Node).
Status
v0.2 — production usable. WebSocket gateway + REST history + attachments
- voice notes + E2EE (Olm/Megolm) + React hooks + offline queue all shipped.
Distributed only inside the Wemany monorepo / private registry — not on public npm. Requires authenticated access to install.
Install
pnpm add @wemany/chat-client socket.io-client
# react opcional, solo si usas los hooks
pnpm add reactQuickstart — connect with mode
The simplest way: pass mode: 'test' | 'live' and the SDK resolves the
right Wemany cluster URLs for you.
import { createChatClient } from '@wemany/chat-client';
const chat = createChatClient({
mode: process.env.NODE_ENV === 'production' ? 'live' : 'test',
userId: currentUser.id,
deviceId: 'web-chrome-1234',
getAccessToken: async () => fetchFreshJwt(),
onError: (err) => console.error(err),
});
await chat.connect();
const channel = chat.channel('channel-uuid');
await channel.join();
// Subscribe — re-renders whenever the channel state changes
const unsub = channel.subscribe((state) => {
console.log('messages:', state.messages.length);
console.log('typing:', Array.from(state.typing.values()));
});
// Send — returns clientMessageId. The optimistic placeholder is in store
// until the server confirms with `message:confirmed` or `message:new`.
const id = await channel.sendMessage('hola mundo');| mode | WS endpoint | REST API base |
|--------|-------------|---------------|
| 'test' | wss://chat-staging.wemany.com | https://chat-staging.wemany.com |
| 'live' | wss://chat.wemany.com | https://chat.wemany.com |
Pointing at a custom cluster
For local dev or preview environments, pass endpoint + apiBase
directly. They override mode.
const chat = createChatClient({
endpoint: 'ws://127.0.0.1:3000',
apiBase: 'http://127.0.0.1:3000',
userId: '...',
getAccessToken: async () => fetchFreshJwt(),
});Authentication — JWT only
The SDK identifies the user via the JWT returned by getAccessToken().
The same JWT that the rest of the Wemany platform issues — no separate
API key, no second auth layer.
getAccessToken is called on:
- initial
connect() - every reconnect (so token refresh is transparent)
- every REST call from
Channel/fetchChannelMessages/ etc.
If the server emits connection:reauth_required, the SDK refreshes the
token and reconnects automatically.
Server-side validations (free)
Every WebSocket event the SDK emits is validated against:
- JWT signature + expiry (
WsJwtGuardon handshake) - Channel membership (
channelsRepo.getMember()— must be a member) - Mute state (rejects if
member.isMuted) - Per-user rate limit (Redis-backed)
- Per-channel rate limit (prevents flooding)
- Friendship requirement for DMs (must be friends both ways)
- Block / ban check (banned users can't write)
- Mod-only operations (pin, delete-other-message) require role
You don't need to implement any of these client-side. The server returns a
shaped Ack like { ok: false, error: 'not a member of this channel' }
that you can surface to the user.
React hooks
import { createChatClient } from '@wemany/chat-client';
import {
ChatProvider,
useChannel,
useMessages,
useTyping,
usePresence,
useAttachmentUpload,
useVoiceRecorder,
usePinnedMessages,
} from '@wemany/chat-client/react';
const chat = createChatClient({ mode: 'test', userId, getAccessToken });
await chat.connect();
function App() {
return (
<ChatProvider client={chat}>
<ChannelView channelId="abc-123" />
</ChatProvider>
);
}
function ChannelView({ channelId }: { channelId: string }) {
useChannel(channelId);
const { messages, sendMessage, editMessage, deleteMessage } = useMessages(channelId);
const { typingUsers, startTyping, stopTyping } = useTyping(channelId);
const presence = usePresence(messages.map((m) => m.senderId));
return (
<div>
{messages.map((m) => (
<div key={m.id}>
[{presence[m.senderId]?.status ?? 'unknown'}] {m.senderId}:{' '}
{m.content.type === 'plain' ? m.content.text : '🔒'}
{m.optimistic?.status === 'pending' && ' (sending…)'}
</div>
))}
{typingUsers.length > 0 && (
<em>{typingUsers.map((u) => u.name).join(', ')} escribiendo…</em>
)}
<input
onChange={(e) => (e.target.value ? startTyping() : stopTyping())}
onBlur={stopTyping}
onKeyDown={(e) => {
if (e.key === 'Enter') {
sendMessage((e.target as HTMLInputElement).value);
(e.target as HTMLInputElement).value = '';
stopTyping();
}
}}
/>
</div>
);
}Architecture
createChatClient(config)— factory que devuelve el handle principal. Internamente: socket.io-client +ChatStoreobservable +OfflineQueue- opcional
StorageAdapter.
- opcional
Channel— facade per-channel devuelto porchat.channel(id). Métodos:join,leave,sendMessage,editMessage,deleteMessage,addReaction,removeReaction,markRead,startTyping,stopTyping,subscribe.ChatStore— in-memory observable. Listeners por canal + presence. Maneja optimistic placeholders y reconciliación.OfflineQueue— pendientes persistidos cuando socket desconectado, re-emitidos al reconnect. UsaStorageAdapter(default in-memory; pasa un adapter custom para IndexedDB / AsyncStorage / etc).- REST history —
fetchChannelMessages({ apiBase, channelId, before, limit, getAccessToken })paginado vía cursor. Ideal para cargar histórico al abrir un canal. - Attachments —
requestUploadUrl()+uploadFileToS3()flow. El servidor firma una presigned URL S3, el cliente sube directo (no pasa por gateway). - Voice notes —
requestVoiceUploadUrl()+uploadVoiceBlob()con límites duros (MAX_VOICE_DURATION_MS,MAX_VOICE_SIZE_BYTES,VOICE_MIMElista permitida). - E2EE Olm/Megolm —
OlmService+SessionManager+KeyRegistrypara canalesprivate. El servidor solo ve ciphertext. - Hooks React (opcionales) —
useChannel,useMessages,useTyping,usePresence,useAttachmentUpload,useVoiceRecorder,usePinnedMessages. Bindings reactivos al store.
REST history
import { fetchChannelMessages } from '@wemany/chat-client';
const { messages, nextCursor } = await fetchChannelMessages({
apiBase: 'https://chat-staging.wemany.com',
channelId: 'abc-123',
limit: 50,
before: undefined, // first page
getAccessToken: () => fetchFreshJwt(),
});
// Paginate older
const older = await fetchChannelMessages({
apiBase: 'https://chat-staging.wemany.com',
channelId: 'abc-123',
limit: 50,
before: nextCursor,
getAccessToken: () => fetchFreshJwt(),
});When you use mode, you can read the resolved apiBase from the SDK so
you don't have to hard-code:
// Coming soon: chat.endpoints — for now, mirror in the consumer.Attachments
import { requestUploadUrl, uploadFileToS3 } from '@wemany/chat-client';
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
const upload = await requestUploadUrl({
apiBase: 'https://chat-staging.wemany.com',
channelId,
contentType: file.type,
size: file.size,
filename: file.name,
getAccessToken,
});
await uploadFileToS3({ url: upload.uploadUrl, file });
await channel.sendMessage('check this out', {
attachments: [{
id: upload.attachmentId,
type: 'image',
url: upload.publicUrl,
name: file.name,
size: file.size,
mime: file.type,
}],
});The same flow exists for voice notes (requestVoiceUploadUrl / uploadVoiceBlob).
Offline persistence
// Web — IndexedDB via idb-keyval
import { get, set, del, keys } from 'idb-keyval';
import type { StorageAdapter } from '@wemany/chat-client';
const idbAdapter: StorageAdapter = {
getItem: async (k) => (await get(k)) ?? null,
setItem: async (k, v) => { await set(k, v); },
removeItem: async (k) => { await del(k); },
keys: async (prefix) =>
(await keys()).filter((k): k is string => typeof k === 'string' && k.startsWith(prefix)),
};
const chat = createChatClient({ mode: 'test', userId, getAccessToken, storage: idbAdapter });// Expo — AsyncStorage
import AsyncStorage from '@react-native-async-storage/async-storage';
const expoAdapter: StorageAdapter = {
getItem: (k) => AsyncStorage.getItem(k),
setItem: (k, v) => AsyncStorage.setItem(k, v),
removeItem: (k) => AsyncStorage.removeItem(k),
keys: async (prefix) => (await AsyncStorage.getAllKeys()).filter((k) => k.startsWith(prefix)),
};Error handling
The factory accepts an onError sink. The SDK emits errors for:
- Unhandled socket errors
- Server-side rate-limit notifications (
error:rate_limited) - REST call failures from
Channelmethods (logged then thrown) - E2EE setup failures
const chat = createChatClient({
mode: 'test',
userId,
getAccessToken,
onError: (err) => {
if (err.message.startsWith('rate_limited:')) {
// surface a toast
} else {
Sentry.captureException(err);
}
},
});Manual POC test (2 browsers)
Ver examples/two-browser-poc.html en este package: HTML standalone
que carga socket.io-client por CDN + script bundle del cliente y
permite enviar mensajes entre 2 ventanas conectadas al mismo channel.
Requisitos:
- chat-service corriendo (
pnpm --filter chat-service start:dev) - Redis local en
redis://localhost:6379 - DynamoDB Local o tablas creadas en AWS staging (ver
apps/chat-service/scripts/create-chat-tables.ts) - JWT válido firmado con el mismo
JWT_SECRETdel chat-service
Versioning
v0.x — public API may have small adjustments. v1.0.0 lands once the
frontend integration is fully soaked in production. Any breaking change
is documented in this README + git tag.
License
UNLICENSED — internal use only. Distribution restricted to Wemany teams and authorized contractors.
