Arcwayv0.3.0

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 shutdown

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/* matches users/created but not users/profile/updated
  • To match multiple levels, use a file-based [...rest].js listener or subscribe to each pattern separately

Error Handling

Handlers run as detached, fire-and-forget promises. The emitter never receives handler errors:

  • emit never rejects due to a handler error — errors are logged to console.error and 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, emit has 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 here

Event Drivers

  • memory (default) — in-process, single-server. emit schedules handlers as detached promises and returns immediately — handlers run after the current call stack clears.
  • redis — distributed across multiple servers via Redis pub/sub. emit awaits the Redis publish call (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
  },
};

On this page