@hapnd/client
v0.2.0
Published
TypeScript SDK for the Hapnd event sourcing platform
Maintainers
Readme
@hapnd/client
TypeScript SDK for the Hapnd event sourcing platform.
Append events, query aggregate state, and let Hapnd handle projections, reducers, and all the infrastructure complexity — from any JavaScript runtime.
Installation
npm install @hapnd/client
# or
pnpm add @hapnd/client
# or
yarn add @hapnd/clientRequirements: Node.js 18+, ESM only. No CommonJS support.
Quick Start
import { createHapndClient } from "@hapnd/client";
const hapnd = createHapndClient("sk_live_your_api_key");
// Append an event
const result = await hapnd
.aggregate("order_123")
.append("OrderPlaced", { customerId: "cust_456", total: 99.99 });
console.log(result.eventId); // evt_abc123
console.log(result.version); // 1
console.log(result.state); // computed state (if a reducer is bound)Full API
Client Creation
import { createHapndClient } from "@hapnd/client";
// Simple — just an API key
const hapnd = createHapndClient("sk_live_your_api_key");
// Or, With options
const hapnd = createHapndClient({
apiKey: "sk_live_your_api_key",
baseUrl: "https://hapnd-api.lightestnight.workers.dev", // default
timeout: 30_000, // ms, default 30s
resilience: {
maxRetryAttempts: 3, // default
retryDelay: 500, // base delay ms, default
maxRetryDelay: 10_000, // max delay ms, default
enableCircuitBreaker: true, // default
circuitBreakerDuration: 30_000, // ms open before half-open
circuitBreakerSamplingDuration: 60_000, // sliding window ms
circuitBreakerFailureRatio: 0.5, // trip threshold (50%)
circuitBreakerMinimumThroughput: 10, // min requests before tripping
},
});
// Or if you'd prefer to disable all resilience (no retries, no circuit breaker)
const hapnd = createHapndClient({
apiKey: "sk_live_your_api_key",
resilience: false,
});Appending Events
The aggregate type is inferred from the ID using the {type}_{id} or {type}-{id} convention. Use .withAggregateType() for non-conforming IDs.
// Single event — minimal
const result = await hapnd
.aggregate("order_123")
.append("OrderPlaced", { customerId: "cust_456" });
// Single event — with all options
const result = await hapnd
.aggregate("order_123")
.expectVersion(5) // optimistic concurrency
.withCorrelation("corr_abc")
.withCausation("evt_xyz")
.withMetadata({ userId: "u_1" })
.append("ItemAdded", { item: "Widget", price: 25.0 });
// Non-conforming ID — specify type explicitly
const result = await hapnd
.aggregate("ORD-2024-00123")
.withAggregateType("order")
.append("OrderPlaced", { customerId: "cust_456" });AppendResult:
{
eventId: string; // unique event ID
aggregateId: string;
aggregateType: string;
version: number; // version after this event
timestamp: string; // ISO 8601
state?: unknown; // computed state if reducer is bound
}Batch Append
Atomically append multiple events in a single request:
const result = await hapnd
.aggregate("order_123")
.expectVersion(5)
.appendMany([
{ eventType: "ItemAdded", data: { item: "Widget", price: 25.0 } },
{ eventType: "ItemAdded", data: { item: "Gadget", price: 15.0 } },
]);AppendManyResult:
{
aggregateId: string;
aggregateType: string;
startVersion: number; // version of first event
endVersion: number; // version of last event
timestamp: string;
eventIds: string[];
state?: unknown;
}Querying State
interface OrderState {
total: number;
items: string[];
}
const state = await hapnd.aggregate("order_123").getState<OrderState>();
if (state === null) {
// Aggregate doesn't exist, or no reducer is bound to this type
} else {
console.log(state.version); // current version
console.log(state.state.total); // typed state
}AggregateState<T>:
{
aggregateId: string;
aggregateType: string;
version: number;
state: T;
lastModified: string; // ISO 8601
}Real-Time Subscriptions
Subscribe to projection state changes in real time over WebSocket:
const subscription = hapnd.subscribe({
projections: [
{
id: "proj_orders",
onUpdate: async (update) => {
console.log(`Order ${update.aggregateId} updated:`, update.state);
},
},
],
onError: async (error) => {
console.error(`Stream error on ${error.projectionId}:`, error.error);
error.action.reconnect(); // resume from last successful sequence
},
});Subscribe to multiple projections simultaneously — each runs on its own WebSocket:
const subscription = hapnd.subscribe({
projections: [
{
id: "proj_orders",
onUpdate: async (update) => {
await updateDashboard(update.state);
},
},
{
id: "proj_inventory",
onUpdate: async (update) => {
await refreshStock(update.state);
},
},
],
onError: async (error) => {
console.error(`Stream ${error.projectionId} failed:`, error.error);
error.action.reconnect();
},
});Error handling: When an onUpdate handler throws, the stream pauses and your onError handler is called. You must call exactly one action:
error.action.reconnect()— resume from the last successfully processed sequenceerror.action.stop()— stop this projection's stream only; others continueerror.action.shutdown()— close the entire subscription
If no action is called within 30 seconds, the stream is stopped automatically.
Cleanup:
await subscription.close(); // gracefully closes all WebSocket connectionsPer-Request Cancellation
All terminal methods accept an optional AbortSignal:
const controller = new AbortController();
setTimeout(() => controller.abort(), 5_000);
const result = await hapnd
.aggregate("order_123")
.append(
"OrderPlaced",
{ customerId: "cust_1" },
{ signal: controller.signal },
);Aggregate ID Convention
| ID | Inferred type |
| -------------- | -------------------------------------------- |
| order_123 | order |
| cart-abc | cart |
| CUSTOMER_456 | customer |
| ORD-2024-001 | ord (or use .withAggregateType("order")) |
Rules: first separator (_ or -) wins; type portion normalised to lowercase; must start with a letter.
Disposal
hapnd.dispose();
// Aborts in-flight requests and resets circuit breaker stateError Handling
All errors extend HapndError. Use instanceof to handle specific cases:
import {
HapndConcurrencyError,
HapndApiError,
HapndCircuitOpenError,
HapndNetworkError,
HapndValidationError,
} from "@hapnd/client";
try {
await hapnd
.aggregate("order_123")
.expectVersion(5)
.append("ItemAdded", { item: "Widget", price: 25.0 });
} catch (err) {
if (err instanceof HapndConcurrencyError) {
// Version mismatch — reload and retry
console.log(`Expected ${err.expectedVersion}, got ${err.actualVersion}`);
} else if (err instanceof HapndCircuitOpenError) {
// Circuit is open — back off
console.log(`Retry after ${err.remainingDuration}ms`);
} else if (err instanceof HapndNetworkError) {
// DNS failure, timeout, connection refused, etc.
console.log(`Network error: ${err.message}`);
} else if (err instanceof HapndApiError) {
// Server returned a non-2xx response
console.log(
`API error ${err.statusCode} (${err.errorCode}): ${err.message}`,
);
} else if (err instanceof HapndValidationError) {
// Bad arguments before the request was made
console.log(`Validation: ${err.message}`);
}
}Error Class Reference
| Class | errorCode | HTTP | Description |
| --------------------------------- | ------------------------- | ------ | --------------------------------- |
| HapndValidationError | VALIDATION_ERROR | — | Client-side validation failure |
| HapndApiError | from server | varies | Server returned an error response |
| HapndConcurrencyError | CONCURRENCY_CONFLICT | 409 | Version mismatch |
| HapndAggregateTypeMismatchError | AGGREGATE_TYPE_MISMATCH | 400 | Wrong type for existing aggregate |
| HapndNetworkError | NETWORK_ERROR | — | Network failure or timeout |
| HapndCircuitOpenError | CIRCUIT_OPEN | — | Circuit breaker is open |
Resilience
Retry
Built-in exponential backoff with full jitter. Retries on:
- Network failures
- HTTP 5xx (server errors)
- HTTP 429 (rate limited, honours
Retry-Afterheader) - HTTP 408 (request timeout)
Does not retry on 4xx (except 429/408) or 409 (concurrency conflicts need business logic).
Circuit Breaker
Sliding-window circuit breaker prevents cascading failures:
- Closed → requests pass through normally
- Open → all requests immediately rejected with
HapndCircuitOpenError - Half-Open → one probe request allowed; success closes the circuit, failure re-opens it
Configure via resilience options or disable with resilience: false.
Edge Runtime Support
@hapnd/client uses only the Web Fetch API and standard Web APIs — no Node.js built-ins. It works in:
- Node.js 18+
- Cloudflare Workers
- Vercel Edge Functions
- Deno
- Bun
Zero Dependencies
The SDK has no runtime dependencies. Everything — retry, circuit breaker, error handling, HTTP — is implemented from scratch using standard Web APIs.
Links - Coming Soon.. a nice TODO!
License
Apache 2.0 — see LICENSE.
