Skip to main content
The @ooneex/event component is a Redis-backed publish/subscribe layer. You declare an event as a class that extends RedisPubSub, give it a channel name, and implement a handler for incoming messages. Publishers call publish(data); subscribers receive the same typed payload on the channel — so components communicate without knowing about each other, even across separate processes or server instances.

Why this component

  • Decoupled communication. Publishers and subscribers only share a channel name and a data shape — never a direct reference.
  • Typed payloads. Each event class is generic over its Data type, carried through publish and into the handler.
  • One class per event. getChannel(), handler(), publish(), and subscribe() live together in a single RedisPubSub subclass.
  • Distributed by default. Messages travel through Redis, so every server instance subscribed to a channel receives them.
  • Container-managed. Register an event class with a decorator and resolve it (with its injected client) from the container.

How it works

You extend the abstract RedisPubSub base class, declare a channel with getChannel(), and implement handler() for messages arriving on that channel. The base class wires publish, subscribe, unsubscribe, and unsubscribeAll to an injected RedisPubSubClient, which talks to Redis and JSON-serializes payloads on the way out and parses them on the way in.
MemberPurpose
getChannel()Return the channel name to publish/subscribe on (sync or async).
handler(context)Handle an incoming message; receives { data, channel }.
publish(data)Serialize and publish data to the channel.
subscribe()Start receiving messages on the channel, routed to handler.
unsubscribe()Stop receiving on this event’s channel.
unsubscribeAll()Stop receiving on every subscribed channel.
getChannel() and handler() are the two abstract members you implement; the rest are provided by RedisPubSub. Data types must extend Record<string, ScalarType> so they round-trip cleanly through JSON. Malformed messages that fail to parse are silently ignored by the client.

Environment variables

VariableRequiredPurpose
PUBSUB_REDIS_URLYes (unless passed in options)Redis connection string, e.g. redis://localhost:6379. Missing throws EventException (CONNECTION_FAILED).
The client reads PUBSUB_REDIS_URL from the app environment when no connectionString is given in its options.
PUBSUB_REDIS_URL=redis://localhost:6379

Decorator and usage

@decorator.event()

Registers an event class with the container. It accepts an optional scope (defaults to singleton). The decorated class gets its RedisPubSubClient injected, so you resolve the fully wired event from the container.
import { inject } from "@ooneex/container";
import type { ScalarType } from "@ooneex/types";
import { decorator, RedisPubSub, RedisPubSubClient } from "@ooneex/event";

interface UserSignedUpData extends Record<string, ScalarType> {
  userId: string;
  email: string;
}

@decorator.event()
export class UserSignedUpEvent extends RedisPubSub<UserSignedUpData> {
  constructor(
    @inject(RedisPubSubClient)
    client: RedisPubSubClient<UserSignedUpData>,
  ) {
    super(client);
  }

  public getChannel(): string {
    return "user-signed-up";
  }

  public async handler(context: { data: UserSignedUpData; channel: string }): Promise<void> {
    const { data } = context;
    await sendWelcomeEmail(data.email);
  }
}
Resolve the event from the container, subscribe once at startup, and publish whenever the event occurs:
import { container } from "@ooneex/container";

const event = container.get(UserSignedUpEvent);

// Start receiving messages on the channel
await event.subscribe();

// Publish from anywhere — every subscribed instance gets it
await event.publish({ userId: "123", email: "john@example.com" });
The publisher and the subscriber can live in different processes. As long as both resolve an event with the same channel, the message published by one reaches the other’s handler through Redis. A channel can be dynamic. Compute it in getChannel() to scope subscriptions per tenant, room, or user:
public async getChannel(): Promise<string> {
  const userId = await this.resolveUserId();
  return `user:${userId}:events`;
}

Exceptions

The component throws EventException when the client cannot connect or an operation fails. It carries a machine-readable key, a human-readable message, and a data object.
KeyWhen
CONNECTION_FAILEDRedisPubSubClient is created without a connection string or PUBSUB_REDIS_URL.
PUBLISH_FAILEDPublishing a message to a channel fails.
SUBSCRIBE_FAILEDSubscribing to a channel fails.
UNSUBSCRIBE_FAILEDUnsubscribing from a channel fails.
UNSUBSCRIBE_ALL_FAILEDUnsubscribing from all channels fails.
import { EventException } from "@ooneex/event";

try {
  await event.publish({ userId: "123", email: "john@example.com" });
} catch (error) {
  if (error instanceof EventException) {
    logger.error(`Event error [${error.key}]: ${error.message}`, error.data);
  } else {
    throw error;
  }
}

Best practices

  • One channel, one shape. Keep each channel tied to a single Data type so publishers and subscribers stay in agreement.
  • Subscribe once at startup. Call subscribe() during bootstrap, not per request, and unsubscribe() on shutdown.
  • Keep payloads serializable. Data round-trips through JSON; use scalar fields and avoid class instances, functions, and circular references.
  • Handle errors inside handler. A throwing handler should catch and route its own failures — don’t let one bad message stop processing.
  • Name channels consistently. Use a stable scheme like user-signed-up or user:123:events so dynamic channels stay predictable.
  • Inject the client. Let the container provide RedisPubSubClient rather than constructing connections by hand.

CLI command

Scaffold an event class and its test file with the generator. It writes the class under modules/<module>/src/events/<Name>Event.ts, registers it in the module, and installs @ooneex/event if it is missing.
# Interactive: prompts for the name
ooneex event:create

# Provide the name
ooneex event:create --name=UserSignedUp

# Target a module, set a channel, and overwrite
ooneex event:create --name=UserSignedUp --module=auth --channel=user-signed-up --override
OptionDescriptionDefault
--nameEvent class name. The Event suffix is appended automatically.Prompted if omitted
--moduleTarget module the class is generated into.shared
--channelChannel name the event publishes/subscribes on.Kebab-case of the name
--overrideOverwrite an existing class without prompting.false
The generated class extends RedisPubSub with the client injected, ready for you to type the payload and fill in the handler:
import { inject } from "@ooneex/container";
import type { ScalarType } from "@ooneex/types";
import { decorator, RedisPubSub, RedisPubSubClient } from "@ooneex/event";

@decorator.event()
export class UserSignedUpEvent<Data extends Record<string, ScalarType> = Record<string, ScalarType>> extends RedisPubSub<Data> {
  constructor(
    @inject(RedisPubSubClient)
    client: RedisPubSubClient<Data>,
  ) {
    super(client);
  }

  public getChannel(): string {
    return "user-signed-up";
  }

  public async handler(context: { data: Data; channel: string }): Promise<void> {
    console.log(context);
    // TODO: Implement handler logic here
  }
}
See event:create for the full command reference.

Use with Claude and Codex

The generator ships a matching event:create skill. It runs the scaffold and then guides your AI agent through completing the event — defining a real Data type, setting the channel in getChannel(), and implementing handler(). Initialize the skills once for your agent:
ooneex claude:init
Then ask Claude in natural language — it maps the request to the generator, runs it, and fills in the implementation:
Prompt
Create an event that fires when a user signs up.
For example, the prompt above maps to event:create --name=UserSignedUp --channel=user-signed-up, then types the payload and implements handler().