@hostloom/websocket-client
v0.4.0
Published
Dependency-free browser client primitives for the HostLoom WebSocket protocol.
Readme
@hostloom/websocket-client
Dependency-free ESM browser primitives for the HostLoom hostloom.json.v1 WebSocket protocol.
This package currently provides:
- discriminated TypeScript types for every version-one frame;
- direction-aware client encoding and server decoding;
- runtime validation of untrusted server frames;
- Base64 application-JSON payload helpers;
- an injectable
HostLoomConnectionthat negotiates JSON-v1, waits for a validwelcome, exposes connection-state and server-frame observers, supports explicit close and manual reconnect, and can opt into jittered exponential reconnect; - a request API with automatic stream identifiers, response correlation, typed remote faults,
welcome-advertised concurrency and message-size enforcement, gateway timeouts, and
AbortSignalcancellation; - a subscription API with shared stream allocation, confirmation gating, bounded pre-listener event
buffering, automatic low-watermark credit replenishment, acknowledgements, typed terminal faults,
AbortSignalunsubscription, and automatic resubscription on a replacement socket, with credit and acknowledgements coalesced and paced below the gateway's control-frame budget; - client
pingand serverpongframes for application-level liveness and round-trip time; - conformance tests against the schema and exact fixtures shipped by
HostLoom.AspNetCore.WebSockets.
Install
After the first package release:
npm install @hostloom/websocket-clientThe published package has no runtime dependencies. TypeScript is used only to build its ESM and declaration output.
Connect and exchange frames
import {
decodeJsonPayload,
encodeJsonPayload,
HostLoomConnection,
} from "@hostloom/websocket-client";
const connection = new HostLoomConnection("wss://inventory.example.com/realtime", {
reconnect: {
// Runs after every 1008 close. The gateway reports why in close.reason.
refreshCredentials: async (close) => {
if (close.reason === "session_expired") {
await fetch("/api/session/refresh", { method: "POST" });
}
},
},
});
connection.onStateChange(({ state, close }) => {
console.log(state, close?.code);
});
const welcome = await connection.connect();
console.log(welcome.maximumMessageSize);
const responsePayload = await connection.request(
"inventory.get",
encodeJsonPayload({ itemId: "item-42" }),
{ timeoutMilliseconds: 3_000 },
);
const response = decodeJsonPayload<{ itemId: string; available: number }>(responsePayload);
console.log(response.available);
const subscription = await connection.subscribe("inventory.level.changed", {
key: "item-42",
credit: 32,
});
subscription.onEvent((event) => {
const value = decodeJsonPayload<{ itemId: string; available: number }>(event.payload);
console.log(value);
});
// When this view no longer needs updates:
await subscription.unsubscribe();connect() is idempotent while an attempt is in progress and reports connected only after the
server selects hostloom.json.v1 and sends a valid welcome. Each attempt must finish within
welcomeTimeoutMilliseconds (10,000 by default, configurable from 1 to 2,147,483,647). A silent
socket is detached and closed when the deadline expires, even if it never emits a close event.
Close teardown has its own closeTimeoutMilliseconds (5,000 by default, configurable from 1 to
2,147,483,647). If a close event never arrives, the client detaches the socket and settles pending
operations locally, reporting code 1006, reason close_timeout, and wasClean: false. This
deadline also covers closing after a connection error or protocol failure.
Without the reconnect option, a close returns the connection to disconnected; call connect()
again when the application chooses to retry. When connect() is called while a caller-requested
close is still in progress, repeated calls share one promise: the client waits for the close event
or local teardown deadline before opening exactly one replacement socket. A close caused by a
protocol failure remains terminal and rejects connect() until teardown finishes.
Providing reconnect enables automatic retry after an established session is lost, meaning the
socket had received its welcome. A first connect() that never reaches a welcome rejects and
leaves the connection disconnected, so the application decides whether to try again; once a
retry cycle is running, attempts that fail before their welcome keep the cycle alive. The delay
starts at 1 second, doubles after each failed attempt, is capped at 30 seconds, and applies ±20%
jitter by default. Each value is configurable. A valid welcome resets the delay. Calling
close() or encountering a protocol error is terminal and never retries. Close code 1008 is also
terminal unless refreshCredentials is provided; the client awaits that callback before creating
the next socket. The callback runs for every 1008 close and receives the close details. The
HostLoom gateway reports session_expired or rate_limited in close.reason, so a callback can
return without refreshing when the session is still valid. A rejected refresh ends reconnection.
Browser handshake failures remain generic HostLoomConnectionError values because browsers do not
expose the HTTP response details.
request() allocates a new stream identifier for each request and resolves with the opaque Base64
response payload. It rejects with HostLoomRemoteFaultError when the gateway sends a fault, and
with HostLoomRequestCapacityError before sending when the effective concurrency limit is already
occupied. maximumConcurrentRequests sets a local bound (8 by default); the welcome can lower it
but cannot raise it, even if a hostile server advertises a larger value. Every client frame is
measured as encoded UTF-8 before it reaches the socket; HostLoomMessageSizeError reports the
actual and advertised maximum sizes when it is too large. Pass an AbortSignal to send one
cancel frame and reject locally with HostLoomRequestCanceledError; the slot remains reserved
until the gateway sends the terminal response or fault because the request is still active on the
server until then.
subscribe() shares stream allocation with request() and resolves only
after the matching subscribed frame. Events received before the first onEvent listener are
buffered within the initial credit; credit does not replenish until a listener exists.
maximumCreditPerSubscription sets the local bound (1,024 by default), and the effective limit is
the smaller of that value and the welcome credit on both initial subscription and reconnect. After
that, the client restores the requested credit whenever the remaining amount reaches lowWatermark
(half the initial credit by default). unsubscribe() sends one frame and resolves after the
gateway replies complete. An AbortSignal follows the same wire shutdown; aborting before
confirmation rejects the pending call with HostLoomSubscriptionCanceledError.
Credit and the control-frame budget
The gateway closes a session with 1008 rate_limited when more subscribe, credit, ack,
unsubscribe, cancel, and ping frames than its MaximumControlFramesPerSecond (50 by default)
arrive in one second. Credit replenished after every event would reach that limit with a small
credit on a busy topic, or with many subscriptions on one connection, so the client:
- sends credit and acknowledgements after the current task, at most one
creditframe per subscription carrying everything consumed and oneackframe carrying the highest sequence acknowledged; - counts every control frame it sends, including
subscribe,unsubscribe,cancel, andping, against a connection-wide budget, and holds automatic credit and acknowledgements back while that budget is spent. With the default gateway limit they use at most 25 frames in any one-second interval: a burst of 5, then 20 per second. Subscriptions waiting for the budget are served in turn.
Automatic resubscription uses the same budget as credit and acknowledgements, so large subscription sets recover over multiple windows. Unsubscribing while a restart is queued removes it before any new subscribe frame is sent.
Set maximumControlFramesPerSecond in the connection options when the gateway is configured with a
different limit; the pacing is half of it, and never less than one frame per second.
Pacing prevents the rate-limit close, not event loss. While a subscription's credit is held back
the gateway has no credit for it and drops its live events, counting them as no_credit. Size
credit so that the automatic frames stay below the pace. With the default low watermark, a
subscription receiving R events per second needs about 2 × R ÷ credit credit frames per
second; keep the total across the connection well below 20. For example, ten subscriptions receiving 20
events per second each need a credit of at least 32, which needs about 12 credit frames per second.
An acknowledgement adds up to one ack frame per flush, so acknowledging every event on busy
subscriptions takes budget from credit; acknowledge as often as progress tracking needs.
Several streams may subscribe to the same (topic, key) pair. They remain independent: each
receives its own event frame and unsubscribing one leaves its siblings registered. unsubscribe()
is idempotent after a subscription has already reached closed, including after a remote fault or
connection loss. An unsubscribe that was already waiting for complete still rejects if a terminal
failure interrupts that in-flight operation.
acknowledge(sequence) records positive live-event progress in the current gateway session; it
does not enable replay. The gateway keeps only the highest acknowledged sequence, so the client
sends it after the current task and skips a sequence no higher than one already sent. An acknowledgement made while the subscription is reconnecting is a
no-op because an old session's sequence cannot be applied to its replacement. Other invalid
lifecycle states throw HostLoomSubscriptionStateError with the current public state. With
automatic reconnect enabled, a logical subscription and its listeners enter reconnecting,
discard buffered events and session credit, and resubscribe with a new session-scoped stream
identifier after the replacement socket receives welcome. It becomes active only after the
matching subscribed frame. Missed events are not replayed, so applications must still use their
snapshot/version contract; a gateway snapshot provider may supply a fresh sequence-zero snapshot
during resubscription. Pending requests fail on connection loss and are never replayed, avoiding
duplicate side effects. Without automatic reconnect, connection loss ends subscriptions as before.
The low-level send() API remains available; callers using it own their stream identifiers and
must keep them distinct from automatically allocated request and subscription streams;
newStreamId() is exported for that purpose. The connection tracks a low-level subscribe until
its terminal complete or fault, so its server frames remain caller-owned and visible through
onFrame. A subscribed or event frame owned by neither API is treated as an orphan: the client
sends exactly one unsubscribe for that stream so a desynchronized gateway subscription cannot
leak for the session lifetime. A subscription frame targeting a known request stream, or a response
targeting a known subscription stream, is a protocol failure. Ping/pong remains independent of
request and subscription ownership.
Tests can supply webSocketFactory in the constructor options. The returned object implements the
small exported HostLoomWebSocket interface, so lifecycle behavior is testable without a browser
or network listener. streamIdFactory replaces the random allocator, which makes stream routing
deterministic in a test and lets an application derive a stream from its own trace identifier.
The payload helper assumes the application's HostLoom serializer produces UTF-8 JSON. Do not use it when the application serializer produces another byte representation; frame encode/decode still works with an opaque Base64 payload. The helper enforces the same Base64 syntax as the frame decoder, including required padding and rejection of whitespace.
streamId, sessionId, and eventId are identifiers, not numbers: each is a string of 32
lowercase hexadecimal digits, and the decoder rejects any other spelling, including the dashed and
uppercase forms. The all-zero identifier, exported as HOSTLOOM_SESSION_STREAM, addresses the
session itself and is valid only on welcome.
JavaScript cannot represent every .NET 64-bit integer exactly. The decoder therefore rejects
sequence and numeric limit values outside JavaScript's safe-integer range rather than silently
rounding ordering values.
Develop
From this directory:
npm ci
npm run verifyverify checks Prettier formatting, runs ESLint, compiles with TypeScript 7, runs the Vitest suite,
and performs an npm package dry-run. ESLint uses Babel's TypeScript parser because the stable
typescript-eslint release does not yet declare TypeScript 7 compatibility; tsc remains the
type-aware symbol and unused-code gate. Development requires Node 22.18 or a compatible newer
release, while the published browser package retains no runtime dependencies.
The Vitest tests load the canonical schema and fixtures directly from
src/HostLoom.AspNetCore.WebSockets/protocol, so changes to either implementation must remain
compatible with the same files. They use the injectable fake WebSocket boundary rather than a DOM
emulator for deterministic protocol and lifecycle coverage.
Release
The package has an independent websocket-client-vX.Y.Z tag stream. Before publishing, set the
same strict semantic version in package.json and add its section to this package's CHANGELOG.md.
Publishing a GitHub release for that tag runs the package gate, uploads the exact .tgz artifact,
and publishes that artifact through the protected npm environment. The ordinary .NET vX.Y.Z
release stream does not publish this package.
Publication authenticates through the package's npm trusted publisher, configured for repository
kidoz/hostloom, workflow websocket-client-release.yml, environment npm, and the npm publish
action; the workflow reads no npm token. Keep two-factor authentication required and token
publication disallowed in the npm package settings.
Releases use GitHub OIDC without a long-lived write credential and publish provenance
automatically. A manual run packages and uploads the current version without publishing by default;
select a websocket-client-vX.Y.Z tag (a branch is refused) and explicitly enable its publish
input only for controlled recovery.
