Events
Asynchronous event bus for decoupling business logic with file-based listeners
Arcway provides an event system for decoupling business logic. Events are dispatched via an event bus with fire-and-forget semantics — await events.emit() confirms the event was placed on the transport, but handler execution happens asynchronously and the emitter does not wait for consumers to finish.
Emitting Events
// In a route handler
export const POST = {
handler: async (ctx) => {
const { db, events } = ctx;
const [id] = await db('users').insert(ctx.req.body);
await events.emit('users/created', { id, ...ctx.req.body });
return { status: 201, data: { id } };
},
};No event declaration or schema registration is needed. Any string can be used as an event name. The convention is resource/action (e.g., users/created, billing/charged).
await events.emit() confirms the event was delivered to the transport — it does not wait for listeners to finish. Handlers run detached, after the await resolves:
await events.emit('users/created', { id });
// At this point the event is on the bus — listeners have been scheduled
// but may not have run yet. The response is already on its way to the client.Listening to Events
Create listener files in listeners/. The folder path determines which event the listener handles:
listeners/
├── users/
│ └── created.js # Handles 'users/created'
├── billing/
│ └── charged.js # Handles 'billing/charged'
└── system/
├── init.js # Convention: runs during boot
├── ready.js # Convention: runs after server starts
└── shutdown.js # Convention: runs on graceful shutdownBare function export (recommended)
Export a default async function that receives a context object:
// listeners/users/created.js
export default async (ctx) => {
const { db } = ctx;
await db('billing_accounts').insert({
user_id: ctx.event.payload.id,
plan: 'free',
});
};The listener context includes:
{
db, events, queue, cache, files, mail, log, // infrastructure
event: {
name, // e.g. 'users/created'
payload, // the data passed to events.emit()
},
}Array export (multiple handlers per event)
Export an array of functions to attach multiple listeners from a single file. All handlers run concurrently when the event fires:
// listeners/users/created.js
const createBillingAccount = async (ctx) => {
const { db } = ctx;
await db('billing_accounts').insert({ user_id: ctx.event.payload.id, plan: 'free' });
};
const sendWelcomeEmail = async (ctx) => {
const { mail } = ctx;
await mail.send({ to: ctx.event.payload.email, subject: 'Welcome!' });
};
export default [createBillingAccount, sendWelcomeEmail];Wildcard Listeners
Use [...rest].js in a listener directory to catch all events under a path:
// listeners/users/[...rest].js — handles all users/* events
export default async (ctx) => {
const { log } = ctx;
log.info(`User event: ${ctx.event.name}`, ctx.event.payload);
};Programmatic Subscriptions
Use programmatic subscriptions when you need to subscribe dynamically at runtime — for example, based on configuration, in tests, or within modules loaded conditionally. File-based listeners in listeners/ are the recommended approach for static application logic.
File-based and programmatic subscribers use the same internal subscriptions array. When an event fires, all matching handlers from both sources run together.
subscribe() returns an unsubscribe function:
// Subscribe with a wildcard pattern
const unsubscribe = events.subscribe('users/*', (payload, eventName) => {
console.log(`User event: ${eventName}`, payload);
});
// Unsubscribe when done (e.g. in tests or on teardown)
unsubscribe();Wildcard matching rules:
*matches exactly one path segment —users/*matchesusers/createdbut notusers/profile/updated- To match multiple levels, use a file-based
[...rest].jslistener or subscribe to each pattern separately
Error Handling
Handlers run as detached, fire-and-forget promises. The emitter never receives handler errors:
emitnever rejects due to a handler error — errors are logged toconsole.errorand discarded- All matching handlers run concurrently — one failing handler doesn't prevent others from running
- The emitter doesn't know or care about handler outcomes — by the time a handler throws,
emithas already resolved
// emit resolves normally even if a handler throws
await events.emit('users/created', { id: 1, email: 'user@example.com' });
// handler errors appear in logs, never surface hereEvent Drivers
memory(default) — in-process, single-server.emitschedules handlers as detached promises and returns immediately — handlers run after the current call stack clears.redis— distributed across multiple servers via Redis pub/sub.emitawaits the Redispublishcall (transport delivery confirmation), then returns. Handler execution happens in subscriber processes and is already fire-and-forget.
// arcway.config.js
export default {
events: {
driver: 'redis',
// Uses the redis config from the cache section
},
};