Skip to content

TypeScript SDK

Official TypeScript SDK for Hapnd, the event sourcing platform built for engineers. Append events, query aggregate state, subscribe to real-time projection updates with a fluent immutable API, zero runtime dependencies, and compatibility with any JavaScript runtime that supports the Fetch API.

Terminal window
npm install @hapnd/client
  • Node.js 18+ for aggregate operations (append, state queries)
  • Node.js 22+ for subscriptions (requires global WebSocket)
import { createHapndClient } from "@hapnd/client";
const hapnd = createHapndClient("sk_live_your_api_key");
const result = await hapnd.aggregate("order_001")
.append("OrderCreated", { customerId: "customer_123" });
console.log(`EventId: ${result.eventId}, Version: ${result.version}`);
const hapnd = createHapndClient("sk_live_your_api_key");
const hapnd = createHapndClient({
apiKey: "sk_live_your_api_key",
baseUrl: "https://hapnd-api.lightestnight.workers.dev",
timeout: 60_000,
resilience: {
maxRetryAttempts: 5,
enableCircuitBreaker: true,
},
});
Option Type Default Description
apiKey string (required) Your Hapnd API key
baseUrl string https://hapnd-api.lightestnight.workers.dev API base URL
timeout number 30_000 Request timeout in milliseconds
resilience ResilienceOptions | false Enabled with defaults Retry and circuit breaker config. Set to false to disable.
Option Default Description
maxRetryAttempts 3 Maximum retry attempts after the initial request
retryDelay 500 Base delay in ms for the first retry
maxRetryDelay 10_000 Maximum delay in ms between retries
enableCircuitBreaker true Enable the circuit breaker
circuitBreakerDuration 30_000 How long the circuit stays open before half-open (ms)
circuitBreakerSamplingDuration 60_000 Sliding window for failure tracking (ms)
circuitBreakerFailureRatio 0.5 Failure ratio threshold that trips the circuit
circuitBreakerMinimumThroughput 10 Minimum requests before the circuit can trip
hapnd.dispose();

Aborts all in-flight requests, resets resilience state, and releases resources. Do not use the client after calling dispose().

const result = await hapnd.aggregate("order_123")
.append("ItemAdded", { item: "Widget", price: 25.00 });
console.log(result.eventId); // "evt_abc123"
console.log(result.aggregateId); // "order_123"
console.log(result.aggregateType); // "order"
console.log(result.version); // 1
console.log(result.timestamp); // "2025-01-15T10:30:00Z"
console.log(result.state); // computed state or undefined

AppendResult properties:

Property Type Description
eventId string Unique identifier for the appended event
aggregateId string The aggregate this event was appended to
aggregateType string The type of the aggregate
version number Version of the aggregate after this event
timestamp string ISO 8601 timestamp
state unknown Computed state if a reducer is bound, else undefined
const result = await hapnd.aggregate("order_123")
.appendMany([
{ eventType: "ItemAdded", data: { item: "Widget", price: 25.00 } },
{ eventType: "ItemAdded", data: { item: "Gadget", price: 15.00 } },
]);
console.log(`Appended ${result.eventIds.length} events (versions ${result.startVersion}-${result.endVersion})`);

All events in a batch are stored atomically; either all succeed or none do.

AppendManyResult properties:

Property Type Description
aggregateId string The aggregate these events were appended to
aggregateType string The type of the aggregate
startVersion number Version after the first event in the batch
endVersion number Version after the last event in the batch
timestamp string ISO 8601 timestamp
eventIds string[] Unique identifiers for each event, in order
state unknown Computed state if a reducer is bound

All terminal methods accept an optional AbortSignal for cancellation:

const controller = new AbortController();
const result = await hapnd.aggregate("order_123")
.append("ItemAdded", { item: "Widget", price: 25.00 }, { signal: controller.signal });

The SDK infers the aggregate type from the ID using the {type}_{id} or {type}-{id} convention:

Aggregate ID Inferred Type
order_123 order
cart-abc cart
CUSTOMER_456 customer

Override when your IDs don’t follow this convention:

await hapnd.aggregate("ORD-2024-00123")
.withAggregateType("order")
.append("OrderCreated", { customerId: "customer_456" });

The parseAggregateType utility is also exported for direct use:

import { parseAggregateType } from "@hapnd/client";
const type = parseAggregateType("order_123"); // "order"
import { HapndConcurrencyError } from "@hapnd/client";
const current = await hapnd.aggregate("order_123").getState<OrderState>();
try {
await hapnd.aggregate("order_123")
.expectVersion(current.version)
.append("ItemAdded", { item: "Widget", price: 25.00 });
} catch (err) {
if (err instanceof HapndConcurrencyError) {
console.log(`Expected ${err.expectedVersion}, actual ${err.actualVersion}`);
// Reload state and retry
}
}
interface OrderState {
total: number;
items: string[];
itemCount: number;
}
const aggregate = await hapnd.aggregate("order_123").getState<OrderState>();
if (aggregate !== null) {
console.log(`Version: ${aggregate.version}`);
console.log(`Total: ${aggregate.state.total}`);
console.log(`Last Modified: ${aggregate.lastModified}`);
}

Returns AggregateState<T> | null, where null indicates the aggregate does not exist or no reducer is bound.

const correlationId = crypto.randomUUID();
await hapnd.aggregate("order_123")
.withCorrelation(correlationId)
.append("OrderCreated", { customerId: "customer_456" });
await hapnd.aggregate("inventory_widget")
.withCorrelation(correlationId)
.append("StockReserved", { orderId: "order_123", quantity: 1 });
const orderResult = await hapnd.aggregate("order_123")
.append("OrderCreated", { customerId: "customer_456" });
await hapnd.aggregate("notification_123")
.withCausation(orderResult.eventId)
.append("OrderConfirmationSent", { customerId: "customer_456" });
await hapnd.aggregate("order_123")
.withMetadata({
userId: "user_456",
ipAddress: "192.168.1.1",
userAgent: "MyApp/1.0",
})
.append("OrderCreated", { customerId: "customer_456" });

The TypeScript builder is immutable; each chaining method returns a new AggregateBuilder instance. The original builder is unchanged. This is different from the .NET SDK, where the builder is mutable.

const base = hapnd.aggregate("order_123").withCorrelation("corr_abc");
// base is NOT modified; each call creates a new builder
const v1 = base.expectVersion(1);
const v2 = base.expectVersion(2);
// v1 and v2 are independent builders
await v1.append("ItemAdded", { item: "Widget", price: 25.00 });
await v2.append("ItemAdded", { item: "Gadget", price: 15.00 });

Configuration methods (return new AggregateBuilder): withAggregateType, expectVersion, withCorrelation, withCausation, withMetadata

Terminal methods (execute HTTP request): append, appendMany, getState

const result = await hapnd.aggregate("order_123")
.withAggregateType("order")
.expectVersion(3)
.withCorrelation("corr_abc")
.withCausation("evt_previous")
.withMetadata({ userId: "user_456", source: "web" })
.append("ItemAdded", { item: "Widget", price: 25.00 });

Query the current state of a projection directly by its state key.

const state = await hapnd.getProjectionState<OrderSummary>("proj_abc123", "customer-123");
if (state) {
console.log(`Total spend: ${state.state.totalSpend}`);
console.log(`Last updated: ${state.updatedAt}`);
}

Returns null if the projection or state key doesn’t exist.

If the projection is still catching up on historical events, a HapndProjectionCatchingUpError is thrown:

import { HapndProjectionCatchingUpError } from "@hapnd/client";
try {
const state = await hapnd.getProjectionState<OrderSummary>("proj_abc123", "customer-123");
} catch (err) {
if (err instanceof HapndProjectionCatchingUpError) {
console.log(`Projection ${err.projectionId} is still processing historical events`);
}
}

The TypeScript SDK uses a config object pattern for subscriptions (not a builder pattern). This is intentionally different from the .NET SDK.

const subscription = hapnd.subscribe({
projections: [
{
id: "proj_orders",
onUpdate: async (update) => {
console.log(`Order ${update.aggregateId} updated to version ${update.version}`);
console.log(`State:`, update.state);
},
},
],
onError: (error) => {
console.error(`Stream ${error.projectionId} failed:`, error.error.message);
error.action.reconnect();
},
});
// Later, graceful shutdown:
await subscription.close();

Each projection gets its own WebSocket connection:

const subscription = hapnd.subscribe({
projections: [
{
id: "proj_orders",
onUpdate: async (update) => {
console.log(`Order total: ${update.state.total}`);
},
},
{
id: "proj_inventory",
onUpdate: async (update) => {
console.log(`Stock level: ${update.state.stockLevel}`);
},
},
],
onError: (error) => {
console.error(`Stream ${error.projectionId} failed`);
error.action.reconnect();
},
});

The onError handler is required; hapnd.subscribe() throws HapndValidationError if it is missing or not a function.

The error handler receives a StreamError with an action object. Call exactly one method:

onError: (error) => {
switch (error.projectionId) {
case "proj_critical":
// Non-recoverable, shut everything down
error.action.shutdown();
break;
case "proj_optional":
// Not essential, stop just this stream
error.action.stop();
break;
default:
// Transient, reconnect and retry
error.action.reconnect();
break;
}
}
Action Effect
error.action.reconnect() Resume the stream from the last good sequence with backoff
error.action.stop() Stop this projection’s stream only; others continue
error.action.shutdown() Shut down the entire subscription, closing all streams

Safeguards:

  • If the error handler itself throws, the failing stream stops automatically
  • If no action is called within 30 seconds, the stream stops automatically
  • Calling multiple actions on the same error throws an Error
  • Sequence advances only after the onUpdate handler completes successfully; on reconnect, the server resends from the last acknowledged position
Property Type Description
projectionId string The projection that produced this update
aggregateId string The aggregate whose state changed
aggregateType string The aggregate type
version number Aggregate version after this change
state TState The computed state
sequence number Server-assigned sequence number for resumption
timestamp string ISO 8601 timestamp

Automatic reconnection with exponential backoff and jitter:

  • Base delay: 1 second
  • Maximum delay: 30 seconds
  • Jitter: 75%–125% of calculated delay
  • Resets to base delay after a successful connection
  • Uses ?afterSequence= query parameter for resumption from last acknowledged sequence

Exponential backoff with jitter. Automatically retries transient failures:

Retried: Network errors, timeouts, 5xx server errors, 429 (rate limit), 408 (request timeout)

Not retried: 4xx client errors, 409 (concurrency conflict), cancellation

Prevents cascading failures by temporarily rejecting requests. Transitions: ClosedOpenHalf-OpenClosed.

const hapnd = createHapndClient({
apiKey: "sk_live_your_api_key",
resilience: {
maxRetryAttempts: 5,
retryDelay: 200,
maxRetryDelay: 5_000,
enableCircuitBreaker: true,
circuitBreakerDuration: 30_000,
circuitBreakerSamplingDuration: 60_000,
circuitBreakerFailureRatio: 0.5,
circuitBreakerMinimumThroughput: 10,
},
});
const hapnd = createHapndClient({
apiKey: "sk_live_your_api_key",
resilience: false,
});
import {
HapndValidationError,
HapndConcurrencyError,
HapndAggregateTypeMismatchError,
HapndApiError,
HapndNetworkError,
HapndCircuitOpenError,
} from "@hapnd/client";
try {
await hapnd.aggregate("order_123")
.expectVersion(5)
.append("ItemAdded", { item: "Widget", price: 25.00 });
} catch (err) {
if (err instanceof HapndValidationError) {
// Client-side validation failure (before any network request)
console.log(`Validation: ${err.message}`);
} else if (err instanceof HapndConcurrencyError) {
// 409, version mismatch
console.log(`Expected ${err.expectedVersion}, actual ${err.actualVersion}`);
} else if (err instanceof HapndAggregateTypeMismatchError) {
// 400, aggregate type conflict
console.log(`Expected '${err.expectedType}', actual '${err.actualType}'`);
} else if (err instanceof HapndCircuitOpenError) {
// Circuit breaker is open
console.log(`Retry after ${err.remainingDuration}ms`);
} else if (err instanceof HapndApiError) {
// Server returned an error (varies by status code)
console.log(`API error ${err.statusCode}: ${err.message}`);
} else if (err instanceof HapndNetworkError) {
// Network failure (DNS, connection refused, timeout)
console.log(`Network error: ${err.message}`);
}
}
Error Class HTTP Status Description
HapndValidationError N/A Client-side validation failure before request
HapndConcurrencyError 409 Optimistic concurrency version mismatch
HapndAggregateTypeMismatchError 400 Aggregate type conflicts with existing events
HapndApiError Varies Server returned an error response
HapndNetworkError N/A Network-level failure (DNS, timeout, etc.)
HapndCircuitOpenError N/A Circuit breaker is open, requests rejected

The SDK works in any JavaScript runtime with a global fetch implementation:

  • Node.js 18+ - aggregate operations (append, state queries)
  • Node.js 22+ - aggregate operations + subscriptions (requires global WebSocket)
  • Cloudflare Workers - full support
  • Deno - full support
  • Bun - full support

The SDK has zero runtime dependencies. All functionality, including HTTP client, retry logic, circuit breaker, and WebSocket management, is implemented from scratch using standard Web APIs (fetch, AbortController, WebSocket).

Endpoint Method SDK Method
/events POST .append()
/events/batch POST .appendMany()
/aggregate-types/{type}/{id}/state GET .getState()
/projections/{id}/stream WebSocket .subscribe()
import {
createHapndClient,
HapndConcurrencyError,
type AppendResult,
type AggregateState,
type HapndSubscription,
} from "@hapnd/client";
interface OrderState {
total: number;
items: string[];
itemCount: number;
customerId: string | null;
}
const hapnd = createHapndClient("sk_live_your_api_key");
async function createOrder(customerId: string): Promise<AppendResult> {
const orderId = `order_${crypto.randomUUID()}`;
return hapnd.aggregate(orderId)
.expectVersion(0)
.withCorrelation(crypto.randomUUID())
.append("OrderCreated", { customerId });
}
async function addItem(orderId: string, item: string, price: number, expectedVersion: number): Promise<void> {
try {
await hapnd.aggregate(orderId)
.expectVersion(expectedVersion)
.append("ItemAdded", { item, price });
} catch (err) {
if (err instanceof HapndConcurrencyError) {
const current = await hapnd.aggregate(orderId).getState<OrderState>();
if (current !== null) {
await hapnd.aggregate(orderId)
.expectVersion(current.version)
.append("ItemAdded", { item, price });
}
} else {
throw err;
}
}
}
async function getOrder(orderId: string): Promise<OrderState | null> {
const aggregate = await hapnd.aggregate(orderId).getState<OrderState>();
return aggregate?.state ?? null;
}
// Subscribe to order updates
const subscription: HapndSubscription = hapnd.subscribe({
projections: [
{
id: "proj_orders",
onUpdate: async (update) => {
const state = update.state as OrderState;
console.log(`Order ${update.aggregateId} updated: total=${state.total}, items=${state.itemCount}`);
},
},
],
onError: (error) => {
console.error(`Stream ${error.projectionId} error at sequence ${error.sequence}:`, error.error.message);
error.action.reconnect();
},
});
// Run some operations
const order = await createOrder("customer_456");
await addItem(order.aggregateId, "Widget", 25.00, order.version);
await addItem(order.aggregateId, "Gadget", 15.00, order.version + 1);
const finalState = await getOrder(order.aggregateId);
console.log("Final state:", finalState);
// Clean shutdown
await subscription.close();
hapnd.dispose();
import {
// Factory + client interface
createHapndClient,
type HapndClient,
// Fluent builder
AggregateBuilder,
// Error hierarchy
HapndError,
HapndValidationError,
HapndApiError,
HapndConcurrencyError,
HapndAggregateTypeMismatchError,
HapndNetworkError,
HapndCircuitOpenError,
// Public types
type HapndClientOptions,
type ResilienceOptions,
type AppendResult,
type AppendManyResult,
type AggregateState,
type BatchEvent,
type RequestOptions,
// Subscription types
type SubscriptionOptions,
type ProjectionHandler,
type ProjectionUpdate,
type StreamError,
type StreamErrorAction,
type HapndSubscription,
// Utility
parseAggregateType,
} from "@hapnd/client";

Licensed under Apache 2.0.