TypeScript SDK
Hapnd TypeScript SDK
Section titled “Hapnd 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.
Installation
Section titled “Installation”npm install @hapnd/client- Node.js 18+ for aggregate operations (append, state queries)
- Node.js 22+ for subscriptions (requires global
WebSocket)
Quick Start
Section titled “Quick Start”import { createHapndClient } from "@hapnd/client";
const hapnd = createHapndClient("hpnd_your_api_key");
const result = await hapnd.aggregate("order_001") .append("OrderCreated", { customerId: "customer_123" });
console.log(`EventId: ${result.eventId}, Version: ${result.version}`);Creating a Client
Section titled “Creating a Client”Simple
Section titled “Simple”const hapnd = createHapndClient("hpnd_your_api_key");With Options
Section titled “With Options”const hapnd = createHapndClient({ apiKey: "hpnd_your_api_key", baseUrl: "https://hapnd-api.lightestnight.workers.dev", timeout: 60_000, resilience: { maxRetryAttempts: 5, enableCircuitBreaker: true, },});Configuration Options
Section titled “Configuration Options”| 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. |
Resilience Defaults
Section titled “Resilience Defaults”| 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 |
Disposing
Section titled “Disposing”hapnd.dispose();Aborts all in-flight requests, resets resilience state, and releases resources. Do not use the client after calling dispose().
Appending Events
Section titled “Appending Events”Single Event
Section titled “Single Event”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); // 1console.log(result.timestamp); // "2025-01-15T10:30:00Z"console.log(result.state); // computed state or undefinedAppendResult 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 |
Batch Append
Section titled “Batch Append”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 |
Request Options
Section titled “Request Options”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 });Aggregates
Section titled “Aggregates”Aggregate Type Inference
Section titled “Aggregate Type Inference”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"Optimistic Concurrency
Section titled “Optimistic Concurrency”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 }}Querying State
Section titled “Querying State”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 — null if the aggregate does not exist or no reducer is bound.
Distributed Tracing
Section titled “Distributed Tracing”Correlation IDs
Section titled “Correlation IDs”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 });Causation IDs
Section titled “Causation IDs”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" });Metadata
Section titled “Metadata”await hapnd.aggregate("order_123") .withMetadata({ userId: "user_456", ipAddress: "192.168.1.1", userAgent: "MyApp/1.0", }) .append("OrderCreated", { customerId: "customer_456" });Immutable Builder
Section titled “Immutable Builder”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 builderconst v1 = base.expectVersion(1);const v2 = base.expectVersion(2);
// v1 and v2 are independent buildersawait 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 });Real-Time Subscriptions
Section titled “Real-Time Subscriptions”The TypeScript SDK uses a config object pattern for subscriptions (not a builder pattern). This is intentionally different from the .NET SDK.
Basic Usage
Section titled “Basic Usage”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();Multiple Projections
Section titled “Multiple Projections”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(); },});Error Handling
Section titled “Error Handling”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
onUpdatehandler completes successfully — on reconnect, the server resends from the last acknowledged position
ProjectionUpdate Properties
Section titled “ProjectionUpdate Properties”| 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 |
Reconnection
Section titled “Reconnection”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
Resilience
Section titled “Resilience”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
Circuit Breaker
Section titled “Circuit Breaker”Prevents cascading failures by temporarily rejecting requests. Transitions: Closed → Open → Half-Open → Closed.
Configuration
Section titled “Configuration”const hapnd = createHapndClient({ apiKey: "hpnd_your_api_key", resilience: { maxRetryAttempts: 5, retryDelay: 200, maxRetryDelay: 5_000, enableCircuitBreaker: true, circuitBreakerDuration: 30_000, circuitBreakerSamplingDuration: 60_000, circuitBreakerFailureRatio: 0.5, circuitBreakerMinimumThroughput: 10, },});Disabling
Section titled “Disabling”const hapnd = createHapndClient({ apiKey: "hpnd_your_api_key", resilience: false,});Error Handling
Section titled “Error Handling”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 |
Edge Runtime Support
Section titled “Edge Runtime Support”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
Zero Dependencies
Section titled “Zero Dependencies”The SDK has zero runtime dependencies. All functionality — HTTP client, retry logic, circuit breaker, WebSocket management — is implemented from scratch using standard Web APIs (fetch, AbortController, WebSocket).
API Endpoints
Section titled “API Endpoints”| Endpoint | Method | SDK Method |
|---|---|---|
/events | POST | .append() |
/events/batch | POST | .appendMany() |
/aggregate-types/{type}/{id}/state | GET | .getState() |
/projections/{id}/stream | WebSocket | .subscribe() |
Full Example
Section titled “Full Example”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("hpnd_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 updatesconst 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 operationsconst 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 shutdownawait subscription.close();hapnd.dispose();Exports
Section titled “Exports”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";License & Links
Section titled “License & Links”Licensed under Apache 2.0.