NPM package

Protocol contracts for JavaScript and TypeScript clients.

Product
Version

Introduction

This documentation refers to version 2.0 of the Five Minute npm packages.

The npm packages connect a JavaScript or TypeScript client, such as a web page or a browser game, to Five Minutes. They speak protocol 2.0 of the Five Minute wire protocol over SignalR. The client keeps one connection open, logs the user in, reconnects on its own, and carries every feature of protocol 2.0: user sign-in and sessions, remote config and experiments, metric events, leaderboards, chat and support tickets.

There are three packages. @fiveminutes-io/chat-client and @fiveminutes-io/support-client hold the client: FiveMinutesClient, which has every feature, and ChatClient and SupportClient, which extend it with methods for chat and for support tickets. @fiveminutes-io/contracts holds the TypeScript types of every message and record of protocol 2.0, for the whole protocol or one feature at a time.

Requirements

  • An Application ID, and an Application secret (dashboard: "App client secrets") or ephemeral app keys that your server mints (see Ephemeral app keys).
  • @microsoft/signalr 8 and a JavaScript environment with WebSockets: a browser, a bundler or Node. The packages are ES modules; from CommonJS, require() needs Node 22.12 or later.
  • To sign users in through your own accounts: your own backend, which verifies users and issues their Custom sign-in pair (see Signing in through your backend).

Quick facts for AI agents

  • Covers: @fiveminutes-io/contracts, @fiveminutes-io/chat-client and @fiveminutes-io/support-client 2.0.x.
  • Client: protocol 2.0 over SignalR. Give the client the host as serverUrl, for example https://signalr.fiveminutes.cloud; it connects to the path of protocol 2.0 on it itself (hosts: Hosts).
  • Order: create the client, register the callbacks, then await connect(). It resolves when the login has completed; from then on every method can be called. onConnected fires after every login: the first one and each one after a reconnect.
  • Identity: with signIn (a Device or Custom credential) the user signs in and the client keeps the session's tokens; without it, the server trusts the unique user id the client presents (uniqueUserId, or a random id per client instance). Store the session from onSessionChanged and pass it back as session. Unique user ids are private: other users see the display id.
  • Every feature: send(type, fields), request(type, fields) and on(type, handler) take every message of the contracts reference by its type name, with typed fields and typed replies. Helpers: fetchRemoteConfig(), getLeaderboard(), getLeaderboardRecord(), submitScore(), reportMetricEvent() and logout().
  • Errors: connect() rejects with ClientConfigError (a value the server would refuse, before connecting), ConnectionClosedError (a refused login, with the server's goodbye; a connection lost during the login; a login attempt that neither a welcome nor a goodbye ends within 35 seconds) and ProtocolVersionNotServedError (the host does not serve protocol 2.0; one attempt, no retry). A goodbye that allows a reconnect, such as a server update or the server's own 30-second login deadline, does not fail connect(): the client tries again with the reconnect backoff, and connect() rejects only after the last attempt. A request rejects with RequestFailedError (the server's FailureReason, also when the server refused the message for breaking a rule of its type) or RequestTimeoutError (no reply within 30 seconds; acknowledged says whether the server received the request). Every message is checked before it is sent: one that breaks a rule of its type rejects at once with MessageValidationError (field names the field at fault), and one over 128 KB as sent with FrameTooLargeError; neither is sent.
  • Retries: after a dropped connection, and after a goodbye that allows it (also during connect()), the client reconnects on its own after a random, growing delay (up to 1, 2, 4 ... seconds, at most 60, for 10 attempts) and logs in again as the same user. Repeat a failed read as a new request; resend an unanswered score submission or metric event unchanged, with the same MessageId (see Resending a score or a metric event).
  • Enums: by name, as strings: a goodbye's ReasonType is "BackendHandover", not a number.
  • Does not exist: a fallback to another protocol version or to the path /signalr; a remote config cache that outlives the client instance; a token refresh during a connection; more than the first page of channel history from fetchChannelHistory (read further pages with request).
  • Related: wire protocol, API Contracts 2.0, Player Authentication, Remote Config, Leaderboards, Server API, npm packages 1.8 for the earlier packages.

Packages

Package Version What it is
@fiveminutes-io/chat-client 2.0.x FiveMinutesClient, and ChatClient with methods to join channels, send chat messages, read a channel's history and receive chat messages and whispers.
@fiveminutes-io/support-client 2.0.x FiveMinutesClient, and SupportClient with methods to create and fetch support tickets, send ticket messages and receive ticket messages and whispers.
@fiveminutes-io/contracts 2.0.x Every message and record of protocol 2.0 as TypeScript interfaces, the enums by name, and a few helpers, from the root or from one subpath per feature (see Root and subpaths). It was called @fiveminutes-io/five-minute-chat-contracts before 2.0. The package's major and minor version is the protocol version.

Install one client with its two peer dependencies, @fiveminutes-io/contracts (~2.0.0: any 2.0.x, not 2.1) and @microsoft/signalr (^8.0.0):

npm install @fiveminutes-io/chat-client @fiveminutes-io/contracts @microsoft/signalr
  • Both clients have every feature. Choose ChatClient or SupportClient for their extra methods; an app that needs neither uses FiveMinutesClient from either package. Every method on this page that is not listed under ChatClient or SupportClient belongs to FiveMinutesClient.
  • All three are ES modules with type declarations. They load in browsers, bundlers and Node with import; from CommonJS, require() works on Node 22.12 or later.
  • Each client package ships its own copy of FiveMinutesClient and of its error classes, so import the error classes from the package your client comes from. FrameTooLargeError, MessageValidationError and ProtocolVersionNotServedError come from the contracts package, so instanceof works with either import.
  • Each package carries a CHANGELOG.md that lists its breaking changes; Moving from the 1.8 packages sums them up.
The unscoped package five-minute-chat-client (last version 1.0.13) is deprecated. It speaks protocol 0.8, whose messages the server does not recognise, so it cannot log in. Use the scoped packages on this page.

@fiveminutes-io/five-minute-chat-contracts is deprecated. From 2.0 the contracts package is @fiveminutes-io/contracts; the old name only re-exports it, with the same version and the same exports. Install and import @fiveminutes-io/contracts instead.

Configuration

import { ChatClient } from "@fiveminutes-io/chat-client";

const client = new ChatClient({
  serverUrl: "https://signalr.fiveminutes.cloud",
  applicationId: "<application id>",
  applicationSecret: "<application secret>",
  username: "Ada",
});
Field Default Meaning
serverUrl Required The host, for example https://signalr.fiveminutes.cloud or the global https://signalr.fiveminutes.cloud (see Hosts). The client connects to the path of protocol 2.0 on it. A URL that ends in /signalr, the form the 1.8 packages took, is taken for the host it names.
applicationId Required Your Application ID, or Demo01 / Demo02 for the public demo applications.
applicationSecret Required Your Application secret (DemoSecret for the demo applications), or a function that returns the secret for one connection, such as an ephemeral app key (see Ephemeral app keys).
username Empty The user's name, which other users see: empty, or 3 to 64 letters (of any script), digits or -. A non-empty name renames the user on every login. With an empty one, an existing user keeps their name and a new user is named Anonymous- followed by 7 digits. An e-mail address is not a valid name.
uniqueUserId A random id per client instance The unique user id to present: 1 to 256 characters, not only spaces, for example the id of your own account system or an e-mail address. Without sign-in the server trusts it; with sign-in it is offered once to keep an existing user (see Who the user is). The unique user id of a session you pass takes its place. Keep it private.
signIn None A Device or Custom credential, or a function that returns one when the client needs it (see Signing users in).
session None A session you stored from getSession() or onSessionChanged, to resume it (see Sessions).
autoJoinChannel None ChatClient only. A channel to join after every login, unless the user is a member already. A join that fails is reported through onError.
language navigator.language The device language, for your usage statistics.
platform Web The platform, for your usage statistics.
appVersion Empty Your app's version, for troubleshooting and your usage statistics.
requestTimeoutMs 30000 How long a request waits for its answer, in milliseconds.
loginTimeoutMs 35000 How long one login attempt may take, in milliseconds, counted from its start: the applicationSecret and signIn functions count against it. It ends an attempt that neither the server's welcome nor a goodbye has ended by then. The server itself says goodbye, allowing a reconnect, to a connection that has not logged in within 30 seconds of connecting (see Connecting and reconnecting).
reconnect { maxAttempts: 10, initialDelayMs: 1000, maxDelayMs: 60000 } How the client reconnects on its own (see Reconnects and goodbyes), or false to never reconnect on its own.
connectionOptions None Options for the SignalR connection of @microsoft/signalr (IHttpConnectionOptions), for example headers, the transport or a logger.

Checks before connecting

connect() checks the configuration before it opens a connection, and rejects at once with a ClientConfigError whose field names the option at fault, for example username or signIn.deviceId, when a value is one the server would refuse:

  • serverUrl or applicationId is missing, or applicationSecret is neither a non-empty text nor a function.
  • username is not empty and not 3 to 64 letters, digits or -. An e-mail address, or a name with a space, is refused.
  • uniqueUserId, or the unique user id of session, is not 1 to 256 characters, or only spaces. An e-mail address is a valid unique user id.
  • signIn.provider is not exactly Device or Custom; signIn.deviceId or signIn.customId is empty or longer than 256 characters; signIn.secret is empty or longer than 1,024 characters; signIn.create is not true or false; or signIn.username breaks the name rule.
  • language, platform or appVersion is not a text.
  • A text option holds half of a character (an unpaired surrogate), which the server cannot read.
  • requestTimeoutMs or loginTimeoutMs is not a positive number, or a number in reconnect is negative.
  • The configuration has a deviceId, the option of the 1.8 packages (see Moving from the 1.8 packages).

What an applicationSecret or signIn function returns is checked each time the client calls it: an invalid value ends that login attempt with a ClientConfigError, and the client stops. Before every login the client also checks the login messages it builds from these values, the session's tokens included, by the server's rules; a value the server would refuse ends the attempt the same way. The error messages never repeat a unique user id, a device id or a secret.

isValidUsername(name) and isValidUniqueUserId(id) apply the same rules, for example to a form field:

import { isValidUniqueUserId, isValidUsername } from "@fiveminutes-io/chat-client";

isValidUsername("Ada-1984");             // true
isValidUsername("ada@example.com");      // false: an e-mail address is not a name
isValidUniqueUserId("ada@example.com");  // true

Every message the client sends after the login is checked as well; see Checks before sending.

Who the user is

Without sign-in

Without signIn and without session tokens, the login presents only a unique user id, and the server trusts it (a legacy login; see How a login decides who the user is). The client presents the unique user id of session, otherwise uniqueUserId, otherwise an id it makes up with createDeviceId() and keeps for the lifetime of the client instance. A new client instance without one of them, such as one after a page reload, is a new user, so store the id and pass it back:

import { ChatClient, createDeviceId } from "@fiveminutes-io/chat-client";

const userId = localStorage.getItem("fm-user") ?? createDeviceId();
localStorage.setItem("fm-user", userId);

const client = new ChatClient({
  serverUrl: "https://signalr.fiveminutes.cloud",
  applicationId: "<application id>",
  applicationSecret: "<application secret>",
  uniqueUserId: userId,                // the same user on every visit
});
await client.connect();
  • Anyone who knows a unique user id can log in as that user this way. Keep it private: never show it to other users or send it to them; they see and address the user by display id.
  • An application that requires authenticated sessions refuses such a login: connect() rejects with a ConnectionClosedError whose goodbye.reasonType is UserAuthenticationFailed (see Requiring authenticated sessions). Sign users in instead.

Signing users in

With signIn, the user proves who they are, and the server decides the unique user id. This example signs in with a device id and keeps the session across visits:

import { ChatClient, createDeviceId, type UserSession } from "@fiveminutes-io/chat-client";

// Once per installation: a new device id is a new user
const deviceId = localStorage.getItem("fm-device") ?? createDeviceId();
localStorage.setItem("fm-device", deviceId);

const stored = localStorage.getItem("fm-session");
const client = new ChatClient({
  serverUrl: "https://signalr.fiveminutes.cloud",
  applicationId: "<application id>",
  applicationSecret: "<application secret>",
  signIn: { provider: "Device", deviceId },
  session: stored ? (JSON.parse(stored) as UserSession) : null,
});
client.onSessionChanged((session) =>
  session ? localStorage.setItem("fm-session", JSON.stringify(session)) : localStorage.removeItem("fm-session"));
await client.connect();
  • { provider: "Device", deviceId, create?, username? } signs in with a device id alone. Make the device id once per installation with createDeviceId() and store it: a new one is a new user. Whoever has the device id is the user, so keep it private. username names a new user and renames the user on every Device sign-in; a non-empty username option wins over it.
  • { provider: "Custom", customId, secret, create? } signs in with the pair your backend issued after verifying the user (see Signing in through your backend). Pass a function, such as signIn: async () => fetchPairFromYourBackend(), to fetch the pair only when the client needs a credential.
  • create: false signs in existing users only; without it, an unknown credential creates a new user.
  • uniqueUserId together with signIn is offered on the sign-in that creates the user, so that a user who logged in with that id before keeps their data. Pass the id your app used before it signed users in (see Keeping existing users).

On every connection, reconnects included, the client presents one sign-in input, in this order:

  1. The access token, while it is valid for at least another minute.
  2. Otherwise the refresh token.
  3. Otherwise the signIn credential, calling the function when you passed one.
  4. Without any of them, the unique user id alone.
  • After a refused access token the client connects once more with the refresh token, or else the credential; after a refused refresh token, once more with the credential. It drops the refused tokens. Without a next input it stops.
  • A sign-in that fails on the server's side, or that the server could not decide yet, is no refusal: its goodbye allows a reconnect, and the client signs in again with the same input and the same unique user id after the reconnect delay (see Reconnects and goodbyes). The same credential signs in the same user, and a uniqueUserId offered for adoption is still offered.
  • Any other refusal stops the client: onDisconnected reports the goodbye, and a pending connect() rejects with a ConnectionClosedError whose goodbye holds the ReasonType and the Reason. When a login is refused lists every Reason.
  • On every login the client takes the unique user id the server returns, and stores new tokens when the login issued them.

This is the recommended client behaviour of Player Authentication.

Sessions

getSession() returns the user as the server last named them, as a UserSession, or null before the client knows the user:

Field Meaning
uniqueUserId The user's unique user id. Private to the user.
accessToken The access token of the session, or empty.
accessTokenExpiresAt When the access token expires, in milliseconds since 1970 (the time of receipt plus its lifetime); 0 without one.
refreshToken The refresh token of the session, or empty. It works once; every refresh replaces it.
  • onSessionChanged(session) fires whenever the unique user id or the tokens change: when a login names a user the client did not know yet, after a login that issued new tokens, after the client dropped a refused token, and with null after logout(). Store what it gives you and pass it back as session to resume.
  • Store the session per Application ID, in the platform's secure storage where there is one, and never write it to logs. The refresh token signs the user in for up to 30 days, so treat it like a password.
  • Keep one session per installation. Two clients that hold the same stored session, such as two tabs of one browser, share one refresh token, which works once: when both need it, one of them is refused and signs in with the credential again, starting a new session, or stops without one.
  • isSignedIn() tells whether the connection logged in with a token or a credential, so that it holds a session. Linking and unlinking sign-in methods need one (see Linking and unlinking sign-in methods).
  • Tokens are refreshed only when the client logs in. A connection that stays open past the access token's expiry keeps working, but linking and unlinking then answer NotAuthenticated: call disconnect() and connect(), and the client logs in with the refresh token.

Logging out

logout() signs the user out: it ends the session on the server, in every region (see Revocation), forgets it (onSessionChanged gets null) and disconnects (onDisconnected reports it with requested true). The user's sessions on other devices are not affected.

try {
  await client.logout();
} catch (error) {
  // The session could not be ended now and is kept: offer to try again
}
  • It needs a connection: with a stored refresh token and no login, it rejects with a NotConnectedError.
  • A session that the server no longer knows, for example one ended from another device, counts as ended.
  • When the server is temporarily unable to end the session, or no answer comes, logout() rejects and keeps the session: call it again.
  • Any other refusal still forgets the session and disconnects, then rejects with the RequestFailedError.
  • Without a refresh token, as after a login without sign-in, it only forgets the user and disconnects.
  • After logout(), the next connect() of the same client presents uniqueUserId or a new random id, and signs in with signIn when you set it.

Ephemeral app keys

An ephemeral app key (it starts with ecs_) keeps your Application secret out of the client: your server mints a key for one connection (see Ephemeral app keys). Pass a function as applicationSecret: the client calls it before every connection, reconnects included.

import { ChatClient } from "@fiveminutes-io/chat-client";

const client = new ChatClient({
  serverUrl: "https://signalr.fiveminutes.cloud",
  applicationId: "<application id>",
  applicationSecret: async () => (await fetch("/api/fiveminutes-key")).text(),
});
  • After a refused key the client calls the function once more and connects again with the new key; a second refusal stops it.
  • The function's time counts against loginTimeoutMs: an attempt whose function has not returned within loginTimeoutMs (35 seconds by default) fails like a login that took too long.
  • A function that throws fails that attempt: connect() rejects with its error, and during reconnects the client goes on with its next attempt.

Connecting and reconnecting

Method What it does
connect(): Promise<void> Connects and logs in; resolves when the server has accepted the login. See the outcomes below.
disconnect(): Promise<void> Closes the connection and stops reconnecting. onDisconnected reports it with requested true. A later connect() starts over.
destroy() Disconnects and drops every callback and handler. The client cannot connect again.
getConnectionState() Disconnected, Connecting (during connect()), Connected (logged in) or Reconnecting.
isConnectedToServer() Whether the client is logged in, so that it can send.
getReconnectionAttempts() The number of reconnect attempts since the last login.
getSession(), isSignedIn(), logout() See Sessions and Logging out.

connect() resolves when the server has welcomed the user. It rejects:

  • with a ClientConfigError before connecting, when a value breaks a rule (see Checks before connecting);
  • with a ProtocolVersionNotServedError when the host does not serve protocol 2.0, after that one attempt (see Errors);
  • with a ConnectionClosedError when the server refuses the login (error.goodbye holds the goodbye), when the connection is lost during the login, when neither the server's welcome nor a goodbye has come within loginTimeoutMs ("The server did not complete the login in time"), when an applicationSecret or signIn function does not return in time, or when disconnect() is called meanwhile;
  • with the error of the connection attempt when no connection can be opened, for example without a network.

A goodbye that allows a reconnect does not fail connect(). When the server says such a goodbye during the login, for example during a server update or because the login did not complete within the server's 30 seconds, the client tries again the way it reconnects (see Reconnects and goodbyes): onReconnecting fires before each attempt, and getConnectionState() stays Connecting. connect() resolves when a later attempt logs in. It rejects with a ConnectionClosedError after the last attempt (maxAttempts), or at once with reconnect: false, with that goodbye in error.goodbye, and onDisconnected reports the goodbye too. A lost connection or a login timeout during one of these attempts fails connect() at once. With the default reconnect settings these attempts can keep connect() waiting for several minutes; to give up earlier, call disconnect(), and connect() rejects.

When connect() has rejected, the client does not try again on its own: call it again after a delay. While a connect() is under way, or the client is reconnecting, a second call waits for that login; once the client is logged in, a call resolves at once.

Reconnects and goodbyes

  • A dropped connection. After a login, when the connection drops, the client opens a new connection and logs in again: after a random delay of up to 1 second, then up to 2, 4, 8 ... seconds (doubling from initialDelayMs, at most maxDelayMs), for up to maxAttempts attempts (10). onReconnecting fires before each attempt. After the last failed attempt the client stops and onDisconnected fires. A connection lost during connect() fails connect() instead.
  • A goodbye. When the server says goodbye, the client closes the connection at once. When the goodbye allows a reconnect (AllowAutoReconnect true, for example during a server update), it reconnects as above, also during connect() (see the outcomes of connect() above). Otherwise it stops and onDisconnected reports the goodbye, except for the one more attempt after a refused token or a refused ephemeral app key (see Signing users in).
  • Every reconnect is a new login. The client calls the applicationSecret function again, presents the session's tokens or the credential, and logs in as the same user. At every login the server puts the connection back into every channel the user has joined, so a reconnect needs no joinChannel.
  • Waiting requests. Requests waiting for an answer reject with a ConnectionClosedError when the connection drops or a goodbye arrives; the error's goodbye holds the goodbye. While the client reconnects, sending rejects with a NotConnectedError.
  • A host that stops serving protocol 2.0 during a reconnect stops the client after that one attempt, and onDisconnected reports the ProtocolVersionNotServedError.
  • A close that does not allow reconnecting. When the server ends the connection without a goodbye and without allowing a reconnect, the client stops as well.
  • With reconnect: false the client does not reconnect after a dropped connection or a goodbye that allows it; onDisconnected fires instead. The one more attempt after a refused token or key still happens.

Callbacks

Each registration returns a function that removes the callback.

Callback When it fires
onConnected((info) => ...) After every login: the first one and each one after a reconnect. info has displayId, uniqueUserId, signedIn (the login presented a token or a credential) and reconnected (the login followed a lost connection or a goodbye, not connect()).
onDisconnected((info) => ...) Once, when the connection has ended and the client will not reconnect on its own. info has message (a text for logs), requested (true after disconnect(), destroy() or logout()), goodbye (reasonType by name, reason and allowAutoReconnect) when a goodbye ended the connection or started the reconnects that failed, and error, the cause, unless disconnect() ended it. It does not fire when connect() could not open a connection at all; connect() rejects instead.
onReconnecting((info) => ...) Before each new connection the client opens on its own: attempt, delayMs and reason (what ended the previous connection).
onSessionChanged((session) => ...) When the unique user id or the tokens change; null after logout() (see Sessions).
onError((text) => ...) Texts for your logs about failures no promise reports: an automatic channel join that failed, a message the client could not read, a handler that threw. A connect() that failed before any connection opened is reported here too.

A goodbye's reasonType is a name such as "BackendHandover"; a newer server can send a name this version does not list. Decide what to do by allowAutoReconnect and requested, and use reasonType and reason for what to show and log (see When the server closes the connection).

Every feature: send, request and on

The client sends every message a client can send, by its type name in the contracts reference, such as ClientSetUsernameRequest, with its fields as the reference names them. TypeScript checks the type name and the fields, and types the reply. The fields you leave out get their defaults, and every message gets a new MessageId and IsAckRequested true unless you pass them.

// Fire and forget: resolves with the message as sent
await client.send("ClientWhisperMessage", { Recipient: "<display id>", Content: "Well played!" });

// A request and its typed reply
const renamed = await client.request("ClientSetUsernameRequest", { Username: "Alice" });
console.log(renamed.DisplayId);

// What the server sends on its own; the returned function removes the handler
const off = client.on("ServerUserEvent", (event) => console.log(event.EventType));
  • send(type, fields?) resolves with the message as sent, MessageId included, once it is on its way. It does not wait for an answer.
  • request(type, fields?, { timeoutMs }?) sends the message and waits for its answer: the typed reply that answers its MessageId, or, for a message type without a typed reply, the ServerAck. The server acknowledges a message as soon as it has received it, before any reply, so for a request with a typed reply the ServerAck says only that the request arrived, and request waits on for the reply. The outcomes are in the table below.
  • on(type, handler) calls the handler with every message of that type the server sends, except the replies that answer a request(); off(type, handler) removes it. Messages of a type this version of the package does not know are ignored.
  • send and request check the message before sending it, and reject at once with a MessageValidationError when it breaks a rule of its type and with a FrameTooLargeError when it is over 128 KB as sent; neither is sent (see Checks before sending).
  • send and request reject with a NotConnectedError before the login and while the client reconnects, and with a TypeError for a type name that is not a client message, and for ClientCredentialsResponse and ClientUserInfoResponse, which the client sends itself at every login.
  • A request for a message type without a typed reply needs IsAckRequested true, the default; with false, request rejects with a TypeError: use send.
  • One request per MessageId can wait at a time. ServerDeleteChannelResponse does not name its request, so the client lets one ClientDeleteChannelRequest wait at a time, with IsAckRequested true (the default): its ServerAck pairs the reply with it.
What comes back request()
The typed reply with Success true Resolves with the reply.
The typed reply with Success false Rejects with a RequestFailedError: its message is the FailureReason, failureReason holds it too, and response the whole reply, for fields such as ErrorCode.
The ServerAck of a message type without a typed reply Resolves with the ServerAck.
A ServerAck with Success false Rejects with a RequestFailedError: the server refused the message for breaking a rule of its type, and the FailureReason names the rule. The message was not processed.
The ServerAck of a request with a typed reply Nothing yet: the request arrived, and request waits on for the reply.
No typed reply within requestTimeoutMs (30 seconds) Rejects with a RequestTimeoutError (type, messageId, timeoutMs, acknowledged). The outcome is unknown. acknowledged is true when the ServerAck came: the server received the request, but its reply was lost or late, or the server dropped a repeat of a MessageId it saw in the last 5 minutes, which gets only the ServerAck.
The connection drops, a goodbye arrives, or disconnect() is called first Rejects with a ConnectionClosedError. The outcome is unknown.

After a RequestTimeoutError, repeat a read as a new request, which gets a new MessageId. Repeat a write only when it is safe to repeat (a chat message sent twice is posted twice), and handle a score submission or a metric event as below. Replies and acknowledgements explains what the server sends.

Helpers

The helpers are requests, with the checks and the outcomes above.

Method Resolves with
fetchRemoteConfig() ServerRemoteConfigResponse: the user's public remote config entries and experiment assignments. The client keeps the last full answer of the user for its lifetime and presents its ETag; when nothing changed, the reply has NotModified true and the client fills in the kept Entries and Assignments. See Fetch remote config.
reportMetricEvent(name, value?, { MessageId }?) ServerReportMetricEventResponse. Pass a value for a Number or Decimal event and none for a Binary event. See Report a metric event.
getLeaderboard(key, { Window, Offset, Limit, CycleId, GroupMoniker }?) ServerGetLeaderboardResponse: by default the 50 records around the user in the current cycle; { Window: "Top" } reads from the top. See Read a leaderboard view.
getLeaderboardRecord(key, cycleId?) ServerGetLeaderboardRecordResponse: the user's own record, with Found false when there is none.
submitScore(key, score, { Subscore, Metadata, MessageId }?) ServerSubmitScoreResponse. See Submit a score.
logout() Nothing: the session is ended and forgotten, and the client disconnected (see Logging out).
const config = await client.fetchRemoteConfig();
const maxPlayers = Number(config.Entries.find((e) => e.Key === "max_players")?.Value ?? "8");

const result = await client.submitScore("weekly_arena", 18250);
const page = await client.getLeaderboard("weekly_arena", { Limit: 10 });
await client.reportMetricEvent("level_complete");

Resending a score or a metric event

The server applies a score submission or a metric event once, recognising it by its MessageId for 24 hours. When submitScore or reportMetricEvent rejects with a RequestTimeoutError, the outcome is unknown:

  • error.acknowledged false: it may not have arrived. Send it again unchanged, with the same MessageId (error.messageId), at most 3 times within 5 minutes.
  • error.acknowledged true: the server has it. A resend within 5 minutes of the previous send gets only another acknowledgement and times out again, so wait until 5 minutes have passed since the last send, then send it once more, unchanged and with the same MessageId, to learn the outcome.
import { RequestTimeoutError } from "@fiveminutes-io/chat-client";
import { newMessageId } from "@fiveminutes-io/contracts";

const messageId = newMessageId();   // keep it until the outcome is known
try {
  const result = await client.submitScore("weekly_arena", 18250, { MessageId: messageId });
  showResult(result.Accepted, result.Score, result.Rank);
} catch (error) {
  if (error instanceof RequestTimeoutError) {
    // Unknown outcome: resend unchanged with { MessageId: messageId }. Not acknowledged: at most
    // 3 times within 5 minutes. Acknowledged: once more, 5 minutes after the last send
  }
}
  • A ConnectionClosedError carries no MessageId. To resend after a lost connection, make the MessageId yourself with newMessageId() from the contracts package, as above, and pass it with every send of that score or event.
  • Resend only an unchanged message: a score submission with the same MessageId and another board, score, subscore or metadata is refused with This message was already used for a different submission.
  • A metric event is recognised only in the region that accepted it. A client that may resend one after a reconnect connects to a regional host, such as https://signalr.fiveminutes.cloud, rather than to the global host.
  • Details: After a lost response for scores and Resending a metric event for metric events.

ChatClient

Method What it does
sendChatMessage(content, channel?): Promise<void> Sends a chat message of 1 to 16,384 characters to a channel the user has joined; without channel it sends to Global. Resolves when the message is sent; an empty or longer text rejects with a MessageValidationError and is not sent. sendMessage is the same method.
joinChannel(channel): Promise<void> Joins a channel. Resolves when the server has confirmed the join, and rejects with a RequestFailedError when it refuses it, for example with You are banned from this channel.
fetchChannelHistory(channel): Promise<ChatMessageEvent[]> Resolves with the first page of the channel's history. For a channel that does not exist it rejects with a RequestFailedError whose FailureReason is Channel not found.. Read further pages with request("ClientChannelHistoryPagedRequest", { ChannelName, PageKey }), with PageKey set to the NextPageKey of the previous page.
onMessageReceived((message) => ...) For every chat message in a channel the user has joined, the user's own included.
onWhisperReceived((message) => ...) For every whisper to or from the user, including notices from the server.

A ChatMessageEvent has messageId, content, sender (the sender's name, or display id), timestamp, channel and metadata. A WhisperMessageEvent has messageId, content, sender, receiver, timestamp and metadata. A refused chat message arrives as a whisper from a system user; see Fire-and-forget: sending a chat message. Send a whisper with send("ClientWhisperMessage", { Recipient, Content }), addressing the recipient by display id. A failure of sendChatMessage other than a NotConnectedError is also reported through onError.

import { ChatClient, ConnectionClosedError, ProtocolVersionNotServedError } from "@fiveminutes-io/chat-client";

const chat = new ChatClient({
  serverUrl: "https://signalr.fiveminutes.cloud",
  applicationId: "<application id>",
  applicationSecret: "<application secret>",
  username: "Ada",
  uniqueUserId: storedUserId,       // the same user on every visit
  autoJoinChannel: "general",       // joined after every login
});

chat.onMessageReceived((m) => showLine(m.channel, m.sender, m.content));
chat.onWhisperReceived((w) => showWhisper(w.sender, w.content));
chat.onDisconnected((info) => showOffline(info.message));

try {
  await chat.connect();
} catch (error) {
  if (error instanceof ProtocolVersionNotServedError) {
    // The host does not serve protocol 2.0: tell the user, do not retry in a loop
  } else if (error instanceof ConnectionClosedError && error.goodbye) {
    // The login was refused: error.goodbye.reasonType and error.goodbye.reason say why
  }
  throw error;
}

await chat.sendMessage("Hello!", "general");

SupportClient

Method What it does
createSupportTicket(topic, message): Promise<string> Creates a ticket and resolves with its id; onTicketCreated fires with it too. Rejects with a MessageValidationError, without sending, when the topic has more than 512 characters, the message more than 32,768, or both are empty, and with a RequestFailedError when the server refuses the ticket.
getSupportTicket(ticketId): Promise<SupportTicket> Asks for one of the user's tickets. Resolves with { id, messages: [] }; the ticket's messages then arrive one by one through onSupportMessageReceived. Rejects with a RequestFailedError, for example with Support ticket not found.
sendSupportMessage(ticketId, message): Promise<void> Adds a message to the ticket and resolves when it is sent; an empty message rejects with a MessageValidationError and is not sent. The server answers with the message itself, through onSupportMessageReceived.
onSupportMessageReceived((message) => ...) For every message of the user's tickets, from the user and from your support staff.
onTicketCreated((ticketId) => ...) After createSupportTicket has created a ticket.
onWhisperReceived((message) => ...) For every whisper to or from the user.

A SupportMessageEvent has messageId, ticketId, content, sender and timestamp. The timestamp is the send time only in the server's answer to the user's own message; fetched messages and replies from your support staff carry the minimum date (year 1). Show ticket messages in the order they arrive and drop duplicates by messageId; see Support ticket fetching for how the server delivers them. A WhisperMessageEvent has messageId, content, sender, receiver and timestamp.

A ticket belongs to the unique user id that created it, so log in as the same user whenever the user comes back to a ticket: with the same uniqueUserId, or signed in. As another user, getSupportTicket fails with Support ticket not found. A ticket's topic can have at most 512 characters and its first message at most 32,768; a later message has no limit of its own beyond the 128 KB of a message as sent. The client checks these rules before sending (see Checks before sending).

import { SupportClient } from "@fiveminutes-io/support-client";

const support = new SupportClient({
  serverUrl: "https://signalr.fiveminutes.cloud",
  applicationId: "<application id>",
  applicationSecret: "<application secret>",
  username: "Ada",
  uniqueUserId: "ada@example.com",   // an e-mail address can be the unique user id, not the name
});
support.onSupportMessageReceived((m) => showTicketLine(m.ticketId, m.sender, m.content));

await support.connect();
const ticketId = await support.createSupportTicket("Billing", "How do I upgrade my plan?");
await support.sendSupportMessage(ticketId, "I pay by card.");

Errors

All are exported by both client packages.

Error When What to do
ClientConfigError connect() found a value the server would refuse, before connecting; or an applicationSecret or signIn function returned one. field names the option, such as username. Fix the value (see Checks before connecting).
ProtocolVersionNotServedError The host refused the connection to the path of protocol 2.0, before any login: it does not serve protocol 2.0. connect() rejects with it after that one attempt; on a reconnect the client stops, and onDisconnected reports it. It carries protocolVersion ("2.0") and url (the URL it tried). When connectionOptions sets skipNegotiation, the client cannot recognise the refusal and reports an ordinary connection error instead. Do not retry in a loop: the answer does not change soon. Check that serverUrl names a Five Minutes host (see Hosts). If it does, the server has not been updated to protocol 2.0 yet: tell the user that the service is unavailable and try again much later, after minutes rather than seconds. With skipNegotiation, treat a connection error that comes back on every attempt the same way.
ConnectionClosedError The connection ended before the login completed or before an answer came: a refused login or a goodbye (goodbye holds reasonType, reason and allowAutoReconnect), a login that did not complete in time, a lost connection, or disconnect(). For a refused login, act as When the server closes the connection and When a login is refused say, and do not retry it in a loop. For a waiting request, the outcome is unknown.
NotConnectedError A message was to be sent before connect() resolved, while the client reconnects, or after the connection ended. Send after await connect(), or from onConnected.
MessageValidationError A message breaks a rule the server checks it by, found before sending. type is the message type, field the field at fault, such as Limit or Metadata[1].Key (empty when the message as a whole is at fault), and the message says what is wrong. Nothing is sent, and the connection stays open. Fix the value: the same message fails the same way every time. See Checks before sending.
RequestFailedError The server answered a request with Success false. Its message is the FailureReason; response is the whole reply. The feature's document explains each FailureReason.
RequestTimeoutError No reply within requestTimeoutMs. type, messageId and timeoutMs describe it; acknowledged says whether the server acknowledged the request, that is, received it. The outcome is unknown: repeat a read as a new request; resend a score or a metric event as above.
FrameTooLargeError A message is larger than the server accepts: 128 KB (131,072 bytes) per message as sent, counted over the whole message, not only its text. The message is not sent and the connection stays open. It carries type, bytes (the message's size as sent) and limit. Shorten or split the text. A chat message within its own limit of 16,384 characters fits, unless it carries large metadata.

A connect() on a client after destroy() rejects with an Error.

Checks before sending

The client checks every message before it sends it, by the rules the server checks it by: in send, request, the helpers and the methods of ChatClient and SupportClient. A message that breaks a rule rejects at once with a MessageValidationError and is not sent, and the connection stays open. Without the check the server would refuse such a message: the request would reject with a RequestFailedError that names the rule, and more than 10 refused messages within 10 seconds would close the connection (see Replies and acknowledgements).

  • The rules of each message type, which the contracts reference and the feature documents give: for example a chat message of 1 to 16,384 characters, a whisper of 1 to 1,024 characters, a leaderboard key of 1 to 128 characters, a Limit of 1 to 100, a metric event name of 1 to 64 lower-case letters, digits or _, metadata keys of 1 to 128 characters, and event types of one kind (users or channels) in one subscription.
  • The value of every field, as the contracts package types it: texts without half a character (an unpaired surrogate), GUIDs, ISO 8601 date-times, whole numbers within the field's range, finite numbers, true or false, enums by their exact names ("Top", not "top" or a number), flags as names joined by ", " (see Enums by name), and null only where a field is typed T | null. TypeScript keeps your code to most of these; the checks also catch values from plain JavaScript, stored data or user input.

error.type is the message type, error.field the field at fault, such as Limit, Metadata[1].Key or OtherUserIds[0] (empty when the message as a whole is at fault), and the error's message says which rule it broke. The checks look at the message only: whether a board, channel or ticket exists, and what the user may do, is the server's answer, for example a RequestFailedError with Unknown leaderboard '<key>'.

validateClientMessage(type, message) checks a message the same way without sending it, and returns it or throws the MessageValidationError. It takes a complete message, as send sends it: build one from your fields with createClientMessage(type, fields), which fills in the defaults and a new MessageId as send does. Both come from the contracts package, and both client packages export them.

import { MessageValidationError, createClientMessage, validateClientMessage } from "@fiveminutes-io/chat-client";

try {
  await client.getLeaderboard("weekly_arena", { Limit: 500 });
} catch (error) {
  if (error instanceof MessageValidationError) {
    // error.type "ClientGetLeaderboardRequest", error.field "Limit",
    // error.message "Parameter 'Limit' must be 1-100"; nothing was sent
  }
}

// Check a text before offering to send it, for example from a form
const draft = createClientMessage("ClientChatMessage", { ChannelName: "general", Content: text });
try {
  validateClientMessage("ClientChatMessage", draft);
} catch (error) {
  if (error instanceof MessageValidationError) showError(error.message);  // empty or over 16,384 characters
}

Device ids

createDeviceId() returns a new random id, a version 4 UUID from the platform's secure random source: crypto.randomUUID(), or crypto.getRandomValues() where the former is missing, as on pages served over http. It throws when neither exists.

  • Make a device id once per installation and store it, for example in localStorage: a new one is a new user.
  • A device id signs the user in with Device. Never show it, log it or send it anywhere else.
  • The client uses the same source for the random unique user id it makes up without uniqueUserId. Where there is no secure random source, connect() rejects with a ClientConfigError for uniqueUserId: pass one.

Enums by name

The contracts package writes every enum as a frozen object of its names, with a string union type of the same name. A value is the member's name, as the server sends it:

import { EventTypeFlags, Language, ServerGoodbyeReasonTypes } from "@fiveminutes-io/contracts";

client.onDisconnected((info) => {
  if (info.goodbye?.reasonType === ServerGoodbyeReasonTypes.ServiceCapReached) {
    // The same as info.goodbye.reasonType === "ServiceCapReached"
  }
});
const translateTo = Language.English;                                   // "English"
const events = EventTypeFlags.of("UserConnected", "UserDisconnected");  // "UserDisconnected, UserConnected"
EventTypeFlags.has(events, "UserConnected");                            // true
  • EventType is a flags enum: a field typed EventTypeFlags holds one name or several joined by ", ", in the order the server writes them. Build one with EventTypeFlags.of(...), read it with EventTypeFlags.names(value) and test it with EventTypeFlags.has(value, name).
  • A newer server can send a name this package does not list, for example a new ReasonType. Handle an unknown name like one you have no special handling for, and decide on reconnecting by AllowAutoReconnect.
  • The contracts reference lists every enum with its members. It also gives their numbers, which the npm packages do not use.

The contracts package

@fiveminutes-io/contracts gives the clients, and your code, the types:

Export What it is for
The interfaces One per message and record, named as in the contracts reference, with every field as the server names it and its description as a doc comment. Ids and date-times are strings (GUIDs, and ISO 8601 date-times in UTC); 64-bit numbers are number, exact up to 2^53. A field the server may leave empty is typed T | null.
ClientWireTypes, ServerWireTypes, ClientMessage, ServerMessage The interface of every message by its type name, and unions that narrow on the type name. isClientWireType(type) and isServerWireType(type) tell whether a type name is one of them.
The enums By name; see Enums by name. AuthProviderNames holds the sign-in provider names (Device, Custom).
newMessageId() A new MessageId, for a score submission or a metric event that you may have to resend (see Resending a score or a metric event).
validateClientMessage, createClientMessage, MessageValidationError The checks the clients make before sending, for checking a message without sending it, and the complete message send builds from your fields. Both client packages export them too (see Checks before sending).
MAX_FRAME_BYTES, FrameTooLargeError, ProtocolVersionNotServedError The size limit of a message as sent (131,072 bytes) and the two errors the clients take from this package.

Root and subpaths

The root, @fiveminutes-io/contracts, exports everything. Each subpath exports one feature: its interfaces, its enums and its constants. A subpath loads only its feature and /common, so a bundler leaves the other features out. A name is the same whichever way it is imported, so root and subpath imports mix freely.

Import from What it holds
@fiveminutes-io/contracts Everything below, plus the helpers of the whole protocol: ClientWireTypes, ServerWireTypes, ClientMessage, ServerMessage, createClientMessage, isClientWireType, isServerWireType, validateClientMessage, MessageValidationError.
@fiveminutes-io/contracts/common Login, user info, metadata, acknowledgements and goodbyes (ServerWelcome, UserInfo, ServerGoodbyeReasonTypes, AuthProviderNames, ...), and newMessageId, MAX_FRAME_BYTES, ProtocolVersionNotServedError, FrameTooLargeError.
@fiveminutes-io/contracts/chat Channels, chat messages and whispers, and Language.
@fiveminutes-io/contracts/social Event subscriptions and personal groups, and EventType, EventTypeFlags.
@fiveminutes-io/contracts/leaderboards Scores and boards.
@fiveminutes-io/contracts/remote-configuration Remote config, experiments and metric events.
@fiveminutes-io/contracts/support Support tickets.
@fiveminutes-io/contracts/voice Peer-to-peer stream negotiation.
import { newMessageId, ServerGoodbyeReasonTypes } from "@fiveminutes-io/contracts/common";
import type { ServerGetLeaderboardResponse } from "@fiveminutes-io/contracts/leaderboards";
import type { ServerRemoteConfigResponse } from "@fiveminutes-io/contracts/remote-configuration";

TypeScript resolves the subpaths with moduleResolution node16, nodenext or bundler. The older node setting sees only the root.

Behaviour and limits

Topic Behaviour
Protocol Protocol 2.0, on the path of protocol 2.0 of the host (/signalr/v2.0), which sets the protocol version of the connection. The packages speak this version only; there is no fallback to another version or to the path /signalr.
Login The client runs the login on every connection itself. connect() resolves and onConnected fires when the server has welcomed the user. The server says goodbye to a connection that has not logged in within 30 seconds of connecting, allowing a reconnect, and the client then tries again with the reconnect backoff, also during connect(). A login attempt that neither a welcome nor a goodbye ends within 35 seconds (loginTimeoutMs) fails.
Calls before the login Sending rejects with a NotConnectedError until the login has completed and while the client reconnects, so nothing reaches the server before its welcome.
Reconnects After a dropped connection, and after a goodbye that allows it: a new connection and a new login, after a random delay of up to 1, 2, 4 ... seconds, at most 60, for up to 10 attempts (reconnect). Every reconnect presents the session's tokens or the credential, calls the applicationSecret function again and keeps the same user.
Goodbyes The client closes the connection at once. It reconnects when the goodbye allows it; otherwise onDisconnected reports the goodbye, with its ReasonType by name.
Answers A request waits up to 30 seconds (requestTimeoutMs) for its answer, and rejects at once when the connection drops or a goodbye arrives.
Message fields Every message is checked before it is sent, by the rules of its type and the type of each field; one that breaks a rule rejects with MessageValidationError and is not sent (see Checks before sending).
Message size 128 KB per message as sent; a larger one rejects with FrameTooLargeError and is not sent.
Message pacing As for every client: 10 times the "Messages Per Second Per User" of your plan per 10 seconds; see Pacing.
Connection attempts Each connection makes HTTP requests that count against the edge limit of 5 requests per second per IP address; see Pacing. The random reconnect delays spread the attempts of many users behind one IP address.
Remote config cache The last full answer of fetchRemoteConfig() is kept in memory, per user, for the lifetime of the client instance. To keep it across page reloads, store the answer yourself (see Fetch remote config).

Moving from the 1.8 packages

The 2.0 packages replace @fiveminutes-io/five-minute-chat-contracts 1.8.x and the clients 1.0.x. The CHANGELOG.md of each package lists every change; this is what to change in your code.

With the chat or support client

  1. Install the 2.0 clients with @fiveminutes-io/contracts 2.0.x, and remove @fiveminutes-io/five-minute-chat-contracts; the clients do not work with contracts 1.8.
  2. Set serverUrl to the host, such as https://signalr.fiveminutes.cloud. A URL ending in /signalr still works and is taken for the host it names.
  3. Rename deviceId to uniqueUserId: connect() refuses a deviceId option with a ClientConfigError rather than ignore it. For a Device sign-in, the device id goes into signIn.deviceId. username is now optional; an empty one keeps the user's name, and an e-mail address is refused as a name (it can be the unique user id).
  4. Call the other methods after await connect(), which now resolves when the login has completed. Remove code that waited for onConnected before the first call, or that skipped its extra call before a reconnect's login: onConnected now fires once per login.
  5. Handle a rejected connect(): ClientConfigError before connecting, ConnectionClosedError for a refused login (with the server's goodbye), a lost connection or a login not completed, and ProtocolVersionNotServedError. A goodbye that allows a reconnect makes connect() try again with the reconnect backoff before it rejects; call disconnect() to give up earlier.
  6. Read onConnected(info) and onDisconnected(info) as objects (ConnectedInfo, DisconnectInfo) instead of texts, and move reconnect reporting from the "Reconnecting..." texts of onError to onReconnecting.
  7. Remove your own reconnect logic: the client reconnects with a random, growing delay for 10 attempts, also after a goodbye that allows it, and onDisconnected fires once, when it will not reconnect. The reconnect option changes this.
  8. Expect the states Disconnected, Connecting, Connected and Reconnecting from getConnectionState().
  9. Catch the new errors where you send: NotConnectedError, MessageValidationError, RequestFailedError, RequestTimeoutError (30 seconds by default; acknowledged says whether the server received the request), ConnectionClosedError and FrameTooLargeError. joinChannel now waits for the server's confirmation and rejects when the server refuses the join.
  10. Keep the function a callback registration returns if you want to remove the callback later. The ConnectionStateEvent type is gone.
  11. With autoJoinChannel set, the client now joins that channel after every login; drop a joinChannel call that does the same.
  12. Load the packages as ES modules; require() from CommonJS needs Node 22.12 or later.

The 2.0 clients also have what the 1.0 clients lacked: sign-in and sessions (signIn, session, getSession(), onSessionChanged, isSignedIn(), logout()), an applicationSecret function for ephemeral app keys, every message through send, request and on, and the helpers for remote config, metric events and leaderboards. A web app that used its own server and the Server API for these features can now use the client instead.

With the contracts package on its own

  1. Replace @fiveminutes-io/five-minute-chat-contracts with @fiveminutes-io/contracts in your dependencies and imports. Each feature can also be imported on its own (see Root and subpaths).
  2. Send and receive messages through a 2.0 client: send, request and on take every message of the package by its type name. There is no message to construct with new: messages and records are interfaces now.
  3. Drop $type and ApiVersion, and the MessageBase and Version classes: messages no longer carry them.
  4. Compare enum values by name (ServerGoodbyeReasonTypes.BackendHandover, "English"), not by number, and build and read EventType values with EventTypeFlags.
  5. Read FailureReason instead of Reason on ServerJoinChannelResponse, ServerLeaveChannelResponse, ServerSetMetadataResponse, ServerSetUsernameResponse, ServerSubscribeEventResponse, ServerUnsubscribeEventResponse, ServerUserInfoResponse and ServerUserInfosResponse. Stop reading UserInfo.Id and ServerChatMessage.ApplicationId, and stop sending UniqueUserId in ClientWhisperHistoryRequest and ClientWhisperHistoryPagedRequest: protocol 2.0 does not carry them.
  6. Follow the types in your null checks: a field is T | null only where the server may leave it empty.
  7. Load the package as an ES module, as above.

Related documents