xPulse
πŸ‡¬πŸ‡§ EN

Event – Message Structure Concept

Status: CONCEPT Β· Created March 2026 Parent: COMP_event_spec.md, GLOBAL_adr-010-event-driven.md, GLOBAL_concept-ecosystem.md Related: 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

@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.

en/concept/message-structure.md 2026-03-30