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. A.didthat is not on disk yet can be fetched from the live canister first. - 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-exact @candid-core/schema@0.4.0npm install --save-dev --save-exact @candid-core/cli@0.3.0npm install --save-dev @ic-reactor/vite-pluginThe 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.
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, 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.
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 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 verifyNever 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.
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. 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.
Fetch a .did from a live canister
Section titled “Fetch a .did from a live canister”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:
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", }, }, }), ],})didFileon disk: the plugin generates from it and makes no network request for the canister, undervite buildandvite devalike. Builds stay reproducible and work offline. Commit the file.didFilemissing: the plugin readscandid:servicefrom certified state (HttpAgent.readState, the certificate checked against the network’s root key), writes it todidFileas the canister publishes it, then generates. It logsic-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, orall). A build without it never rewrites adidFile. Undervite devit is served once, when the server starts. - Canisters that name one
didFileshare 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 owndidFile, or write the file yourself. AdidFileon disk is theirs to share, whatever theircanisterIds, since nothing is fetched for it. AdidFilethat 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/cli0.3.0) reads adidFileonly by its exact name on disk, and not through a symlink to a file, so an entry that names it either way fails withdid_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.
When a fetch fails
Section titled “When a fetch fails”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 withicp network start. - The read is refused: the canister keeps its interface private, and only
its controllers can read it (HTTP 403). Get the
.didfrom 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
canisterIdandnetwork. - 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.
Only the methods the app calls
Section titled “Only the methods the app calls”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:
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
didFileon disk is not fetched again whenmethodschanges. The plugin compares the two, with no network access, when the build starts and when the file is saved. A name ofmethodsthat the file does not hold fails the canister, since its module would lack the method. A method the file holds thatmethodsdoes not name is a warning, and the module keeps it. Both say to runIC_REACTOR_FETCH=<name>, which writes the projection to the new list. A file whose methods cannot be listed (it does not compile, orcandid-core-clifails) 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
didFileshare its fetch only when they name one list. Every one of them generates from the file, so an entry with nocanisterIdcounts as much as the one it is fetched for: itsmethods, 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. AdidFileon disk that nobody asked to fetch again is read by them all, and compared with the list of each as above. methodsis a list of names: an empty list, or a name with a comma (whichcandid-core-cli projectcannot take), fails the canister before any request. Only an omittedmethodsis the whole interface: any other value a JavaScript config gives (null,false,0,"") fails the canister too. Likewise only an omittedcanisterIdis none: one given as""ornull, 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 theic_envcookie with a warning (see Options).
The live check
Section titled “The live check”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.
How the plugin runs candid-core
Section titled “How the plugin runs candid-core”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.
A .did saved while the plugin reads it
Section titled “A .did saved while the plugin reads it”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 followsfailOnError: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.
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). - Closing the dev server (or restarting it, as a
vite.configedit 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, whichserver.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
.didregenerates only the canisters that name it. Undervite build --watch, a canister is regenerated only when its.didtext changed, or its module or envelope is missing. - 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. - 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. failOnErroroverrides each default. It takestrueorfalse: any other value (null,0,"false") throws when the config loads, as does one forinjectEnvironment.
After each generation
Section titled “After each generation”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 adidFileand anoutDirshare one module and one call), the absolute pathsdidFile,moduleandenvelope,status("written", or"unchanged"when the module on disk was already what the generator would write), and asignalthat 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
buildStartwaits for it, so undervite builda 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
buildStartof Vite 6 and later, avite build --watchrebuild not caused by a.didor by a missing module or envelope). A save undervite devalways 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
failOnErrorisfalse, and otherwise the failure is logged and, undervite 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" }}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 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
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. 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).
Detection
Section titled “Detection”- If detection fails, the plugin falls back to proxying
/apito 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 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, so any network restart (icp network stop, thenstartandicp 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 devreloads the pages it has open so that they drop the old cookie.{ name }canisters then rejectcanister_id_unresolveduntil the dev server restarts. Undervite preview, which has no HMR connection, reload open pages yourself. - 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. "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.