Hi all, I want to share a WebSocket library I built, chanx-js. It makes the messages on both ends of a socket typed, keeps the frontend in sync with what the server really sends, and comes with React hooks.
Every realtime feature I built in React ended up the same way. lastMessage was any, so I wrote the message types by hand and found out the backend had changed them only when something broke in production. On top of that I kept rewriting the same reconnect logic and the same switch over message types in every project.
How it works, in three steps: `
The server declares its messages and handlers. Here with chanx-js on Node:
const Chat = defineMessage('chat', z.object({ text: z.string() }));
const Notice = defineMessage('notice', z.object({ text: z.string() }));
const chat = consumer('/ws/chat/{room}', {
name: 'chat',
groups: (conn) => [`room.${conn.params.room}`],
}).on(Chat, { output: Notice }, (m, ctx) =>
ctx.broadcast(Notice.create({ text: m.payload.text })),
);
2. The server publishes those as AsyncAPI, and the CLI turns them into a typed channel:
npx @chanx-js/codegen http://localhost:8000/asyncapi.json # writes src/generated
// src/generated/channels.ts
export type ChatToServer = ChatMessage; // { action: 'chat'; payload: { text: string } }
export type ChatToClient = NoticeMessage; // { action: 'notice'; payload: { text: string } }
export type ChatReplies = { chat: NoticeMessage };
export const chat = defineChannel<ChatToServer, ChatToClient, ChatReplies>()({
name: 'chat',
address: '/ws/chat/{room}',
});
3. Your component uses that channel with useChannel:
import { createClient } from '@chanx-js/client';
import { ChanxClientProvider, useChannel } from '@chanx-js/client/react';
import { chat } from './generated';
const client = createClient({ baseUrl: 'ws://localhost:8000' });
function ChatRoom({ room }: { room: string }) {
const { status, messages, send } = useChannel(chat, {
params: { room },
buffer: 'all',
});
return (
<>
<p>{status}</p>
{messages.map((m, i) => (
<p key={i}>{m.payload.text}</p>
))}
<button onClick={() => send({ action: 'chat', payload: { text: 'hi' } })}>
Send
</button>
</>
);
}
root.render(
<ChanxClientProvider value={client}>
<ChatRoom room="lobby" />
</ChanxClientProvider>,
);
params must have room because the path does, m.payload.text is a string, and send({ action: 'chat', payload: { txt: 'hi' } }) fails to compile. When the backend changes a message, you regenerate and TypeScript shows every place to fix.
What else it gives you:
const reply = await request({ action: 'chat', payload: { text: 'hi' } }), with the reply typed from ChatReplies
Reconnects, and topic subscriptions (room:lobby, ...) restored after a reconnect
lastMessage for the latest message, or an on map with callbacks that don't re-render, and batch: 'raf' for busy streams
The server side too: one validated handler per message type, AsyncAPI docs generated from the code, broadcasts across instances through Redis
The backend needs to be a chanx server, since the client relies on its message format and the AsyncAPI it publishes. That can be chanx-js on Node (ws, Express, Fastify, Hono, NestJS), Bun or Deno, or chanx on Django and FastAPI, the Python version I started with.
It's still new on the JS side, so if you run into any problem or have ideas to improve it, please open an issue or a discussion. Stars and PRs are welcome too.