@mmgt-cloud/realtime-client
v1.0.1
Published
Universal TypeScript client for the realtime service.
Downloads
173
Maintainers
Readme
@mmgt-cloud/realtime-client
Universal TypeScript client for the MMGT realtime service. It targets browser
frontends and backend/BFF runtimes with standards-based WebSocket and fetch
implementations.
The package exports:
RealtimeClientfor authenticated browser WebSocket connections.RealtimeAppClientfor backend grant creation and event publishing.
Install
pnpm add @mmgt-cloud/realtime-clientNo .npmrc, GitHub account, or access token is required. Browser use requires WebSocket and fetch; Node.js 22+ callers can supply a WebSocket factory. Public exports include both clients, channel helpers, cursor stores, typed errors, and protocol types. Licensed under MIT.
Browser client
import { RealtimeClient, userChannel } from "@mmgt-cloud/realtime-client";
import { AuthClient } from "@mmgt-cloud/auth-client";
const auth = new AuthClient({
baseUrl: "https://api.mmgt.cloud/auth",
appId: "00000000-0000-0000-0000-000000000001"
});
const realtime = new RealtimeClient({
baseUrl: "https://api.mmgt.cloud/realtime",
appId: "00000000-0000-0000-0000-000000000001",
tokenProvider: async () => (await auth.getTokens())?.accessToken
});
realtime.onEvent<{ todoId: string }>("todo.updated", (event) => {
console.log(event.id, event.payload.todoId);
});
const ready = await realtime.connect();
await realtime.subscribe(userChannel(ready.user_id));The auth token is sent in the first WebSocket frame:
{ "type": "auth", "app_id": "<app-id>", "access_token": "<auth-access-token>" }It is never put in the WebSocket URL.
Group channels
Group channels require a short-lived grant minted by an app backend or BFF:
await realtime.subscribe("project:alpha", {
grant: grantFromBackend,
presence: true
});Client-side publishing also requires a grant with publish permission:
realtime.publish("project:alpha", "todo.updated", { todoId: "todo-1" }, { grant });Backend/BFF client
import { RealtimeAppClient, targetChannel, targetUser } from "@mmgt-cloud/realtime-client";
const realtime = new RealtimeAppClient({
baseUrl: "https://api.mmgt.cloud/realtime",
appId: process.env.REALTIME_APP_ID!,
apiKey: process.env.REALTIME_APP_API_KEY!
});
const grant = await realtime.createSubscriptionGrant({
userId: "00000000-0000-0000-0000-000000000001",
channels: ["project:alpha"],
permissions: ["subscribe", "presence"],
ttlSeconds: 300
});
await realtime.publish({
targets: [
targetChannel("project:alpha"),
targetUser("00000000-0000-0000-0000-000000000001")
],
eventType: "todo.updated",
payload: { todoId: "todo-1" }
});Never expose REALTIME_APP_API_KEY to browser code.
Backend and BFF code can also inspect current channel presence and persisted transport acknowledgement state:
const presence = await realtime.getPresence("project:alpha");
const ack = await realtime.getAckState({
channel: "project:alpha",
userId: "00000000-0000-0000-0000-000000000001"
});Reconnect and replay
connect() resolves after the server's ready frame. Each attempt has a
15-second deadline covering the token provider, WebSocket handshake and ready.
It rejects with connection_timeout on expiry, connection_closed if the
socket closes before ready, or connection_cancelled on manual disconnect.
Handle the returned promise, including when unmount/logout cancels a pending
connection. Automatic reconnect uses the configured backoff after transient
failures; manual disconnect cancels it. invalid_auth, invalid_token and
auth_required stop handshake reconnect until an explicit new connection.
Each attempt owns its socket and callbacks. Late events and token-provider completions from a retired attempt cannot replace the current connection or send frames on it. Retiring a socket aborts a pending WebSocket handshake too. An already-running custom cursor-store operation cannot be cancelled by the client; implementations must handle concurrent writes themselves. Close and discard the client and use user-scoped storage when changing accounts.
Delivery is at-least-once. A backend publication retried after losing its
response can receive a different transport event.id. Include a stable domain
event or message ID and deduplicate application effects by that identity.
The browser client stores the last event ID per appId + channel in
localStorage by default and sends it as resume_after when it reconnects or
resubscribes. You can replace this with MemoryRealtimeCursorStore or any
custom RealtimeCursorStore.
If the server emits replay_gap, the cursor is older than the bounded replay
buffer. Treat that as a signal to refresh the full application state.
Presence and transport acknowledgements
Presence is per app and channel. Subscribe with presence: true and a grant
that includes the presence permission to receive presence_snapshot,
presence_joined, and presence_left.
realtime.onPresence("project:alpha", (users) => {
console.log(users.map((user) => user.user_id));
});ack(channel, eventId) persists the last acknowledged event ID server-side for the
authenticated user and channel. The server confirms persisted state with
ack_confirmed. This is transport progress, not proof that a person read a
domain message. Domain read receipts require an authorized message and recipient.
The default ackMode is manual. Use ackMode: "auto" only when your UI is
ready to acknowledge transport events after handlers run:
await realtime.subscribe("project:alpha", {
grant,
ackMode: "auto"
});Future extensions
Unknown server message types are emitted as RealtimeUnknownMessage, so future
messages such as richer presence diffs or read-receipt confirmations can be
added without replacing the dispatcher.
