chanx-js: typed WebSocket hooks for React, generated from your chanx server's AsyncAPI
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 })), ); 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}', });
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 fromChatReplies- Reconnects, and topic subscriptions (
room:lobby, ...) restored after a reconnect lastMessagefor the latest message, or anonmap with callbacks that don't re-render, andbatch: '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.
- Repo: https://github.com/chanx-dev/chanx-js
- React guide: https://js.chanx.dev/client/integrations/react
- npm: https://www.npmjs.com/package/@chanx-js/client
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.
[link] [comments]