Skip to content

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. A .did that is not on disk yet can be fetched from the live canister first.
  • 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/schema@0.4.0
npm install --save-dev --save-exact @candid-core/cli@0.3.0
npm install --save-dev @ic-reactor/vite-plugin

The plugin imports neither package, and no @ic-reactor runtime. Its peers are the CLI, Vite 4.2 up to 8, and @icp-sdk/core 6.1 or later, which is optional: the plugin loads it only to fetch a .did, and an app on @ic-reactor/core has it installed already. 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, its name without the final .did, not after the key in canisters, and it reads only a file whose name ends in .did. 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 and Vitest true, dev false Abort the Vite run when a canister fails to generate. See Failures
onGenerated none Called after each generated module. See After each generation

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, and the canister a missing didFile is fetched from
network "ic" Where that canister is, for the fetch: "ic", "local" or { host }
methods none The methods the app calls: a fetched didFile is written as the projection of the interface to them
verify none "live": check the didFile against the live canister when a build or dev server starts

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, kept as given: __proto__ or constructor (from a JSON config, or a computed key) is a name as any other, and so is a name that begins with -. The cookie joins its entries with & and each name to its id with =, as the asset canister writes it, so a name that holds either, which no name icp gives a canister does, is left out of the cookie, with a warning under vite dev, and the canister is generated as any other. A canister without a didFile generates nothing and is only named in the cookie.

Every value given is checked when the plugin is made, on every canister (one without a didFile included) and whether or not a build would use it: only an omitted field is one not given. A canister whose entry is not a plain object, or whose field holds a value that is not one (a didFile or outDir that is not a path, a canisterId that is not the text of a principal, a network, methods or verify that is none of the values above, or a getter that throws) fails, under failOnError, with a message that quotes the value as given, whatever its kind. The plugin fetches, writes, runs and requests nothing to generate it, nor fetches a didFile it shares with other canisters, and the others build as they would. So does a canister whose module a canister before it writes (see below), and one that IC_REACTOR_FETCH or IC_REACTOR_VERIFY names but that has no canisterId. Under vite dev, a save of its file, and the file’s deletion, leave it failed for the same reason and run nothing for it. Such a canister is still named in the cookie, as every entry is (but one whose name the cookie cannot carry, above), and under vite dev icp is still asked for its id: the app may still call it. A canisters, injectEnvironment or failOnError that is not one, null included, throws when the config loads: only an omitted option takes its default.

Every key is checked too, whatever its value, undefined included. A key of an entry that is none of the fields above (a misspelled didfile or canisterID), or a key of a network of { host } other than host, fails its canister, and a key of the options that is none of canisters, injectEnvironment, failOnError and onGenerated throws when the config loads. The message names the option the key may misspell:

ic-reactor: could not generate 1 of 1 canisters:
- ledger (did/icp_ledger.did): "canisterID" (did you mean canisterId?) is not an option of a canister: its options are didFile, outDir, canisterId, network, methods and verify

Never read, a misspelled key would leave a canister ungenerated, or generated without its id, without a word.

The options, canisters, each entry and a network of { host } are plain objects: written { … }, or made by JSON.parse, Object.fromEntries or Object.create(null). A Map, a Promise (a loader that was not awaited), an instance of a class or an object that inherits from another is refused as any other value that is not one: read for its keys, it would be read as none.

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. The entry later in canisters fails, with a message naming both, at the start of each build and dev server and before anything is fetched, run or written for it, and a save of its file does not generate it either. 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.

A canister can publish its Candid interface as candid:service metadata. Give its entry a canisterId, and a didFile that is not on disk is written from the live canister before it is generated:

vite.config.ts
import { icReactor } from "@ic-reactor/vite-plugin"
import { defineConfig } from "vite"
export default defineConfig({
plugins: [
icReactor({
canisters: {
ledger: {
didFile: "did/icp_ledger.did", // written when missing: commit it
canisterId: "ryjl3-tyaaa-aaaaa-aaaba-cai",
network: "ic",
},
},
}),
],
})
  • didFile on disk: the plugin generates from it and makes no network request for the canister, under vite build and vite dev alike. Builds stay reproducible and work offline. Commit the file.
  • didFile missing: the plugin reads candid:service from certified state (HttpAgent.readState, the certificate checked against the network’s root key), writes it to didFile as the canister publishes it, then generates. It logs ic-reactor: wrote did/icp_ledger.did from the certified candid:service of ryjl3-tyaaa-aaaaa-aaaba-cai on ic (17368 bytes): commit it with the app. The next build reads the file and makes no request.
  • Fetching again happens only when asked: run the build or the dev server with IC_REACTOR_FETCH=ledger (names separated by commas, or all). A build without it never rewrites a didFile. Under vite dev it is served once, when the server starts.
  • Canisters that name one didFile share one fetch. It is one file however it is named: through a symlinked directory, or in another case where the filesystem ignores case (macOS and Windows by default). When they name different canisters, or one canister on different networks, which one’s interface the file holds cannot be told: nothing is fetched, and each of them fails with a message naming them and the file. Give each its own didFile, or write the file yourself. A didFile on disk is theirs to share, whatever their canisterIds, since nothing is fetched for it. A didFile that is a symlink is written through: the fetch goes to the file it links to. The sharing is the fetch’s only: the generator (@candid-core/cli 0.3.0) reads a didFile only by its exact name on disk, and not through a symlink to a file, so an entry that names it either way fails with did_source_not_found. In each entry, name the file as it is on disk.

network says where the canister is:

network Host Root key the certificate is checked against
"ic" https://icp-api.io Mainnet’s, which the agent ships with. The default
"local" http://127.0.0.1:8000 Fetched from the replica: icp-cli’s local network
{ host } host Fetched from the replica only when host is local (localhost, *.localhost or a loopback address), and mainnet’s otherwise

These are the rules createClient follows: a root key is fetched only from a local host, so a host that is not local cannot hand over a key of its own. The fetch needs @icp-sdk/core, an optional peer of the plugin. An app whose .did files are all on disk never loads it.

canisterId is also the id the cookie carries under vite dev (see The local environment). A canister deployed to the local network under an id of its own, whose interface you want from mainnet, keeps its committed didFile, or fetches it once and drops canisterId.

Each failure says what happened to which canister, and what to do next:

  • The network cannot be reached (no connection, or the local network is not running): the message names the host. Under "local" it says to start the network with icp network start.
  • The read is refused: the canister keeps its interface private, and only its controllers can read it (HTTP 403). Get the .did from the canister’s developers.
  • No metadata: the canister publishes no candid:service, so there is no interface to fetch.
  • No such canister on that network: check canisterId and network.
  • The certificate does not verify against the root key in the table above: the message names that key. A { host } that is not local is checked against mainnet’s key, so a testnet’s own certificates fail there.
  • No answer within 30 seconds, retries included: the message names the host.
  • The file cannot be written (a read-only directory, say): only the canisters that name it fail, and the others generate.

A canister whose fetch failed is not generated, and the failure follows failOnError as a generation failure does: under vite build it fails the build, under Vitest the run’s startup, and under vite dev it is logged and shown in the overlay. A fetch that failed, one that IC_REACTOR_FETCH asked for included, is tried again at the next buildStart: each rebuild of vite build --watch, and each Vite environment that starts one. Writing the didFile yourself always works instead: the plugin reads a file on disk and fetches nothing.

A canister’s interface can be far larger than what the app calls: the ICP ledger’s is 17 kB. With methods, the fetched didFile holds only those methods and every type they reach, so the generated module has only them:

vite.config.ts
import { icReactor } from "@ic-reactor/vite-plugin"
import { defineConfig } from "vite"
export default defineConfig({
plugins: [
icReactor({
canisters: {
ledger: {
didFile: "did/icp_ledger.did", // the projection: commit it
canisterId: "ryjl3-tyaaa-aaaaa-aaaba-cai",
methods: ["icrc1_balance_of", "icrc1_fee", "icrc1_transfer"],
},
},
}),
],
})

The plugin fetches the certified candid:service as above, and writes its projection to methods (candid-core-cli project) instead of the whole interface. It logs ic-reactor: wrote did/icp_ledger.did from the certified candid:service of ryjl3-tyaaa-aaaaa-aaaba-cai on ic, projected to icrc1_balance_of, icrc1_fee, icrc1_transfer (1327 bytes): commit it with the app. The projection keeps the canister’s type names and doc comments, and prints methods in name order. The same interface and the same list give the same bytes, so IC_REACTOR_FETCH rewrites the file only when the canister changed.

  • A name the canister does not have fails the canister, naming it and listing the methods the canister has. Nothing is written.
  • A didFile on disk is not fetched again when methods changes. The plugin compares the two, with no network access, when the build starts and when the file is saved. A name of methods that the file does not hold fails the canister, since its module would lack the method. A method the file holds that methods does not name is a warning, and the module keeps it. Both say to run IC_REACTOR_FETCH=<name>, which writes the projection to the new list. A file whose methods cannot be listed (it does not compile, or candid-core-cli fails) fails the canister: a comparison that did not run does not pass. A file saved after its methods were listed, before its module is generated, is not generated by that run (see A .did saved while the plugin reads it).
  • Canisters that name one didFile share its fetch only when they name one list. Every one of them generates from the file, so an entry with no canisterId counts as much as the one it is fetched for: its methods, or the whole interface when it names none. With different lists, or when one of them fails for a value of its own (a list that cannot be projected to, say), nothing is fetched and no request is made: each fails with one message naming the entries and the file, and their lists when they differ, or the reasons of the one that fails. A didFile on disk that nobody asked to fetch again is read by them all, and compared with the list of each as above.
  • methods is a list of names: an empty list, or a name with a comma (which candid-core-cli project cannot take), fails the canister before any request. Only an omitted methods is the whole interface: any other value a JavaScript config gives (null, false, 0, "") fails the canister too. Likewise only an omitted canisterId is none: one given as "" or null, or as text with a letter wrong (its checksum tells) or of more than 63 characters (a principal of the IC is at most 29 bytes), fails the canister, whether or not a fetch or a check would use it, and is left out of the ic_env cookie with a warning (see Options).

A committed didFile is what the app was compiled against. To find out whether the live canister still answers it, ask for the live check: verify: "live" on an entry, or the variable IC_REACTOR_VERIFY for a run: 1 (or true, or all) for every entry with a canisterId, or canister names separated by commas. The plugin fetches the certified interface as above and checks the didFile on disk against it (candid-core-cli check) when a build or the dev server starts. Each check is a network request, so verify: "live" makes every build need the network. IC_REACTOR_VERIFY=1 vite build in CI keeps the other builds offline, and IC_REACTOR_VERIFY=0 (or false) turns the check off for a run, verify: "live" included, so a project that checks every build can still build offline.

The check asks whether every method of the file is still in the live service, with the same mode, its arguments accepted and its results readable. Methods only the canister has are not checked: a projection checks only the methods the app calls.

Live canister Outcome
Unchanged for the file’s methods (new methods beside them included) Logged: did/icp_ledger.did matches the live interface …
Changed compatibly for those methods (an optional field added to a result) A warning, and the canister generates
Compatible only by reading a value as null (special_opt_rule) A warning naming each method and path
A method missing (method_missing) or of another mode (mode_changed) The canister fails
A method whose types no longer match (method_incompatible) The canister fails, with the path into the type

A failure lists each finding (up to 20) and says to fetch the file again, regenerate, and fix the calls the type checker then reports:

ic-reactor: could not generate 1 of 1 canisters:
- ledger (did/icp_ledger.did): the live check found ryjl3-tyaaa-aaaaa-aaaba-cai on ic incompatible with did/icp_ledger.did (1 error):
icrc1_gone: method_missing: the live service has no method "icrc1_gone"
The generated module no longer matches the canister for these methods: fetch did/icp_ledger.did again with IC_REACTOR_FETCH=ledger, regenerate, and fix the calls the type checker then reports.

A canister that fails the check is not generated, and the failure follows failOnError as a failed fetch does. A check that cannot be made (the network cannot be reached, the canister keeps its interface private, a side does not compile) fails the canister too, with the fetch’s own message and how to turn the check off (IC_REACTOR_VERIFY=0): a check that was asked for and did not run does not pass. A file the same build just fetched is the live interface: it is not checked, and passes as fetched.

Within one build or dev server, a check that passed is not made again for the same text by a rebuild (vite build --watch) or by another Vite environment. A check of that canister that fails, or cannot be made, drops the pass, and so does a fetch of its file that fails or reads another interface. Restoring a text that passed earlier then checks it again rather than standing in for a check. What passes or fails is the text the run read when it began: the check of a file saved before the check is done (even saved back) counts for nothing, passed or failed. It records no pass, neither sets nor clears a failure that stands, and that run does not generate the file (see A .did saved while the plugin reads it). Each build and each start of the dev server, a restart included, checks every canister again, so a restart finds a canister that changed since its last check.

A save under vite dev never makes a request. So under vite dev a canister whose check failed (or could not be made, as with the network down) stays failed after a save, whatever the save changed, and is not generated, until a start checks it again and the check passes, fetches the file again (IC_REACTOR_FETCH), or runs with the check off. Restart the dev server (r and Enter in its terminal) to check again.

verify takes only "live": any other value, true or false included, fails the canister, under IC_REACTOR_VERIFY=0 too, before anything is fetched for it, and so does "live" with no canisterId. Remove verify for no check.

The projection, the comparison of methods with a file on disk, and the check run candid-core-cli project and candid-core-cli check in a child process, as generation runs gen (see Generation): the app’s own CLI, killed after 60 seconds, so a bad interface ends that process and not the dev server. Each run costs a Node process (under 0.1 s on the ledger’s interface), and runs only when a file is fetched, when a methods entry’s file changed, and in a live check.

Each step reads the didFile itself: listing its methods, the live check, and generation each run candid-core-cli on the file as it is then. A file saved between two steps would let one step approve a text and another generate a different one. So a run (the start of a build or dev server, or the regeneration a save starts) reads each didFile once, when its steps start, and reads it again after each step. A file written before the run is done with it (even saved back as it was) is not taken: the run does not generate it, reports nothing its steps found of it, and puts back a module the generator wrote while the file was written. Then:

  • Under vite build, the canister fails for that reason alone, and follows failOnError: did/icp_ledger.did changed during the build, after the build read it: … Build again.
  • Under vite dev, the file is queued again, as a save is, and that run reports the file as it is then. Until it does, the overlay is left as it was: the run that took nothing says nothing of the canister, not even that it is fixed. Like any save, the queued run makes no request, so a text saved while the start’s live check ran is not checked.

A live check whose file was saved before the check was done counts for nothing, passed or failed: it records no pass, and under vite dev it neither sets nor clears a failure that stands. One that read the file before it was saved keeps its result, as for a save made just after the run.

Only the didFile itself is read again. candid-core-cli also reads every .did beneath its directory, for the files it imports; a change to one of those during a run is not seen.

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).
  • Closing the dev server (or restarting it, as a vite.config edit does) kills a generator of that server that is still running and drops its runs still waiting, so none outlives the server it belonged to or runs beside the next one. A server restarted with the same plugin object (an inline config, which server.restart() keeps) has runs of its own: Vite starts it before it closes the old one, and the old server’s close does not stop them. Its overlay starts as the old one’s, and its runs change it.
  • Editing a .did regenerates only the canisters that name it. Under vite build --watch, a canister is regenerated only when its .did text changed, or its module or envelope is missing.
  • 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.
  • Under Vitest (mode test), the run fails at startup, as a build does: tests that pass against the modules left over from the last good run prove nothing.
  • failOnError overrides each default. It takes true or false: any other value (null, 0, "false") throws when the config loads, as does one for injectEnvironment.

onGenerated is called once for each module a run generated, after the generator wrote or checked it and its envelope, under vite build and vite dev alike. Use it for a step that reads the generated output and must run after every generation:

import { readFile } from "node:fs/promises"
import { icReactor } from "@ic-reactor/vite-plugin"
icReactor({
canisters: { ledger: { didFile: "did/ledger.did" } },
onGenerated: async ({ names, envelope, status }) => {
const text = await readFile(envelope, "utf-8")
// check it, or write derived files; throw to fail the run
console.log(names, status, text.length)
},
})
  • It is handed names (canisters that share a didFile and an outDir share one module and one call), the absolute paths didFile, module and envelope, status ("written", or "unchanged" when the module on disk was already what the generator would write), and a signal that aborts when the build or dev server closes. A close stops waiting for the hook, and the next run generates the module again and calls the hook again.
  • It runs inside the plugin’s one queue of runs: it never overlaps a generation, and buildStart waits for it, so under vite build a hook that never settles holds the build.
  • It is not called for a canister that failed or was refused, nor for a run that does not generate the module again (the second buildStart of Vite 6 and later, a vite build --watch rebuild not caused by a .did or by a missing module or envelope). A save under vite dev always generates again, so saving the same text calls it with "unchanged".
  • A throw or a rejection fails the module’s canisters as a failed generation does: the build or the Vitest run fails unless failOnError is false, and otherwise the failure is logged and, under vite dev, shown in the error overlay. The next run generates them again and calls the hook again.
  • A value that is not a function throws when the config loads.

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 is used for that canister, and icp is not asked for its id. internet_identity is asked for even then: what icp reports decides whether the cookie also names icp-cli’s built-in Internet Identity. Set the ICP_ENVIRONMENT environment variable to use a network other than "local".

The plugin runs icp from PATH, with no shell. On Windows, where npm links icp-cli’s npm package as icp.cmd, it runs @icp-sdk/icp-cli with Node instead: the app’s own install, else a global npm install, else an icp.exe on PATH.

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. A client that trusts the cookie hands that url to its auth factory, so auth: (network) => new AuthClient(network) signs in with the local Internet Identity (see Auth).

  • If detection fails, the plugin falls back to proxying /api to icp-cli’s local network, http://127.0.0.1:8000, and sets no cookie (a page load clears one the browser kept), 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, so any network restart (icp network stop, then start and icp deploy) needs a restart of the dev server, even when the root key stays the same: icp-cli 1.2.0’s managed network kept its root key across starts while a new deploy gave the names other ids.
  • While it serves, the plugin holds a connection to the detected network, so it notices the network stop when it stops, whether or not a request reaches it. From then on it no longer vouches for the ids it detected, which a network started again may have given to other canisters: it warns once, page loads get the cookie without those ids and that root key, and vite dev reloads the pages it has open so that they drop the old cookie. { name } canisters then reject canister_id_unresolved until the dev server restarts. Under vite preview, which has no HMR connection, reload open pages yourself.
  • 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. "env" is for localhost pages: opened by a LAN address (vite --host), a Docker host name or a tunnel, the page still sends only to the dev server, but the client does not believe the cookie there, so { name } targets do not resolve and certified calls fail. The plugin warns once when a page is loaded by a host that is not loopback. See The client. Nothing in the code names a host, a port or an id. Deployed with icp deploy -e ic, "env" still sends through the page’s origin on <id>.icp0.io, where the client believes the asset canister’s cookie for canister ids, so { name } resolves. On <id>.ic0.app and on a custom domain it does not: give the canister an { id } baked in at build time. allowEnvConfig: true is safe on <id>.icp0.io, a public suffix, and refused on ic0.app, because every other canister’s page can set a cookie there. See Deploy to mainnet for each domain and 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. A canister that fails to generate fails the run at startup, unless failOnError is false: a .did that does not parse, a fetch that fails, a live check (verify: "live", IC_REACTOR_VERIFY) that fails or cannot reach the network, an onGenerated that throws. For an offline test run with the live check on, set IC_REACTOR_VERIFY=0. A .did that stops generating after startup, in watch mode, is logged, as under vite dev. A Vitest run in another mode (--mode, test.mode) keeps the dev default, and a Vitest config that does not load the plugin generates nothing. To test canister code, see Testing.