Skip to content

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 ValueSignal and let the handler reduce the event into it with an immutable update — rendering then follows automatically via Effect, 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: @DataModel classes or types supported by BinarySerializer.

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.