@ic-reactor/vite-plugin 0.14.0 → 4.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -1,48 +1,91 @@
1
1
  /**
2
2
  * @ic-reactor/vite-plugin
3
3
  *
4
- * Vite plugin that:
5
- * 1. Generates hooks at build time (using @ic-reactor/codegen pipeline)
6
- * 2. Injects `ic_env` cookie for local development (via proxy)
7
- * 3. Hot-reloads when .did files change
4
+ * Vite plugin for an app built on a candid-core generated module.
5
+ *
6
+ * - Generation: at the start of a build or dev server, and when a `.did` file
7
+ * changes, it runs `candid-core-cli gen` in a child process (see
8
+ * generate.ts) and leaves candid-core's module as the generator wrote it. No
9
+ * wrapper files, hooks or reactors are generated.
10
+ * - Environment: under `vite dev` and `vite preview` it sets the `ic_env`
11
+ * cookie and proxies `/api` to the local IC network (see dev-environment.ts).
8
12
  */
9
13
 
10
- import type { Plugin, ResolvedConfig, UserConfig, ViteDevServer } from "vite"
14
+ import fs from "node:fs"
11
15
  import path from "node:path"
16
+ import type {
17
+ Logger,
18
+ Plugin,
19
+ ProxyOptions,
20
+ ResolvedConfig,
21
+ UserConfig,
22
+ ViteDevServer,
23
+ } from "vite"
12
24
  import {
13
- runCanisterPipeline,
14
- type CanisterConfig,
15
- type CodegenConfig,
16
- type CodegenTarget,
17
- } from "@ic-reactor/codegen"
18
- import { getIcEnvironmentInfo, buildIcEnvCookie } from "./env.js"
25
+ createLocalEnvironment,
26
+ icEnvMiddleware,
27
+ type LocalEnvironment,
28
+ type LocalEnvironmentState,
29
+ } from "./dev-environment.js"
30
+ import { GENERATE_TIMEOUT_MS, generate, resolveCliBin } from "./generate.js"
19
31
 
20
32
  const PLUGIN_NAME = "ic-reactor-plugin"
21
- const DEFAULT_LOCAL_REPLICA = "http://127.0.0.1:4943"
22
33
 
34
+ /** Where a canister's module goes when it sets no `outDir`. */
35
+ const DEFAULT_OUT_DIR = "src/canisters"
36
+
37
+ /** The first line the plugin logs: where an agent reads how to use the library. */
38
+ const GUIDE_LINE =
39
+ "ic-reactor: agent guide at node_modules/@ic-reactor/core/llms.txt"
40
+
41
+ /**
42
+ * The options of {@link icReactor}. Every one is optional: with none, the
43
+ * plugin generates nothing and, under `vite dev`, only injects the local IC
44
+ * environment, for no canister of the app's.
45
+ */
23
46
  export interface IcReactorPluginOptions {
24
47
  /**
25
- * Canister configurations.
26
- * `name` is required for each canister.
27
- */
28
- canisters: CanisterConfig[]
29
- /**
30
- * Default output directory (relative to the Vite project root).
31
- * Default: "src/declarations"
32
- */
33
- outDir?: string
34
- /**
35
- * Default client manager import path.
36
- * Default: "../../clients"
37
- */
38
- clientManagerPath?: string
39
- /**
40
- * Default generated runtime target.
41
- * Default: "react"
48
+ * The app's canisters, by name: the canister's name in the `icp` project,
49
+ * which is also the name the `ic_env` cookie carries its ID under.
50
+ *
51
+ * - `didFile`: the canister's Candid interface, relative to the Vite root.
52
+ * The plugin runs `candid-core-cli gen` on it at the start of a build or
53
+ * dev server and again each time the file changes, regenerating only the
54
+ * canisters that name the file that changed. The generator names its
55
+ * output after the file, so `didFile: "../backend/ledger.did"` writes `ledger.ts` (the
56
+ * module: it exports `actor` and the type `Actor`) and
57
+ * `ledger.envelope.json` into `outDir`. Canisters that name the same
58
+ * `didFile` and `outDir` share that one module, which is generated once.
59
+ * Different `.did` files that would write the same module are refused:
60
+ * give one an `outDir` of its own. A canister without a `didFile`
61
+ * generates nothing and is only named in the cookie.
62
+ * - `outDir`: where the generator writes, relative to the Vite root.
63
+ * Default: `"src/canisters"`.
64
+ * - `canisterId`: a fixed ID for the cookie, which wins over the ID `icp`
65
+ * reports for the canister.
66
+ *
67
+ * The generator is the `@candid-core/cli` the app has installed, run as a
68
+ * child process so that a failure on one `.did` stops that process and not
69
+ * the dev server.
42
70
  */
43
- target?: CodegenTarget
71
+ canisters?: Record<
72
+ string,
73
+ { didFile?: string; outDir?: string; canisterId?: string }
74
+ >
44
75
  /**
45
- * Automatically inject `ic_env` cookie for local development?
76
+ * Inject the local IC environment under `vite dev` and `vite preview`: set
77
+ * the `ic_env` cookie on each response and proxy `/api` to the network the
78
+ * `icp` CLI reports.
79
+ *
80
+ * Until `icp` reports a network and every configured canister has an ID
81
+ * (a configured `canisterId` counts), each page load asks `icp` again, so a
82
+ * deploy after the server started needs only a reload. Once detection is
83
+ * complete, page loads run no `icp` command, and a redeploy into a fresh
84
+ * network needs a restart. An `/api` proxy that the Vite config or another
85
+ * plugin sets is left alone.
86
+ *
87
+ * Never injected in mode `"test"` (Vitest's), where `icp` is not run at all.
88
+ *
46
89
  * Default: true
47
90
  */
48
91
  injectEnvironment?: boolean
@@ -50,445 +93,569 @@ export interface IcReactorPluginOptions {
50
93
  * Abort the Vite run when a canister fails to generate.
51
94
  *
52
95
  * Default: `true` under `vite build`, `false` under `vite dev`. A build that
53
- * silently ships the bindings left over from the last successful run is worse
54
- * than no build at all, while a dev server has to survive the broken
55
- * intermediate states of a `.did` file being edited — there the failure is
56
- * reported to the terminal and the browser error overlay instead.
96
+ * silently ships the bindings left over from the last successful run is
97
+ * worse than no build at all, while a dev server has to survive the broken
98
+ * intermediate states of a `.did` file being edited: there the failure is
99
+ * logged and shown in the browser's error overlay, and the server keeps
100
+ * serving.
57
101
  */
58
102
  failOnError?: boolean
59
103
  }
60
104
 
61
- export function icReactor(options: IcReactorPluginOptions): Plugin {
62
- const {
63
- canisters,
64
- outDir = "src/declarations",
65
- clientManagerPath = "../../clients",
66
- target = "react",
67
- injectEnvironment = true,
68
- failOnError,
69
- } = options
70
-
71
- // Construct a partial CodegenConfig to pass to the pipeline
72
- const globalConfig: Pick<
73
- CodegenConfig,
74
- "outDir" | "clientManagerPath" | "target"
75
- > = {
76
- outDir,
77
- clientManagerPath,
78
- target,
79
- }
105
+ /** A canister with a `.did` to generate from. */
106
+ interface Generated {
107
+ name: string
108
+ didFile: string
109
+ outDir: string
110
+ }
111
+
112
+ /** A canister that did not generate, and why. */
113
+ interface Failure {
114
+ canister: Generated
115
+ message: string
116
+ }
117
+
118
+ type PluginLog = Pick<Logger, "info" | "warn" | "error">
119
+
120
+ /** What the hooks log through before Vite hands over its logger. */
121
+ const consoleLog: PluginLog = {
122
+ info: (message) => console.log(message),
123
+ warn: (message) => console.warn(message),
124
+ error: (message) => console.error(message),
125
+ }
126
+
127
+ /**
128
+ * The Vite plugin for an app built on a candid-core generated module.
129
+ *
130
+ * It does two things:
131
+ *
132
+ * - **Generates the module.** When a build or the dev server starts, and
133
+ * when a configured `.did` changes, it runs the app's `candid-core-cli gen`
134
+ * on each `didFile` and leaves the module as the generator wrote it: no
135
+ * wrapper files, hooks or reactors. The generator is WebAssembly, so it runs
136
+ * in a child process (the running Node binary on the CLI's bin script, never
137
+ * through a shell, killed after 60 seconds). A trap, a crash or a hang on a
138
+ * bad `.did` then ends that process and not the dev server: under
139
+ * `vite build` it fails the build with the CLI's own message, and under
140
+ * `vite dev` it is logged and shown in the error overlay while the server
141
+ * keeps serving. See {@link IcReactorPluginOptions.failOnError}.
142
+ * - **Injects the local IC environment.** Under `vite dev` and `vite preview`
143
+ * it sets the `ic_env` cookie, which carries the replica's root key and the
144
+ * canister IDs, and proxies `/api` to the local replica, so the app needs no
145
+ * configuration to find them. It asks the `icp` CLI for both, and is off in
146
+ * mode `"test"` (Vitest's), where `icp` is never run.
147
+ *
148
+ * The first thing it logs names where an agent reads how to use the library:
149
+ * `ic-reactor: agent guide at node_modules/@ic-reactor/core/llms.txt`.
150
+ *
151
+ * The plugin needs `@candid-core/cli`, at the exact release that pairs with
152
+ * the `@candid-core/schema` the generated modules import, installed in the
153
+ * app. It imports neither, and no `@ic-reactor` runtime package.
154
+ *
155
+ * @example
156
+ * ```ts
157
+ * // vite.config.ts
158
+ * export default defineConfig({
159
+ * plugins: [
160
+ * icReactor({
161
+ * canisters: { ledger: { didFile: "../backend/ledger.did" } },
162
+ * }),
163
+ * ],
164
+ * })
165
+ * ```
166
+ */
167
+ export function icReactor(options: IcReactorPluginOptions = {}): Plugin {
168
+ const { canisters = {}, injectEnvironment = true, failOnError } = options
169
+ const names = Object.keys(canisters)
170
+
171
+ const configuredCanisterIds = Object.fromEntries(
172
+ names.flatMap((name) => {
173
+ const { canisterId } = canisters[name]
174
+ return canisterId ? [[name, canisterId]] : []
175
+ })
176
+ )
177
+
178
+ /** The canisters that have a `.did` to generate from. */
179
+ const generated: Generated[] = names.flatMap((name) => {
180
+ const { didFile, outDir = DEFAULT_OUT_DIR } = canisters[name]
181
+ return didFile === undefined ? [] : [{ name, didFile, outDir }]
182
+ })
80
183
 
81
184
  // Vite resolves relative project paths against the resolved `config.root`,
82
185
  // which only equals the process cwd when vite happens to be started from the
83
- // project directory — not for `root: "frontend"`, and not when the root is
84
- // passed positionally (`vite build apps/web`). Note `--config` on its own does
85
- // NOT move the root; it only selects the config file.
86
- // `configResolved` overwrites this before any hook that resolves a path runs;
87
- // the cwd is only the pre-resolution default, which is also Vite's own
186
+ // project directory, not for `root: "frontend"` and not when the root is
187
+ // passed positionally (`vite build apps/web`). `configResolved` overwrites
188
+ // this before any hook that resolves a path runs; the cwd is only Vite's own
88
189
  // default root.
89
190
  let projectRoot = process.cwd()
90
191
 
91
192
  // `vite build` and `vite dev` want opposite failure behaviour, so remember
92
- // which one we are in. Both `config` and `configResolved` carry the command;
93
- // build is the safer default for the case where neither has run.
193
+ // which one we are in. Build is the safer default for the case where neither
194
+ // `config` nor `configResolved` has run.
94
195
  let command: ResolvedConfig["command"] = "build"
95
196
 
96
- // Set once the dev server exists, so a `buildStart` failure in dev can reach
97
- // the browser overlay too — in dev, `configureServer` runs before Vite calls
98
- // `buildStart` on the plugin container.
197
+ let log: PluginLog = consoleLog
198
+ let announced = false
199
+
200
+ /**
201
+ * The local IC environment `vite dev` and `vite preview` inject. The
202
+ * `config` hook creates it when `injectEnvironment` is on.
203
+ */
204
+ let localEnvironment: LocalEnvironment | undefined
205
+
206
+ /** The options of the plugin's `/api` proxy, as Vite hands them over. */
207
+ const apiProxyOptions = new Set<ProxyOptions>()
208
+
209
+ // Set once the dev server exists. In dev, `configureServer` runs before Vite
210
+ // calls `buildStart`, so a startup failure can reach the overlay too.
99
211
  let devServer: ViteDevServer | null = null
100
212
 
101
- const resolveDidPath = (didFile: string) =>
102
- path.normalize(
103
- path.isAbsolute(didFile) ? didFile : path.resolve(projectRoot, didFile)
104
- )
105
- const configuredCanisterIds = Object.fromEntries(
106
- canisters
107
- .filter((canister) => !!canister.canisterId)
108
- .map((canister) => [canister.name, canister.canisterId as string])
109
- )
213
+ // ── Generation ──────────────────────────────────────────────────────────
214
+
215
+ const didPath = (canister: Generated) =>
216
+ path.resolve(projectRoot, canister.didFile)
217
+ const relativeToRoot = (file: string) =>
218
+ path.relative(projectRoot, file) || "."
110
219
 
111
220
  /**
112
- * Report a generation failure everywhere a developer might be looking.
113
- *
114
- * Terminal output scrolls away behind request logs and HMR chatter, so in dev
115
- * the browser error overlay is the signal that actually gets noticed.
221
+ * The `.did` text each canister last generated from. A rebuild that finds
222
+ * the text unchanged skips the canister, which is every rebuild of
223
+ * `vite build --watch` that was not caused by a `.did`, and the second
224
+ * `buildStart` Vite 6 and later run for another environment.
116
225
  */
226
+ const generatedFrom = new Map<string, string>()
227
+
117
228
  /**
118
- * The failures that are still unfixed, one per canister, kept so a browser
119
- * that was not connected when one happened still gets the overlay.
229
+ * The failures not yet fixed, by canister name. Vite awaits `buildStart`
230
+ * before the HTTP server listens, so a failure at startup is sent to no
231
+ * browser at all; each browser that connects later is handed these.
232
+ */
233
+ const unfixed = new Map<string, Failure>()
234
+
235
+ // Generation runs one job after another. Two `buildStart`s (one per Vite 6+
236
+ // environment) or a save during a run would otherwise start two generator
237
+ // processes that write the same files.
238
+ let tail: Promise<unknown> = Promise.resolve()
239
+ const serially = <T>(job: () => Promise<T>): Promise<T> => {
240
+ const result = tail.then(job)
241
+ tail = result.catch(() => undefined)
242
+ return result
243
+ }
244
+
245
+ /** The `.did` files waiting for a run that has not started. See `onDidSaved`. */
246
+ const queued = new Set<string>()
247
+
248
+ // Aborted when the build or dev server ends (`closeBundle`), which kills
249
+ // the generator processes then running and drops the runs still queued. A
250
+ // generator that outlived its server would keep Node alive for up to its
251
+ // timeout, and after a restart (a `vite.config` edit) it would write the
252
+ // same outDir as the new server's first run. A fresh controller follows each
253
+ // abort, since the same plugin object serves a server that is started again.
254
+ let stopper = new AbortController()
255
+
256
+ const readDid = (canister: Generated): string | undefined => {
257
+ try {
258
+ return fs.readFileSync(didPath(canister), "utf-8")
259
+ } catch {
260
+ return undefined
261
+ }
262
+ }
263
+
264
+ /**
265
+ * Generate `wanted`, in one generator process for each output directory, and
266
+ * report what the generator said. Never rejects: an unexpected error fails
267
+ * the canisters, since a rejection from a watcher callback could end the dev
268
+ * server.
120
269
  *
121
- * Vite awaits the plugin container's `buildStart` before the HTTP server
122
- * starts listening, so a generation failure during `vite dev` startup is
123
- * broadcast when there are no WebSocket clients at all and the payload is
124
- * simply dropped. The terminal shows it; the overlay never appears — for
125
- * precisely the failures a developer is most likely to hit.
270
+ * Resolves `undefined` when `signal` was aborted before it finished: the
271
+ * server it belonged to is gone, so nothing is recorded, logged or shown.
126
272
  *
127
- * A success only proves that the canister which regenerated is fixed. This
128
- * used to be one slot that any success emptied, so a canister that was still
129
- * broken vanished from the overlay as soon as another canister regenerated
130
- * and its reload reconnected every tab.
273
+ * @param force - Generate even a canister whose `.did` text is unchanged.
274
+ * @param signal - The `stopper` signal current when the run was asked for.
131
275
  */
132
- const pendingFailures = new Map<string, { message: string; stack: string }>()
133
-
134
- const reportFailure = (
135
- server: ViteDevServer | null,
136
- message: string,
137
- cause?: unknown
138
- ) => {
139
- console.error(`[ic-reactor] ${message}`)
140
-
141
- const err = {
142
- message: `[ic-reactor] ${message}`,
143
- stack: cause instanceof Error && cause.stack ? cause.stack : "",
144
- plugin: PLUGIN_NAME,
145
- }
276
+ const generateNow = async (
277
+ wanted: Generated[],
278
+ force: boolean,
279
+ signal: AbortSignal
280
+ ): Promise<Failure[] | undefined> => {
281
+ if (signal.aborted) return undefined
282
+ try {
283
+ const sources = new Map(
284
+ wanted.map((canister) => [canister.name, readDid(canister)])
285
+ )
286
+ const stale = wanted.filter(
287
+ ({ name }) =>
288
+ force ||
289
+ sources.get(name) === undefined ||
290
+ generatedFrom.get(name) !== sources.get(name)
291
+ )
292
+ if (stale.length === 0) return []
146
293
 
147
- server?.ws.send({ type: "error", err })
148
- return { message: err.message, stack: err.stack }
149
- }
294
+ let cli: string
295
+ try {
296
+ cli = resolveCliBin(projectRoot)
297
+ } catch (error) {
298
+ return stale.map((canister) => ({ canister, message: describe(error) }))
299
+ }
150
300
 
151
- // Keep at most one regeneration per canister in flight and collapse every save
152
- // that arrives meanwhile into a single trailing rerun, so the last saved
153
- // `.did` still wins without piling up concurrent runs.
154
- //
155
- // Scope, precisely: this covers the watcher path only. `buildStart` calls the
156
- // pipeline directly and does not register here, so a save landing during the
157
- // initial generation can still run concurrently with it. That is deliberate
158
- // rather than an oversight -- since @ic-reactor/codegen generates into a
159
- // staging directory and swaps atomically, concurrent runs for one canister no
160
- // longer interleave inside a delete-then-write sequence; the loser is simply
161
- // overwritten. What this buys is ordering and wasted work, not integrity.
162
- //
163
- // Note the coalesced promise resolves when the RUNNING pass finishes, not the
164
- // trailing rerun, so `handleHotUpdate` can return before the newest `.did` has
165
- // been written. The trailing run sends its own full-reload, so the browser
166
- // still converges.
167
- const inFlight = new Map<string, Promise<void>>()
168
- const rerunQueued = new Set<string>()
169
-
170
- const regenerate = (
171
- canisterConfig: CanisterConfig,
172
- server: ViteDevServer
173
- ): Promise<void> => {
174
- const { name } = canisterConfig
175
- const running = inFlight.get(name)
176
-
177
- if (running) {
178
- rerunQueued.add(name)
179
- return running
180
- }
301
+ const result = await generate({
302
+ cli,
303
+ root: projectRoot,
304
+ timeoutMs: GENERATE_TIMEOUT_MS,
305
+ signal,
306
+ // Shown as it arrives: a run that takes long, or is killed at the
307
+ // timeout, would otherwise say nothing until it is over.
308
+ onStderr: (line, didFiles) => {
309
+ if (signal.aborted) return
310
+ log.warn(
311
+ `ic-reactor: candid-core-cli (${didFiles.map((file) => relativeToRoot(file)).join(", ")}): ${line}`
312
+ )
313
+ },
314
+ canisters: stale.map((canister) => ({
315
+ name: canister.name,
316
+ didFile: didPath(canister),
317
+ outDir: path.resolve(projectRoot, canister.outDir),
318
+ })),
319
+ })
181
320
 
182
- const run = runCanisterPipeline({
183
- canisterConfig,
184
- projectRoot,
185
- globalConfig,
186
- })
187
- .then((result) => {
188
- if (result.success) {
189
- // A later connection must not be handed a failure that has since been
190
- // fixed.
191
- pendingFailures.delete(name)
192
- // Reload page to reflect new types/hooks
193
- server.ws.send({ type: "full-reload" })
194
- } else {
195
- pendingFailures.set(
196
- name,
197
- reportFailure(
198
- server,
199
- `Regeneration failed for ${name}: ${result.error ?? "unknown error"}`
200
- )
321
+ if (signal.aborted) return undefined
322
+
323
+ const failures: Failure[] = []
324
+ for (const outcome of result.outcomes) {
325
+ // The names of one outcome share a `.did`, so they succeed or fail
326
+ // together and are reported once.
327
+ const members = stale.filter(({ name }) => outcome.names.includes(name))
328
+ const names = members.map(({ name }) => name).join(", ")
329
+ if (outcome.status === "failed") {
330
+ for (const canister of members) {
331
+ generatedFrom.delete(canister.name)
332
+ failures.push({ canister, message: outcome.failure ?? "" })
333
+ }
334
+ continue
335
+ }
336
+ for (const { name } of members) {
337
+ const source = sources.get(name)
338
+ if (source !== undefined) generatedFrom.set(name, source)
339
+ }
340
+ if (outcome.status === "written" && outcome.module) {
341
+ log.info(
342
+ `ic-reactor: generated ${names} into ${relativeToRoot(outcome.module)}`
201
343
  )
202
344
  }
203
- })
204
- // A throw from the pipeline (a malformed `.did` makes the parser throw
205
- // rather than return a failed result) would otherwise be an unhandled
206
- // rejection: invisible in the browser and, depending on the Node version,
207
- // fatal to the dev server.
208
- .catch((error: unknown) => {
209
- pendingFailures.set(
210
- name,
211
- reportFailure(
212
- server,
213
- `Regeneration failed for ${name}: ${describeError(error)}`,
214
- error
345
+ for (const { kind, name: what, reason, via } of outcome.omitted) {
346
+ log.warn(
347
+ `ic-reactor: ${names}: omitted ${kind} ${what} (${reason}${via ? ` via ${via}` : ""})`
215
348
  )
216
- )
217
- })
218
- .finally(() => {
219
- inFlight.delete(name)
220
- if (rerunQueued.delete(name)) {
221
- void regenerate(canisterConfig, server)
222
349
  }
223
- })
224
-
225
- inFlight.set(name, run)
226
- return run
350
+ }
351
+ return failures
352
+ } catch (error) {
353
+ if (signal.aborted) return undefined
354
+ return wanted.map((canister) => ({ canister, message: describe(error) }))
355
+ }
227
356
  }
228
357
 
229
- const plugin: Plugin = {
230
- name: PLUGIN_NAME,
231
- enforce: "pre", // Run before other plugins
358
+ /**
359
+ * The error text for failed canisters. Canisters that failed for the same
360
+ * reason (no CLI installed, one crash) are listed under it once.
361
+ */
362
+ const describeFailures = (failures: Failure[]): string => {
363
+ const byMessage = new Map<string, string[]>()
364
+ for (const { canister, message } of failures) {
365
+ const label = `${canister.name} (${relativeToRoot(didPath(canister))})`
366
+ byMessage.set(message, [...(byMessage.get(message) ?? []), label])
367
+ }
368
+ return (
369
+ `ic-reactor: could not generate ${failures.length} of ${generated.length} canisters:\n` +
370
+ [...byMessage]
371
+ .map(
372
+ ([message, labels]) =>
373
+ ` - ${labels.join(", ")}: ${message.replace(/\n/g, "\n ")}`
374
+ )
375
+ .join("\n")
376
+ )
377
+ }
232
378
 
233
- config(userConfig, { command: viteCommand }) {
234
- command = viteCommand
379
+ /**
380
+ * Report the outcome of a run that does not end the Vite run (a save, or a
381
+ * build with `failOnError` off): log a failure and put every unfixed one in
382
+ * the browser's error overlay. When a canister is fixed, the overlay is
383
+ * replaced by one that lists only the canisters still failing, or cleared
384
+ * once there are none.
385
+ */
386
+ const publish = (attempted: Generated[], failures: Failure[]): void => {
387
+ // Every attempted canister leaves `unfixed`, not just the first one found
388
+ // there: a save regenerates all the canisters that name the `.did`, and
389
+ // `some` would stop at the first, leaving the others listed in an overlay
390
+ // nothing clears.
391
+ let wasFailing = false
392
+ for (const { name } of attempted) {
393
+ if (unfixed.delete(name)) wasFailing = true
394
+ }
395
+ for (const failure of failures) unfixed.set(failure.canister.name, failure)
396
+ if (failures.length > 0) {
397
+ log.error(describeFailures(failures))
398
+ showOverlay()
399
+ } else if (wasFailing && unfixed.size > 0) {
400
+ // The open overlay still lists the canister that was just fixed.
401
+ showOverlay()
402
+ } else if (wasFailing) {
403
+ // Nothing may have changed on disk when a canister is fixed back to what
404
+ // it generated before, so the page has nothing else to reload it.
405
+ devServer?.ws.send({ type: "full-reload" })
406
+ }
407
+ }
235
408
 
236
- if (viteCommand !== "serve" || !injectEnvironment) {
237
- return {}
409
+ /**
410
+ * Put the unfixed failures in the error overlay: of the browser that just
411
+ * connected when the WebSocket hands it over (`ws.on("connection")` does),
412
+ * and of every browser otherwise. Vite's client clears the overlay on every
413
+ * hot update it applies, and nothing here sends it again, so the overlay can
414
+ * go while a canister is still broken. The terminal log keeps the error, and
415
+ * the replay on connection shows it after the next full reload.
416
+ */
417
+ const showOverlay = (client?: { send?: (data: string) => void }): void => {
418
+ if (unfixed.size === 0) return
419
+ const payload = {
420
+ type: "error" as const,
421
+ err: {
422
+ message: describeFailures([...unfixed.values()]),
423
+ stack: "",
424
+ plugin: PLUGIN_NAME,
425
+ },
426
+ }
427
+ if (typeof client?.send === "function") {
428
+ try {
429
+ client.send(JSON.stringify(payload))
430
+ } catch {
431
+ // A browser that is already gone has no use for the overlay, and a
432
+ // listener must not throw into the WebSocket server.
238
433
  }
434
+ return
435
+ }
436
+ devServer?.ws.send(payload)
437
+ }
239
438
 
240
- // ── Local Development Proxy & Cookies ────────────────────────────────
439
+ /**
440
+ * A `.did` file was saved: regenerate the canisters that name it, and only
441
+ * those. Canisters with one interface share its file and its run. Saves that
442
+ * arrive while it runs collapse into one run after it, so the last saved
443
+ * file wins without runs piling up.
444
+ */
445
+ const onDidSaved = (file: string): void => {
446
+ const saved = path.normalize(file)
447
+ const affected = generated.filter((canister) => didPath(canister) === saved)
448
+ if (affected.length === 0 || queued.has(saved)) return
449
+ queued.add(saved)
450
+ log.info(
451
+ `ic-reactor: ${relativeToRoot(saved)} changed, regenerating ${affected.map(({ name }) => name).join(", ")}`
452
+ )
453
+ const { signal } = stopper
454
+ void serially(async () => {
455
+ queued.delete(saved)
456
+ const failures = await generateNow(affected, true, signal)
457
+ if (failures) publish(affected, failures)
458
+ })
459
+ }
241
460
 
242
- // Always include internet_identity if not present (common need)
243
- const canisterNames = canisters
244
- .map((c) => c.name)
245
- .filter((n): n is string => !!n)
246
- if (!canisterNames.includes("internet_identity")) {
247
- canisterNames.push("internet_identity")
461
+ /**
462
+ * A `.did` file was deleted: fail the canisters that name it, in the log and
463
+ * the overlay, until it comes back (an `add` regenerates them). The module
464
+ * generated from it is still on disk and would otherwise go on looking
465
+ * current. No generator runs, since there is nothing to read.
466
+ *
467
+ * It waits behind the run in flight, so that run cannot report the file as
468
+ * generated after this has reported it gone.
469
+ */
470
+ const onDidRemoved = (file: string): void => {
471
+ const removed = path.normalize(file)
472
+ const affected = generated.filter(
473
+ (canister) => didPath(canister) === removed
474
+ )
475
+ if (affected.length === 0) return
476
+ const { signal } = stopper
477
+ void serially(async () => {
478
+ try {
479
+ // Back already, and the run its `add` queued regenerates it.
480
+ if (signal.aborted || fs.existsSync(removed)) return
481
+ for (const { name } of affected) generatedFrom.delete(name)
482
+ publish(
483
+ affected,
484
+ affected.map((canister) => ({
485
+ canister,
486
+ message:
487
+ "the .did file was deleted; its module is not regenerated until the file comes back",
488
+ }))
489
+ )
490
+ } catch (error) {
491
+ // A listener must not throw into the watcher.
492
+ log.error(`ic-reactor: ${describe(error)}`)
248
493
  }
494
+ })
495
+ }
249
496
 
250
- // `configResolved` has not run yet, so resolve the root the way Vite
251
- // will. icp finds the project from the directory it starts in, and with
252
- // `vite apps/web` or a `root` option that is not the process cwd.
253
- const { environment: icEnv, diagnostics } = getIcEnvironmentInfo(
254
- canisterNames,
255
- path.resolve(userConfig.root ?? process.cwd())
256
- )
257
-
258
- if (!icEnv) {
259
- // Failing detection used to be indistinguishable from success: no
260
- // cookie was set, no warning was printed, and the app only broke much
261
- // later on an undefined canister id. Stay quiet in env-only mode
262
- // (no canisters configured), where there is nothing to inject anyway.
263
- if (canisters.length > 0) {
264
- console.warn(
265
- `[ic-reactor] Could not detect the local IC environment, falling back to ${DEFAULT_LOCAL_REPLICA}. ` +
266
- `Canister IDs and the root key will not be injected — is the local replica running? ` +
267
- `Re-run with DEBUG=ic-reactor to see the \`icp\` output.`
268
- )
269
- }
270
-
271
- for (const diagnostic of diagnostics) {
272
- debugLog(diagnostic)
273
- }
497
+ /** Log the guide line once, before anything else the plugin says. */
498
+ const announceGuide = (userConfig: UserConfig): void => {
499
+ if (announced) return
500
+ announced = true
501
+ if (userConfig.customLogger) {
502
+ userConfig.customLogger.info(GUIDE_LINE)
503
+ } else if (
504
+ !["silent", "error", "warn"].includes(userConfig.logLevel ?? "")
505
+ ) {
506
+ console.log(GUIDE_LINE)
507
+ }
508
+ }
274
509
 
275
- const envOnlyCookie =
276
- canisters.length === 0
277
- ? buildIcEnvCookie(
278
- {},
279
- undefined,
280
- "http://id.ai.localhost:8000/authorize"
281
- )
282
- : undefined
283
-
284
- // Fallback: proxy /api to default local replica. In env-only mode,
285
- // still provide the standard ICP CLI built-in local II URL.
286
- return {
287
- server: {
288
- headers: envOnlyCookie
289
- ? {
290
- "Set-Cookie": `ic_env=${envOnlyCookie}; Path=/; SameSite=Lax;`,
291
- }
292
- : undefined,
293
- proxy: apiProxy(userConfig, DEFAULT_LOCAL_REPLICA),
294
- },
295
- }
296
- }
510
+ const plugin: Plugin = {
511
+ name: PLUGIN_NAME,
512
+ enforce: "pre", // Run before other plugins
297
513
 
298
- // The replica can be UP -- `icp network status` succeeds, so icEnv is
299
- // truthy and the check above never fires -- while a configured canister
300
- // has never been deployed. Every `icp canister status <name>` then fails
301
- // and that id is simply absent, so the cookie goes out carrying a root key
302
- // and no PUBLIC_CANISTER_ID for it. That is the same "indistinguishable
303
- // from success until the app breaks on an undefined canister id" failure
304
- // the branch above exists to prevent, and it is the more common one.
305
- //
306
- // Only configured canisters are reported: `internet_identity` is appended
307
- // to canisterNames for convenience and is routinely not deployed.
308
- // An explicitly configured `canisterId` counts as resolved: the cookie
309
- // below merges configuredCanisterIds over the detected ones, so the app
310
- // does receive a valid PUBLIC_CANISTER_ID. Warning on those told the user
311
- // to deploy a canister whose id they had already supplied.
312
- const missingCanisterIds = canisters
313
- .map((canister) => canister.name)
314
- .filter((name): name is string => !!name)
315
- .filter(
316
- (name) => !icEnv.canisterIds[name] && !configuredCanisterIds[name]
317
- )
514
+ async config(userConfig, { command: viteCommand, mode }) {
515
+ announceGuide(userConfig)
516
+ command = viteCommand
318
517
 
319
- if (missingCanisterIds.length > 0) {
320
- const names = missingCanisterIds.map((name) => `"${name}"`).join(", ")
321
- const it = missingCanisterIds.length === 1 ? "it" : "them"
322
- console.warn(
323
- `[ic-reactor] The local replica is running, but no canister ID could be resolved for ${names}. ` +
324
- `Deploy ${it} (\`icp deploy\`) — until then the injected ic_env carries no PUBLIC_CANISTER_ID ` +
325
- `for ${it} and the app will see an undefined canister id. ` +
326
- `Re-run with DEBUG=ic-reactor to see the \`icp\` output.`
327
- )
518
+ // Vitest runs the plugin with the `serve` command and mode `test`, and
519
+ // a test run has no use for a cookie or a proxy, or for asking `icp`
520
+ // about a network.
521
+ if (viteCommand !== "serve" || mode === "test" || !injectEnvironment) {
522
+ return {}
328
523
  }
329
524
 
330
- for (const diagnostic of diagnostics) {
331
- debugLog(diagnostic)
332
- }
525
+ // ── Local Development Proxy & Cookies ────────────────────────────────
333
526
 
334
- const cookieValue = buildIcEnvCookie(
335
- {
336
- ...icEnv.canisterIds,
337
- ...configuredCanisterIds,
527
+ // The plugin's own `/api` entry, unless the Vite config has one.
528
+ const ownsApiProxy = !userConfig.server?.proxy?.["/api"]
529
+
530
+ const environment = createLocalEnvironment({
531
+ canisterNames: names,
532
+ configuredCanisterIds,
533
+ // `configResolved` has not run yet, so resolve the root the way Vite
534
+ // will. icp finds the project from the directory it starts in, and
535
+ // with `vite apps/web` or a `root` option that is not the process cwd.
536
+ projectRoot: path.resolve(userConfig.root ?? process.cwd()),
537
+ onDiagnostic: debugLog,
538
+ onUpdate: (previous, next) => {
539
+ for (const proxyOptions of apiProxyOptions) {
540
+ proxyOptions.target = next.proxyTarget
541
+ }
542
+ if (previous) {
543
+ // Only a proxy the plugin kept following moves with detection.
544
+ reportDetectionProgress(previous, next, apiProxyOptions.size > 0)
545
+ }
338
546
  },
339
- icEnv.rootKey,
340
- icEnv.internetIdentityProvider
341
- )
547
+ })
548
+ localEnvironment = environment
549
+
550
+ const state = await environment.detect()
551
+ warnAboutIncompleteDetection(state, names.length > 0, ownsApiProxy)
342
552
 
343
553
  return {
344
554
  server: {
345
- headers: {
346
- "Set-Cookie": `ic_env=${cookieValue}; Path=/; SameSite=Lax;`,
347
- },
348
- proxy: apiProxy(userConfig, icEnv.proxyTarget),
555
+ // The cookie is not a static `server.headers` entry: the middleware
556
+ // that configureServer adds sets it per response, from the latest
557
+ // detection. See dev-environment.ts.
558
+ proxy: apiProxy(userConfig, state.proxyTarget, (proxyOptions) => {
559
+ // A plugin whose config hook runs after this one can proxy /api
560
+ // as well. Vite merges its entry over the one returned here and
561
+ // keeps this `configure`, so a target other than the one returned
562
+ // here is that plugin's, and it stays where that plugin put it.
563
+ if (proxyOptions.target !== state.proxyTarget) {
564
+ debugLog(
565
+ "Another plugin changed the target of the /api proxy, so the plugin leaves that proxy alone."
566
+ )
567
+ return
568
+ }
569
+ // Vite hands the proxy these options on every request, so a new
570
+ // target set here takes effect on the next one.
571
+ apiProxyOptions.add(proxyOptions)
572
+ proxyOptions.target =
573
+ environment.state?.proxyTarget ?? state.proxyTarget
574
+ }),
349
575
  },
350
576
  }
351
577
  },
352
578
 
353
579
  configResolved(config) {
354
- // Everything the plugin resolves — `didFile`, `outDir`,
355
- // `clientManagerPath` — is documented as relative to the project root, so
356
- // it has to be Vite's resolved root and not wherever the process started.
580
+ // Everything the plugin resolves, `didFile` and `outDir`, is documented
581
+ // as relative to the project root, so it has to be Vite's resolved root
582
+ // and not wherever the process started.
357
583
  projectRoot = config.root
358
584
  command = config.command
585
+ log = config.logger
359
586
  },
360
587
 
361
588
  configureServer(server) {
362
589
  devServer = server
363
590
 
364
- // Replay the unfixed failures described at pendingFailures to each client
365
- // that connects. A canister leaves the replay once it regenerates.
366
- // Guarded: the peer range spans several Vite majors and `ws.on` is not
367
- // present on every one of them. Losing the replay is acceptable; throwing
368
- // out of configureServer is not.
369
- server.ws.on?.("connection", () => {
370
- if (pendingFailures.size === 0) return
371
- const failures = [...pendingFailures.values()]
372
- server.ws.send({
373
- type: "error",
374
- err: {
375
- message: failures.map((failure) => failure.message).join("\n"),
376
- stack: failures
377
- .map((failure) => failure.stack)
378
- .filter(Boolean)
379
- .join("\n"),
380
- plugin: PLUGIN_NAME,
381
- },
382
- })
383
- })
384
-
385
- // Explicitly watch configured DID files so HMR works even when they are not in the module graph.
386
- const didFiles = canisters.map((c) => resolveDidPath(c.didFile))
387
- server.watcher.add(didFiles)
388
- },
389
-
390
- async buildStart() {
391
- // ── Code Generation ──────────────────────────────────────────────────
392
-
393
- console.log(
394
- `[ic-reactor] Generating canister bindings for ${canisters.length} canisters...`
395
- )
396
-
397
- const outcomes = await Promise.allSettled(
398
- canisters.map((canisterConfig) =>
399
- runCanisterPipeline({
400
- canisterConfig,
401
- projectRoot,
402
- globalConfig,
403
- })
404
- )
405
- )
406
-
407
- // Collect every failure before reporting one: a canister failing must not
408
- // hide what the others did, and the error should name all of them so a CI
409
- // log shows the whole picture in one go.
410
- const failures = outcomes.flatMap((outcome, index) => {
411
- const name = canisters[index]?.name ?? `canister #${index}`
412
-
413
- if (outcome.status === "rejected") {
414
- return [{ name, detail: `${name}: ${describeError(outcome.reason)}` }]
415
- }
416
- if (!outcome.value.success) {
417
- return [
418
- {
419
- name,
420
- detail: `${name}: ${outcome.value.error ?? "unknown error"}`,
421
- },
422
- ]
423
- }
424
- return []
425
- })
426
-
427
- if (failures.length === 0) {
428
- return
429
- }
430
-
431
- const message =
432
- `Failed to generate ${failures.length} of ${canisters.length} canisters:\n` +
433
- failures.map(({ detail }) => ` - ${detail}`).join("\n")
434
-
435
- // Previously every failure here was a `console.error` and nothing more,
436
- // so `vite build` exited 0 and CI shipped whatever stale bindings were
437
- // still on disk — bindings that no longer match the deployed canister.
438
- if (failOnError ?? command === "build") {
439
- this.error(`[ic-reactor] ${message}`)
591
+ // Added here rather than returned as a post hook, so it runs before
592
+ // Vite's own middlewares, which serve the page.
593
+ if (localEnvironment) {
594
+ server.middlewares.use(icEnvMiddleware(localEnvironment))
440
595
  }
441
596
 
442
- reportFailure(devServer, message)
443
-
444
- // One entry per canister, so fixing one of them removes only its own line
445
- // from the replay.
446
- for (const { name, detail } of failures) {
447
- pendingFailures.set(name, {
448
- message: `[ic-reactor] Failed to generate ${detail}`,
449
- stack: "",
450
- })
451
- }
597
+ if (generated.length === 0) return
598
+
599
+ // Hand each browser that connects the failures not yet fixed. Guarded:
600
+ // the peer range spans several Vite majors and `ws.on` is not present on
601
+ // every one of them. Losing the replay is acceptable; throwing out of
602
+ // configureServer is not.
603
+ server.ws.on?.("connection", showOverlay)
604
+
605
+ // `.did` files are not in the module graph, so the watcher is told about
606
+ // them. Regenerate from its own events and not from `handleHotUpdate`,
607
+ // which Vite calls only for a file changed in place and only while HMR
608
+ // is on: a `.did` created after startup, or written again by a build
609
+ // tool or `git checkout`, arrives as an `add` event, and a deleted one
610
+ // as `unlink`.
611
+ server.watcher.add(generated.map(didPath))
612
+ server.watcher.on("change", onDidSaved)
613
+ server.watcher.on("add", onDidSaved)
614
+ server.watcher.on("unlink", onDidRemoved)
452
615
  },
453
616
 
454
- handleHotUpdate({ file, server }) {
455
- // ── Hot Reload on .did changes ───────────────────────────────────────
456
- if (!file.endsWith(".did")) {
457
- return
617
+ // `vite preview` resolves the config with the `serve` command too, and
618
+ // used to inherit the cookie from `server.headers`.
619
+ configurePreviewServer(server) {
620
+ if (localEnvironment) {
621
+ server.middlewares.use(icEnvMiddleware(localEnvironment))
458
622
  }
623
+ },
459
624
 
460
- const changedPath = path.normalize(file)
461
- // Every canister, not only the first match. Deployed instances of one
462
- // canister, such as two ledgers, share a .did file. Stopping at the first
463
- // match left the others on stale bindings, and the full reload hid that.
464
- const affectedCanisters = canisters.filter(
465
- (canister) => resolveDidPath(canister.didFile) === changedPath
466
- )
625
+ async buildStart() {
626
+ if (generated.length === 0) return
467
627
 
468
- if (affectedCanisters.length === 0) {
469
- return
470
- }
628
+ // `vite build --watch` rebuilds when a file it watches changes, and a
629
+ // `.did` file is never part of the module graph. Registered here, a save
630
+ // starts a rebuild, and the rebuild's buildStart regenerates.
631
+ for (const canister of generated) this.addWatchFile(didPath(canister))
471
632
 
472
- console.log(
473
- `[ic-reactor] .did file changed: ${affectedCanisters
474
- .map((canister) => canister.name)
475
- .join(", ")}. Regenerating...`
633
+ const { signal } = stopper
634
+ const failures = await serially(() =>
635
+ generateNow(generated, false, signal)
476
636
  )
637
+ if (!failures) return
638
+ if (failures.length > 0 && (failOnError ?? command === "build")) {
639
+ // A build that exits 0 would ship whatever stale bindings are still on
640
+ // disk, which no longer match the canister.
641
+ this.error(describeFailures(failures))
642
+ }
643
+ publish(generated, failures)
644
+ },
477
645
 
478
- // Returned so Vite waits for the write to finish before applying the
479
- // update; it resolves to `undefined`, which leaves the affected module
480
- // list untouched.
481
- return Promise.all(
482
- affectedCanisters.map((canister) => regenerate(canister, server))
483
- ).then(() => undefined)
646
+ // The end of a build, and the close of a dev server, which Vite reports
647
+ // here once for each of its environments.
648
+ closeBundle() {
649
+ stopper.abort()
650
+ stopper = new AbortController()
484
651
  },
485
652
  }
486
653
 
487
654
  return plugin
488
655
  }
489
656
 
490
- /** One readable line for whatever the pipeline threw. */
491
- function describeError(error: unknown): string {
657
+ /** One readable line for whatever was thrown. */
658
+ function describe(error: unknown): string {
492
659
  return error instanceof Error ? error.message : String(error)
493
660
  }
494
661
 
@@ -501,8 +668,16 @@ function describeError(error: unknown): string {
501
668
  * pointed `/api` at icp-cli's port 8000 got 4943 instead, and nothing reported
502
669
  * the swap. Vite's merge skips an undefined value, so returning nothing leaves
503
670
  * the user's entry in place.
671
+ *
672
+ * `configure` receives the options object Vite builds the proxy from. Every
673
+ * supported Vite major copies it for each request, so the target can follow
674
+ * detection while the server runs.
504
675
  */
505
- function apiProxy(userConfig: UserConfig, target: string) {
676
+ function apiProxy(
677
+ userConfig: UserConfig,
678
+ target: string,
679
+ configure: (options: ProxyOptions) => void
680
+ ): Record<string, ProxyOptions> | undefined {
506
681
  if (userConfig.server?.proxy?.["/api"]) {
507
682
  debugLog(
508
683
  `The Vite config already proxies /api, so the plugin keeps that proxy instead of sending /api to ${target}.`
@@ -510,7 +685,101 @@ function apiProxy(userConfig: UserConfig, target: string) {
510
685
  return undefined
511
686
  }
512
687
 
513
- return { "/api": { target, changeOrigin: true } }
688
+ return {
689
+ "/api": {
690
+ target,
691
+ changeOrigin: true,
692
+ configure: (_proxy, options) => configure(options),
693
+ },
694
+ }
695
+ }
696
+
697
+ /** `"a"`, or `"a", "b"`: canister names as the warnings quote them. */
698
+ function quoteNames(names: string[]): string {
699
+ return names.map((name) => `"${name}"`).join(", ")
700
+ }
701
+
702
+ /**
703
+ * Warn at startup when detection is incomplete, and say what the plugin does
704
+ * about it: it asks `icp` again on each page load until detection completes.
705
+ *
706
+ * Failing detection used to be indistinguishable from success: no cookie was
707
+ * set, no warning was printed, and the app only broke later on an undefined
708
+ * canister id.
709
+ */
710
+ function warnAboutIncompleteDetection(
711
+ state: LocalEnvironmentState,
712
+ hasCanisters: boolean,
713
+ ownsApiProxy: boolean
714
+ ): void {
715
+ if (!state.environment) {
716
+ // Env-only mode (no canisters configured) has nothing to inject.
717
+ if (!hasCanisters) return
718
+ const proxyNote = ownsApiProxy
719
+ ? ` and /api goes to ${state.proxyTarget} for now`
720
+ : ""
721
+ console.warn(
722
+ `[ic-reactor] Could not detect the local IC environment, so no ic_env cookie is set${proxyNote}. ` +
723
+ `Is the local network running? The plugin asks \`icp\` again on each page load until it answers` +
724
+ `${ownsApiProxy ? ", then sends /api to the network it reports" : ""}: start the network ` +
725
+ `(\`icp network start\`) and reload the page. Re-run with DEBUG=ic-reactor to see the \`icp\` output.`
726
+ )
727
+ return
728
+ }
729
+
730
+ // The network can be up while a configured canister has never been
731
+ // deployed. Every `icp canister status <name>` then fails and that id is
732
+ // absent, so the cookie carries a root key and no PUBLIC_CANISTER_ID for it,
733
+ // which is the same silent failure. Only configured canisters count, and one
734
+ // with a configured `canisterId` is resolved.
735
+ const missing = state.missingCanisterIds
736
+ if (missing.length > 0) {
737
+ const it = missing.length === 1 ? "it" : "them"
738
+ console.warn(
739
+ `[ic-reactor] The local replica is running, but no canister ID could be resolved for ${quoteNames(missing)}. ` +
740
+ `Until one is, the ic_env cookie carries no PUBLIC_CANISTER_ID for ${it} and the app will see an ` +
741
+ `undefined canister id. Deploy ${it} (\`icp deploy\`) and reload the page: the plugin asks \`icp\` ` +
742
+ `again on each page load until every configured canister has an ID. ` +
743
+ `Re-run with DEBUG=ic-reactor to see the \`icp\` output.`
744
+ )
745
+ }
746
+ }
747
+
748
+ /**
749
+ * Report what a detection after startup found that the one before had not.
750
+ *
751
+ * @param followsApiProxy - Whether the `/api` proxy moves with detection. It
752
+ * does not when the Vite config or another plugin set its target.
753
+ */
754
+ function reportDetectionProgress(
755
+ previous: LocalEnvironmentState,
756
+ next: LocalEnvironmentState,
757
+ followsApiProxy: boolean
758
+ ): void {
759
+ if (!previous.environment && next.environment) {
760
+ console.log(
761
+ `[ic-reactor] Detected the local IC network: the ic_env cookie now carries its root key` +
762
+ (followsApiProxy ? ` and /api goes to ${next.proxyTarget}.` : ".")
763
+ )
764
+ }
765
+
766
+ const resolved = previous.missingCanisterIds.filter(
767
+ (name) => !next.missingCanisterIds.includes(name)
768
+ )
769
+ if (next.environment && resolved.length > 0) {
770
+ console.log(
771
+ `[ic-reactor] The ic_env cookie now carries the canister ID${
772
+ resolved.length === 1 ? "" : "s"
773
+ } for ${quoteNames(resolved)}.`
774
+ )
775
+ }
776
+
777
+ if (next.complete && !previous.complete) {
778
+ console.log(
779
+ "[ic-reactor] Every configured canister has an ID, so page loads no longer run `icp`. " +
780
+ "Restart the dev server after redeploying into a fresh network."
781
+ )
782
+ }
514
783
  }
515
784
 
516
785
  /**