@spinajs/http-socket-client
v3.0.26
Published
Isomorphic socket client for spinajs: connection state, reconnect, commands, events, typed contracts
Maintainers
Readme
@spinajs/http-socket-client
Client for @spinajs/http-socket, for browsers and Node. It handles the connection state, reconnects with backoff, and offers commands with timeouts and cancellation, server events, stream replies with flow control, and typed contracts. Its only dependencies are socket.io-client and @spinajs/http-socket-common, which it re-exports.
A runnable server and client are in packages/http-socket-example.
Full documentation, including outbox stores and every extension point: docs/http-socket-client.
Connect
import { SocketClient } from '@spinajs/http-socket-client';
const client = new SocketClient({
url: 'https://app.example.com',
clientId: storedClientId, // stable per install; generated when omitted
auth: async () => ({ token: await tokens.current() }), // asked on every (re)connect
reconnect: { minMs: 500, maxMs: 30_000, jitter: 0.3 }, // the defaults
defaultTimeoutMs: 15_000, // the default
});storedClientId and tokens stand for your own code. Other options: path (socket.io endpoint, /socket.io by default), connectTimeoutMs (20 s), io (passed to socket.io-client; reconnection, autoConnect, forceNew, retries and auth stay owned by the client) and onListenerError.
- The client connects on its own unless
autoConnect: false. client.stateis'connecting' | 'online' | 'offline' | 'closed', andclient.on('state', (state, previous) => ...)reports every change.disconnect()goes offline untilconnect().close()is final: it rejects everything pending, except durable commands the server already answered (see Durable commands). It returns a promise; see below.
Reconnect. The client retries with exponential backoff and jitter, and asks auth() again on every attempt. It stops in these cases:
| Server answer | Lifecycle event |
|---|---|
| unauthenticated | auth_required |
| version_mismatch | incompatible |
| any other refusal with retryable: false, except internal, which is retried (forbidden for a foreign origin, invalid_argument ...) | refused |
connect() tries again. A sys.shutdown { retryAfterMs } notice delays the next attempt. A sys.disconnect whose reason is a non-retryable error code (the server sends unauthenticated for a revoked credential) stops the client in the same way.
Commands
const doc = await client.command<OpenResult>('docs.open', { id }, { timeoutMs: 5_000, signal, whenOffline: 'wait' });timeoutMscovers the whole call. The server gets the remaining time asdeadline.whenOffline: 'fail'(the default) rejects at once withunavailable.'wait'holds the command until the client is online or the timeout passes.- Aborting
signalsendscancel; the server's handler sees its own signal fire. - A drop in the middle of a command rejects it with a retryable
unavailable. Plain commands are never sent twice. - Every rejection is a
SocketExceptionwithcode,message,retryableand, when the server sent them,retryAfterMs,detailsanderrorId. TrustretryableandretryAfterMswhen deciding to retry.
Events
const off = client.on<Committed>('docs.committed', (payload) => apply(payload));
client.emit('docs.cursor', { x, y }); // fire-and-forget; false and dropped when not onlineThe lifecycle names (state, auth_required, incompatible, refused, acked, failed, held) are routed to the client's own listeners, so a server event with one of these names never reaches on().
Durable commands
import { SocketClient } from '@spinajs/http-socket-client';
import { IndexedDbOutboxStore } from '@spinajs/http-socket-client/browser'; // Node: FileOutboxStore from '/node'
const client = new SocketClient({ url, clientId: storedClientId, outbox: new IndexedDbOutboxStore('app') });
client.on('acked', ({ entry, result }) => {});
client.on('failed', ({ entry, error }) => {}); // refused for good; the entry waits in the store's failed list
await client.durable('docs.edit', { id, op }, { key: opId }).catch(() => undefined);- The command is written to the outbox before it is sent, then sent in order, up to
durable.window(16) at once, each withafterset to the previous unacknowledged command. It carries no deadline. The server command must beidempotent. Withordered: 'client'it waits in the server's queue behind its predecessor; without it, the server only checks that the predecessor has completed and otherwise answers a retryableunavailable, which parks it until the predecessor's answer, then sends it again. Either way it runs after its predecessor, whatever its queue position. - After any drop everything unacknowledged is sent again, and so is a command unanswered for
durable.ackTimeoutMs(60 s). Retryable errors are retried with the reconnect backoff. unauthenticatedkeeps the queue and emitsheld; nothing is sent until the connection comes online again.- The key is your
key(1 to 256 printable ASCII characters, not starting with${clientId}:) or a generated${clientId}:<nonce>. - Any
outboxoption, an explicitMemoryOutboxStoreincluded, requires an explicitclientIdstored by the app (the constructor throws aRangeErrorotherwise): a new random id after a reload would break exactly-once and ordering. Without the option, the defaultMemoryOutboxStoreis used; it does not survive a reload. durable()rejects when the server refuses the command for good and when the client closes before the server answered it (the entry stays in the outbox). A command the server already answered settles with that answer once the outbox has applied it, also afterclose(). Attach acatchor followacked/failed.ackedandfailedfire in send order, also for entries resumed from the outbox after a reload. The exception isclose(): a command the server answered settles after the close, while an earlier one still unanswered is rejected and reported by the next client.await client.close()before you open the next client or store on the same outbox, and keep youracked/failedlisteners until it resolves. It resolves once the outbox has no write of this client in flight and every answered command has been reported. An answered command whose write lands after the close is reported only there: a resumed entry has no caller, and itsackedis its only report. AFileOutboxStoreopened on the same file while the old one still writes would overwrite that file from a stale copy, dropping new entries and bringing back removed ones.FileOutboxStorekeeps payloads as JSON, so itsadd(anddurable()) rejects with aTypeErrora payload that a JSON round trip would change:Uint8Array,Buffer,ArrayBuffer,Date,Map,Set, class instances,NaN/Infinity,undefinedinside an array. Nothing is written. Encode such values yourself (bytes as base64, dates as ISO strings). Anundefinedproperty is allowed: the wire drops it too.IndexedDbOutboxStoreandMemoryOutboxStorekeep binary.
Streams
for await (const frame of client.stream<SnapshotFrame>('docs.snapshot', { docId }, { credits: 8 })) {
apply(frame);
if (cancelled) break; // sends cancel; the server generator's finally runs
}stream()returns an async iterator. The request goes out on the firstnext(). Calls tonext()must not overlap.- Flow control.
credits(defaultDEFAULT_STREAM_CREDITS, 8) is the most chunks that are ever in flight or buffered.- The client grants more as you read, in batches of at least half the window.
- It must not exceed the server's
http.socket.streams.maxCredits(64 by default). The server also refuses a window that, with what is granted but not yet used, goes over it.
- Timeouts.
timeoutMsbounds the wait for the reply, and then each wait innext()for the next frame. A consumer that is slow to callnext()is never timed out. The server ends a stream that gets no credit for itsstreams.stallTimeoutMs. - Failures. On a server
fail, a dropped connection (unavailable, retryable) or a closed client, the chunks already received are delivered first, then the iterator throws. Abortingsignaldrops buffered chunks, cancels the stream and throwscancelled. - Mismatches.
stream()on a command that answers a plain result throwsfailed_precondition, and so doescommand()on a stream command. - Resume. The library does not resume streams. Put the position in the payload (for example
fromChunk) and start a new stream after a retryable failure; withwhenOffline: 'wait'it waits for the reconnect.loadSnapshotinpackages/http-socket-example/src/client.tsis a complete example.
Typed contracts
One contract module, shared by the server and the client, types names and payloads:
import { defineContract } from '@spinajs/http-socket-client';
export type DocsContract = {
commands: {
'docs.open': [{ id: string }, { title: string }];
'docs.snapshot': [{ docId: string; fromChunk?: number }, AsyncIterable<SnapshotFrame>];
};
events: { 'docs.committed': { seq: number } };
clientEvents: { 'docs.cursor': { x: number; y: number } };
};
export const docsContract = defineContract<DocsContract>();
const docs = client.typed(docsContract);
const opened = await docs.command('docs.open', { id: 'a' }); // { title: string }
for await (const frame of docs.stream('docs.snapshot', { docId: 'a' })) {
} // frame: SnapshotFrame
docs.on('docs.committed', ({ seq }) => {});Declare contracts with type, not interface. A stream command declares its result as AsyncIterable<Item>. The server side imports defineContract and the contract types from @spinajs/http-socket-common (or @spinajs/http-socket, which re-exports it); packages/http-socket-example/src/contract.ts is shared by both sides.
Binary
Uint8Array, ArrayBuffer and Buffer anywhere in a payload travel as binary attachments and count toward the server's maxPayloadBytes by byte length. Binary results arrive as an ArrayBuffer in browsers and as a Buffer in Node; new Uint8Array(result) reads both.
