Skip to content

Architecture

This page walks the two tools end to end and maps each stage to the code that implements it, so you can jump straight to the source.

calimero-abi-codegen is a straight-line pipeline: read the manifest, validate it, walk it, and print TypeScript. There is no intermediate build step and no config file — the CLI (src/cli.ts) orchestrates two library calls.

loadAbiManifestFromFile(path) reads the JSON and hands it to parseAbiManifest(json), which:

  • validates it against the embedded JSON Schema (schema/wasm-abi-v1.schema.json) using AJV, with all formats registered;
  • confirms schema_version === 'wasm-abi/1';
  • enforces invariants beyond the schema — every $ref must resolve to a defined type or a recognized raw Rust type, state_root must name a defined type, and map keys must be string;
  • rejects duplicate method, event, or type names;
  • returns a deeply frozen AbiManifest, so downstream code cannot mutate it.

Any failure throws a descriptive Error rather than returning a partial result.

model.ts is pure types: TypeScript interfaces that mirror the JSON schema 1:1 — AbiManifest, AbiMethod, AbiEvent, AbiParameter, AbiTypeRef, and every type-definition variant (AbiRecord, AbiVariantDef, AbiAlias, bytes types). It carries no logic; it is the shared vocabulary the parser and generators speak.

generateClient(manifest, clientName?, importPath?, options?) walks the manifest and returns a single TypeScript source string. In order, it emits:

  1. a generated-file banner and an import { MeroJs } from '<importPath>';
  2. one declaration per named type — interface for records, a string-literal union or a discriminated-union Payload type (plus a factory const) for variants, and for aliases either a branded type plus a constructor (over a string or numeric primitive) or a plain type alias — see Aliases are newtypes;
  3. error-code / error-union types for any method that declares errors, and <Event>Payload types plus an AbiEvent union for events;
  4. the CalimeroBytes helper class and byte-conversion helpers — only if the manifest actually uses bytes types;
  5. the client class itself.

emit.ts holds the naming utilities the generator leans on: formatIdentifier (escapes reserved words and illegal characters), toCamelCase (method names), sanitizeClassName and deriveClientNameFromPath (client-class naming), and mapRustTypeToTs (maps raw Rust type strings like Vec<u32>, Option<T>, Result<T, E>, and tuples to TypeScript).

The emitted class is thin. Its constructor takes a MeroJs runtime and a contextId; each method wraps one this._mero.rpc.execute(...) call.

export class KVStoreClient {
private _mero: MeroJs;
private _contextId: string;
constructor(mero: MeroJs, contextId: string) {
this._mero = mero;
this._contextId = contextId;
}
/**
* set
*
* @intent mutating
*/
public async set(params: { key: string; value: string }): Promise<void> {
const response = await this._mero.rpc.execute({
contextId: this._contextId,
method: 'set',
argsJson: params,
});
}
}

Note the shape, because it differs from older hand-written clients:

  • All arguments go in a single params object — a method with parameters is set(params: { key, value }), never positional set(key, value). A method with no parameters takes no arguments.
  • The first constructor argument is the MeroJs instance itself, not mero.rpc.
  • rpc.execute is called with a named-field object (contextId, method, argsJson) and returns the result directly, throwing on error — there is no { output } wrapper to unwrap.
  • There is no executor argument. The node resolves the executor from its own owned identity for the context, so a client-supplied key was always ignored.
  • A declared method intent (e.g. read_only) is surfaced as a @intent JSDoc tag only. It is documentation; there is no separate read transport yet, so read and write methods are called identically.
  • A cross-context entry point is tagged @xcall <callers>, and a declared migration entrypoint @migration from state version N to N+1. Both are documentation; the node enforces the caller policy and drives migrations.
  • bytes-typed values are represented by the generated CalimeroBytes class (CalimeroBytes.fromHex(...), .toUint8Array()), converted to/from plain arrays at the RPC boundary.

create-mero-app is an independent CLI built on commander (create-mero-app/src/index.ts). It copies the KV Store reference app out of the calimero-network/apps monorepo into a new standalone project directory.

Step by step:

  1. Resolve the target directory from the optional [project-name] argument (defaults to the current directory) and validate its basename with validate-npm-package-name. A non-empty existing directory is rejected.
  2. Sparse-clone calimero-network/apps into a temporary directory (os.tmpdir()) and sparse-checkout set apps/kv-store. There is one template, rust, so nothing is prompted.
  3. Copy the app into the target directory, excluding .git, .github, .gitignore, .gitattributes, .gitmodules, and node_modules.
  4. Detach it from the monorepo. Cone-mode sparse checkout also materializes the repo-root files, and those are what the inherited references resolve against — see the CLI reference for the full table.
  5. Write a project root (package.json, pnpm-workspace.yaml) and a README with the standalone commands, then delete the temp clone.
  6. Print next steps: cd <dir>, pnpm install, pnpm logic:build, pnpm dev.
abi-codegen/
├── src/
│ ├── cli.ts ← calimero-abi-codegen CLI entrypoint
│ ├── parse.ts ← loadAbiManifestFromFile, parseAbiManifest
│ ├── model.ts ← TypeScript model of the ABI manifest
│ ├── index.ts ← package entry: re-exports model + parse
│ ├── generate/
│ │ ├── client.ts ← generateClient() — full source emitter
│ │ └── emit.ts ← naming utilities, Rust→TS mapping
│ ├── schema/
│ │ └── wasm-abi-v1.schema.json ← embedded JSON Schema (AJV)
│ └── utils/ ← deepFreeze, AJV error formatting
create-mero-app/
└── src/
├── index.ts ← scaffolding CLI
└── monorepo.ts ← resolves catalog:/workspace/extends refs
codegen-example/ ← reference React app (not published)