@pilimo/velo-client
v1.0.0
Published
Browser / Node WebSocket client for the Velo binary protocol
Downloads
133
Maintainers
Readme
@pilimo/velo-client
A browser / Node.js WebSocket client for the Velo binary protocol.
| Feature | API |
|---|---|
| Handshake + session criteria | VeloClient.connect() |
| Request / response | client.request() |
| Fire-and-forget messages | client.sendMessage() |
| Server-push messages | client.onMessage() |
| Streaming (STREAM_REQUEST → STREAM_ITEM* → STREAM_FINISH) | client.stream() |
| Concurrency control (peer's concurrentRequests, FIFO queuing) | built in |
| Keepalive (auto PONG, idle PING / half-open detection, on by default) | options.keepalive |
| Optional request timeout | options.requestTimeout, per call { timeout } |
| Graceful close | client.close() |
Installation
npm install @pilimo/velo-client| Entry | Use |
|---|---|
| import { VeloClient } from '@pilimo/velo-client' | bundlers, Node (ESM or CommonJS) |
| import { VeloClient } from '@pilimo/velo-client/browser' | a single minified ESM file for <script type="module"> and import maps |
| dist/velo-client.js | classic <script> tag; exposes the Velo global |
Quick start
import { VeloClient, ResponseCode, VeloHandshakeError } from '@pilimo/velo-client';
// The second argument is the handshake payload (auth token, user id, …):
// Uint8Array | string | any JSON-serialisable object.
const { client, handshakePayload } = await VeloClient.connect('wss://host:6672', 'my-session-token');
const profile = JSON.parse(new TextDecoder().decode(handshakePayload));
// Request / response
const res = await client.request(1_001_007);
if (res.code === ResponseCode.OK) {
const chats = JSON.parse(new TextDecoder().decode(res.payload));
}
// Objects are JSON-serialised for you
await client.request(1_001_001, { chat_id: '…', message: 'hello!' });
// Server-push messages
const stop = client.onMessage((msg) => console.log(msg.code, msg.payload));
stop();
// Fire-and-forget
client.sendMessage(2_001_001, { event: 'typing' });
// Streaming
for await (const event of client.stream(1_002_001, { page: 0 })) {
if (event.type === 'item') console.log('chunk', event.payload);
if (event.type === 'finish') console.log('done, code', event.code);
}
client.onClose((ev) => console.log('closed', ev.code, ev.reason));
client.close();API
VeloClient.connect(url, payload?, options?) → Promise<{ client, handshakePayload }>
| Parameter | Type | Default | Description |
|---|---|---|---|
| url | string | — | ws:// or wss:// URL |
| payload | Uint8Array \| string \| object | empty | Handshake payload |
| options.criteria | Partial<SessionCriteria> | DEFAULT_CRITERIA | Criteria advertised to the server |
| options.keepalive | { idleTimeout, pingGrace? } \| false | DEFAULT_KEEPALIVE (30 s / 5 s) | Idle keepalive (see below); false disables |
| options.requestTimeout | number (ms) | off | Fail a request() with no RESPONSE after this long |
| options.handshakeTimeout | number (ms) | unlimited | Fail connect() if the handshake takes longer |
| options.socketFactory | (url) => VeloSocket | new WebSocket(url) | Supply the socket (Node ws, tests, proxies) |
Rejects with VeloHandshakeError (server said no), VeloConnectionError
(socket failed, closed or timed out first) or VeloProtocolError (the first
frame was not a handshake response).
client.request(code, payload?, { timeout? }?) → Promise<VeloResponse>
Sends a REQUEST and resolves with { code, payload }. Requests beyond the
server's concurrentRequests wait locally, in call order. Rejects with
VeloCriteriaViolation before sending when the payload exceeds the server's
maxRequestPayload, and with VeloConnectionError if the socket closes first.
There is no timeout by default: keepalive already detects a dead connection and
rejects everything pending, so a request only waits while the server is
provably alive and still working. To bound a slow server anyway, set
requestTimeout on the client or timeout on one call (false lifts a
client-level limit for that call). A timed-out request rejects with
VeloTimeoutError, but it still counts toward the server's concurrentRequests
until the server answers, because the server counts it too; the late response
frees the slot and is otherwise ignored.
client.sendMessage(code, payload?)
Sends a one-way MESSAGE. Throws VeloCriteriaViolation when the server refuses
messages or the payload is too large.
client.stream(code, payload?) → AsyncGenerator<StreamEvent>
type StreamEvent =
| { type: 'item'; payload: Uint8Array }
| { type: 'finish'; code: number; payload: Uint8Array };A stream counts as one in-flight request until the server finishes it, even if you leave the loop early. Streams have no timeout; a dead connection is caught by keepalive.
client.onMessage(listener) → unsubscribe and client.onClose(listener) → unsubscribe
A listener that throws does not stop the others from being called; the error is rethrown afterwards.
client.close(code?, reason?), client.isConnected, client.localCriteria, client.remoteCriteria
Keepalive
Inbound PINGs are always answered with a PONG. The client also detects
half-open connections, on by default: after idleTimeout ms (30 s) without
any inbound frame it sends a PING and, if nothing arrives within pingGrace ms
(5 s), closes the socket, so every pending request rejects and your onClose
listener fires. Without this, a connection lost to a network switch or a sleep
looks open forever and requests never come back.
const { client } = await VeloClient.connect(url, token, {
keepalive: { idleTimeout: 60_000, pingGrace: 10_000 }, // or `false` to disable
});Criteria enforcement
The client enforces the server's criteria before sending, and its own on what
arrives. A server MESSAGE that violates the local criteria closes the socket
with code 4008; a malformed or out-of-place frame closes it with 4002
(exported as CLOSE_CRITERIA_VIOLATION and CLOSE_PROTOCOL_ERROR). Text
WebSocket messages and server-initiated requests are ignored.
export const DEFAULT_CRITERIA: SessionCriteria = {
concurrentRequests: 32,
messagePossible: true,
maxRequestPayload: 4 * 1024 * 1024,
maxMessagePayload: 4 * 1024 * 1024,
};Errors
| Class | When |
|---|---|
| VeloError | Base class |
| VeloHandshakeError | Server returned a non-OK handshake code (responseCode, responsePayload) |
| VeloConnectionError | Socket failed to open, timed out, or closed (wsCode, wsReason) |
| VeloTimeoutError | No RESPONSE within a configured request timeout (code, requestId, timeout) |
| VeloCriteriaViolation | A frame would break the negotiated criteria |
| VeloProtocolError | Malformed or unexpected frame from the server |
Development
npm install
npm test # vitest, no network: the socket is faked
npm run typecheck
npm run build # dist/esm, dist/cjs, dist/types, IIFE and ESM bundlesThe source is split by responsibility: frame.ts (codec), handshake.ts,
frame-router.ts, semaphore.ts (concurrency permits), pending-requests.ts,
stream-registry.ts, keepalive.ts, listener-set.ts, and client.ts, which
only composes them. Each unit has its own test file under test/; the client
itself is tested end to end against test/fake-socket.ts with hand-built wire
bytes from test/wire.ts.
Transport note
WebSocket only. The WebSocket framing omits the 4-byte TCP length prefix: each
binary message is exactly one Velo frame. For raw TCP from Node.js, supply a
socketFactory whose socket adds the length prefix from the protocol spec.
