Skip to content
IC Reactor

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 gen on each configured .did file 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 dev and vite preview it sets the ic_env cookie and proxies /api to the local IC network, so the app finds its canister ids and the replica’s root key without configuration.

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:

Terminal window
npm install --save-exact @candid-core/[email protected]
npm install --save-dev --save-exact @candid-core/[email protected]
npm install --save-dev @ic-reactor/vite-plugin@beta

The 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.

vite.config.ts
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.

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.

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:

vite.config.ts
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.

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 outDir share one process, and each outDir has 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 .did regenerates only the canisters that name it. Under vite build --watch, a canister is regenerated only when its .did text changed.
  • Deleting a .did under vite dev fails 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.

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.
  • failOnError overrides 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"
}
}

When injectEnvironment is on, under vite dev and vite preview, the plugin:

  1. asks icp for the local network’s status (icp network status);
  2. resolves canister ids: the keys of canisters, and internet_identity, which is added if it is not listed;
  3. sets the ic_env cookie on each response;
  4. proxies /api to 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 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.

  • If detection fails, the plugin falls back to proxying /api to http://127.0.0.1:4943 and 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 with DEBUG=ic-reactor to see the icp output behind the warning.
  • Detection is complete once icp reports the network and every configured canister has an id. Until then the plugin asks icp again on each page load, so start vite dev first, run icp network start and icp deploy, and reload the page.
  • Once detection is complete, page loads run no more icp commands. 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 canisterId and it counts as resolved. If you never run a local network, set injectEnvironment: false.
  • If your Vite config or another plugin sets server.proxy["/api"], the plugin leaves it alone.

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.