use-signalr

Quick start

Install the package, declare a contract, and call a hub from a component.

Requirements

  • Node 20.19 or later.
  • @microsoft/signalr 8, 9, or 10.
  • A server with ASP.NET Core SignalR. The older ASP.NET SignalR for .NET Framework uses a different protocol and does not work with this client.
  • Lit 3.

Install

Install the package and its SignalR peer:

npm install @dammers/use-signalr-lit @microsoft/signalr

Set up

Write the server hub

The samples in this documentation use this hub:

RoomHub.cs
using System.Collections.Concurrent;
using Microsoft.AspNetCore.SignalR;

namespace Server.Hubs;

public record ChatMessage(string RoomId, string User, string Text, DateTimeOffset SentAt);

/// <summary>Teaching hub: chat rooms built on SignalR groups.</summary>
public class RoomHub : Hub
{
    private const int HistoryLimit = 50;
    private static readonly ConcurrentDictionary<string, Queue<ChatMessage>> History = new();

    // Adding a connection that is already in the group changes nothing, so the client can retry.
    public Task JoinRoom(string roomId) => Groups.AddToGroupAsync(Context.ConnectionId, roomId);

    public Task LeaveRoom(string roomId) => Groups.RemoveFromGroupAsync(Context.ConnectionId, roomId);

    public Task SendMessage(string roomId, string text)
    {
        var message = new ChatMessage(roomId, Context.User?.Identity?.Name ?? "anonymous", text, DateTimeOffset.UtcNow);
        var history = History.GetOrAdd(roomId, _ => new Queue<ChatMessage>());
        lock (history)
        {
            history.Enqueue(message);
            if (history.Count > HistoryLimit) history.Dequeue();
        }
        return Clients.Group(roomId).SendAsync("MessageReceived", message);
    }

    public ChatMessage[] GetHistory(string roomId)
    {
        if (!History.TryGetValue(roomId, out var history)) return [];
        lock (history) return history.ToArray();
    }
}

Declare the contract

List each hub path, its events, and its methods. Names and argument types must match the server hub:

contract.ts
import { event, method } from "@dammers/use-signalr-lit";

export interface ChatMessage {
  roomId: string;
  user: string;
  text: string;
  sentAt: string;
}

// Keys are hub paths. Names and argument types match the server hub.
export const hubs = {
  "/hubs/rooms": {
    events: {
      MessageReceived: event<[message: ChatMessage]>(),
    },
    methods: {
      JoinRoom: method<[roomId: string]>(),
      LeaveRoom: method<[roomId: string]>(),
      SendMessage: method<[roomId: string, text: string]>(),
      GetHistory: method<[roomId: string], ChatMessage[]>(),
    },
  },
  "/hubs/presence": {
    lazy: true,
    graceMs: 2000,
    events: {
      OnlineCount: event<[count: number]>(),
    },
  },
};

Create the client

Pass the contract to createSignalRClient. The call returns the typed helpers that your components use:

client.ts
import { createSignalRClient } from "@dammers/use-signalr-lit";
import { hubs } from "./contract";

export const signalR = createSignalRClient({ hubs });

Start the session

Give createSession the base URL of your server and a function that returns the access token:

provider.ts
import { signalR } from "./client";

export const session = signalR.createSession({
  baseUrl: "https://api.example.com",
  accessTokenFactory: () => localStorage.getItem("token") ?? "",
  onError: (hub, error, info) => console.error(hub, info.source, error),
});

Use a hub in a component

This component shows the messages of a room and sends one message:

quick-start.ts
import { LitElement, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { ignoreAbort } from "./ignore-abort";
import { session } from "./provider";
import type { ChatMessage } from "./contract";

@customElement("chat-room")
export class ChatRoom extends LitElement {
  private roomId = "general";
  private rooms = session.hub(this, "/hubs/rooms");
  private joinRoom = this.rooms.invoke("JoinRoom");
  private sendMessage = this.rooms.invoke("SendMessage");
  @state() private messages: ChatMessage[] = [];
  private wasConnected = false;

  constructor() {
    super();
    this.rooms.on("MessageReceived", (message) => {
      if (message.roomId === this.roomId) this.messages = [...this.messages, message];
    });
  }

  updated() {
    const connected = this.rooms.status === "connected";
    if (connected && !this.wasConnected) this.joinRoom(this.roomId).catch(ignoreAbort);
    this.wasConnected = connected;
  }

  disconnectedCallback() {
    super.disconnectedCallback();
    this.wasConnected = false;
  }

  render() {
    return html`
      <ul>
        ${this.messages.map((message) => html`<li>${message.user}: ${message.text}</li>`)}
      </ul>
      <button ?disabled=${this.rooms.status !== "connected"} @click=${this.send}>Say hello</button>
    `;
  }

  private send() {
    this.sendMessage(this.roomId, "Hello").catch(ignoreAbort);
  }
}

ignoreAbort ignores the AbortError that a call gets when the component unmounts while the call waits. See Calls.

Run the example app

The repository has a runnable example app with a matching server.

Open examples/lit.

Next, read Client config and Connection status.

On this page