Skip to content

Concepts

A channel is a named scope for messages. Think of it as a namespace — subscribers on the "orders" channel only see messages published to that channel.

import { getChannel } from "postal";

const orders = getChannel("orders");
const users = getChannel("users");

Channels are singletons. getChannel("orders") always returns the same instance, regardless of where you call it. This means you can import getChannel in multiple modules and they’ll all share the same subscriber list.

If you don’t need multiple channels, call getChannel() with no arguments:

const bus = getChannel();

Channels can be torn down with dispose():

const ch = getChannel("temp");
// ... use it ...
ch.dispose();

Disposing a channel:

  • Clears all subscribers and handlers
  • Rejects any pending RPC promises with PostalDisposedError
  • Removes the channel from the singleton registry
  • Makes subsequent subscribe, publish, request, and handle calls throw PostalDisposedError

Calling dispose() on an already-disposed channel is a no-op. Unsubscribe functions returned before disposal become silent no-ops.

Topics are dot-delimited strings that describe what a message is about:

order.placed
order.placed.rush
user.profile.updated
system.health.check

There’s no registration step — any string works as a topic. The dots are meaningful only for wildcard matching.

Subscription patterns support two wildcards, following the AMQP standard:

ch.subscribe("order.*", callback);
// Matches: order.placed, order.cancelled
// Does NOT match: order.placed.rush (two segments after "order")
ch.subscribe("order.#", callback);
// Matches: order, order.placed, order.placed.rush, order.a.b.c.d

Wildcards can appear anywhere in the pattern:

ch.subscribe("*.placed", callback); // user.placed, order.placed
ch.subscribe("#.updated", callback); // updated, user.updated, user.profile.updated
ch.subscribe("order.*.rush", callback); // order.placed.rush, order.cancelled.rush
ch.subscribe("*", callback); // Any single-segment topic
ch.subscribe("#", callback); // Every topic on the channel

Every message flowing through postal is wrapped in an Envelope:

type Envelope<TPayload = unknown> = {
    id: string; // UUID v4, unique per message
    type: EnvelopeType; // "publish" | "request" | "reply"
    channel: string; // Channel name
    topic: string; // Topic string
    payload: TPayload; // The message data
    timestamp: number; // Date.now() when created
    source?: string; // Set by transports for echo prevention
    replyTo?: string; // Present on request envelopes
    correlationId?: string; // Links replies to requests
};

Subscribers always receive the full envelope, not just the payload. This gives you routing context without needing to thread metadata through your application.

  • "publish" — Standard pub/sub message. Created by channel.publish().
  • "request" — RPC request. Created by channel.request(). Includes replyTo and correlationId.
  • "reply" — RPC response. Created internally by handle(). Carries the handler’s return value and the correlationId from the original request.
  1. Publish: You call channel.publish(topic, payload) or channel.request(topic, payload).
  2. Envelope creation: postal wraps the payload in an Envelope with a unique ID, timestamp, and routing fields.
  3. Dispatch: The envelope is matched against every subscriber’s pattern. All matches receive the envelope.
  4. Transports: If any transports are registered, the envelope is forwarded to remote contexts (with echo prevention).
  5. Wiretaps: After transports fire, registered wiretaps see the envelope.

Subscriber errors don’t stop dispatch — if one subscriber throws, the rest still execute. Errors are collected and thrown as a single AggregateError after all subscribers have been called.