@rivalis/browser
v6.0.0
Published
π Browser client for rivalis server
Maintainers
Readme
@rivalis/browser
The browser WebSocket client for Rivalis. Connects to a @rivalis/core server, decodes binary frames into typed events, and handles reconnection.
β Features
- Tiny surface β
connect,disconnect,send,on/once/off. That's it. - Typed events β
client:connect,client:disconnect,client:kicked,client:reconnecting,client:reconnect_failedwith their actual payload shapes; user topics typed via an optional generic. - Exponential-backoff reconnect with jitter (opt-in).
- Token-refresh hook β
getTicketis called before every reconnect attempt; perfect for short-lived JWTs. - Two ticket-delivery modes β query string (default, back-compat) or
Sec-WebSocket-Protocolheader (recommended for production β keeps credentials out of access logs and browser history). - Native browser WebSocket only β no
wsdependency, no polyfill.
π Install
npm install @rivalis/browser@rivalis/browser declares its dependencies as peers:
npm install @toolcase/base @toolcase/logging @toolcase/serializerπ Hello world
import { WSClient } from '@rivalis/browser'
const ws = new WSClient('ws://localhost:8080')
const encoder = new TextEncoder()
const decoder = new TextDecoder()
ws.on('client:connect', () => console.log('connected'))
ws.on('client:disconnect', (reason) => console.log('disconnected:', decoder.decode(reason)))
ws.on('chat', (payload) => console.log('chat:', decoder.decode(payload)))
ws.connect('alice') // ticket = "alice"
ws.send('chat', encoder.encode('hello world')) // payload is opaque bytespayload is always a Uint8Array. The framework never inspects it β encode it however you like (JSON + TextEncoder, protobuf, msgpackβ¦).
π§ API
Constructor
new WSClient<TTopics extends string = string>(baseURL: string, options?: WSClientOptions)type WSClientOptions = {
reconnect?: boolean | WSClientReconnectOptions
ticketSource?: 'query' | 'protocol' // default 'query'
getTicket?: () => string | Promise<string>
}
type WSClientReconnectOptions = {
maxAttempts?: number // default Infinity
baseDelayMs?: number // default 500
maxDelayMs?: number // default 10_000
}Methods
| Method | Description |
|---|---|
| connect(ticket?) | Open a new connection. The ticket is what your server's AuthMiddleware.authenticate receives. |
| disconnect() | Close gracefully. Cancels pending reconnects, nulls lastTicket. |
| send(topic, payload?) | Send a frame. payload: Uint8Array \| string (strings are UTF-8 encoded for you). Drops with a warning if not in OPEN state. |
| on(event, listener, context?) | Subscribe to an event. |
| once(event, listener, context?) | One-shot subscribe. |
| off(event, listener, context?) | Unsubscribe. |
Events
| Event | Payload | When it fires |
|---|---|---|
| client:connect | β | WebSocket handshake completed (auth may still kick the connection right after). |
| client:disconnect | Uint8Array (close-frame reason, UTF-8) | Socket closed for any reason. |
| client:kicked | { code: number, reason: string } | Server closed with a 4xxx app-level code. Fires before client:disconnect. |
| client:reconnecting | Uint8Array (attempt number as UTF-8 string) | Reconnect attempt scheduled; backoff is already running. |
| client:reconnect_failed | β | maxAttempts exhausted, or getTicket threw. Terminal β no further attempts. |
| <your topic> | Uint8Array | Inbound frame on a server-broadcast topic. |
The default β non-reconnecting β flow is client:connect β β¦ β client:disconnect (with a client:kicked in between if the server actively closed with a 4xxx code).
Typed topics generic
Constrain user-topic listeners to a known set:
type AppTopics = 'lobby:state' | 'chat' | 'game:tick'
const ws = new WSClient<AppTopics>('ws://localhost:8080')
ws.on('chat', (payload) => { ... }) // β
ws.on('typo:state', (payload) => { ... }) // type error
ws.on('client:kicked', ({ code, reason }) => { ... }) // built-in events still typedBuilt-in client:* events keep their typed payload shape regardless of the generic.
π Reconnection
const ws = new WSClient(url, { reconnect: true }) // exp backoff with jitter, no attempt limit
const ws2 = new WSClient(url, {
reconnect: { maxAttempts: 8, baseDelayMs: 250, maxDelayMs: 5000 }
})
ws2.on('client:reconnecting', (n) => {
console.log('attempt', new TextDecoder().decode(n))
})
ws2.on('client:reconnect_failed', () => {
console.log('gave up β surface a "reconnect" button to the user')
})Reconnect skips terminal close codes. If the server kicked the connection with INVALID_TICKET, KICKED, or ROOM_REJECTED, the client treats it as terminal and won't reconnect β those mean the server doesn't want you back, so retrying is just noise.
πͺͺ Refreshing tickets across reconnects
If your tickets are short-lived (signed JWTs that expire), reconnects must fetch a fresh one. The getTicket hook is called before every reconnect attempt:
const ws = new WSClient(url, {
reconnect: true,
getTicket: async () => {
const res = await fetch('/api/realtime-token', { credentials: 'include' })
if (!res.ok) throw new Error('token endpoint failed')
return await res.text()
}
})
ws.connect(initialTicket) // first call still uses its argument verbatimIf getTicket throws or rejects, the loop terminates with client:reconnect_failed (you can't reconnect without a ticket).
πͺ§ Putting the ticket in the subprotocol header
Production deployments should keep credentials out of URL access logs and browser history. Set ticketSource: 'protocol' on both the server (WSTransportOptions) and the client (WSClientOptions):
const ws = new WSClient('wss://api.example.com/ws', {
ticketSource: 'protocol',
reconnect: true
})
ws.connect(jwt) // sent via Sec-WebSocket-Protocol, NOT ?ticket=The ticket must conform to the WebSocket subprotocol token grammar β no spaces, no commas, no padding =. Standard base64url JWTs satisfy this. Empty tickets in protocol mode throw a clear error before opening the socket.
π‘ Sending and receiving structured payloads
The framework treats payload as opaque bytes. Most apps wrap a JSON encode/decode helper:
const encoder = new TextEncoder()
const decoder = new TextDecoder()
export const encode = <T>(value: T): Uint8Array => encoder.encode(JSON.stringify(value))
export const decode = <T>(payload: Uint8Array): T => JSON.parse(decoder.decode(payload)) as T
// usage
ws.send('chat', encode({ text: 'hello' }))
ws.on('chat', (payload) => console.log(decode<{ text: string }>(payload)))For higher-throughput / lower-overhead protocols, swap in protobufjs, @bufbuild/protobuf, msgpackr, etc. β the wire layer doesn't care.
π Close codes
CloseCode is re-exported from @rivalis/handshake (bundled into @rivalis/browser β no extra install).
import { CloseCode } from '@rivalis/browser'
CloseCode.INVALID_TICKET // 4001 β bad / missing ticket
CloseCode.INVALID_FRAME // 4002 β non-binary frame
CloseCode.KICKED // 4003 β server-initiated kick
CloseCode.ROOM_REJECTED // 4004 β room_full / room_not_joinable
CloseCode.RATE_LIMITED // 4005 β pre-handshake connection limiterclient:kicked fires for any 4xxx code with the parsed { code, reason } so you don't have to peek into the close payload yourself.
License
MIT β see LICENSE.
