Pages
Contents
Event β Message Structure Concept
Status: CONCEPT Β· Created March 2026 Parent:
COMP_event_spec.md,GLOBAL_adr-010-event-driven.md,GLOBAL_concept-ecosystem.mdRelated:COMP_logger_concept-message-structure.md
Why a dedicated concept?
Events are the primary communication layer between all @xpulse/* packages.
What the logger is for output, events are for internal communication β
and just like the logger, clear rules are needed to keep the ecosystem
consistent and comprehensible.
This concept defines how an event name is constructed, how a payload is structured, what rules apply β and why.
Anatomy of an Event
An event consists of two parts:
| namespace:operation β event name |
| { key, key, key } β payload |
Example:
| event.emit('controller:navigation:ready', { |
| items: [...], |
| count: 5, |
| }); |
Event Name
Schema
| {namespace}:{operation} |
| {namespace}:{area}:{operation} |
Maximum three segments, separated by :.
| Segment | Meaning |
|---|---|
namespace |
Where the event comes from β package or feature |
area |
Optional β sub-area within the namespace |
operation |
What happened |
Namespace
Identical to the package name without the @xpulse/ prefix:
| @xpulse/controller β controller |
| @xpulse/http β http |
| @xpulse/router β router |
| @xpulse/template β template |
| @xpulse/logger β logger |
| @xpulse/app β app |
App-specific events (not from a package) use a freely chosen namespace β e.g. the feature name:
| auth:login |
| chat:message:received |
| peer:connected |
Operation
Always past tense or past participle β an event describes something that has already happened, never something that is happening or should happen:
| β controller:ready β discovery completed |
| β http:request β request received |
| β router:matched β route matched |
| β navigation:collected β navItems collected |
| β template:render:after β rendering completed |
| β controller:discovering β present progressive β don't use |
| β http:will-receive-request β future tense β don't use |
| β router:match β imperative β don't use |
Exception: before events that deliberately fire before an operation β
present progressive is allowed here because it is explicitly a pre-hook:
| β template:render:before β explicit pre-hook, data still mutable |
| β http:response:before β explicit pre-hook, body still mutable |
Lifecycle Pattern
Every package that initialises always fires both:
| {namespace}:init β initialisation begins |
| {namespace}:ready β initialisation complete |
This is mandatory β not optional. Other packages can reliably wait for startup without timing issues.
Naming Examples
| app:init |
| app:ready |
| app:stopping |
| app:stopped |
| http:init |
| http:ready |
| http:started |
| http:stopped |
| http:request |
| http:response:before |
| http:response |
| controller:init |
| controller:ready |
| controller:discovered |
| controller:called |
| controller:navigation:collected |
| controller:navigation:ready |
| router:matched |
| router:not-found |
| template:render:before |
| template:render:after |
| logger:init |
| logger:ready |
| logger:write |
| dotenv:loaded |
| config:loaded |
Payload
Ground Rules
Always an object β even when only one value is transmitted:
| // β Always object |
| event.emit('controller:ready', { count: 5, routes: 8 }); |
| // β No primitive value |
| event.emit('controller:ready', 5); |
Flat β no deeply nested structures. Anyone needing to transmit complex data serialises it themselves or rethinks the payload structure.
| // β Flat |
| event.emit('http:request', { |
| traceId: 'abc123', |
| method: 'GET', |
| path: '/info', |
| ip: '192.168.1.0', |
| }); |
| // β Too deeply nested |
| event.emit('http:request', { |
| request: { |
| meta: { |
| traceId: 'abc123', |
| }, |
| }, |
| }); |
Arrays are allowed β but only as top-level values, not nested:
| // β Array as top-level |
| event.emit('controller:navigation:collected', { |
| items: [ ... ], |
| count: 5, |
| }); |
Key Naming
All keys are camelCase, English (Developer Layer β see GLOBAL_adr-013):
| // β |
| { traceId, methodName, filePath, itemCount } |
| // β |
| { trace_id, method_name, file_path, item_count } |
Mandatory Keys
No global mandatory key for all events β each event defines its own minimal payload. But there are conventions by event type:
Lifecycle events (*:init, *:ready):
| // :init β what is being initialised |
| { root?, config? } |
| // :ready β result of initialisation |
| { count?, routes?, items?, uptime? } |
Request/Response events:
| // Always traceId so requests can be correlated |
| { traceId, method, path, ... } |
Discovery events:
| // Always count β how many were found |
| { count, ... } |
Error events (when a package communicates errors as events):
| // Always message + optional err (the Error object itself) |
| { message: 'template not found', err, context? } |
`before` Events β Mutable Payload
Events with :before suffix signal that the payload is mutable β
listeners can change values before the operation is executed.
| // template:render:before β data is mutable |
| event.on('template:render:before', ({ data }) => { |
| data._nav = navigationRegistry.getItems(); // inject |
| data._route = currentRoute; // inject |
| }); |
This is the only pattern where mutation is explicitly intended. For all other events, mutation of the payload is not defined behaviour β listeners should treat the payload as read-only.
Full Event Anatomy (Example)
| // Name: controller:navigation:collected |
| // βββββββββββ¬ββββββββββ βββββββββ |
| // namespace βarea operation |
| // ββ sub-area of controller |
| // Payload: |
| { |
| items: NavItem[], // mutable β listeners can filter/extend |
| count: number, // number of collected items before pipeline |
| } |
| // Behaviour: fired-and-forgotten, payload is mutable (pre-hook) |
What Events Are Not
Not RPC. An emit() is not a function call with a return value β
anyone needing data back uses the mutable payload pattern (:before)
or a direct API.
Not an error-handling mechanism. Errors are not communicated as events
unless there is a dedicated *:error event. Exceptions remain exceptions.
Not persistence. Events are fired-and-forgotten. Anyone needing historical
events builds a collector (@xpulse/debug does exactly that).
Not cross-process. Events live in the same Node process / browser tab. No IPC, no WebSockets, no message queues.
Separation from Log Messages
Events and log messages are two different systems with different purposes:
| Event | Log Message | |
|---|---|---|
| Purpose | Communication between packages | Output for developers/operations |
| Consumer | Other packages / app code | Human / log aggregator |
| Format | name + payload (object) |
Structured text with context |
| Persistence | No | Yes (file / console) |
| Language | English (keys) | English (message + keys) |
The logger itself uses events β logger:write β so that @xpulse/debug
can listen in. Events and logs are complementary, not redundant:
| log.debug('navItem registered', { key: 'info_team' }) |
| β |
| logger:write event β @xpulse/debug collects |
| β |
| console / file β developer reads |
Registration (optional, but recommended)
@xpulse/event allows optional registration via event.register() β
this makes events introspectable for @xpulse/debug and debug:events:list.
Every @xpulse/* package should register its events on init:
| // Internally in @xpulse/controller on init() |
| event.register('controller:navigation:collected', { |
| description: 'navItems collected from all controllers β mutable, pipeline open', |
| emittedBy: '@xpulse/controller', |
| payload: '{ items: NavItem[], count: number }', |
| mutable: true, |
| }); |
| event.register('controller:navigation:ready', { |
| description: 'Navigation finalized β read-only', |
| emittedBy: '@xpulse/controller', |
| payload: '{ items: NavItem[], count: number }', |
| mutable: false, |
| }); |
mutable: true signals that the payload may be changed in listeners.
This is documentation β @xpulse/event does not enforce it technically.