--- status: draft last_updated: 2026-05-18 --- # Events EventEnvelope, PubSubLike, and the UjsxEventMap that define the observability layer. ## Overview UJSX uses a typed event system for observability. HostConfigs emit events at key lifecycle points — root render, instance creation, component invocation — and consumers subscribe to those events for logging, debugging, or forwarding to external systems. The system is fire-and-forget: events are published with no expectation of acknowledgment, no retry, and no built-in routing beyond type-based subscription. The event layer is intentionally decoupled from the reactive layer. Signals handle state propagation (data flow). Events handle observability (what happened). Mixing them would conflate "the tree changed" with "the host was notified," which are different concerns with different timing guarantees. ## EventEnvelope ```typescript interface EventEnvelope { readonly type: TType; readonly id: string; readonly payload: TPayload; } ``` Every event is wrapped in an envelope with three fields: - **type** — a string discriminator that identifies the event kind. Typed via `TType` so consumers can narrow payloads by type. - **id** — a unique identifier for correlation and deduplication. Events with the same id represent the same occurrence. - **payload** — the typed data carried by the event. Shape is determined by `UjsxEventMap[TType]`. The envelope is `readonly` — consumers cannot mutate a received event. This prevents accidental mutation of shared event objects and makes it safe to forward or store events without copying. ### Why both type and id? `type` answers "what kind of event is this?" and enables type-based subscription. `id` answers "which specific occurrence?" and enables correlation across systems. A root render event and an instance create event for the same render cycle may share no data except timing — `id` gives consumers a join key. The id generation strategy (currently `Date.now()` in `proxyEventEmitter`) is not specified by the envelope interface; consumers should not assume monotonicity or uniqueness beyond a reasonable best-effort guarantee. ## PubSubLike ```typescript interface PubSubLike> { publish(type: TType, id: string, payload: TEventMap[TType]): void; subscribe(type: TType, id: string): AsyncIterable>; } ``` PubSubLike is a generic typed publish/subscribe interface. It is not a concrete implementation — it is the contract that UJSX's event system expects from a pubsub provider. ### Design decisions - **subscribe returns AsyncIterable** — not a callback, not an EventEmitter, not a Promise. AsyncIterable is the most composable streaming primitive in modern JavaScript: it works with `for await ... of`, can be wrapped in backpressure-aware pipelines, and composes naturally with `AbortSignal`-based cancellation. This matches the consuming patterns of workflow engines and async orchestrators. - **Both type and id on subscribe** — `type` filters by event kind. `id` filters by occurrence. A subscriber can listen to all `root.render` events (type-only) or to a specific render cycle (type + id). The concrete pubsub implementation decides whether `id` is a filter or a partition key. - **publish returns void** — fire-and-forget. No acknowledgment, no response, no promise. The publisher does not wait for subscribers. This is consistent with the observability role: events are side effects of tree operations, not request/response messages. ### @alkdev/pubsub The `PubSubLike` interface is defined in `@alkdev/pubsub`. UJSX re-exports it as the type constraint for its event system. The actual pubsub implementation is injected by the consumer — UJSX does not bundle a pubsub runtime. ## UjsxEventMap ```typescript type UjsxEventMap = { "root.render": { childCount: number }; "root.unmount": Record; "instance.create": { kind: "text" | "element"; tag?: string; value?: string; props?: Record }; "component.invoke": { type: string }; "type.call": { objectName: string; methodName: string; args: unknown[] }; "transform.apply": { ruleName: string; direction: string }; }; ``` The event map defines the known event types and their payload shapes. Each key is an event type string; each value is the expected payload structure. | Event | When emitted | Payload | |-------|-------------|---------| | `root.render` | HostConfig calls `render()` on a root | `childCount` of the rendered tree | | `root.unmount` | HostConfig calls `unmount()` on a root | Empty record (no data) | | `instance.create` | HostConfig creates a text or element instance | `kind` discriminates text vs. element; `tag`, `value`, `props` are element-specific | | `component.invoke` | HostConfig calls a component function | `type` is the component's type string or display name | | `type.call` | Proxy event for remote type system dispatch | `objectName`, `methodName`, `args` describe the invoked method | | `transform.apply` | TransformRegistry applies a matching rule | `ruleName` and `direction` identify which rule matched | ### Event granularity These events are coarse-grained. They answer "what happened at this boundary?" not "what was the state of every node?". Fine-grained node-level changes are handled by the reactive layer (signals), not by events. ## createPubSubEmitter ```typescript function createPubSubEmitter(pubsub: PubSubLike): (type: string, id: string, payload: unknown) => void ``` Wraps a `PubSubLike` into a simple callback function. This is the bridge between the HostConfig's `emit()` function and the typed pubsub system. HostConfig needs a `(type, id, payload) => void` emitter — `createPubSubEmitter` provides exactly that signature while preserving type safety at the call site. The returned function does not validate that `type` is a known event key. If an unknown type is passed, the underlying pubsub implementation decides what happens (likely a no-op or a log warning). ## proxyEventEmitter ```typescript function proxyEventEmitter(pubsub: PubSubLike): { onTypeCall(objectName, methodName, args): void } ``` Convenience wrapper that maps the `type.call` event type to a method-call interface. Instead of: ```typescript pubsub.publish("type.call", id, { objectName, methodName, args }); ``` Consumers write: ```typescript proxy.onTypeCall("MyObject", "myMethod", [arg1, arg2]); ``` The id is auto-generated from `Date.now()`. This is adequate for debugging and local event forwarding, but not for distributed deduplication. The id is not guaranteed to be unique across concurrent calls within the same millisecond. ## Known Gaps ### No event routing or middleware Events are published directly to the pubsub. There is no middleware chain for filtering, transforming, or enriching events before they reach subscribers. Consumers that need middleware should wrap their pubsub implementation. ### No event history or replay The event system is live-only. Subscribers receive events from the moment they subscribe; there is no replay of past events. Consumers that need event sourcing should persist events in their own store. ### No error events The event map does not include error events (e.g., `instance.error`, `render.error`). HostConfig errors currently propagate as thrown exceptions, not as events. Adding error events would require deciding between fire-and-forget error logging and error-handling orchestration. ### type.call is a narrow proxy `proxyEventEmitter` only handles `type.call`. There is no proxy for other event types. As the event map grows, this convenience may need to expand or be replaced by a more general proxy pattern. ### No cleanup on unmount The current `unmount()` implementation in `HostConfig` does not tear down event subscriptions. If a pubsub emitter was wired to the host's `emit()` during render, unmount does not unsubscribe. This gap is shared with the broader reconciler disposal gap — see [lifecycle.md](lifecycle.md) and the unmount & dispose research (`docs/research/reconciler/03-unmount-dispose-support.md`). ## Constraints - **Fire-and-forget** — events are published without acknowledgment. The publisher does not know if any subscriber received or processed the event. - **PubSubLike is injected** — UJSX does not provide a pubsub implementation. The consumer supplies one. This keeps UJSX platform-agnostic and avoids coupling to a specific pubsub runtime. - **UjsxEventMap is a closed type** — the event map is a fixed union. Adding new event types requires modifying the `UjsxEventMap` type definition. This is intentional: event types define the observability contract and should be explicitly enumerated. - **AsyncIterable subscription** — `subscribe` returns `AsyncIterable`, not a callback. Consumers must use `for await ... of` or convert to a callback-based API. This is a deliberate choice for composability. - **id generation is caller-specified** — the envelope requires an `id`, but does not prescribe how it is generated. `proxyEventEmitter` uses `Date.now()`, but this is a convenience, not a contract. Other callers may use UUIDs, counters, or any unique string. ## References - Source: `src/core/events.ts` - PubSubLike interface: `@alkdev/pubsub` - HostConfig emit: `src/host/config.ts` - Direction type: `src/core/context.ts`