@wemany/chat-client
v0.6.3
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 (proactively, ~120s
before that socket's JWT exp), the SDK — since 0.6.2 — fetches a token
via getAccessToken FIRST and only swaps the connection when the token
actually changed. While getAccessToken keeps returning the same
cached token, the live socket is kept working (open sockets are not
re-validated mid-connection) and the fetch retries every 15s; after ~10
minutes without a rotation the SDK forces the swap anyway, so the
handshake re-validates fail-closed. Automatic reconnect retries also
refresh socket.auth.token on every cycle, so a connection that died with
an expired token self-heals as soon as getAccessToken can return a valid
one — no re-login needed. Returning a proactively refreshed token from
getAccessToken is still recommended (it makes the swap seamless), but no
longer required for the client to survive token rotation.
Hardening (0.6.3): the rotation cycle is also armed client-side from the
JWT's own exp (works even if the server nudge never arrives; absurd exp
values re-evaluate daily instead of overflowing setTimeout); every
getAccessToken call is bounded to 30s so a hung provider can never brick
the client instance; a natural transport drop resets the 10-min fail-closed
clock (its re-handshake already re-validated the JWT); and disconnect()/
pause() are final — a connect attempt still awaiting its token when the
app shuts the client down aborts instead of resurrecting the connection.
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.
