The Vite plugin
@ic-reactor/vite-plugin is for an app built on a module that
candid-core-cli gen generates from a .did file. It does two things, and it
exports only icReactor and the type IcReactorPluginOptions:
- Generation. It runs
candid-core-cli genon each configured.didfile when a build or the dev server starts, and again when that file changes. The module is the generator’s, as it wrote it: the plugin adds no wrapper files, hooks or reactors. - Environment. Under
vite devandvite previewit sets theic_envcookie and proxies/apito the local IC network, so the app finds its canister ids and the replica’s root key without configuration.
Install
Section titled “Install”The plugin runs the @candid-core/cli that your app installs, and that CLI has
to pair with the @candid-core/schema runtime the generated modules import.
Both are pinned to one exact release, as candid-core asks of every release
before 1.0, and so is the plugin’s peer on the CLI:
npm install --save-dev @ic-reactor/vite-plugin@betaThe plugin imports neither package, and no @ic-reactor runtime. Its peers are
the CLI and Vite 4.2 up to 8. See @ic-reactor/vite-plugin.
Quick start
Section titled “Quick start”import { icReactor } from "@ic-reactor/vite-plugin"import { defineConfig } from "vite"
export default defineConfig({ plugins: [ icReactor({ canisters: { icrc1: { didFile: "did/icrc1.did" }, }, }), ],})On vite dev and vite build this writes src/canisters/icrc1.ts, the
generated module (it exports actor and the type Actor), and
src/canisters/icrc1.envelope.json beside it. The generator names its output
after the .did file, not after the key in canisters. The app imports it:
import { createClient } from "@ic-reactor/core"import { actor, type Actor } from "./canisters/icrc1"
const client = createClient({ network: "ic", identity: "anonymous" })const ledger = client.canister<Actor>(actor, { id: "ryjl3-tyaaa-aaaaa-aaaba-cai",})Without an outDir the plugin writes to src/canisters. To match the
./generated/... imports of Getting started, set
outDir: "src/generated".
The first line the plugin logs says where an agent reads how to use the
library: ic-reactor: agent guide at node_modules/@ic-reactor/core/llms.txt.
Options
Section titled “Options”| Option | Default | Meaning |
|---|---|---|
canisters |
{} |
The app’s canisters, by their name in the icp project |
injectEnvironment |
true |
Set the cookie and the /api proxy under vite dev and vite preview |
failOnError |
build true, dev false |
Abort the Vite run when a canister fails to generate. See Failures |
Each entry of canisters takes:
| Field | Default | Meaning |
|---|---|---|
didFile |
none | The canister’s Candid file, relative to the Vite root. Without it nothing is generated |
outDir |
"src/canisters" |
Where the generator writes, relative to the Vite root |
canisterId |
none | A fixed id for the cookie, which wins over the one icp reports |
The key of an entry is the canister’s name in the icp project, and it is the
name the cookie carries its id under. A canister without a didFile generates
nothing and is only named in the cookie.
Canisters that share an interface
Section titled “Canisters that share an interface”An ICP ledger and a ckBTC ledger can both use icrc1.did. They are two
entries, so that the cookie carries each id, and one generated module:
import { icReactor } from "@ic-reactor/vite-plugin"import { defineConfig } from "vite"
export default defineConfig({ plugins: [ icReactor({ canisters: { icp_ledger: { didFile: "did/icrc1.did" }, ckbtc_ledger: { didFile: "did/icrc1.did" }, }, }), ],})The generator is given icrc1.did once, writes src/canisters/icrc1.ts once,
and a save of the file regenerates it once.
Different .did files that name the same module cannot share an outDir:
a/ledger.did and b/ledger.did both mean ledger.ts, and the second is
refused with a message naming both. Give one an outDir of its own. Names that
differ only in case (Ledger.did, ledger.did) collide only where the
filesystem of the outDir ignores case, as macOS and Windows do by default.
Generation
Section titled “Generation”The generator is WebAssembly, so the plugin does not load it. It runs the bin
script of the @candid-core/cli installed for your app (resolved from the Vite
root, so a monorepo’s hoisted copy is found) with the running Node binary, in a
child process and not through a shell. A trap, a crash or a runaway loop on a
bad .did ends that process, and the plugin reports it. The dev server is not
affected. The command it runs is
gen <didFiles...> -o <outDir> --json.
- Canisters that write into one
outDirshare one process, and eachoutDirhas its own. A process that dies without a report is run again, one canister at a time, so the failure lands on the canister that caused it. - A process that runs longer than 60 seconds is killed. A hang therefore costs up to two timeouts (120 seconds) before the others are generated.
- What the generator writes to stderr is logged as a warning, a line at a time. Past 200 lines from one process the rest is not logged.
- A declaration the generator cannot represent is left out of the module, and
the plugin logs each one as a warning, for example
ic-reactor: ledger: omitted declaration Bad (reserved_field_name). - Editing a
.didregenerates only the canisters that name it. Undervite build --watch, a canister is regenerated only when its.didtext changed. - Deleting a
.didundervite devfails the canisters that name it at once, because the module it generated is still on disk and would look current. Putting the file back regenerates them.
When generation fails
Section titled “When generation fails”A failed canister costs that canister, and nothing else:
- Under
vite build, the build fails with the generator’s diagnostics (or its stderr, if it crashed) in the error message. A build that exits 0 would ship the bindings left over from the last good run. - Under
vite dev, the failure is logged and shown in the browser’s error overlay, and the server keeps serving. Fixing the last broken canister clears the overlay. Vite’s client clears an error overlay whenever it applies a hot update, so a canister that is still broken can drop out of the overlay after you save another file. Its error stays in the terminal log, and a full reload shows the overlay again. failOnErroroverrides either default.
candid-core-cli gen --check compares the generated files with the ones on
disk, writes nothing, and exits 1 on any difference. Run it with the arguments
the plugin uses, to fail a pipeline when the committed output is stale:
{ "scripts": { "gen:check": "candid-core-cli gen did/icrc1.did -o src/canisters --check" }}The local environment
Section titled “The local environment”When injectEnvironment is on, under vite dev and vite preview, the plugin:
- asks
icpfor the local network’s status (icp network status); - resolves canister ids: the keys of
canisters, andinternet_identity, which is added if it is not listed; - sets the
ic_envcookie on each response; - proxies
/apito the local replica.
A canisterId in the plugin config overrides the detected id for that
canister. Set the ICP_ENVIRONMENT environment variable to use a network other
than "local".
The cookie
Section titled “The cookie”The cookie is ic_env, set with Path=/; SameSite=Lax. Its value is URI-encoded
text of this form:
ic_root_key=<hex key>&PUBLIC_CANISTER_ID:<name>=<id>&...&INTERNET_IDENTITY_PROVIDER=<url>It carries the replica’s root key, one PUBLIC_CANISTER_ID:<name> for each
canister, and the local Internet Identity’s url when there is one.
Detection
Section titled “Detection”- If detection fails, the plugin falls back to proxying
/apitohttp://127.0.0.1:4943and sets no cookie, or with no canisters configured, one that names only the built-in Internet Identity. It warns when that happens with canisters configured, and when a configured canister has no id. Run withDEBUG=ic-reactorto see theicpoutput behind the warning. - Detection is complete once
icpreports the network and every configured canister has an id. Until then the plugin asksicpagain on each page load, so startvite devfirst, runicp network startandicp deploy, and reload the page. - Once detection is complete, page loads run no more
icpcommands. Deploying into a fresh network, with new ids and a new root key, needs a restart of the dev server. - A configured canister you never deploy locally keeps detection incomplete.
Set its
canisterIdand it counts as resolved. If you never run a local network, setinjectEnvironment: false. - If your Vite config or another plugin sets
server.proxy["/api"], the plugin leaves it alone.
How the app reads it
Section titled “How the app reads it”The app reads the cookie through network: "env" and calls canisters by name:
import { createClient } from "@ic-reactor/core"import { actor, type Actor } from "./canisters/backend"
const client = createClient({ network: "env", identity: "anonymous" })const backend = client.canister<Actor>(actor, { name: "backend" })On a local page the client trusts the cookie, so { name: "backend" } finds the
id that icp deploy gave the backend. Nothing in the code names a host, a port
or an id. Deployed on a mainnet domain, "env" still sends through the page’s
origin, but the client no longer believes the cookie, so a { name } target
does not resolve there: give the canister an { id }, or write
allowEnvConfig: true when every subdomain of the page’s domain is trusted.
See The client for when the cookie is
trusted. The Vite wallet example is built this way
for a local network.
Vitest runs the plugin in mode test. There it injects no environment and
never runs icp, whatever injectEnvironment says. Generation is not an
environment concern and still runs, so the modules a test imports exist. To
test canister code, see Testing.