@exhumer/signalr-client
v1.0.2
Published
ASP.NET Core SignalR client for Node.js
Downloads
176
Maintainers
Readme
@exhumer/signalr-client
ASP.NET Core SignalR client for Node.js. HTTP and WebSocket layers are backed entirely by undici - no node-fetch, no ws, no browser shims required.
Requirements
- Node.js ≥ 22
- TypeScript ≥ 5.9 (for consumers using types)
Installation
npm install @exhumer/signalr-clientQuick start
import { HubConnectionBuilder, LogLevel } from '@exhumer/signalr-client';
const connection = new HubConnectionBuilder()
.withUrl('https://example.com/chathub')
.configureLogging(LogLevel.Information)
.withAutomaticReconnect()
.build();
connection.on('ReceiveMessage', (user: string, message: string) => {
console.log(`${user}: ${message}`);
});
await connection.start();
await connection.send('SendMessage', 'Alice', 'Hello!');
const result = await connection.invoke<string>('Echo', 'hello');
await connection.stop();HubConnectionBuilder
All configuration is done through the fluent builder before calling build().
.withUrl(url, options?)
Required. Sets the hub endpoint URL.
builder.withUrl('https://example.com/hub', {
// Bearer token factory - called before each negotiate attempt
accessTokenFactory: () => fetchToken(),
// Bitmask of transports to allow (default: all three)
transport: HttpTransportType.WebSockets | HttpTransportType.ServerSentEvents,
// Extra headers appended to every request
headers: { 'X-Tenant': 'acme' },
// Skip /negotiate and connect directly via WebSocket
// (only valid when transport is exclusively WebSockets)
skipNegotiation: true,
// How long without a server message before the connection is considered dead
serverTimeoutInMilliseconds: 60_000, // default: 30 000
// How often to send a keep-alive ping to the server
keepAliveIntervalInMilliseconds: 10_000, // default: 15 000
});.configureLogging(logLevelOrLogger)
Pass a LogLevel constant to use the built-in ConsoleLogger, or an object implementing ILogger to use your own.
builder.configureLogging(LogLevel.Warning);
// Custom logger
builder.configureLogging({
log(level, message) { myLogger.write(level, message); },
});.withAutomaticReconnect()
Enable automatic reconnection on unexpected disconnects.
// Default delays: 0 ms, 2 s, 10 s, 30 s - then give up
builder.withAutomaticReconnect();
// Custom delay sequence (ms)
builder.withAutomaticReconnect([0, 1_000, 5_000, 10_000, 30_000]);
// Custom policy - return null to stop retrying
builder.withAutomaticReconnect({
nextRetryDelayInMilliseconds({ previousRetryCount, elapsedMilliseconds }) {
if (elapsedMilliseconds > 60_000) return null; // give up after 1 min
return Math.min(1_000 * 2 ** previousRetryCount, 30_000); // exponential backoff
},
});.withCookies(jar?)
Enable automatic cookie handling. Every request in the session (negotiate, WebSocket upgrade, SSE, long-polling) shares the same CookieJar.
import { HubConnectionBuilder, CookieJar } from '@exhumer/signalr-client';
// Automatic jar - server cookies are collected and replayed automatically
builder.withCookies();
// Pre-seeded jar - inject an auth cookie obtained before connecting
const jar = new CookieJar();
await jar.setCookie('session=abc123', 'https://example.com');
builder.withCookies(jar);withCookies() and withDispatcher() are mutually exclusive. To combine cookie support with a custom dispatcher, compose the cookie interceptor instead - see Cookie handling with a custom dispatcher.
.withDispatcher(dispatcher)
Set an undici Dispatcher for the entire connection session. Accepts any subclass - Agent, Pool, Client, ProxyAgent, MockAgent, etc.
import { ProxyAgent } from 'undici';
builder.withDispatcher(new ProxyAgent('http://proxy.corp:8080'));.withHttpClient(client)
Substitute the HTTP client used for negotiate, SSE, and long-polling requests. Useful for swapping to a different undici primitive or injecting a mock in tests.
import { FetchHttpClient } from '@exhumer/signalr-client';
builder.withHttpClient(new FetchHttpClient());.build()
Returns a HubConnection. Throws if .withUrl() was never called.
HubConnection
Lifecycle
await connection.start(); // Negotiate, pick transport, perform handshake
await connection.stop(); // Graceful shutdownstart() throws if the connection is not in the Disconnected state.
State
connection.state // HubConnectionState string
connection.connectionId // string | null - assigned after negotiateHubConnectionState values: "Disconnected", "Connecting", "Connected", "Disconnecting", "Reconnecting".
Invoking hub methods
// invoke - awaits the server's return value
const result = await connection.invoke<string>('Echo', 'hello');
// send - fire-and-forget; server sends no completion message
await connection.send('Broadcast', 'hello everyone');Server streaming
const stream = connection.stream<number>('Counter', 10);
const sub = stream.subscribe({
next: (value) => console.log(value),
error: (err) => console.error(err),
complete: () => console.log('done'),
});
// Cancel early
sub.dispose();
// Or with the `using` keyword (TS 5.2+)
using sub = connection.stream<number>('Counter', 10).subscribe({ next: console.log });Receiving hub method calls
// Register a handler - multiple handlers per method are supported
connection.on('ReceiveMessage', (user: string, msg: string) => {
console.log(`${user}: ${msg}`);
});
// Remove a specific handler
connection.off('ReceiveMessage', handler);
// Remove all handlers for a method
connection.off('ReceiveMessage');Connection lifecycle callbacks
connection.onclose((error) => {
if (error) console.error('Connection closed with error:', error);
else console.log('Connection closed cleanly.');
});
connection.onreconnecting((error) => {
console.warn('Reconnecting...', error?.message);
});
connection.onreconnected((connectionId) => {
console.log('Reconnected. New connection ID:', connectionId);
});Transports
The client negotiates the best available transport automatically, trying them in this order of preference:
- WebSockets - full-duplex, lowest latency
- Server-Sent Events - server-push only; client sends via separate HTTP POSTs
- Long Polling - maximum compatibility; fallback of last resort
You can restrict which transports are attempted via the transport bitmask in withUrl():
import { HttpTransportType } from '@exhumer/signalr-client';
builder.withUrl(url, {
transport: HttpTransportType.WebSockets | HttpTransportType.LongPolling,
});
// Skip negotiate and force WebSocket directly
builder.withUrl(url, {
transport: HttpTransportType.WebSockets,
skipNegotiation: true,
});All three transport classes are also exported for advanced direct use: WebSocketTransport, ServerSentEventsTransport, LongPollingTransport.
Cookie handling with a custom dispatcher
withCookies() and withDispatcher() cannot be used together. To combine both - for example, routing through a proxy while also handling cookies - compose the cookie interceptor onto your dispatcher:
import { ProxyAgent } from 'undici';
import { HubConnectionBuilder, CookieJar, cookie } from '@exhumer/signalr-client';
const jar = new CookieJar();
const agent = new ProxyAgent('http://proxy:8080').compose(cookie({ jar }));
const connection = new HubConnectionBuilder()
.withUrl('https://example.com/hub')
.withDispatcher(agent)
.build();CookieJar and cookie are re-exported from @exhumer/signalr-client - no separate install of tough-cookie or @exhumer/undici-cookie-agent is needed.
Protocols
The library currently ships two hub protocol implementations.
JSON (default, always active):
import { JsonHubProtocol } from '@exhumer/signalr-client';HubConnection uses JsonHubProtocol internally - no configuration required.
MessagePack (optional):
import { MsgpackHubProtocol } from '@exhumer/signalr-client';MsgpackHubProtocol is exported for custom transport/protocol use cases. It depends on @msgpack/msgpack which is a regular dependency of this package.
HTTP clients
Five undici-backed HTTP client implementations are exported. The default (DispatchHttpClient) is created automatically and fits most use cases. The others are available if you need a specific undici primitive.
| Export | Undici primitive | Notes |
|---|---|---|
| DispatchHttpClient | Dispatcher#dispatch() | Default. Also aliased as HttpClient. |
| RequestHttpClient | undici.request() | |
| FetchHttpClient | undici.fetch() | WHATWG-compatible. |
| StreamHttpClient | undici.stream() | Factory-callback pattern. |
| PipelineHttpClient | undici.pipeline() | Duplex pipe pattern. |
Inject via .withHttpClient() on the builder, or use standalone:
import { DispatchHttpClient } from '@exhumer/signalr-client';
const client = new DispatchHttpClient();
const res = await client.get('https://example.com/api/data');Errors
All error classes are exported and support both instanceof checks and type guard helpers.
| Class | When thrown |
|---|---|
| HubError | Server-side hub method error |
| AbortError | In-flight operation cancelled (e.g. stop() called) |
| TransportError | Network-level failure; has .statusCode |
| HandshakeError | Server rejected the SignalR protocol handshake |
| UnsupportedTransportError | No acceptable transport could be negotiated |
import { isHubError, isAbortError, isTransportError } from '@exhumer/signalr-client';
try {
await connection.invoke('DoWork');
} catch (err) {
if (isHubError(err)) console.error('Server error:', err.message);
else if (isAbortError(err)) console.warn('Cancelled');
else if (isTransportError(err)) console.error('HTTP', err.statusCode);
else throw err;
}Logging
import { LogLevel, ConsoleLogger, NullLogger } from '@exhumer/signalr-client';LogLevel values (ascending severity): Trace, Debug, Information, Warning, Error, Critical, None.
ConsoleLogger routes messages to console.debug / console.log / console.warn / console.error depending on severity, prefixed with an ISO timestamp and level label.
NullLogger discards everything. It is the default when no logger is configured.
To plug in a third-party logger, implement ILogger:
import type { ILogger, LogLevel } from '@exhumer/signalr-client';
import pino from 'pino';
const logger = pino();
const signalrLogger: ILogger = {
log(level: LogLevel, message: string) {
logger.info({ level }, message);
},
};
builder.configureLogging(signalrLogger);Advanced: implementing a custom retry policy
import type { IRetryPolicy, RetryContext } from '@exhumer/signalr-client';
class ExponentialBackoff implements IRetryPolicy {
nextRetryDelayInMilliseconds({ previousRetryCount, elapsedMilliseconds }: RetryContext): number | null {
if (elapsedMilliseconds > 2 * 60 * 1000) return null; // give up after 2 min
return Math.min(500 * 2 ** previousRetryCount, 30_000);
}
}
builder.withAutomaticReconnect(new ExponentialBackoff());Advanced: implementing a custom transport
Implement ITransport to use a completely different underlying mechanism (e.g. a raw TCP socket):
import type { ITransport } from '@exhumer/signalr-client';
import { TransferFormat } from '@exhumer/signalr-client';
class MyTransport implements ITransport {
readonly name = 'MyTransport';
onreceive: ((data: string | Uint8Array) => void) | null = null;
onclose: ((error?: Error) => void) | null = null;
async connect(url: string, transferFormat: TransferFormat): Promise<void> { /* ... */ }
async send(data: string | Uint8Array): Promise<void> { /* ... */ }
async stop(): Promise<void> { /* ... */ }
}Build & development
# Build ESM + CJS bundles and type declarations
npm run build
# Type-check source files
npm run typecheck
# Type-check test files
npm run typecheck:test
# Run tests once
npm test
# Run tests in watch mode
npm run test:watch
# Run all benchmarks
npm run bench
# Run a specific benchmark suite
npm run bench:protocol
npm run bench:transportThe build uses tsup and produces:
| File | Format | Purpose |
|---|---|---|
| dist/index.js | ESM | import condition |
| dist/index.cjs | CJS | require condition |
| dist/index.d.ts | Types | ESM consumers |
| dist/index.d.cts | Types | CJS consumers |
Source maps are emitted for all four files.
License
MIT
