Realtime
Overview
Realtime works on topics. A topic is a plain string a page declares interest in, and any backend code can publish a message on it. RegisterPageTopic binds a topic to a page id; PublishMessage sends an event on that topic to every client currently subscribed to it. SubscribeMessage is the read half: it lets backend code receive what is published, instead of only feeding the dashboard's own streams.
Everything on this page lives in @antelopejs-private/cms/interfaces/cms/realtime.
How a Page Ends Up Subscribed
Subscription is derived, not requested by the publisher:
- A page — or a component on it — calls
RegisterPageTopic(pageId, topic)at registration time.pageIdis the page'sfullId(Pages and Modules), not its slug. - A client opens a realtime session and reports which page it is on. The backend checks that the user may access that page, then subscribes the session to every topic registered for that page id, and drops the topics that are no longer wanted.
PublishMessage(topic, type)delivers to the sessions currently subscribed totopic.
So a publisher never addresses a page or a user. It addresses a topic, and the page-topic registry decides who is listening.
Registering the same topic for the same page twice is harmless — a page's topics are a set.
Register a Topic for a Page
The typical caller is a component builder's onCreated callback, which receives the PageMetadata of the page the component was declared on (Components):
import { ComponentBuilder } from "@antelopejs-private/cms/interfaces/cms/component";
import { RegisterPageTopic } from "@antelopejs-private/cms/interfaces/cms/realtime";
export const STOCK_TOPIC = "catalog:stock";
export function StockCounter(options: { fetchUrl: string }) {
return new ComponentBuilder<{ fetchUrl: string }>("acme-stock-counter")
.options(options)
.onCreated((page) => {
const pageInfo = page.pageInfo;
if (!pageInfo) return;
RegisterPageTopic(pageInfo.fullId, STOCK_TOPIC);
});
}
Registering from onCreated is what makes the binding follow the component: whichever page the component is placed on gets the topic. The cms-base chart builders do exactly this — a chart declaring realtimeTopic registers each of its topics on the page it is placed on.
To bind a topic to a page you know statically, call RegisterPageTopic with that page's fullId directly.
RegisterPageTopic(pageId: string, topic: string): void
Publish a Message
import { Controller, Post } from "@antelopejs/interface-api";
import { PublishMessage } from "@antelopejs-private/cms/interfaces/cms/realtime";
import { STOCK_TOPIC } from "../components/stock-counter";
export class StockController extends Controller("/api/stock") {
@Post("/restock")
async restock(): Promise<{ ok: boolean }> {
await applyRestock();
await PublishMessage(STOCK_TOPIC, "stock.updated", {
payload: { warehouse: "eu-west" },
actorId: "system",
});
return { ok: true };
}
}
PublishMessage(
topic: string,
type: string,
options?: PublishMessageOptions,
): Promise<void>
| Parameter | Description |
|---|---|
topic | The topic to publish on. Subscribers are the sessions on a page that registered this topic. |
type | The event type carried with the message, for subscribers that distinguish several kinds of event on one topic. |
options | Optional payload and actor. |
PublishMessageOptions
| Option | Type | Description |
|---|---|---|
payload | Record<string, unknown> | Data carried with the event. |
actorId | string | Identifies who caused the event, so a client can ignore an event it triggered itself. |
PublishMessage resolves once the message has been handed to the broker. It does not report how many sessions received it, and publishing on a topic no page registered is not an error — it simply reaches nobody.
Subscribe from the Backend
SubscribeMessage receives every message published on a topic, whichever instance published it: the subscription rides the same broker as PublishMessage, so what one instance publishes reaches subscribers on every other.
import { SubscribeMessage } from "@antelopejs-private/cms/interfaces/cms/realtime";
import { STOCK_TOPIC } from "../components/stock-counter";
const unsubscribe = SubscribeMessage(STOCK_TOPIC, (message) => {
if (message.type !== "stock.updated") return;
const warehouse = message.payload?.warehouse;
if (typeof warehouse === "string") {
void refreshWarehouseCache(warehouse);
}
});
function SubscribeMessage(
topic: string,
handler: RealtimeMessageHandler,
): UnsubscribeMessage;
type RealtimeMessageHandler = (message: RealtimeMessage) => void;
type UnsubscribeMessage = () => void;
RealtimeMessage
| Field | Type | Description |
|---|---|---|
topic | string | Topic the message was published on. |
type | string | Event type given to PublishMessage. |
payload | Record<string, unknown> | Data carried with the event, when the publisher sent any. |
actorId | string | Who caused the event, when the publisher named an actor. |
ts | number | Publication timestamp. |
Paired with PublishMessage, this turns the CMS's broker into one a module can use for realtime channels of its own — its own endpoint subscribes here and the CMS carries the messages — instead of bringing a second broker to do the same job.
Three properties to design around:
- Handlers must not throw. They are called on the delivering instance's event loop, and nothing catches for you.
- Delivery is fire-and-forget. There is no replay: a message published while nobody was subscribed is gone. Do not use a topic as a queue.
- Unsubscribing is automatic when the registering module is unloaded. The returned
UnsubscribeMessageis for ending a subscription earlier than that; calling it twice is a no-op.
Next Steps
Quick Actions covers the other way a module reaches the running dashboard: entries in the command palette.