@apogeelabs/hoppity
v1.0.0
Published
Core hoppity library
Readme
@apogeelabs/hoppity
Contract-driven RabbitMQ topology builder for Node.js microservices, built on Rascal.
Declare your domain contracts, wire up handlers, and let Hoppity derive the entire RabbitMQ topology automatically. No manual topology files. No Rascal config by hand.
Installation
pnpm add @apogeelabs/hoppity
# or
npm install @apogeelabs/hoppityRequires Node >= 22.
Quick Start
import { z } from "zod";
import hoppity, { defineDomain, onEvent, onCommand } from "@apogeelabs/hoppity";
// 1. Define your domain contracts
const OrdersDomain = defineDomain("orders", {
events: {
orderCreated: z.object({ orderId: z.string(), total: z.number() }),
},
commands: {
cancelOrder: z.object({ orderId: z.string() }),
},
});
// 2. Wire up handlers
const handleOrderCreated = onEvent(
OrdersDomain.events.orderCreated,
async (content, { broker }) => {
console.log("New order:", content.orderId);
}
);
const handleCancelOrder = onCommand(
OrdersDomain.commands.cancelOrder,
async ({ orderId }, { broker }) => {
await cancelOrder(orderId);
}
);
// 3. Build the service
const broker = await hoppity
.service("order-service", {
connection: { url: "amqp://localhost" },
handlers: [handleOrderCreated, handleCancelOrder],
publishes: [OrdersDomain.events.orderCreated],
logger, // optional — defaults to ConsoleLogger
})
.build();
// 4. Use the broker
await broker.publishEvent(OrdersDomain.events.orderCreated, {
orderId: "ord-123",
total: 49.99,
});
await broker.shutdown();Features
- Contract-driven topology —
defineDomain+ handlers = all exchanges, queues, bindings, publications, and subscriptions derived automatically - Type-safe handlers —
onEvent,onCommand,onRpcinfer content types from Zod schemas at compile time - RPC built in —
broker.request()/broker.cancelRequest()with correlation IDs, timeouts, and typed responses - Middleware pipeline — Cross-cutting concerns (logging, custom topology) via composable middleware
- Interceptors — Per-message wrappers for telemetry, tracing, metrics, and header injection
- Schema validation — Optional inbound/outbound Zod validation on every message
- Escape hatch — Pass raw Rascal
BrokerConfigviatopologyinServiceConfigfor anything that can't be derived
API
Entry Point
import hoppity from "@apogeelabs/hoppity";
const builder = hoppity.service("my-service", config);
const broker = await builder.use(middleware).build();ServiceConfig
interface ServiceConfig {
connection: ConnectionConfig;
handlers?: HandlerDeclaration[];
publishes?: (EventContract | CommandContract | RpcContract)[];
interceptors?: Interceptor[]; // per-message wrappers for telemetry, tracing, metrics
logger?: Logger; // custom logger — replaces ConsoleLogger for entire build pipeline
topology?: BrokerConfig; // raw Rascal config — merged as base
instanceId?: string; // auto-generated UUID if omitted
defaultTimeout?: number; // RPC timeout in ms (default 30_000)
validateInbound?: boolean; // default true
validateOutbound?: boolean; // default false
}ServiceBroker
Returned by .build(). Extends Rascal's BrokerAsPromised with:
publishEvent(contract, message, overrides?)— publish a domain eventsendCommand(contract, message, overrides?)— send a domain commandrequest(contract, message, overrides?)— make an RPC callcancelRequest(correlationId)— cancel a pending RPC request
Interceptors
Interceptors wrap handler execution (inbound) and publish calls (outbound) on every message. They're the right place for telemetry, tracing, metrics, and header injection — anything that needs to observe or modify message processing at runtime.
interface Interceptor {
name: string;
inbound?: InboundWrapper; // wraps event, command, and RPC handler execution
outbound?: OutboundWrapper; // wraps publishEvent, sendCommand, and request calls
}Either field is optional — an interceptor can be inbound-only, outbound-only, or both.
Example: handler timing
import hoppity, { Interceptor } from "@apogeelabs/hoppity";
const withHandlerTiming: Interceptor = {
name: "handler-timing",
inbound: (handler, meta) => async (payload, ctx) => {
const start = performance.now();
try {
return await handler(payload, ctx);
} finally {
console.log(`${meta.contract._name} took ${performance.now() - start}ms`);
}
},
};
const broker = await hoppity
.service("order-service", {
connection: { url: process.env.RABBITMQ_URL },
handlers: [handleOrderCreated],
publishes: [OrdersDomain.events.orderCreated],
interceptors: [withHandlerTiming],
})
.build();Composition
For interceptors: [A, B], the call chain is A → B → handler → B → A. The first interceptor in the array is the outermost wrapper.
Inbound wrappers receive InboundMetadata — the contract, operation kind ("event" | "command" | "rpc"), service name, and AMQP message headers. Outbound wrappers receive OutboundMetadata — the contract, kind, and service name.
Interceptors vs. middleware
| | Middleware (.use()) | Interceptors |
| -------- | ------------------------------------ | ---------------------------------- |
| When | Before broker creation | During message processing |
| What | Modifies topology, lifecycle hooks | Wraps handler/publish execution |
| Scope | Service-level setup | Per-message |
| Examples | Custom logger, topology augmentation | Tracing, metrics, header injection |
Interceptor Packages
@apogeelabs/hoppity-open-telemetry provides production-ready withTracing and withMetrics interceptors built on @opentelemetry/api. Both are dual-use: pass them directly as values for default configuration, or call them as factories to supply a custom tracer/meter name or histogram buckets. withTracing handles W3C context propagation across service boundaries automatically — trace context is injected into AMQP headers on publish and extracted on receive, so spans link up without any extra plumbing.
Documentation
ReadMe.LLM— complete API reference with all type signaturesllms-usage.md— LLM code generation guide for this package
License
ISC
