Skip to content

Getting Started

Install the core package:

# Core (required) — includes contracts, handlers, topology derivation, RPC
npm install @apogeelabs/hoppity rascal zod

Contracts are the single source of truth. Define events, commands, and RPC operations with Zod schemas:

import { z } from "zod";
import { defineDomain } from "@apogeelabs/hoppity";

export const OrdersDomain = defineDomain("orders", {
    events: {
        orderCreated: z.object({
            orderId: z.string(),
            total: z.number(),
        }),
    },
    commands: {
        cancelOrder: z.object({ orderId: z.string() }),
    },
    rpc: {
        getOrderSummary: {
            request: z.object({ orderId: z.string() }),
            response: z.object({ orderId: z.string(), status: z.string(), total: z.number() }),
        },
    },
});

The returned OrdersDomain object contains typed contract objects for each operation. These carry all naming conventions — exchange names, routing keys, publication/subscription names — so you never deal with string literals.

Handlers declare what your service processes. Each handler is bound to a contract:

import { onEvent, onCommand, onRpc } from "@apogeelabs/hoppity";
import { OrdersDomain } from "./contracts";

const cancelOrderHandler = onCommand(
    OrdersDomain.commands.cancelOrder,
    async ({ orderId }, { broker }) => {
        // Handle the command — orderId is typed from the Zod schema
        await broker.publishEvent(OrdersDomain.events.orderCreated, {
            orderId,
            total: 0,
        });
    }
);

const getOrderSummaryHandler = onRpc(OrdersDomain.rpc.getOrderSummary, async ({ orderId }) => {
    // Return type is enforced by the response schema
    return { orderId, status: "active", total: 42.0 };
});

Handler payloads are typed via z.infer from the contract’s schema. The context.broker gives handlers access to typed outbound methods for publishing events, sending commands, or making RPC calls.

Pass handlers and publish declarations to hoppity.service() and call .build():

import hoppity from "@apogeelabs/hoppity";

const broker = await hoppity
    .service("order-service", {
        connection: {
            url: process.env.RABBITMQ_URL ?? "amqp://localhost",
            vhost: "/",
            options: { heartbeat: 10 },
            retry: { factor: 2, min: 1000, max: 5000 },
        },
        handlers: [cancelOrderHandler, getOrderSummaryHandler],
        publishes: [OrdersDomain.events.orderCreated],
        logger: myLogger, // optional — defaults to ConsoleLogger
    })
    .build();

That’s it. Hoppity derives the full Rascal topology from your handlers and publish declarations — exchanges, queues, bindings, publications, and subscriptions — creates the broker, wires handlers, and attaches typed outbound methods.

The returned ServiceBroker extends Rascal’s BrokerAsPromised with typed methods:

// Publish a typed event
await broker.publishEvent(OrdersDomain.events.orderCreated, {
    orderId: "ord-123",
    total: 99.99,
});

// Make a typed RPC call
const summary = await broker.request(OrdersDomain.rpc.getOrderSummary, {
    orderId: "ord-123",
});
// summary is typed as { orderId: string; status: string; total: number }

// Shut down cleanly (drains pending RPC requests)
await broker.shutdown();

broker.request() rejects with an RpcError that carries a code you can switch on:

import { RpcError, RpcErrorCode } from "@apogeelabs/hoppity";

try {
    const summary = await broker.request(OrdersDomain.rpc.getOrderSummary, {
        orderId: "ord-123",
    });
} catch (err) {
    if (err instanceof RpcError) {
        switch (err.code) {
            case RpcErrorCode.HANDLER_ERROR:
                break; // the remote responder threw
            case RpcErrorCode.TIMEOUT:
                break; // no response within defaultTimeout
            case RpcErrorCode.CANCELLED:
                break; // broker.cancelRequest() was called
            case RpcErrorCode.NO_RESPONDER:
                break; // unroutable — no responder queue bound; fails fast
        }
    }
}

NO_RESPONDER comes back immediately (via the AMQP mandatory flag) when a request routes to no queue — for example when no responder service is deployed — so you fail fast instead of waiting out the timeout. A responder whose durable queue exists but is currently offline still routes into that queue and surfaces as TIMEOUT.

  • Read the Concepts guide for a deep dive into middleware, the seven build phases, and naming conventions
  • Read the Interceptors guide for tracing, metrics, and per-message cross-cutting concerns
  • Browse the Examples to see runnable demos of pub/sub and contract-driven multi-service patterns
  • Check the API reference in the sidebar for full type documentation