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("hpnd_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("hpnd_your_api_key");
const hapnd = createHapndClient({
apiKey: "hpnd_your_api_key",
baseUrl: "https://hapnd-api.lightestnight.workers.dev",
timeout: 60_000,
resilience: {
maxRetryAttempts: 5,
enableCircuitBreaker: true,
},
});
OptionTypeDefaultDescription
apiKeystring(required)Your Hapnd API key
baseUrlstringhttps://hapnd-api.lightestnight.workers.devAPI base URL
timeoutnumber30_000Request timeout in milliseconds
resilienceResilienceOptions | falseEnabled with defaultsRetry and circuit breaker config. Set to false to disable.
OptionDefaultDescription
maxRetryAttempts3Maximum retry attempts after the initial request
retryDelay500Base delay in ms for the first retry
maxRetryDelay10_000Maximum delay in ms between retries
enableCircuitBreakertrueEnable the circuit breaker
circuitBreakerDuration30_000How long the circuit stays open before half-open (ms)
circuitBreakerSamplingDuration60_000Sliding window for failure tracking (ms)
circuitBreakerFailureRatio0.5Failure ratio threshold that trips the circuit
circuitBreakerMinimumThroughput10Minimum 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:

PropertyTypeDescription
eventIdstringUnique identifier for the appended event
aggregateIdstringThe aggregate this event was appended to
aggregateTypestringThe type of the aggregate
versionnumberVersion of the aggregate after this event
timestampstringISO 8601 timestamp
stateunknownComputed 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:

PropertyTypeDescription
aggregateIdstringThe aggregate these events were appended to
aggregateTypestringThe type of the aggregate
startVersionnumberVersion after the first event in the batch
endVersionnumberVersion after the last event in the batch
timestampstringISO 8601 timestamp
eventIdsstring[]Unique identifiers for each event, in order
stateunknownComputed 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 IDInferred Type
order_123order
cart-abccart
CUSTOMER_456customer

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> | nullnull if 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 });

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 requiredhapnd.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;
}
}
ActionEffect
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
PropertyTypeDescription
projectionIdstringThe projection that produced this update
aggregateIdstringThe aggregate whose state changed
aggregateTypestringThe aggregate type
versionnumberAggregate version after this change
stateTStateThe computed state
sequencenumberServer-assigned sequence number for resumption
timestampstringISO 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: "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,
},
});
const hapnd = createHapndClient({
apiKey: "hpnd_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 ClassHTTP StatusDescription
HapndValidationErrorN/AClient-side validation failure before request
HapndConcurrencyError409Optimistic concurrency version mismatch
HapndAggregateTypeMismatchError400Aggregate type conflicts with existing events
HapndApiErrorVariesServer returned an error response
HapndNetworkErrorN/ANetwork-level failure (DNS, timeout, etc.)
HapndCircuitOpenErrorN/ACircuit 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 — HTTP client, retry logic, circuit breaker, WebSocket management — is implemented from scratch using standard Web APIs (fetch, AbortController, WebSocket).

EndpointMethodSDK Method
/eventsPOST.append()
/events/batchPOST.appendMany()
/aggregate-types/{type}/{id}/stateGET.getState()
/projections/{id}/streamWebSocket.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("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 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.