@candid-core/cli

The same compiler as WebAssembly: a JavaScript-only on-ramp for Node and the browser.

Published on npm

@candid-core/cli 0.1.0 is on the npm registry under the latest tag, published from this repository by its dispatch-only release workflow. The registry also holds 0.0.0-bootstrap under the bootstrap tag, which exists only so a trusted publisher could be attached to the name; do not install it. The candid-core binary does the same compilation from Rust.

Published as a beta

This page describes the package the repository builds, published as 0.2.0-beta.1 under the npm beta dist-tag. latest is still 0.1.0, which has the old behaviour: it emits modules in the old layout with an object-shaped principal type, exits 1 with ts_generation_refused for an interface with one declaration it cannot represent, and returns no omitted list. Migrating from 0.2.0 lists every difference with before and after code, and each is recorded in the package changelog under 0.2.0-beta.1.

bash
# 0.2.0-beta.1, the beta this page describes (see the note above)
npx @candid-core/cli@0.2.0-beta.1 gen ./service.did -o ./generated

Here is what the package is and what it does. Everything candid-core does to a .did file normally happens in Rust. @candid-core/cli is that same Rust code — the compiler's compiler feature surface plus the candid-core-ts TypeScript generator — compiled once to WebAssembly and wrapped in a small Node CLI and an ES module. A JavaScript project can therefore go from a Candid interface to typed, validated schemas with npm as its only toolchain.

It is not a reimplementation, and that is the point of its test suites: the same crates are compiled into one wasm artifact, and CI compares the artifact's output byte-for-byte against the reviewed goldens and against a document the native binary actually produced. The crate that produces it sets publish = false and is its own workspace root, so it never appears in the published Rust crate's dependency graph; the npm package is the deliverable.

What is in the package#

Field Value
Name and version @candid-core/cli, 0.1.0 — published on npm under latest
Binary candid-core-cli → ./bin/cli.js
Library entry point "." → ./lib/index.js, under a types condition. ./package.json is the only other entry in the map
Module format "type": "module" — ESM only, no CommonJS require path
Runtime dependencies None. @candid-core/schema is declared as an optional peer at exactly 0.3.0-beta.1 — every module the CLI emits imports from it, but a consumer calling only didToContract never loads it. The modules this page's generator emits need the Principal export that 0.2.0 lacks, and while the two packages move in lockstep every schema beta is paired with a CLI beta that names it exactly. The published 0.1.0 still declares ^0.2.0. The one devDependency, playwright-core, drives the browser test
Types A hand-written lib/index.d.ts, reached through the types condition. The wasm-pack generated declarations are deliberately not shipped: they name BufferSource, URL, Response and WebAssembly, which do not resolve without the DOM library
Licence Apache-2.0

The command#

One command, and these options, nothing else:

textcrates/candid-core-wasm/npm/bin/cli.js
usage: candid-core-cli gen <service.did>... [-o <dir>] [--json] [--check]
Several entries, --json and --check are not in 0.1.0

latest, 0.1.0, takes exactly one entry and neither flag: gen <service.did> [-o <dir>]. Several entries, --json and --check, described in their own section below, ship in 0.2.0-beta.1. Everything else on this page describes both.

It writes two files and prints the identities:

  • <stem>.ts — the generated @candid-core/schema module: one reviewed type alias and one schema builder per declaration.
  • <stem>.envelope.json — the one-document ContractEnvelope: the canonical Contract plus its field-name table under the org.candid-core.field-names/v1 extension, ready to hand whole to schemaFromContract.
  • the content-addressed identities, on stdout. The interface: line appears only when the interface identity is present, which means only when the source has an actor.

Run over the repository's two-line basic.did fixture — the same one shown on the candid-core binary page — with -o generated, stdout is four lines and the exit status is 0:

text
contract:  candid-core:contract:v1:sha256:0b553cb65eb436a7ac5d35869d0016d043867bab0a97e844350ae72a5b4aea7d
interface: candid-core:interface:v1:sha256:99f400820c0ca2fb7c32cc3ab1df6d6c000bc614cffe0e350b6a9a576da18d48
wrote generated/basic.ts
wrote generated/basic.envelope.json

Those are the real identities for that fixture, and they are the same addresses the Rust binary computes — a test compares the emitted envelope against the native binary's committed output byte for byte. The stem comes from the entry file's name, so service.did would produce service.ts and service.envelope.json. -o defaults to "." — the current working directory, not the entry file's directory — and the output directory is created if it does not exist.

How imports are found#

The wasm side never touches a filesystem, so the Node host does all the I/O. gen walks the entry file's directory recursively, collects every .did beneath it keyed by its /-separated relative path, and hands that map to the compiler as one in-memory bundle. Relative imports then resolve inside the bundle with no filesystem access from wasm. The consequence to plan for: unrelated sibling .did files under the same directory are pulled in too. The parity tests copy each fixture into an otherwise-empty temporary directory precisely so the walk cannot smuggle unrelated sources into provenance.

The walk is bounded before anything is read. File count, per-file size (through stat) and aggregate size are checked against the compiler's own default limits — 256 sources, 1 MiB per file, 8 MiB in total — so an oversized tree fails with a structured diagnostic rather than by exhausting the JavaScript heap:

json
{
  "ok": false,
  "diagnostics": [
    {
      "code": "resource_limit_exceeded",
      "phase": "load",
      "severity": "error",
      "message": "huge.did is 1048577 bytes, over the 1048576-byte source limit",
      "resource_limit": { "resource": "source_bytes", "limit": 1048576, "observed": 1048577 }
    }
  ]
}

Exit codes#

They follow the native binary's convention exactly.

Exit When Where the output goes
0 Both files written, including a module that omits declarations Identities and two wrote lines on stdout; one warning: omitted … line per omission on stderr
1 Compile or generation failure The { "ok": false, "diagnostics": […] } document on stdout
1 Entry file missing or unreadable, or a determinism mismatch A plain message on stderr — the two paths that print no JSON
1 With several entries, any one entry failed; with --check, a file is missing or has drifted As above, per entry; with --json, everything is in the one document on stdout
64 Usage error, including two entries that would write the same output stem The usage line on stderr, nothing on stdout

That determinism path is unusual enough to name. Every generation is run twice and the results compared; on any byte mismatch the tool prints determinism check failed: two <label> runs disagreed; refusing to write and exits without writing anything. Determinism is enforced at run time, not only asserted in CI.

Several entries, --json and --check#

This section describes the unreleased command on main. Each entry generates its own <stem>.ts and <stem>.envelope.json into the one -o, byte for byte what a run of that entry alone writes, and a failing entry does not stop the others: every entry is attempted, and the exit status is 1 if any failed. Omissions never fail an entry. Two rules are recommendations the maintainer may still overturn: an entry's bundle is the .did files beneath its own directory, with the limits above applied per entry (entries sharing a directory share one read of it); and two entries with the same stem, compared case-insensitively, are a usage error before any work. An output that already holds the generated bytes is not rewritten, and is reported unchanged. The CLI never reads stdin and never writes outside -o.

--check generates in memory and compares byte for byte with the files on disk. It writes nothing, not even the output directory, and exits 1 if any file is missing or differs, which makes it the CI gate for committed output.

--json prints exactly one document on stdout, with stderr empty; combine it with --check. The content-addressed identities are not in it. A usage error prints no document.

json
{
  "schemaVersion": 1,
  "ok": true,
  "check": false,
  "entries": [
    {
      "entry": "a/service.did",
      "status": "written",
      "module": "generated/service.ts",
      "envelope": "generated/service.envelope.json",
      "omitted": [],
      "diagnostics": []
    }
  ],
  "drift": []
}

status is written, unchanged, drifted (--check only) or failed. omitted is the list didToModule returns, and diagnostics is the compiler's own, unchanged in shape; ok is true exactly when the exit status is 0; drift lists the paths --check found missing or different. schemaVersion changes only when an existing field's meaning or shape does. The types ship as CliReport and CliEntryReport.

The library API#

Three exports, and the whole surface. Both operations take one JSON-serializable request and return one parsed JSON document; nothing is thrown for a data error.

didToContract
jscrates/candid-core-wasm/npm/lib/index.js
export async function didToContract(sources)

Compiles Candid sources into a one-document ContractEnvelope carrying the org.candid-core.field-names/v1 extension — the same document candid-core compile <path> --envelope emits. sources is either a string of Candid text or { entry, files: { name: text } } for a bundle. On success it returns the envelope itself, so you test "contract" in result; on failure it returns { ok: false, diagnostics }.

didToModule
jscrates/candid-core-wasm/npm/lib/index.js
export async function didToModule(sources)

Generates the @candid-core/schema TypeScript module for the same two source shapes. Returns { ok: true, module, omitted }, where module is the generated text and omitted lists what it left out (empty when nothing; see Omissions), or { ok: false, diagnostics }.

init
jscrates/candid-core-wasm/npm/lib/index.js
export function init(input)

Initializes the embedded wasm module, memoized, so calling it again is a no-op. Under Node with no argument it reads the .wasm bytes from inside the package; in a browser with no argument the artifact is fetched relative to the module, and a browser may instead pass its own BufferSource, URL or Response. Both functions above await it internally, so calling it yourself is optional.

The two functions signal success differently

didToModule returns { ok: true, module, omitted }. didToContract does not carry an ok key on success — it returns the envelope document, and you check for "contract" in result. This mirrors the native binary, where compile --envelope prints the envelope rather than an ok-wrapped response. Only failures share one shape.

The examples below import from the published package; from a clone, import crates/candid-core-wasm/npm/lib/index.js directly.

js
import { didToContract, didToModule } from "@candid-core/cli";

const envelope = await didToContract("service : { ping : () -> () };");
// → the ContractEnvelope document ("contract" in envelope), or
//   { ok: false, diagnostics } with the compiler's diagnostics verbatim.

const generated = await didToModule({
  entry: "main.did",
  files: {
    "main.did": 'import "types.did";\nservice : { get : () -> (Item) query };',
    "types.did": "type Item = record { id : nat };",
  },
});
// → { ok: true, module, omitted } with the generated TypeScript text and
//   what it left out (empty here), or { ok: false, diagnostics }.

The envelope is designed to need no glue on the way out. schemaFromContract accepts either a bare Contract or an envelope — it recognises the envelope by its contract key — and consumes the field-name table itself, which is what lets record and variant fields come back with their source spellings instead of numeric label hashes:

js
import { schemaFromContract } from "@candid-core/schema/contract";

const didText = "type Payload = record { owner : principal; amount : nat };";
const built = schemaFromContract(await didToContract(didText));

Omissions#

A declaration the generator cannot represent costs only itself and what depends on it. It is left out together with every declaration and actor method that references it — through nested func and service types too, up to the containing declaration — and the module is still a success: gen writes it and exits 0, printing one line per omission on stderr, worded exactly as the module header lists it:

text
warning: omitted type Bad (reserved_field_name)
warning: omitted type Registry (references_omitted via Bad)
warning: omitted method bad (references_omitted via Bad)

didToModule returns the same list as omitted: an array of { kind, name, reason, via? }, declarations first, then methods, each sorted by name. kind is "declaration" or "method" (a method of the actor's service, dropped from both actor and Actor). reason is one of a closed set — reserved_field_name, ambiguous_variant_arm, reserved_export_name, invalid_declaration_name (Contract documents only) and references_omitted — explained on the code generator page; via, present only for references_omitted, names the omitted declaration referenced. Everything the module still emits is exactly what it would be had the omitted declarations never been written, and schemaFromContract omits the same entries from the envelope.

Failures#

Data errors never throw and never half-succeed. Every failure is { ok: false, diagnostics: [...] }. Compiler diagnostics pass through verbatim with their stable codes — did_parse_error, resolver refusals such as did_source_scheme_mismatch, resource bounds. Two codes originate in this layer: invalid_request (phase load) when the request document is neither of the two accepted shapes, and ts_generation_refused (phase generate), kept for a generator refusal of an invalid Contract graph — which a Contract compiled from Candid source never is, so no .did reaches it: what cannot be represented is omitted instead. A matrix of nine malformed requests — including "not json", "{}", and a request carrying both source and entry — is pinned to return invalid_request rather than panicking.

In a browser#

The artifact is built for the web target, not nodejs, and one artifact serves both hosts — under Node the wrapper supplies the bytes itself. The wasm side evaluates no code, opens no network connection and touches no filesystem: it sees only the strings handed to it. The wrapper's one node: import is a dynamic node:fs/promises inside init, reached only when it detects Node and reads the packaged .wasm; a page never takes that branch, which is what the headless-Chrome test below exercises. Candid imports are resolved from the map you hand over, so a page that already fetched its .did sources can compile them in place.

A headless-Chrome test in the repository serves the wasm and the schema runtime over HTTP and runs this, asserting that the envelope-carried names really do become the schema's keys — a value keyed owner/amount validates:

jscrates/candid-core-wasm/npm/test/browser/smoke.test.js
import init, { didToContract } from "/wasm/candid_core_wasm.js";
import { schemaFromContract } from "/schema/contract.js";
import { validate } from "/schema/validate.js";

await init();
const envelope = JSON.parse(didToContract(JSON.stringify({ source: didText })));
const built = schemaFromContract(envelope);
// built.schemas.Payload now validates { owner: <a principal>, amount: 5n }

Note the shapes there: importing the wasm module directly gives you the raw wasm-bindgen exports, which are string in, string out. The whole wasm ABI is two functions wide — didToContract(request: string): string and didToModule(request: string): string — and every richer shape lives in the JSON documents, which is what keeps the boundary reviewable. Importing from the package instead gives you the wrapper that does the JSON encoding for you.

Building it from a clone#

The wasm/ directory is a build output and is not checked in, so the build comes first. The package's own build script is the exact command:

jsoncrates/candid-core-wasm/npm/package.json
"scripts": {
  "build": "cd .. && wasm-pack build --target web --out-dir npm/wasm --no-pack --release",
  "test": "node --test test/*.test.js",
  "test:browser": "node --test test/browser/*.test.js"
}
  1. Install the pinned wasm-pack and the wasm target#

    bash
    cargo install wasm-pack --version 0.14.0 --locked
    rustup target add wasm32-unknown-unknown
  2. Build the artifact#

    bash
    cd crates/candid-core-wasm/npm
    npm run build
  3. Run the CLI and its suite#

    bash
    npm ci
    node bin/cli.js gen ./service.did -o ./out
    npm test

    node bin/cli.js gen … is exactly how the tests invoke it, so it is the verified local entry point. npm test runs the byte-for-byte parity suite.

CI runs the same build with -- --locked appended, under the exact-pinned release toolchain rather than the rolling stable of ordinary jobs. wasm-opt is deliberately disabled so the artifact's bytes are a pure function of the pinned rustc and wasm-bindgen, with no binaryen download anywhere in the build path.

What the parity gate actually asserts#

The wasm CLI workflow runs three jobs, and it is worth being precise about what each one establishes, because "there is a gate" and "there is a released package" are different claims.

Job What it asserts
host Format, clippy with warnings denied, and the Rust parity tests: every golden fixture reproduces its reviewed .ts module byte for byte; the emitted envelope equals the committed fixture the native candid-core compile --envelope binary produced, byte for byte; the field-name triples equal the generator's reviewed *.names.json goldens for five fixtures; identical requests produce identical responses.
artifact The same comparisons again through the real WebAssembly under Node — the npm suite runs bin/cli.js, which loads the compiled wasm — plus artifact determinism: hash the build, cargo clean, delete the output, rebuild, and require the hashes to match.
browser didToContract inside a pinned headless Chrome build, feeding schemaFromContract, with envelope-carried field names rendering as schema keys.

What that buys you is specific: for those fixtures, at that commit, the WebAssembly path and the native Rust path emit the same bytes, and the artifact is reproducible from its pinned inputs. What it does not buy you is a published version: the gates run on a commit and publish nothing, and a version reaches the registry only when its release workflow is dispatched, as 0.1.0 was. Nor does it buy a promise about versions beyond the tested set. The version pairing is a revision pairing: the package embeds the compiler and generator from the exact commit a release is dispatched from, and the changelog names the revisions per release. Read the changelog for the pairing rather than assuming a crate version.

Pre-1.0, on both sides

Until 1.0 any release may change the CLI grammar, the library API, and the request and response shapes — the same warning the Rust crate carries. Pin an exact version.

Next#

Schemas from a Contract document shows the TypeScript side consuming the envelope this package writes. The candid-core binary produces the same envelope document from Rust. For where each unit stands, see Status, versions and releases.