Node services and scripts
The client needs no framework: the runtime is the API. That makes it a client for other servers too, such as a worker feeding a dashboard, a bot in a chat room, a backend that talks to another team's chanx server, or a script that checks a deployment.
It runs on Node 22 and later, Bun and Deno, which all ship a global WebSocket.
Connect, ask, listen
import { createClient } from '@chanx-js/client';
import { chat } from './generated/index.js';
const client = createClient({ baseUrl: 'ws://localhost:8000' });
const lobby = client.connect(chat, {
params: { room: 'lobby' },
queryParams: { name: 'bot' },
});
await lobby.ready(); // open, or throw once it gives up
// the reply, typed PongMessage
const pong = await lobby.request({ action: 'ping' });
// the next `said`
const first = await lobby.once('said', { timeout: 5000 });
for await (const message of lobby) {
if (
message.action === 'said' &&
message.payload.text === '!time'
) {
lobby.send({
action: 'say',
payload: { text: new Date().toISOString() },
});
}
}- Sends made before the socket opens are queued and flushed on open, so
ready()is only needed to fail fast. - The
for awaitloop ends when the connection closes, or once the socket gives up reconnecting.lobby.stream({ limit, signal })is the configurable form: it holds at mostlimitmessages (default 1024) while the loop body is busy, dropping the oldest, and stops when the signal aborts. - Topic handles have the same
once,streamandfor await.
Authenticate as a service
A browser can only put credentials in the URL, but a service can send headers. Pass them with headers, on the client for every connection or on one connect():
const client = createClient({
baseUrl: 'wss://api.example.com',
headers: { authorization: `Bearer ${process.env.INGEST_KEY}` },
});Node 22 and later, Bun and Deno send them with the built-in WebSocket. Connections with different headers never share a socket, and in a framework binding, new headers reconnect. A browser cannot send headers, so passing them there reports an error.
On a chanx-js server, read the header in authenticate. With rejectBeforeUpgrade, a wrong key is refused with 401 before any socket opens, which suits a service: no browser needs to read why.
const ingest = consumer('/ws/ingest', {
name: 'ingest',
rejectBeforeUpgrade: true,
authenticate: ({ headers }) =>
headers.get('authorization') ===
`Bearer ${process.env.INGEST_KEY}`
? { service: 'sensors' }
: false,
}).on(Reading, { output: Ack }, () => Ack.create());Node 20 has no global WebSocket. Pass a socketFactory that uses ws; it receives the headers too:
import WebSocket from 'ws';
createClient({
baseUrl: 'wss://api.example.com',
headers: { authorization: `Bearer ${process.env.INGEST_KEY}` },
socketFactory: (url, protocols, options) =>
new WebSocket(url, protocols, {
headers: options?.headers,
}) as never,
});Trust what arrives
A browser trusts its own server; a service often talks to another team's. Generate the client with --validation zod and validate what arrives in production too, so a changed payload fails loudly at the edge instead of deep in your code:
createClient({ baseUrl, validate: { inbound: true } });Retry, or fail fast
A connection retries 10 times by default, backing off from one second to 30, so about three minutes pass before it gives up. That suits a long-running service riding out a deploy. A script or a health check usually wants an answer sooner:
const connection = client.connect(ingest, { reconnectAttempts: 0 });
await connection.ready({ timeout: 5000 });ready() rejects once the socket gives up, and with timeout, after that long. shouldReconnect(event) decides per close, such as giving up on a close code your server uses for "go away".
Stop cleanly
A script should close what it opened, or the process stays alive:
connection.close();
client.closeAll(); // every socket this client openedPlain JavaScript
Generate JavaScript output and import it directly:
npx @chanx-js/codegen <schema> -o src/generated --emit js// @ts-check
import { createClient } from '@chanx-js/client';
import { chat } from './generated/index.js';The emitted .d.ts files give a JavaScript project the same narrowing and checks as a TypeScript one, in the editor or under checkJs.