Skip to content

@glandjs/events@2.0.0

@glandjs/events

The namespaced event broker — channels, mesh networking, request/response calls, async watchers and loop-safe propagation.'

@glandjs/events is the event backbone of Gland. Where @glandjs/emitter is a local pub/sub primitive, a broker is a stateful, connectable node: it owns a namespace, exposes channels, can be linked into a mesh, and can turn events into promises.

Terminal window
pnpm add @glandjs/events
import { EventBroker } from '@glandjs/events'
const broker = new EventBroker<Events>({ name: 'orders' })
interface BrokerOptions {
/** Unique name of this broker. Required — it is also the broker's id. */
name: string
/** Namespace delimiter. @default ':' */
delimiter?: string
/** Listener resolution cache size. @default 6 */
cacheSize?: number
/** Default timeout for watchers, in ms. @default 1000 */
defaultTimeout?: number
/** Maximum listeners per exact event name. @default 5 */
maxListeners?: number
/** Swallow a failure raised while routing to a connected peer. @default false */
ignoreErrors?: boolean
}

broker.options is always fully populated: every default is resolved at construction, so a test can assert the whole object.

const broker = new EventBroker({
name: 'orders',
delimiter: ':',
defaultTimeout: 5_000,
maxListeners: 20,
})

Every broker, channel and context is generic over one map of event names to payload types.

import type { IOEvent } from '@glandjs/events'
// ✓ a type alias: names and payloads are both enforced
type Events = {
'order:placed': { orderId: string; total: number }
'order:paid': IOEvent<{ orderId: string }, boolean>
}
// ✓ a plain interface also works — it has no index signature
interface Events {
'order:placed': { orderId: string; total: number }
}

IOEvent<Payload, Return> declares both sides of a request/response event. A plain type is a payload with a void return.

broker.on('order:placed', (payload) => {
console.log(payload.orderId)
})
// chained — most methods return the broker
broker
.on('order:placed', audit)
.on('order:paid', charge)

Pass null with watch: true and you get a promise instead of a subscription:

const order = await broker.on('order:placed', null, {
watch: true,
timeout: 2_000,
defaultValue: null,
})

Fires a single time, then unsubscribes — it detaches before invoking, so a re-entrant emit from inside the listener cannot run it twice. It also accepts the same { watch: true } form as on.

broker.once('server:ready', () => startWorkers())
broker.off('order:placed', audit) // one listener
broker.off('order:placed') // every listener for that exact name

Returns a copy of the listeners registered for an exact event name, so mutating the result cannot corrupt the registry.

Synchronous dispatch, in registration order, returning the broker.

broker.emit('order:placed', { orderId: 'o-1', total: 42 })

A listener may be async, but emit does not wait for it. If you need the result, use call().

call(event, data) / call(event, data, 'all')

Section titled “call(event, data) / call(event, data, 'all')”

Request/response without a wire.

const ok = broker.call('order:paid', { orderId: 'o-1' })
// ^? boolean — straight from IOEvent
const results = broker.call('order:paid', { orderId: 'o-1' }, 'all')
// ^? boolean[]

'all' is the only strategy. Without it you get the first listener’s result; with it you get every result in registration order. There is no 'first', 'last', 'every', 'some' or 'race'.

Turns an event into a one-shot promise.

const order = await broker.watch('order:placed', 5_000)

Rejects after timeoutMs (defaulting to defaultTimeout) with Event 'order:placed' timed out after 5000ms. watch() always rejects on timeout; only the on/once watch form accepts defaultValue.

const order = await broker.on('order:placed', null, {
watch: true,
timeout: 1_000,
defaultValue: { orderId: 'fallback', total: 0 },
})

defaultValue is honoured for any non-undefined value, so 0, '', false and null all work.

channel(name) returns a live channel scoped to a namespace. Instances are cached per name, so calling it twice returns the same object, and nested channels are cached on the broker under their joined name.

const orders = broker.channel('order')
orders.on('placed', (payload) => {}) // -> 'order:placed'
orders.emit('placed', { orderId: 'o-1' })
orders.call('paid', { orderId: 'o-1' })
orders.watch('placed') // namespaced too
const refunds = orders.channel('refund') // -> 'order:refund:*'
Member Type
id string — the owning broker’s id
name string — the fully joined channel name
on / once / off / emit / call / getListener / watch as on the broker, namespaced
channel(name) a nested channel
broadcast send to the whole mesh

A channel owns no bus of its own: every method rewrites the name and delegates. broker.channel('order').emit('placed', p) and broker.emit('order:placed', p) are the same call.

Brokers can be connected into a network. An event emitted on one broker is relayed to its peers, which serve their own listeners and relay onward.

const a = new EventBroker({ name: 'service-a' })
const b = new EventBroker({ name: 'service-b' })
a.connectTo(b) // bidirectional
a.on('event:name', (data) => console.log('on a', data))
b.emit('event:name', { ok: true })
Method Signature Description
connectTo (broker, options?) => this Connect bidirectionally.
disconnect (brokerId) => boolean Drop one connection, both directions.
disconnectAll () => this Drop every connection.
isConnected (brokerId) => boolean Test a connection.
getConnections () => BrokerId[] Connected broker ids, in insertion order.
getConnection (brokerId) => EventBroker | undefined The connected broker.
createConnections (brokers[], options?) => this Full mesh, peer to peer.
findBroker (brokerId, maxDepth = 3) => EventBroker | undefined Multi-hop lookup.
broadcast (event, payload, options?) => this Emit to the whole mesh.
broadcastTo (brokerIds, event, payload, options?) => this Emit to a subset.
emitTo (brokerId, event, payload, options?) => boolean Emit to one peer.
send (event, target, payload, options?) => this Emit to one peer object.
callTo (brokerId, event, data) => EventReturn Request/response across the mesh.
a.send('order:placed', b, { orderId: 'o-1' })

send targets a broker instance, works whether or not the two are connected, and is not relayed onward. emitTo is the id-addressed variant and does require a connection — it returns false when the peer is unknown.

a.connectTo(b, { events: ['order:placed', 'order:paid'] })

Every propagate carries a correlation id, and each broker remembers the ids it has already processed, so a fully connected mesh terminates and a broker never handles the same event twice. The trace is bounded (the 4096 most recent ids) with FIFO eviction.

EventTrace.visited is bookkeeping inside a broker; only the id travels on the wire. Two brokers can therefore each process the same id once — which is exactly the guarantee you want.

broker.shutdown()

Rejects pending watchers, tells peers, drops every connection, releases cached channels and clears the processed-id trace. It is idempotent and safe on a broker that never started, and the instance stays usable afterwards.

await http.close()
broker.shutdown() // the Gland core's own bus
class EventBroker<TEvents extends EventRecord> implements Broker<TEvents> {
constructor(options: BrokerOptions)
get id(): BrokerId
readonly options: BrokerOptions
on(event, listener, options?): this
on(event, null, { watch: true, timeout?, defaultValue? }): Promise<Payload>
once(event, listener, options?): this
off(event, listener?): this
emit(event, payload, options?): this
getListener(event): Listener[]
call(event, data): EventReturn
call(event, data, 'all'): EventReturn[]
watch(event, timeoutMs?): Promise<Payload>
channel(name): Channel<ChannelEvents>
broadcast(event, payload, options?): this
broadcastTo(brokerIds, event, payload, options?): this
send(event, target, payload, options?): this
emitTo(brokerId, event, payload, options?): boolean
callTo(brokerId, event, data): EventReturn
callTo(brokerId, event, data, 'all'): EventReturn[]
connectTo(broker, options?): this
disconnect(brokerId): boolean
disconnectAll(): this
isConnected(brokerId): boolean
getConnections(): BrokerId[]
getConnection(brokerId): EventBroker | undefined
createConnections(brokers, options?): this
findBroker(brokerId, maxDepth?): EventBroker | undefined
shutdown(): void
}

Two lower-level pieces are exported as well, and both are usable on their own:

Symbol Description
EventEmitter<TEvents> The engine: on, once, off, emit, getListener, watch, shutdown. Its emit returns void, not this.
EventWatcher<TEvents> The promise layer: watch, onEmit, pending, shutdown.
interface EventOptions { watch?: boolean; timeout?: number; defaultValue?: any }
interface EmitOptions extends EventOptions {
_eventId?: string // correlation id
_sourceId?: BrokerId // originating broker
_propagate?: boolean // keep forwarding to peers, which forward onward
}

broadcast() sets all three for you. Setting _eventId yourself is how you replay the same event on purpose; setting one that the broker has already processed is a no-op.

Synchronous dispatch. Listeners run immediately, in registration order, which makes event flows straightforward to reason about and to log.

No persistence. Events flow and are gone. If no listener is registered, the emit is a no-op.

No guaranteed delivery. There is no acknowledgement, retry or dead-lettering.

No built-in serialisation. The event map is compile-time only; nothing is encoded. To move events between processes, put a channel behind a real transport.

These are design choices, not gaps. If you need durability, do the work in a channel that writes to a database or a queue.

Condition Behaviour
on with a null listener and no watch: true throws Listener cannot be null unless 'watch: true' is explicitly set in options.
More than maxListeners on one exact event name throws Maximum listeners (5) exceeded for event 'order:placed'
watch timeout rejects with Event 'order:placed' timed out after 1000ms
shutdown with watchers pending rejects with Watcher shutdown before '…' was emitted
connectTo(self) throws Cannot connect broker "orders" to itself
connectTo(null) throws Invalid broker
A local listener throws propagates out of emit; the remaining listeners for that event are skipped
A peer throws while being routed to rethrown, unless ignoreErrors: true