Server Events: Typed Push Topics¶
ZeroZ Stack lets the server broadcast typed events to connected Wasm clients over the existing binary WebSocket — no REST callbacks, no JSON, no hand-maintained topic strings on either side.
Terminology¶
ZeroZ Stack uses four terms with distinct meanings — keeping them apart keeps the mental model clean:
| Term | Meaning |
|---|---|
| Event | A discrete, fire-and-forget occurrence broadcast from server to client: EventTopic, EventPublisher, ServerEvents. There is no "current value" and no replay. |
| Signal | Reactive state: ValueSignal, Computed, Effect — local to either tier or shared across both via Signals.shared (see SIGNALS.md). A separate, independent feature — events do not require signals. |
| Push | The transport direction: the 0x02 PUSH frame that carries events over the WebSocket. |
| Message | Reserved for application domains (e.g. a ChatMessage in a chat app). Never a framework concept — ZeroZ Stack is not a message broker. |
Declaring topics¶
Declare each topic once, in your shared API module. The declaration binds the wire name to the payload type; server and client both compile against it, so a payload mismatch is a compile error:
public final class ChatEvents {
public static final EventTopic<ChatMessage> MESSAGE_POSTED =
EventTopic.of(ChatMessage.class, "chat.messagePosted");
public static final EventTopic<Void> HISTORY_CLEARED =
EventTopic.of(Void.class, "chat.historyCleared");
}
Topic names are explicit strings, deliberately not derived from class names: renaming or moving a payload class never silently changes the wire protocol, and the names survive Wasm class-name minification.
Publishing (server)¶
Inject EventPublisher into your @RmiService implementation — not the transport engine — and publish:
@ApplicationScoped
public class ChatServiceImpl implements ChatService {
@Inject private EventPublisher events;
@Override
public void sendMessage(String text) {
ChatMessage msg = ...;
// persist ...
events.publish(ChatEvents.MESSAGE_POSTED, msg);
}
@Override
public void clearHistory() {
// persist ...
events.publish(ChatEvents.HISTORY_CLEARED);
}
}
Publishing to somebody in particular¶
publish(topic, payload) reaches every connected session, with no principal check. That is right
for genuinely public news, and wrong for anything else. When the payload belongs to somebody, name
who:
events.publishToUser(AccountEvents.BALANCE_CHANGED, balance, RmiRequestContext.getUsername());
events.publishToClient(CartEvents.ITEM_ADDED, item, RmiRequestContext.getClientId());
events.publishToSession(DraftEvents.SAVED, draft, RmiRequestContext.getSessionId());
events.publish(BillingEvents.PLAN_CHANGED, plan, Scope.TENANT, RmiRequestContext.getTenantId());
| Form | Reaches | Needs a login? |
|---|---|---|
publish(topic, payload) |
every connected session | no |
publishToSession(..., sessionId) |
one WebSocket connection | no |
publishToClient(..., clientId) |
one browser, across reconnects and reloads | no |
publishToUser(..., principalName) |
every tab and device of one person | yes |
publish(..., Scope.TENANT, tenantId) |
everyone signed in to one tenant | yes |
Take the target from RmiRequestContext — the connection's own identity — never from a method
argument the client supplied. A client that can name the recipient can name somebody else's.
Scope.CLIENT is the one for an application with no login. The client id is minted and signed by the
server and kept in an HttpOnly cookie, so it survives reconnects and reloads and page script cannot
read or forge it. It identifies a browser, not a person: someone else at the same machine and
profile is the same client. Use Scope.USER or Scope.TENANT when you mean a person.
A target that cannot be resolved — asking for Scope.USER on an anonymous connection — delivers to
nobody rather than to everybody. Silence is the safe failure here.
Subscribing (client)¶
ServerEvents.on registers a typed handler — an ordinary callback. Update your components directly in it:
Disposable sub = ServerEvents.on(ChatEvents.MESSAGE_POSTED, msg -> {
messages.add(msg);
render();
});
Every subscription returns a Disposable — dispose it when the owning view is permanently removed. Handlers run on the platform UI scheduler when one is configured.
The chat-events example (zerozstack-examples/chat-events) demonstrates this pattern end-to-end.
Avoiding the snapshot race¶
If a view loads initial state via RMI and listens for events, subscribe before fetching, then merge the snapshot with anything that arrived while the fetch was in flight (deduplicate via value equality on the payload):
ServerEvents.on(ChatEvents.MESSAGE_POSTED, msg -> ...); // 1. subscribe first
List<ChatMessage> history = chatService.getHistory(); // 2. then fetch
// 3. merge history with already-received events
Combining with Signals (optional)¶
Signals are not required to consume events. The rule of thumb:
- One render path (a handler updates one component): plain handlers, as above.
- Derived or multiply-rendered state (counts, filters, the same data shown in several places): hold the state in a
ValueSignaland let the handler reduce the event into it with an immutable update — rendering then follows automatically viaEffect, and the two can never drift apart:
ValueSignal<List<ChatMessage>> messages = new ValueSignal<>(new ArrayList<>());
Disposable sub = ServerEvents.on(ChatEvents.MESSAGE_POSTED, msg ->
messages.update(list -> {
List<ChatMessage> next = new ArrayList<>(list);
next.add(msg);
return next;
}));
If what you are broadcasting genuinely is state (a status, a live counter), you usually don't want events at all — declare a shared signal instead: the server set()s it, every client mirror updates automatically, and late joiners receive the retained value. ServerEvents.latest(topic, initialValue) remains as a bridge for deriving last-seen state from a genuine event stream.
See SIGNALS.md and the todo-signals example for the reactive model itself.
Delivery semantics¶
Stated plainly so there are no surprises:
- Broadcast to all currently connected sessions, unless the publish names a scope — then the server sends the frame only to sessions matching that target, so an unscoped payload never reaches a browser it was not meant for.
- No per-topic subscription filtering. Within the sessions a publish reaches, every client gets the frame and its handlers decide; a client that registered no handler for the topic ignores it. Scope decides who receives a frame; registering a topic handler does not.
- At most once — a disconnected client misses events; there is no queueing, acknowledgment, or redelivery.
- No replay — late subscribers do not receive past events.
- Payloads must be wire-serializable:
@DataModelclasses or types supported byBinarySerializer.
If you need durable delivery or replay, model it in your application (as the snapshot-then-merge pattern above does) — ZeroZ Stack deliberately does not include broker semantics.