Skip to content

TypeScript Client Setup ​

The @cocoar/signalarrr npm package provides a TypeScript/JavaScript client for SignalARRR with support for invoke, send, stream, bidirectional streaming, and server-to-client method handling.

Feature Parity

The TypeScript client has full feature parity with the .NET client: core RPC, cancellation, bidirectional streaming, and HTTP stream references.

Installation ​

bash
npm install @cocoar/signalarrr @microsoft/signalr

The package ships as ESM and CJS with full TypeScript declarations.

Create a connection ​

Use the static create() factory with a builder callback:

ts
import { HARRRConnection } from '@cocoar/signalarrr';
import * as signalR from '@microsoft/signalr';

const connection = HARRRConnection.create(builder => {
    builder.withUrl('https://localhost:5001/apphub');
    builder.withAutomaticReconnect();
});

Or wrap an existing HubConnection:

ts
const hubConnection = new signalR.HubConnectionBuilder()
    .withUrl('https://localhost:5001/apphub')
    .build();

const connection = HARRRConnection.create(hubConnection);

Start and stop ​

ts
await connection.start();

// ... use the connection ...

await connection.stop();

Invoke (call with return value) ​

invoke<T>() calls a server method and awaits the result:

ts
const history = await connection.invoke<string[]>('ChatMethods.GetHistory');
const user = await connection.invoke<User>('UserMethods.GetUser', userId);

Send (fire-and-forget) ​

send() calls a server method without waiting for a return value:

ts
await connection.send('ChatMethods.SendMessage', 'Alice', 'Hello!');

Stream ​

stream<T>() opens a server-to-client stream:

ts
connection.stream<string>('ChatMethods.StreamMessages').subscribe({
    next: msg => console.log(msg),
    error: err => console.error(err),
    complete: () => console.log('Stream ended'),
});

Error handling ​

When a server call fails, invoke() rejects with a HARRRInvocationError: the machine-readable code, the message, and the .NET exception type. Branch on normalizedCode, never on the message:

ts
import { HARRRErrorCodes, type HARRRInvocationError } from '@cocoar/signalarrr';

try {
    await connection.invoke('RoomMethods.Join', roomId);
} catch (err) {
    const error = err as HARRRInvocationError;
    switch (error.normalizedCode) {
        case HARRRErrorCodes.Unauthorized: promptLogin(); break;
        case HARRRErrorCodes.MethodNotFound: reportContractMismatch(error); break;
        default:
            if (error.code === 'room_full') showRoomFull();   // application codes travel verbatim
            else showGeneric(error.message);
    }
}

normalizedCode is the code folded to the set this client knows (unknown codes become internal); code is the raw wire value, which is where an application's own HARRRException("room_full", ...) codes appear. The codes are the same in every SignalARRR client.

For an error that did not come through invoke() — a stream's error callback, say — parseHARRRError() reads the same envelope, and normalizeErrorCode() folds its Code:

ts
import { normalizeErrorCode, parseHARRRError } from '@cocoar/signalarrr';

const error = parseHARRRError(err);
console.log(normalizeErrorCode(error.Code), error.Message);

A connection the server rejects at negotiate — 401 or 403 for a missing or invalid connection credential — makes start() reject with SignalR's error, which SignalARRR gives a statusCode with the HTTP status (SignalR itself mentions it only in the message):

ts
try {
    await connection.start();
} catch (err) {
    if ((err as { statusCode?: number }).statusCode === 401) promptLogin();
    else throw err;
}

MessagePack protocol ​

For better performance with many clients, use MessagePack instead of JSON:

bash
npm install @microsoft/signalr-protocol-msgpack
ts
import { MessagePackHubProtocol } from '@microsoft/signalr-protocol-msgpack';

const connection = HARRRConnection.create(builder => {
    builder.withUrl('https://localhost:5001/apphub');
    builder.withHubProtocol(new MessagePackHubProtocol());
});

The server must also have MessagePack enabled (.AddMessagePackProtocol()). Both JSON and MessagePack clients can connect to the same hub simultaneously.

Authentication ​

A connection has two credentials. Usually they are the same token, so one option sets both:

ts
const connection = HARRRConnection.create('https://localhost:5001/apphub', {
    credential: async () => await getAuthToken(),
});

To give them different credentials — a single-use connection ticket, say, which has no business being resent with every message — set them separately:

ts
const connection = HARRRConnection.create('https://localhost:5001/apphub', {
    connectionCredential: async () => await getConnectionTicket(),
    messageCredential: async () => await getAuthToken(),
});
OptionAuthenticatesTravels asChecked byWhen it expires
connectionCredentialnegotiate and the transportAuthorization header; in a browser, the access_token URL parameter for WebSocket and SSE (Node: for SSE)[Authorize] on the hub class, .RequireAuthorization() on the mappingfetched again on every connect and reconnect
messageCredentialevery message, the answer to a challenge, file transfersthe Authorization field of the message; a header on file transfers[Authorize] on a method or a ServerMethods classfetched on every use, so a refreshed token is sent from the next call on
credentialboth of the above

The options are named the same in every SignalARRR client, and nothing is coupled implicitly: messageCredential alone leaves the connection anonymous, connectionCredential alone sends no message credential. A connectionCredential or messageCredential next to credential takes its part over. Each may be synchronous, async, or a plain string. See Authorization for the same table across all clients.

HARRRConnection.create(url, options, configure?) builds the SignalR connection itself and hands the connection credential to it as accessTokenFactory. SignalR's other connection options go into options.httpConnectionOptions; configure receives the builder after withUrl, for the protocol, automatic reconnect or logging:

ts
const connection = HARRRConnection.create(
    'https://localhost:5001/apphub',
    {
        credential: async () => await getAuthToken(),
        httpConnectionOptions: { transport: signalR.HttpTransportType.WebSockets },
    },
    builder => builder.withAutomaticReconnect(),
);

With create(builder => ...) or an existing HubConnection, SignalR's own accessTokenFactory authenticates the connection, and only messageCredential applies — a connection credential there throws, as does accessTokenFactory in httpConnectionOptions next to connectionCredential.

Token challenges are handled automatically — while a stream is running the server may send a ChallengeAuthentication message, and the client calls the message credential to answer it.

A connection without a message credential is not cut off: once the server's auth cache lapses it falls back to the principal established at negotiate, the way plain SignalR would, and the expiry stated on that principal is still enforced. What you lose is the refresh — the server can no longer catch a revoked credential, and cannot ask you for a new one.

A failing factory surfaces where the call does: invoke() and send() reject, and stream() reports the error to the subscriber — the stream is opened only once the credential is in hand.

Former names

authorization is the message credential under its former name. It still works and is marked deprecated; replace it with messageCredential. Using it together with messageCredential or credential throws.

Changed in 5.0.0

The client used to take the message credential from SignalR's accessTokenFactory automatically, by reading private fields off the connection. It no longer does. If your tokens are short-lived and refreshed — the usual reason for having them — set a message credential, or the connection will run on the identity it started with until that identity's stated expiry.

Connection events ​

ts
connection.onClose(error => {
    console.log('Connection closed', error);
});

connection.onReconnecting(error => {
    console.log('Reconnecting...', error);
});

connection.onReconnected(connectionId => {
    console.log('Reconnected as', connectionId);
});

Connection properties ​

PropertyTypeDescription
connectionIdstring | nullCurrent connection ID
stateHubConnectionStateDisconnected, Connecting, Connected, Reconnecting
baseUrlstringHub URL (get/set)
serverTimeoutInMillisecondsnumberServer timeout
keepAliveIntervalInMillisecondsnumberKeepalive interval

Access the raw HubConnection ​

ts
const hubConnection = connection.asSignalRHubConnection();

Method naming ​

The TypeScript client uses string method names. The pattern is ClassName.MethodName:

ts
// Calls ChatMethods.SendMessage on the server
await connection.send('ChatMethods.SendMessage', 'Alice', 'Hello!');

// Calls UserMethods.GetUser on the server
const user = await connection.invoke<User>('UserMethods.GetUser', userId);

Next steps ​

Released under the Apache-2.0 License.