@exhumer/signalr-client 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE.md ADDED
@@ -0,0 +1,25 @@
1
+ The MIT License (MIT)
2
+ =====================
3
+
4
+ Copyright © 2026 eXhumer
5
+
6
+ Permission is hereby granted, free of charge, to any person
7
+ obtaining a copy of this software and associated documentation
8
+ files (the “Software”), to deal in the Software without
9
+ restriction, including without limitation the rights to use,
10
+ copy, modify, merge, publish, distribute, sublicense, and/or sell
11
+ copies of the Software, and to permit persons to whom the
12
+ Software is furnished to do so, subject to the following
13
+ conditions:
14
+
15
+ The above copyright notice and this permission notice shall be
16
+ included in all copies or substantial portions of the Software.
17
+
18
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
19
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
20
+ OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
21
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
22
+ HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
23
+ WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
24
+ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
25
+ OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,460 @@
1
+ # @exhumer/signalr-client
2
+
3
+ ASP.NET Core SignalR client for Node.js. HTTP and WebSocket layers are backed entirely by [undici](https://github.com/nodejs/undici) - no `node-fetch`, no `ws`, no browser shims required.
4
+
5
+ ## Requirements
6
+
7
+ - Node.js ≥ 20
8
+ - TypeScript ≥ 5.9 (for consumers using types)
9
+
10
+ ## Installation
11
+
12
+ ```sh
13
+ npm install @exhumer/signalr-client
14
+ ```
15
+
16
+ ## Quick start
17
+
18
+ ```ts
19
+ import { HubConnectionBuilder, LogLevel } from '@exhumer/signalr-client';
20
+
21
+ const connection = new HubConnectionBuilder()
22
+ .withUrl('https://example.com/chathub')
23
+ .configureLogging(LogLevel.Information)
24
+ .withAutomaticReconnect()
25
+ .build();
26
+
27
+ connection.on('ReceiveMessage', (user: string, message: string) => {
28
+ console.log(`${user}: ${message}`);
29
+ });
30
+
31
+ await connection.start();
32
+
33
+ await connection.send('SendMessage', 'Alice', 'Hello!');
34
+ const result = await connection.invoke<string>('Echo', 'hello');
35
+
36
+ await connection.stop();
37
+ ```
38
+
39
+ ## `HubConnectionBuilder`
40
+
41
+ All configuration is done through the fluent builder before calling `build()`.
42
+
43
+ ### `.withUrl(url, options?)`
44
+
45
+ Required. Sets the hub endpoint URL.
46
+
47
+ ```ts
48
+ builder.withUrl('https://example.com/hub', {
49
+ // Bearer token factory - called before each negotiate attempt
50
+ accessTokenFactory: () => fetchToken(),
51
+
52
+ // Bitmask of transports to allow (default: all three)
53
+ transport: HttpTransportType.WebSockets | HttpTransportType.ServerSentEvents,
54
+
55
+ // Extra headers appended to every request
56
+ headers: { 'X-Tenant': 'acme' },
57
+
58
+ // Skip /negotiate and connect directly via WebSocket
59
+ // (only valid when transport is exclusively WebSockets)
60
+ skipNegotiation: true,
61
+
62
+ // How long without a server message before the connection is considered dead
63
+ serverTimeoutInMilliseconds: 60_000, // default: 30 000
64
+
65
+ // How often to send a keep-alive ping to the server
66
+ keepAliveIntervalInMilliseconds: 10_000, // default: 15 000
67
+ });
68
+ ```
69
+
70
+ ### `.configureLogging(logLevelOrLogger)`
71
+
72
+ Pass a `LogLevel` constant to use the built-in `ConsoleLogger`, or an object implementing `ILogger` to use your own.
73
+
74
+ ```ts
75
+ builder.configureLogging(LogLevel.Warning);
76
+
77
+ // Custom logger
78
+ builder.configureLogging({
79
+ log(level, message) { myLogger.write(level, message); },
80
+ });
81
+ ```
82
+
83
+ ### `.withAutomaticReconnect()`
84
+
85
+ Enable automatic reconnection on unexpected disconnects.
86
+
87
+ ```ts
88
+ // Default delays: 0 ms, 2 s, 10 s, 30 s - then give up
89
+ builder.withAutomaticReconnect();
90
+
91
+ // Custom delay sequence (ms)
92
+ builder.withAutomaticReconnect([0, 1_000, 5_000, 10_000, 30_000]);
93
+
94
+ // Custom policy - return null to stop retrying
95
+ builder.withAutomaticReconnect({
96
+ nextRetryDelayInMilliseconds({ previousRetryCount, elapsedMilliseconds }) {
97
+ if (elapsedMilliseconds > 60_000) return null; // give up after 1 min
98
+ return Math.min(1_000 * 2 ** previousRetryCount, 30_000); // exponential backoff
99
+ },
100
+ });
101
+ ```
102
+
103
+ ### `.withCookies(jar?)`
104
+
105
+ Enable automatic cookie handling. Every request in the session (negotiate, WebSocket upgrade, SSE, long-polling) shares the same `CookieJar`.
106
+
107
+ ```ts
108
+ import { HubConnectionBuilder, CookieJar } from '@exhumer/signalr-client';
109
+
110
+ // Automatic jar - server cookies are collected and replayed automatically
111
+ builder.withCookies();
112
+
113
+ // Pre-seeded jar - inject an auth cookie obtained before connecting
114
+ const jar = new CookieJar();
115
+ await jar.setCookie('session=abc123', 'https://example.com');
116
+ builder.withCookies(jar);
117
+ ```
118
+
119
+ `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](#cookie-handling-with-a-custom-dispatcher).
120
+
121
+ ### `.withDispatcher(dispatcher)`
122
+
123
+ Set an undici `Dispatcher` for the entire connection session. Accepts any subclass - `Agent`, `Pool`, `Client`, `ProxyAgent`, `MockAgent`, etc.
124
+
125
+ ```ts
126
+ import { ProxyAgent } from 'undici';
127
+
128
+ builder.withDispatcher(new ProxyAgent('http://proxy.corp:8080'));
129
+ ```
130
+
131
+ ### `.withHttpClient(client)`
132
+
133
+ 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.
134
+
135
+ ```ts
136
+ import { FetchHttpClient } from '@exhumer/signalr-client';
137
+
138
+ builder.withHttpClient(new FetchHttpClient());
139
+ ```
140
+
141
+ ### `.build()`
142
+
143
+ Returns a `HubConnection`. Throws if `.withUrl()` was never called.
144
+
145
+ ---
146
+
147
+ ## `HubConnection`
148
+
149
+ ### Lifecycle
150
+
151
+ ```ts
152
+ await connection.start(); // Negotiate, pick transport, perform handshake
153
+ await connection.stop(); // Graceful shutdown
154
+ ```
155
+
156
+ `start()` throws if the connection is not in the `Disconnected` state.
157
+
158
+ #### State
159
+
160
+ ```ts
161
+ connection.state // HubConnectionState string
162
+ connection.connectionId // string | null - assigned after negotiate
163
+ ```
164
+
165
+ `HubConnectionState` values: `"Disconnected"`, `"Connecting"`, `"Connected"`, `"Disconnecting"`, `"Reconnecting"`.
166
+
167
+ ### Invoking hub methods
168
+
169
+ ```ts
170
+ // invoke - awaits the server's return value
171
+ const result = await connection.invoke<string>('Echo', 'hello');
172
+
173
+ // send - fire-and-forget; server sends no completion message
174
+ await connection.send('Broadcast', 'hello everyone');
175
+ ```
176
+
177
+ ### Server streaming
178
+
179
+ ```ts
180
+ const stream = connection.stream<number>('Counter', 10);
181
+
182
+ const sub = stream.subscribe({
183
+ next: (value) => console.log(value),
184
+ error: (err) => console.error(err),
185
+ complete: () => console.log('done'),
186
+ });
187
+
188
+ // Cancel early
189
+ sub.dispose();
190
+
191
+ // Or with the `using` keyword (TS 5.2+)
192
+ using sub = connection.stream<number>('Counter', 10).subscribe({ next: console.log });
193
+ ```
194
+
195
+ ### Receiving hub method calls
196
+
197
+ ```ts
198
+ // Register a handler - multiple handlers per method are supported
199
+ connection.on('ReceiveMessage', (user: string, msg: string) => {
200
+ console.log(`${user}: ${msg}`);
201
+ });
202
+
203
+ // Remove a specific handler
204
+ connection.off('ReceiveMessage', handler);
205
+
206
+ // Remove all handlers for a method
207
+ connection.off('ReceiveMessage');
208
+ ```
209
+
210
+ ### Connection lifecycle callbacks
211
+
212
+ ```ts
213
+ connection.onclose((error) => {
214
+ if (error) console.error('Connection closed with error:', error);
215
+ else console.log('Connection closed cleanly.');
216
+ });
217
+
218
+ connection.onreconnecting((error) => {
219
+ console.warn('Reconnecting...', error?.message);
220
+ });
221
+
222
+ connection.onreconnected((connectionId) => {
223
+ console.log('Reconnected. New connection ID:', connectionId);
224
+ });
225
+ ```
226
+
227
+ ---
228
+
229
+ ## Transports
230
+
231
+ The client negotiates the best available transport automatically, trying them in this order of preference:
232
+
233
+ 1. **WebSockets** - full-duplex, lowest latency
234
+ 2. **Server-Sent Events** - server-push only; client sends via separate HTTP POSTs
235
+ 3. **Long Polling** - maximum compatibility; fallback of last resort
236
+
237
+ You can restrict which transports are attempted via the `transport` bitmask in `withUrl()`:
238
+
239
+ ```ts
240
+ import { HttpTransportType } from '@exhumer/signalr-client';
241
+
242
+ builder.withUrl(url, {
243
+ transport: HttpTransportType.WebSockets | HttpTransportType.LongPolling,
244
+ });
245
+
246
+ // Skip negotiate and force WebSocket directly
247
+ builder.withUrl(url, {
248
+ transport: HttpTransportType.WebSockets,
249
+ skipNegotiation: true,
250
+ });
251
+ ```
252
+
253
+ All three transport classes are also exported for advanced direct use: `WebSocketTransport`, `ServerSentEventsTransport`, `LongPollingTransport`.
254
+
255
+ ---
256
+
257
+ ## Cookie handling with a custom dispatcher
258
+
259
+ `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:
260
+
261
+ ```ts
262
+ import { ProxyAgent } from 'undici';
263
+ import { HubConnectionBuilder, CookieJar, cookie } from '@exhumer/signalr-client';
264
+
265
+ const jar = new CookieJar();
266
+ const agent = new ProxyAgent('http://proxy:8080').compose(cookie({ jar }));
267
+
268
+ const connection = new HubConnectionBuilder()
269
+ .withUrl('https://example.com/hub')
270
+ .withDispatcher(agent)
271
+ .build();
272
+ ```
273
+
274
+ `CookieJar` and `cookie` are re-exported from `@exhumer/signalr-client` - no separate install of `tough-cookie` or `@exhumer/undici-cookie-agent` is needed.
275
+
276
+ ---
277
+
278
+ ## Protocols
279
+
280
+ The library currently ships two hub protocol implementations.
281
+
282
+ **JSON** (default, always active):
283
+
284
+ ```ts
285
+ import { JsonHubProtocol } from '@exhumer/signalr-client';
286
+ ```
287
+
288
+ `HubConnection` uses `JsonHubProtocol` internally - no configuration required.
289
+
290
+ **MessagePack** (optional):
291
+
292
+ ```ts
293
+ import { MsgpackHubProtocol } from '@exhumer/signalr-client';
294
+ ```
295
+
296
+ `MsgpackHubProtocol` is exported for custom transport/protocol use cases. It depends on `@msgpack/msgpack` which is a regular dependency of this package.
297
+
298
+ ---
299
+
300
+ ## HTTP clients
301
+
302
+ 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.
303
+
304
+ | Export | Undici primitive | Notes |
305
+ |---|---|---|
306
+ | `DispatchHttpClient` | `Dispatcher#dispatch()` | Default. Also aliased as `HttpClient`. |
307
+ | `RequestHttpClient` | `undici.request()` | |
308
+ | `FetchHttpClient` | `undici.fetch()` | WHATWG-compatible. |
309
+ | `StreamHttpClient` | `undici.stream()` | Factory-callback pattern. |
310
+ | `PipelineHttpClient` | `undici.pipeline()` | Duplex pipe pattern. |
311
+
312
+ Inject via `.withHttpClient()` on the builder, or use standalone:
313
+
314
+ ```ts
315
+ import { DispatchHttpClient } from '@exhumer/signalr-client';
316
+
317
+ const client = new DispatchHttpClient();
318
+ const res = await client.get('https://example.com/api/data');
319
+ ```
320
+
321
+ ---
322
+
323
+ ## Errors
324
+
325
+ All error classes are exported and support both `instanceof` checks and type guard helpers.
326
+
327
+ | Class | When thrown |
328
+ |---|---|
329
+ | `HubError` | Server-side hub method error |
330
+ | `AbortError` | In-flight operation cancelled (e.g. `stop()` called) |
331
+ | `TransportError` | Network-level failure; has `.statusCode` |
332
+ | `HandshakeError` | Server rejected the SignalR protocol handshake |
333
+ | `UnsupportedTransportError` | No acceptable transport could be negotiated |
334
+
335
+ ```ts
336
+ import { isHubError, isAbortError, isTransportError } from '@exhumer/signalr-client';
337
+
338
+ try {
339
+ await connection.invoke('DoWork');
340
+ } catch (err) {
341
+ if (isHubError(err)) console.error('Server error:', err.message);
342
+ else if (isAbortError(err)) console.warn('Cancelled');
343
+ else if (isTransportError(err)) console.error('HTTP', err.statusCode);
344
+ else throw err;
345
+ }
346
+ ```
347
+
348
+ ---
349
+
350
+ ## Logging
351
+
352
+ ```ts
353
+ import { LogLevel, ConsoleLogger, NullLogger } from '@exhumer/signalr-client';
354
+ ```
355
+
356
+ `LogLevel` values (ascending severity): `Trace`, `Debug`, `Information`, `Warning`, `Error`, `Critical`, `None`.
357
+
358
+ `ConsoleLogger` routes messages to `console.debug` / `console.log` / `console.warn` / `console.error` depending on severity, prefixed with an ISO timestamp and level label.
359
+
360
+ `NullLogger` discards everything. It is the default when no logger is configured.
361
+
362
+ To plug in a third-party logger, implement `ILogger`:
363
+
364
+ ```ts
365
+ import type { ILogger, LogLevel } from '@exhumer/signalr-client';
366
+ import pino from 'pino';
367
+
368
+ const logger = pino();
369
+
370
+ const signalrLogger: ILogger = {
371
+ log(level: LogLevel, message: string) {
372
+ logger.info({ level }, message);
373
+ },
374
+ };
375
+
376
+ builder.configureLogging(signalrLogger);
377
+ ```
378
+
379
+ ---
380
+
381
+ ## Advanced: implementing a custom retry policy
382
+
383
+ ```ts
384
+ import type { IRetryPolicy, RetryContext } from '@exhumer/signalr-client';
385
+
386
+ class ExponentialBackoff implements IRetryPolicy {
387
+ nextRetryDelayInMilliseconds({ previousRetryCount, elapsedMilliseconds }: RetryContext): number | null {
388
+ if (elapsedMilliseconds > 2 * 60 * 1000) return null; // give up after 2 min
389
+ return Math.min(500 * 2 ** previousRetryCount, 30_000);
390
+ }
391
+ }
392
+
393
+ builder.withAutomaticReconnect(new ExponentialBackoff());
394
+ ```
395
+
396
+ ---
397
+
398
+ ## Advanced: implementing a custom transport
399
+
400
+ Implement `ITransport` to use a completely different underlying mechanism (e.g. a raw TCP socket):
401
+
402
+ ```ts
403
+ import type { ITransport } from '@exhumer/signalr-client';
404
+ import { TransferFormat } from '@exhumer/signalr-client';
405
+
406
+ class MyTransport implements ITransport {
407
+ readonly name = 'MyTransport';
408
+ onreceive: ((data: string | Uint8Array) => void) | null = null;
409
+ onclose: ((error?: Error) => void) | null = null;
410
+
411
+ async connect(url: string, transferFormat: TransferFormat): Promise<void> { /* ... */ }
412
+ async send(data: string | Uint8Array): Promise<void> { /* ... */ }
413
+ async stop(): Promise<void> { /* ... */ }
414
+ }
415
+ ```
416
+
417
+ ---
418
+
419
+ ## Build & development
420
+
421
+ ```sh
422
+ # Build ESM + CJS bundles and type declarations
423
+ npm run build
424
+
425
+ # Type-check source files
426
+ npm run typecheck
427
+
428
+ # Type-check test files
429
+ npm run typecheck:test
430
+
431
+ # Run tests once
432
+ npm test
433
+
434
+ # Run tests in watch mode
435
+ npm run test:watch
436
+
437
+ # Run all benchmarks
438
+ npm run bench
439
+
440
+ # Run a specific benchmark suite
441
+ npm run bench:protocol
442
+ npm run bench:transport
443
+ ```
444
+
445
+ The build uses [tsup](https://tsup.egoist.dev/) and produces:
446
+
447
+ | File | Format | Purpose |
448
+ |---|---|---|
449
+ | `dist/index.js` | ESM | `import` condition |
450
+ | `dist/index.cjs` | CJS | `require` condition |
451
+ | `dist/index.d.ts` | Types | ESM consumers |
452
+ | `dist/index.d.cts` | Types | CJS consumers |
453
+
454
+ Source maps are emitted for all four files.
455
+
456
+ ---
457
+
458
+ ## License
459
+
460
+ MIT