Skip to content

Calimero JavaScript SDK Guide

Build Calimero services using TypeScript/JavaScript. The JavaScript SDK compiles your code to WebAssembly using QuickJS, enabling you to build distributed applications without writing Rust.

⚠️ Experimental: The JavaScript SDK is still evolving. Expect breaking changes while we stabilize the toolchain.

Overview

The JavaScript SDK (calimero-sdk-js) consists of two main packages:

  • @calimero/sdk - Decorators, CRDT collections, event system, and environment bindings
  • @calimero/cli - Build toolchain (Rollup → QuickJS → WASM)

Key features: - Write services in TypeScript/JavaScript instead of Rust - Same CRDT collections as Rust SDK - Automatic conflict resolution - Event-driven architecture - Private storage for node-local data

Architecture

Build Pipeline

flowchart LR
    TS[TypeScript<br/>Source] --> BUILD[Build<br/>Bundle]
    BUILD --> QJS[QuickJS<br/>Compile]
    QJS --> WASM[WASI-SDK<br/>to WASM]
    WASM --> OPT[Optimize<br/>~500KB]

    style TS fill:#1a1a1a,stroke:#00ff00,stroke-width:3px,color:#ffffff
    style BUILD fill:#1a1a1a,stroke:#00ff00,stroke-width:3px,color:#ffffff
    style QJS fill:#1a1a1a,stroke:#00ff00,stroke-width:3px,color:#ffffff
    style WASM fill:#1a1a1a,stroke:#00ff00,stroke-width:3px,color:#ffffff
    style OPT fill:#000000,stroke:#00ff00,stroke-width:4px,color:#ffffff

Runtime Execution

flowchart LR
    JS[JavaScript<br/>Your code] --> SDK[SDK<br/>Decorators]
    SDK --> QJS[QuickJS<br/>Runtime]
    QJS --> HOST[Host<br/>Functions]
    HOST --> RUNTIME[Calimero<br/>Runtime]

    style JS fill:#1a1a1a,stroke:#00ff00,stroke-width:3px,color:#ffffff
    style SDK fill:#1a1a1a,stroke:#00ff00,stroke-width:3px,color:#ffffff
    style QJS fill:#1a1a1a,stroke:#00ff00,stroke-width:3px,color:#ffffff
    style HOST fill:#1a1a1a,stroke:#00ff00,stroke-width:3px,color:#ffffff
    style RUNTIME fill:#000000,stroke:#00ff00,stroke-width:4px,color:#ffffff

How it works: - Your TypeScript code runs inside QuickJS (a lightweight JavaScript engine) - CRDT operations call host functions that interact with Rust storage - State is serialized and synchronized across the network - Events propagate to all peers automatically

Getting Started

Prerequisites

  • Node.js 18+ with WASI support
  • pnpm ≥ 8 (or npm/yarn)
  • Access to a Calimero node (merod) and CLI (meroctl)

Installation

pnpm add @calimero/sdk
pnpm add -D @calimero/cli typescript

Minimal Service

import { State, Logic, Init, View } from '@calimero/sdk';
import { Counter } from '@calimero/sdk/collections';
import * as env from '@calimero/sdk/env';

@State
export class CounterApp {
  count: Counter = new Counter();
}

@Logic(CounterApp)
export class CounterLogic extends CounterApp {
  @Init
  static init(): CounterApp {
    env.log('Initializing counter');
    return new CounterApp();
  }

  increment(): void {
    env.log('Incrementing counter');
    this.count.increment();
  }

  @View()
  getCount(): bigint {
    return this.count.value();
  }
}

Build & Deploy

# Build WASM from TypeScript
npx calimero-sdk build src/index.ts -o build/service.wasm

# Install on node
meroctl --node node1 app install \
  --path build/service.wasm \
  --context-id <CONTEXT_ID>

# Call methods
meroctl --node node1 call \
  --context-id <CONTEXT_ID> \
  --method increment

meroctl --node node1 call \
  --context-id <CONTEXT_ID> \
  --method getCount

Core Concepts

Decorators

@State

Marks a class as application state:

import { State } from '@calimero/sdk';
import { UnorderedMap, Counter } from '@calimero/sdk/collections';

@State
export class MyApp {
  items: UnorderedMap<string, string> = new UnorderedMap();
  viewCount: Counter = new Counter();
}

Key points: - State is persisted and synchronized across nodes - Initialize CRDT fields inline (runtime reuses persisted IDs) - Don't use regular JavaScript objects for synchronized state

@Logic

Marks a class as application logic (methods):

import { Logic, Init } from '@calimero/sdk';

@Logic(MyApp)
export class MyAppLogic extends MyApp {
  @Init
  static init(): MyApp {
    return new MyApp();
  }

  // Mutation method (changes state)
  addItem(key: string, value: string): void {
    this.items.set(key, value);
  }

  // View method (read-only)
  @View()
  getItem(key: string): string | undefined {
    return this.items.get(key);
  }
}

Key points: - Logic class extends State class - @Init marks the initialization method (called once on context creation) - Methods without @View() are mutations (generate deltas) - Methods with @View() are read-only (no delta generated)

@View()

Marks read-only methods:

@Logic(MyApp)
export class MyAppLogic extends MyApp {
  // Mutation - generates delta
  setValue(value: string): void {
    this.register.set(value);
  }

  // View - read-only, no delta
  @View()
  getValue(): string {
    return this.register.get();
  }
}

Benefits: - Faster execution (no persistence overhead) - No redundant storage deltas - Clear intent in API

@Event

Marks event classes:

import { Event } from '@calimero/sdk';

@Event
export class ItemAdded {
  constructor(
    public key: string,
    public value: string,
    public timestamp: number
  ) {}
}

@Init

Marks the initialization method:

@Logic(MyApp)
export class MyAppLogic extends MyApp {
  @Init
  static init(): MyApp {
    return new MyApp();
  }
}

Requirements: - Must be static - Must return an instance of State class - Called once when context is created

CRDT Collections

The JavaScript SDK provides the same CRDT collections as the Rust SDK:

UnorderedMap

Key-value storage with Last-Write-Wins conflict resolution:

import { UnorderedMap } from '@calimero/sdk/collections';

const map = new UnorderedMap<string, string>();

// Set value
map.set('key', 'value');

// Get value
const value = map.get('key'); // 'value' | undefined

// Check existence
const exists = map.has('key'); // boolean

// Remove entry
map.remove('key');

// Iterate
const entries = map.entries(); // [['key1', 'value1'], ['key2', 'value2']]
const keys = map.keys(); // ['key1', 'key2']
const values = map.values(); // ['value1', 'value2']

// Size
const size = map.entries().length;

Vector

Ordered list maintaining insertion order:

import { Vector } from '@calimero/sdk/collections';

const vec = new Vector<string>();

// Add element
vec.push('first');
vec.push('second');

// Get element
const item = vec.get(0); // 'first'

// Remove element
const last = vec.pop(); // 'second'

// Length
const len = vec.len(); // number

Counter

Distributed counter with automatic summation:

import { Counter } from '@calimero/sdk/collections';

const counter = new Counter();

// Increment
counter.increment();
counter.incrementBy(5);

// Get value
const total = counter.value(); // bigint

// Decrement
counter.decrement();
counter.decrementBy(2);

LwwRegister

Last-Write-Wins register for single values:

import { LwwRegister } from '@calimero/sdk/collections';

const register = new LwwRegister<string>();

// Set value
register.set('hello');

// Get value
const value = register.get(); // 'hello' | null

// Get timestamp
const timestamp = register.timestamp(); // bigint

UnorderedSet

Set with union-based merging:

import { UnorderedSet } from '@calimero/sdk/collections';

const set = new UnorderedSet<string>();

// Add element
set.add('item'); // true on first insert

// Check membership
const has = set.has('item'); // boolean

// Remove element
set.delete('item');

// Get all values
const all = set.toArray(); // string[]

// Size
const size = set.size(); // number

Nested CRDTs

CRDTs can be nested arbitrarily:

@State
export class TeamMetrics {
  // Map of member → Map of metric → Counter
  memberMetrics: UnorderedMap<string, UnorderedMap<string, Counter>>;

  // Map of team → Set of members
  teams: UnorderedMap<string, UnorderedSet<string>>;

  // Vector of profiles (each with nested data)
  profiles: Vector<MemberProfile>;

  constructor() {
    this.memberMetrics = new UnorderedMap();
    this.teams = new UnorderedMap();
    this.profiles = new Vector();
  }
}

Important: When you get a nested CRDT, you receive a handle that retains the CRDT ID. Mutating the handle issues host calls without deserializing the entire structure.

// Get nested map handle (lightweight, ID retained)
const metrics = this.memberMetrics.get('alice');

if (metrics) {
  // Mutate nested CRDT (incremental host call)
  const counter = metrics.get('commits') ?? new Counter();
  counter.increment();
  metrics.set('commits', counter);
}

Event System

Emit events for real-time updates:

import { Event, emit, emitWithHandler } from '@calimero/sdk';

// Define event
@Event
export class ItemAdded {
  constructor(public key: string, public value: string) {}
}

@Logic(MyApp)
export class MyAppLogic extends MyApp {
  addItem(key: string, value: string): void {
    this.items.set(key, value);

    // Emit event without handler
    emit(new ItemAdded(key, value));

    // Or emit with handler (handler executes on receiving nodes)
    emitWithHandler(new ItemAdded(key, value), 'onItemAdded');
  }

  // Event handler (runs on peer nodes, not author node)
  onItemAdded(event: ItemAdded): void {
    this.itemCount.increment();
    env.log(`Item added: ${event.key} = ${event.value}`);
  }
}

Event lifecycle: 1. Emitted during method execution 2. Included in delta broadcast to all peers 3. Handlers execute on peer nodes (not author node) 4. Handlers can update state or trigger side effects

Handler requirements: - Commutative: Order-independent operations - Independent: No shared mutable state between handlers - Idempotent: Safe to retry - Pure: No external side effects (only state updates)

Private Storage

For node-local data (secrets, caches, per-node counters):

import { createPrivateEntry } from '@calimero/sdk';

// Create private entry
const secrets = createPrivateEntry<{ token: string }>('private:secrets');

// Get or initialize
const current = secrets.getOrInit(() => ({ token: '' }));

// Modify (never synced across nodes)
secrets.modify(
  (value) => {
    value.token = 'rotated-token';
  },
  () => ({ token: '' }) // Initial value if not exists
);

Key properties: - Never replicated across nodes - Stored via storage_read / storage_write directly - Never included in CRDT deltas - Only accessible on the executing node

Build Pipeline

Development Setup

# Create project
mkdir my-calimero-service
cd my-calimero-service
pnpm init

# Install dependencies
pnpm add @calimero/sdk
pnpm add -D @calimero/cli typescript @types/node

# Create TypeScript config
cat > tsconfig.json << EOF
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "lib": ["ES2020"],
    "moduleResolution": "node",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src/**/*"]
}
EOF

# Create source directory
mkdir src

Build Process

# Build WASM from TypeScript
npx calimero-sdk build src/index.ts -o build/service.wasm

# With verbose output
npx calimero-sdk build src/index.ts -o build/service.wasm --verbose

# Validate only (no build)
npx calimero-sdk validate src/index.ts

Build Configuration

The CLI automatically handles: - TypeScript compilation - Dependency bundling (Rollup) - QuickJS bytecode generation - WASM compilation (WASI-SDK) - Optimization (wasm-opt)

Output: - service.wasm - Deployable WASM binary (~500KB with QuickJS overhead)

Common Patterns

Pattern 1: Simple Key-Value Store

import { State, Logic, Init, View } from '@calimero/sdk';
import { UnorderedMap, LwwRegister } from '@calimero/sdk/collections';

@State
export class KvStore {
  items: UnorderedMap<string, LwwRegister<string>>;

  constructor() {
    this.items = new UnorderedMap();
  }
}

@Logic(KvStore)
export class KvStoreLogic extends KvStore {
  @Init
  static init(): KvStore {
    return new KvStore();
  }

  set(key: string, value: string): void {
    const register = this.items.get(key) ?? new LwwRegister<string>();
    register.set(value);
    this.items.set(key, register);
  }

  @View()
  get(key: string): string | null {
    const register = this.items.get(key);
    return register ? register.get() : null;
  }

  remove(key: string): void {
    this.items.remove(key);
  }
}

Pattern 2: Metrics with Counters

import { State, Logic, Init, View } from '@calimero/sdk';
import { UnorderedMap, Counter } from '@calimero/sdk/collections';

@State
export class Metrics {
  pageViews: UnorderedMap<string, Counter>;

  constructor() {
    this.pageViews = new UnorderedMap();
  }
}

@Logic(Metrics)
export class MetricsLogic extends Metrics {
  @Init
  static init(): Metrics {
    return new Metrics();
  }

  trackPageView(page: string): void {
    const counter = this.pageViews.get(page) ?? new Counter();
    counter.increment();
    this.pageViews.set(page, counter);
  }

  @View()
  getViews(page: string): bigint {
    const counter = this.pageViews.get(page);
    return counter ? counter.value() : 0n;
  }
}

Pattern 3: Event-Driven Updates

import { State, Logic, Init, Event, emitWithHandler } from '@calimero/sdk';
import { UnorderedMap, Counter } from '@calimero/sdk/collections';
import * as env from '@calimero/sdk/env';

@Event
export class ItemAdded {
  constructor(public key: string, public value: string) {}
}

@State
export class StoreWithEvents {
  items: UnorderedMap<string, string>;
  eventCount: Counter;

  constructor() {
    this.items = new UnorderedMap();
    this.eventCount = new Counter();
  }
}

@Logic(StoreWithEvents)
export class StoreWithEventsLogic extends StoreWithEvents {
  @Init
  static init(): StoreWithEvents {
    return new StoreWithEvents();
  }

  addItem(key: string, value: string): void {
    this.items.set(key, value);
    emitWithHandler(new ItemAdded(key, value), 'onItemAdded');
  }

  // Handler executes on peer nodes
  onItemAdded(event: ItemAdded): void {
    this.eventCount.increment();
    env.log(`Item added: ${event.key} = ${event.value}`);
  }

  @View()
  getEventCount(): bigint {
    return this.eventCount.value();
  }
}

Best Practices

1. Initialize CRDTs Inline

// ✅ GOOD - Runtime reuses persisted IDs
@State
export class MyApp {
  items: UnorderedMap<string, string> = new UnorderedMap();
}

// ❌ BAD - Constructor runs every time
@State
export class MyApp {
  items: UnorderedMap<string, string>;

  constructor() {
    this.items = new UnorderedMap(); // Not reused!
  }
}

2. Use @View() for Read-Only Methods

// ✅ GOOD - No persistence overhead
@View()
getItem(key: string): string | undefined {
  return this.items.get(key);
}

// ❌ BAD - Generates unnecessary deltas
getItem(key: string): string | undefined {
  return this.items.get(key);
}

3. Handle Nested CRDTs Correctly

// ✅ GOOD - Use handles, mutate incrementally
const metrics = this.memberMetrics.get('alice');
if (metrics) {
  const counter = metrics.get('commits') ?? new Counter();
  counter.increment();
  metrics.set('commits', counter);
}

// ❌ BAD - Don't try to clone entire structure
const metrics = this.memberMetrics.get('alice');
if (metrics) {
  // Don't do this - it doesn't work
  const cloned = { ...metrics }; // Wrong!
}

4. Make Event Handlers Safe

// ✅ GOOD - Commutative, independent, idempotent
onItemAdded(event: ItemAdded): void {
  this.itemCount.increment(); // CRDT operations are safe
}

// ❌ BAD - Not safe for parallel execution
onItemAdded(event: ItemAdded): void {
  // Don't make external API calls!
  fetch('/api/notify', { ... }); // Wrong!

  // Don't depend on execution order!
  if (this.items.has(event.key)) { // Race condition!
    // ...
  }
}

5. Use Private Storage for Secrets

// ✅ GOOD - Secrets never leave the node
const secrets = createPrivateEntry<{ token: string }>('private:secrets');

// ❌ BAD - Don't put secrets in CRDT state
@State
export class MyApp {
  apiToken: string = ''; // Never do this!
}

Troubleshooting

Build Errors

Issue: TypeScript compilation errors

# Check TypeScript version
pnpm list typescript

# Use verbose flag for details
npx calimero-sdk build src/index.ts -o build/service.wasm --verbose

Issue: Missing dependencies

# Ensure all dependencies are installed
pnpm install

# Check QuickJS and WASI-SDK are downloaded (CLI handles this)

Runtime Errors

Issue: Method not found - Verify method is in @Logic class - Check method name matches call - Ensure method is public (not private)

Issue: CRDT operations failing - Verify CRDT is initialized inline - Check you're using CRDT collections, not plain objects - Ensure nested CRDTs are handled as handles

Issue: Events not propagating - Verify @Event decorator on event class - Check event is emitted during method execution - Ensure handlers are in @Logic class

Examples

The calimero-sdk-js repository includes comprehensive examples:

Example Demonstrates Location
counter Basic Counter CRDT examples/counter
kv-store UnorderedMap + LwwRegister, events examples/kv-store
team-metrics Nested CRDTs, mergeable structs examples/team-metrics
private-data Private storage patterns examples/private-data
blobs Blob management examples/blobs
xcall Cross-context calls examples/xcall

Run an example:

# Clone repository
git clone https://github.com/calimero-network/calimero-sdk-js
cd calimero-sdk-js

# Run example workflow
cd examples/counter
merobox bootstrap run workflows/counter-js.yml --log-level=trace

Comparison: JavaScript SDK vs Rust SDK

Feature JavaScript SDK Rust SDK
Language TypeScript/JavaScript Rust
Runtime QuickJS (in WASM) Native WASM
Build Size ~500KB (includes QuickJS) ~100KB (optimized)
Performance Slower (JS interpreter) Faster (native code)
Development Easier (familiar JS syntax) More learning curve
Type Safety TypeScript Rust
CRDT Collections Same API Same API

When to use JavaScript SDK: - Familiar with JavaScript/TypeScript - Faster prototyping - Less performance-critical applications - Want to leverage existing JS libraries (via Rollup)

When to use Rust SDK: - Need maximum performance - Already familiar with Rust - Complex algorithms or computations - Minimal binary size requirements

Deep Dives

For detailed JavaScript SDK documentation: