WebSocket transport
The WebSocket transport exposes capabilities over persistent WebSocket connections and delivers server-push events to subscribed clients.
See the package README for the full API.
Message protocol
All messages are JSON.
Client → server (capability call):
{ "id": "msg-1", "capability": "chat.sendMessage", "input": { "text": "hello" } }With headers:
{
"id": "msg-1",
"capability": "chat.sendMessage",
"input": { "text": "hello" },
"headers": { "authorization": "Bearer token" }
}Server → client (success):
{ "id": "msg-1", "ok": true, "status": 200, "data": { "messageId": "abc" } }Server → client (error):
{ "id": "msg-1", "ok": false, "status": 401, "error": "Unauthorized", "message": "Unauthorized" }Client → server (subscribe to event):
{ "id": "msg-2", "action": "subscribe", "event": "chat:message" }Server confirms:
{ "id": "msg-2", "ok": true, "event": "chat:message", "subscribed": true }Server → client (server-push event):
{ "event": "chat:message", "data": { "text": "hello", "from": "Alice" } }Client → server (unsubscribe):
{ "id": "msg-3", "action": "unsubscribe", "event": "chat:message" }Subscriptions are cleaned up automatically on disconnect.
Auth over WebSocket
Authentication uses the headers from the HTTP upgrade request — send your token when opening the connection:
const ws = new WebSocket('ws://localhost:3001', {
headers: { authorization: 'Bearer eyJ...' },
});buildContext is called for each message and receives those connection headers, so guards behave exactly as they do over REST. Browsers cannot set custom WebSocket headers — pass the token as a query parameter (ws://host/?token=...) and read it in buildContext, or use a cookie.
Connection hardening
wsTransport({
port: 3001,
maxPayloadBytes: 256 * 1024, // close oversized senders with 1009 (default 1 MiB)
heartbeatIntervalMs: 30_000, // terminate dead connections (default 30s, false to disable)
authorizeSubscribe: (event, headers) =>
!event.startsWith('admin:') || headers['x-role'] === 'admin',
})maxPayloadBytes— inbound frames larger than this close the connection with1009(message too big). Defaults to 1 MiB, matching the REST body limit.heartbeatIntervalMs— the server pings every client each interval and terminates clients that missed the previous ping. Without it, crashed clients and dropped networks hold their subscriptions forever.authorizeSubscribe(event, headers)— called before asubscribemessage takes effect, with the upgrade-request headers. Returnfalse(or throw) to reject with aForbiddenreply. Without it, any connected client may subscribe to any event.
Event bus
Use createEventBus to connect REST capabilities to WS clients:
// src/events.ts
import { createEventBus } from '@capixjs/core';
export type AppEvents = {
'order:paid': { orderId: string; amount: number };
'task:updated': { id: string; status: string };
};
export const eventBus = createEventBus<AppEvents>();// src/server.ts
import { wsTransport } from '@capixjs/transport-ws';
import { eventBus } from './events.js';
createServer({
transports: [
restTransport({ port: 3000 }),
wsTransport({ port: 3001, eventBus }),
],
...
});Multiple instances
createEventBus is in-memory: behind a load balancer, an event published on instance A never reaches WebSocket clients connected to instance B. Swap in the Redis-backed bus from @capixjs/store-redis — same interface, cross-instance delivery via Redis pub/sub:
import Redis from 'ioredis';
import { createRedisEventBus } from '@capixjs/store-redis';
export const eventBus = createRedisEventBus<AppEvents>(
new Redis(process.env.REDIS_URL), // publisher connection
new Redis(process.env.REDIS_URL), // dedicated subscriber connection
);Everything else — wsTransport({ eventBus }), eventBus.publish(...) in resolvers, client subscribe messages — stays identical.
// src/capabilities/orders/pay.ts
import { eventBus } from '../../events.js';
export const payOrder = authCap(z.object({ id: z.string() }), async ({ id }, ctx) => {
const order = await ctx.db.orders.markPaid(id);
eventBus.publish('order:paid', { orderId: id, amount: order.total }); // typed
return order;
}, 'mutation').guard(mustBeUser);Now any WS client that subscribed to order:paid receives the event when payOrder runs over REST.
Server-side subscription
Subscribe from within your server code (e.g. in a plugin or startup hook):
eventBus.subscribe('task:updated', (data) => {
// Process task update server-side
auditLog.record({ event: 'task:updated', ...data });
});