npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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 HostLoomConnection that negotiates JSON-v1, waits for a valid welcome, 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 AbortSignal cancellation;
  • a subscription API with shared stream allocation, confirmation gating, bounded pre-listener event buffering, automatic low-watermark credit replenishment, acknowledgements, typed terminal faults, AbortSignal unsubscription, and automatic resubscription on a replacement socket, with credit and acknowledgements coalesced and paced below the gateway's control-frame budget;
  • client ping and server pong frames 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-client

The 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 credit frame per subscription carrying everything consumed and one ack frame carrying the highest sequence acknowledged;
  • counts every control frame it sends, including subscribe, unsubscribe, cancel, and ping, 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 verify

verify 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.