@candid-core/cli
The same compiler as WebAssembly: a JavaScript-only on-ramp for Node and the browser.
@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.
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.
# 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:
usage: candid-core-cli gen <service.did>... [-o <dir>] [--json] [--check]
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/schemamodule: one reviewed type alias and one schema builder per declaration. -
<stem>.envelope.json— the one-documentContractEnvelope: the canonical Contract plus its field-name table under theorg.candid-core.field-names/v1extension, ready to hand whole toschemaFromContract. -
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:
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:
{
"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.
{
"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.
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 }.
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 }.
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.
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.
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:
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:
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:
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:
"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"
}-
Install the pinned wasm-pack and the wasm target#
bashcargo install wasm-pack --version 0.14.0 --locked rustup target add wasm32-unknown-unknown -
Build the artifact#
bashcd crates/candid-core-wasm/npm npm run build -
Run the CLI and its suite#
bashnpm ci node bin/cli.js gen ./service.did -o ./out npm testnode bin/cli.js gen …is exactly how the tests invoke it, so it is the verified local entry point.npm testruns 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.
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.