@ic-reactor/vite-plugin 0.14.0 → 0.15.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
@@ -7,23 +7,38 @@
7
7
  * 3. Hot-reloads when .did files change
8
8
  */
9
9
 
10
- import type { Plugin, ResolvedConfig, UserConfig, ViteDevServer } from "vite"
10
+ import type {
11
+ Plugin,
12
+ ProxyOptions,
13
+ ResolvedConfig,
14
+ UserConfig,
15
+ ViteDevServer,
16
+ } from "vite"
17
+ import fs from "node:fs"
11
18
  import path from "node:path"
12
19
  import {
20
+ findSharedOutDirs,
13
21
  runCanisterPipeline,
22
+ sharedOutDirMessage,
14
23
  type CanisterConfig,
15
24
  type CodegenConfig,
16
25
  type CodegenTarget,
17
26
  } from "@ic-reactor/codegen"
18
- import { getIcEnvironmentInfo, buildIcEnvCookie } from "./env.js"
27
+ import {
28
+ createLocalEnvironment,
29
+ icEnvMiddleware,
30
+ type LocalEnvironment,
31
+ type LocalEnvironmentState,
32
+ } from "./dev-environment.js"
19
33
 
20
34
  const PLUGIN_NAME = "ic-reactor-plugin"
21
- const DEFAULT_LOCAL_REPLICA = "http://127.0.0.1:4943"
22
35
 
23
36
  export interface IcReactorPluginOptions {
24
37
  /**
25
38
  * Canister configurations.
26
- * `name` is required for each canister.
39
+ * `name` is required for each canister. Set `factories: true` on an entry to
40
+ * also generate `index.factories.generated.ts`, a query or mutation object
41
+ * per method bound to the generated reactor; see `CanisterConfig.factories`.
27
42
  */
28
43
  canisters: CanisterConfig[]
29
44
  /**
@@ -42,7 +57,17 @@ export interface IcReactorPluginOptions {
42
57
  */
43
58
  target?: CodegenTarget
44
59
  /**
45
- * Automatically inject `ic_env` cookie for local development?
60
+ * Inject the local IC environment under `vite dev` and `vite preview`: set
61
+ * the `ic_env` cookie on each response and proxy `/api` to the network the
62
+ * `icp` CLI reports.
63
+ *
64
+ * Until `icp` reports a network and every configured canister has an ID
65
+ * (a configured `canisterId` counts), each page load asks `icp` again, so a
66
+ * deploy after the server started needs only a reload. Once detection is
67
+ * complete, page loads run no `icp` command, and a redeploy into a fresh
68
+ * network needs a restart. An `/api` proxy that the Vite config or another
69
+ * plugin sets is left alone.
70
+ *
46
71
  * Default: true
47
72
  */
48
73
  injectEnvironment?: boolean
@@ -93,6 +118,15 @@ export function icReactor(options: IcReactorPluginOptions): Plugin {
93
118
  // build is the safer default for the case where neither has run.
94
119
  let command: ResolvedConfig["command"] = "build"
95
120
 
121
+ /**
122
+ * The local IC environment `vite dev` and `vite preview` inject. The
123
+ * `config` hook creates it when `injectEnvironment` is on.
124
+ */
125
+ let localEnvironment: LocalEnvironment | undefined
126
+
127
+ /** The options of the plugin's `/api` proxy, as Vite hands them over. */
128
+ const apiProxyOptions = new Set<ProxyOptions>()
129
+
96
130
  // Set once the dev server exists, so a `buildStart` failure in dev can reach
97
131
  // the browser overlay too — in dev, `configureServer` runs before Vite calls
98
132
  // `buildStart` on the plugin container.
@@ -102,6 +136,49 @@ export function icReactor(options: IcReactorPluginOptions): Plugin {
102
136
  path.normalize(
103
137
  path.isAbsolute(didFile) ? didFile : path.resolve(projectRoot, didFile)
104
138
  )
139
+ /**
140
+ * The `.did` text each entry last generated from, so a watch rebuild can
141
+ * tell which entries need regenerating. See `buildStart`.
142
+ */
143
+ const generatedFrom = new Map<CanisterConfig, string>()
144
+
145
+ /** The entry's `.did` text, or `undefined` when it cannot be read. */
146
+ const readDidSource = (canister: CanisterConfig): string | undefined => {
147
+ try {
148
+ return fs.readFileSync(resolveDidPath(canister.didFile), "utf-8")
149
+ } catch {
150
+ return undefined
151
+ }
152
+ }
153
+
154
+ /** How an error names an entry: its position, since names can repeat. */
155
+ const describeEntry = (canister: CanisterConfig) =>
156
+ `canisters[${canisters.indexOf(canister)}] (${JSON.stringify(canister.name)})`
157
+
158
+ /**
159
+ * The CLI's error for an entry that generates into the directory of an
160
+ * earlier entry, or `undefined` when it has a directory of its own.
161
+ *
162
+ * The pipeline's owner marker records a name, so two entries with the same
163
+ * `name` and `outDir` both passed it. Both generated into one directory at
164
+ * once, and which one's output survived changed from run to run while the
165
+ * build succeeded. Every configured entry takes part, including ones this run
166
+ * does not regenerate. Called right before each entry's pipeline starts: an
167
+ * earlier entry's pipeline creates its directory before its first await, so
168
+ * a later entry reaching that directory through a symlink or a spelling that
169
+ * differs only in case is caught too, as the CLI catches it.
170
+ */
171
+ const sharedOutDirError = (canister: CanisterConfig): string | undefined => {
172
+ const first = findSharedOutDirs(
173
+ canisters.map((entry) => [entry, entry] as const),
174
+ outDir,
175
+ projectRoot
176
+ ).get(canister)
177
+ return first === undefined
178
+ ? undefined
179
+ : sharedOutDirMessage(describeEntry(canister), describeEntry(first))
180
+ }
181
+
105
182
  const configuredCanisterIds = Object.fromEntries(
106
183
  canisters
107
184
  .filter((canister) => !!canister.canisterId)
@@ -115,8 +192,9 @@ export function icReactor(options: IcReactorPluginOptions): Plugin {
115
192
  * the browser error overlay is the signal that actually gets noticed.
116
193
  */
117
194
  /**
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.
195
+ * The failures that are still unfixed, one per configured canister entry,
196
+ * kept so a browser that was not connected when one happened still gets the
197
+ * overlay.
120
198
  *
121
199
  * Vite awaits the plugin container's `buildStart` before the HTTP server
122
200
  * starts listening, so a generation failure during `vite dev` startup is
@@ -128,8 +206,14 @@ export function icReactor(options: IcReactorPluginOptions): Plugin {
128
206
  * used to be one slot that any success emptied, so a canister that was still
129
207
  * broken vanished from the overlay as soon as another canister regenerated
130
208
  * and its reload reconnected every tab.
209
+ *
210
+ * Keyed by the entry rather than by `name`, since two entries can share a
211
+ * name. See `inFlight`.
131
212
  */
132
- const pendingFailures = new Map<string, { message: string; stack: string }>()
213
+ const pendingFailures = new Map<
214
+ CanisterConfig,
215
+ { message: string; stack: string }
216
+ >()
133
217
 
134
218
  const reportFailure = (
135
219
  server: ViteDevServer | null,
@@ -155,45 +239,61 @@ export function icReactor(options: IcReactorPluginOptions): Plugin {
155
239
  // Scope, precisely: this covers the watcher path only. `buildStart` calls the
156
240
  // pipeline directly and does not register here, so a save landing during the
157
241
  // 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.
242
+ // rather than an oversight -- since @ic-reactor/codegen writes a canister's
243
+ // declarations in one synchronous step, after all of them are generated,
244
+ // concurrent runs for one canister no longer interleave inside a
245
+ // delete-then-write sequence; the loser is simply overwritten. What this buys
246
+ // is ordering and wasted work, not integrity.
162
247
  //
163
248
  // 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>()
249
+ // trailing rerun, so it can settle before the newest `.did` has been written.
250
+ // The trailing run sends its own full-reload, so the browser still converges.
251
+ //
252
+ // Both maps are keyed by the configured entry, not by its `name`. Two entries
253
+ // can share a name, for one canister generated twice into different outDirs,
254
+ // say as a DisplayReactor and as a Reactor. Keyed by name, a save that touched
255
+ // both queued the second behind the first, and the trailing rerun then
256
+ // regenerated the first entry again. The second kept stale bindings.
257
+ const inFlight = new Map<CanisterConfig, Promise<void>>()
258
+ const rerunQueued = new Set<CanisterConfig>()
169
259
 
170
260
  const regenerate = (
171
261
  canisterConfig: CanisterConfig,
172
262
  server: ViteDevServer
173
263
  ): Promise<void> => {
174
264
  const { name } = canisterConfig
175
- const running = inFlight.get(name)
265
+ const running = inFlight.get(canisterConfig)
176
266
 
177
267
  if (running) {
178
- rerunQueued.add(name)
268
+ rerunQueued.add(canisterConfig)
179
269
  return running
180
270
  }
181
271
 
272
+ const sharedError = sharedOutDirError(canisterConfig)
273
+ if (sharedError !== undefined) {
274
+ pendingFailures.set(
275
+ canisterConfig,
276
+ reportFailure(server, `Regeneration failed for ${sharedError}`)
277
+ )
278
+ return Promise.resolve()
279
+ }
280
+
182
281
  const run = runCanisterPipeline({
183
282
  canisterConfig,
184
283
  projectRoot,
185
284
  globalConfig,
186
285
  })
187
286
  .then((result) => {
287
+ reportWarnings(name, result.warnings)
188
288
  if (result.success) {
189
289
  // A later connection must not be handed a failure that has since been
190
290
  // fixed.
191
- pendingFailures.delete(name)
291
+ pendingFailures.delete(canisterConfig)
192
292
  // Reload page to reflect new types/hooks
193
293
  server.ws.send({ type: "full-reload" })
194
294
  } else {
195
295
  pendingFailures.set(
196
- name,
296
+ canisterConfig,
197
297
  reportFailure(
198
298
  server,
199
299
  `Regeneration failed for ${name}: ${result.error ?? "unknown error"}`
@@ -207,7 +307,7 @@ export function icReactor(options: IcReactorPluginOptions): Plugin {
207
307
  // fatal to the dev server.
208
308
  .catch((error: unknown) => {
209
309
  pendingFailures.set(
210
- name,
310
+ canisterConfig,
211
311
  reportFailure(
212
312
  server,
213
313
  `Regeneration failed for ${name}: ${describeError(error)}`,
@@ -216,21 +316,55 @@ export function icReactor(options: IcReactorPluginOptions): Plugin {
216
316
  )
217
317
  })
218
318
  .finally(() => {
219
- inFlight.delete(name)
220
- if (rerunQueued.delete(name)) {
319
+ inFlight.delete(canisterConfig)
320
+ if (rerunQueued.delete(canisterConfig)) {
221
321
  void regenerate(canisterConfig, server)
222
322
  }
223
323
  })
224
324
 
225
- inFlight.set(name, run)
325
+ inFlight.set(canisterConfig, run)
226
326
  return run
227
327
  }
228
328
 
329
+ /**
330
+ * Regenerate every entry whose `.did` is `file`, and do nothing for any
331
+ * other file.
332
+ *
333
+ * Every entry, not only the first match. Deployed instances of one canister,
334
+ * such as two ledgers, share a .did file. Stopping at the first match left
335
+ * the others on stale bindings, and the full reload hid that.
336
+ */
337
+ const regenerateForDid = (file: string, server: ViteDevServer): void => {
338
+ if (!file.endsWith(".did")) {
339
+ return
340
+ }
341
+
342
+ const changedPath = path.normalize(file)
343
+ const affectedCanisters = canisters.filter(
344
+ (canister) => resolveDidPath(canister.didFile) === changedPath
345
+ )
346
+
347
+ if (affectedCanisters.length === 0) {
348
+ return
349
+ }
350
+
351
+ console.log(
352
+ `[ic-reactor] .did file changed: ${affectedCanisters
353
+ .map((canister) => canister.name)
354
+ .join(", ")}. Regenerating...`
355
+ )
356
+
357
+ // `regenerate` reports its own failures and never rejects.
358
+ for (const canister of affectedCanisters) {
359
+ void regenerate(canister, server)
360
+ }
361
+ }
362
+
229
363
  const plugin: Plugin = {
230
364
  name: PLUGIN_NAME,
231
365
  enforce: "pre", // Run before other plugins
232
366
 
233
- config(userConfig, { command: viteCommand }) {
367
+ async config(userConfig, { command: viteCommand }) {
234
368
  command = viteCommand
235
369
 
236
370
  if (viteCommand !== "serve" || !injectEnvironment) {
@@ -239,113 +373,56 @@ export function icReactor(options: IcReactorPluginOptions): Plugin {
239
373
 
240
374
  // ── Local Development Proxy & Cookies ────────────────────────────────
241
375
 
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")
248
- }
249
-
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
- )
376
+ // The plugin's own `/api` entry, unless the Vite config has one.
377
+ const ownsApiProxy = !userConfig.server?.proxy?.["/api"]
257
378
 
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
- }
274
-
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
- }
297
-
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
- )
318
-
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
- )
328
- }
329
-
330
- for (const diagnostic of diagnostics) {
331
- debugLog(diagnostic)
332
- }
333
-
334
- const cookieValue = buildIcEnvCookie(
335
- {
336
- ...icEnv.canisterIds,
337
- ...configuredCanisterIds,
379
+ const environment = createLocalEnvironment({
380
+ canisterNames: canisters
381
+ .map((canister) => canister.name)
382
+ .filter((name): name is string => !!name),
383
+ configuredCanisterIds,
384
+ // `configResolved` has not run yet, so resolve the root the way Vite
385
+ // will. icp finds the project from the directory it starts in, and
386
+ // with `vite apps/web` or a `root` option that is not the process cwd.
387
+ projectRoot: path.resolve(userConfig.root ?? process.cwd()),
388
+ onDiagnostic: debugLog,
389
+ onUpdate: (previous, next) => {
390
+ for (const proxyOptions of apiProxyOptions) {
391
+ proxyOptions.target = next.proxyTarget
392
+ }
393
+ if (previous) {
394
+ // Only a proxy the plugin kept following moves with detection.
395
+ reportDetectionProgress(previous, next, apiProxyOptions.size > 0)
396
+ }
338
397
  },
339
- icEnv.rootKey,
340
- icEnv.internetIdentityProvider
341
- )
398
+ })
399
+ localEnvironment = environment
400
+
401
+ const state = await environment.detect()
402
+ warnAboutIncompleteDetection(state, canisters.length > 0, ownsApiProxy)
342
403
 
343
404
  return {
344
405
  server: {
345
- headers: {
346
- "Set-Cookie": `ic_env=${cookieValue}; Path=/; SameSite=Lax;`,
347
- },
348
- proxy: apiProxy(userConfig, icEnv.proxyTarget),
406
+ // The cookie is not a static `server.headers` entry: the middleware
407
+ // that configureServer adds sets it per response, from the latest
408
+ // detection. See dev-environment.ts.
409
+ proxy: apiProxy(userConfig, state.proxyTarget, (proxyOptions) => {
410
+ // A plugin whose config hook runs after this one can proxy /api
411
+ // as well. Vite merges its entry over the one returned here and
412
+ // keeps this `configure`, so a target other than the one returned
413
+ // here is that plugin's, and it stays where that plugin put it.
414
+ if (proxyOptions.target !== state.proxyTarget) {
415
+ debugLog(
416
+ "Another plugin changed the target of the /api proxy, so the plugin leaves that proxy alone."
417
+ )
418
+ return
419
+ }
420
+ // Vite hands the proxy these options on every request, so a new
421
+ // target set here takes effect on the next one.
422
+ apiProxyOptions.add(proxyOptions)
423
+ proxyOptions.target =
424
+ environment.state?.proxyTarget ?? state.proxyTarget
425
+ }),
349
426
  },
350
427
  }
351
428
  },
@@ -361,6 +438,12 @@ export function icReactor(options: IcReactorPluginOptions): Plugin {
361
438
  configureServer(server) {
362
439
  devServer = server
363
440
 
441
+ // Added here rather than returned as a post hook, so it runs before
442
+ // Vite's own middlewares, which serve the page.
443
+ if (localEnvironment) {
444
+ server.middlewares.use(icEnvMiddleware(localEnvironment))
445
+ }
446
+
364
447
  // Replay the unfixed failures described at pendingFailures to each client
365
448
  // that connects. A canister leaves the replay once it regenerates.
366
449
  // Guarded: the peer range spans several Vite majors and `ws.on` is not
@@ -382,41 +465,117 @@ export function icReactor(options: IcReactorPluginOptions): Plugin {
382
465
  })
383
466
  })
384
467
 
385
- // Explicitly watch configured DID files so HMR works even when they are not in the module graph.
468
+ // Explicitly watch configured DID files, since they are not in the module graph.
386
469
  const didFiles = canisters.map((c) => resolveDidPath(c.didFile))
387
470
  server.watcher.add(didFiles)
471
+
472
+ // Regenerate from the watcher's own events, not from `handleHotUpdate`.
473
+ // Vite calls that hook only for a file changed in place, and only while
474
+ // HMR is on. A .did created after startup, or deleted and written again
475
+ // by a build tool or `git checkout`, arrives as an `add` event, which is
476
+ // all Vite 4 to 7 report for it, so its bindings stayed missing or stale.
477
+ // With `server.hmr: false`, no save regenerated at all.
478
+ const onDidEvent = (file: string) => regenerateForDid(file, server)
479
+ server.watcher.on("change", onDidEvent)
480
+ server.watcher.on("add", onDidEvent)
481
+ },
482
+
483
+ // `vite preview` resolves the config with the `serve` command too, and
484
+ // used to inherit the cookie from `server.headers`.
485
+ configurePreviewServer(server) {
486
+ if (localEnvironment) {
487
+ server.middlewares.use(icEnvMiddleware(localEnvironment))
488
+ }
388
489
  },
389
490
 
390
491
  async buildStart() {
391
492
  // ── Code Generation ──────────────────────────────────────────────────
392
493
 
494
+ // `vite build --watch` rebuilds when a file it watches changes, and a
495
+ // `.did` file is never part of the module graph. Registered here, a save
496
+ // starts a rebuild, and the rebuild's buildStart regenerates.
497
+ for (const canister of canisters) {
498
+ if (typeof canister.didFile === "string") {
499
+ this.addWatchFile(resolveDidPath(canister.didFile))
500
+ }
501
+ }
502
+
503
+ // A watch rebuild calls buildStart again, whatever file started it. A
504
+ // run used to rewrite the generated files even when their content was
505
+ // the same, those files are in the module graph, and the watcher then
506
+ // started another rebuild, which regenerated again: one edit to any
507
+ // source file looped forever. Codegen now leaves an unchanged file alone,
508
+ // and a rebuild still regenerates only the entries whose `.did` changed
509
+ // since they last generated, which skips parsing and formatting the
510
+ // rest. A failed entry is retried.
511
+ const sources = canisters.map(readDidSource)
512
+ const pending = canisters.filter(
513
+ (canister, index) =>
514
+ !this.meta.watchMode ||
515
+ sources[index] === undefined ||
516
+ generatedFrom.get(canister) !== sources[index]
517
+ )
518
+
519
+ if (pending.length === 0) {
520
+ return
521
+ }
522
+
393
523
  console.log(
394
- `[ic-reactor] Generating canister bindings for ${canisters.length} canisters...`
524
+ `[ic-reactor] Generating canister bindings for ${pending.length} canisters...`
395
525
  )
396
526
 
527
+ // Each entry is checked just before its pipeline starts, so the check
528
+ // sees the directories the entries before it have claimed.
397
529
  const outcomes = await Promise.allSettled(
398
- canisters.map((canisterConfig) =>
399
- runCanisterPipeline({
530
+ pending.map((canisterConfig) => {
531
+ const sharedError = sharedOutDirError(canisterConfig)
532
+ if (sharedError !== undefined) {
533
+ return Promise.reject(new SharedOutDirError(sharedError))
534
+ }
535
+ return runCanisterPipeline({
400
536
  canisterConfig,
401
537
  projectRoot,
402
538
  globalConfig,
403
539
  })
404
- )
540
+ })
405
541
  )
406
542
 
543
+ outcomes.forEach((outcome, index) => {
544
+ const canister = pending[index]
545
+ const source = sources[canisters.indexOf(canister)]
546
+ if (outcome.status === "fulfilled") {
547
+ reportWarnings(canister.name, outcome.value.warnings)
548
+ }
549
+ if (
550
+ outcome.status === "fulfilled" &&
551
+ outcome.value.success &&
552
+ source !== undefined
553
+ ) {
554
+ generatedFrom.set(canister, source)
555
+ } else {
556
+ generatedFrom.delete(canister)
557
+ }
558
+ })
559
+
407
560
  // Collect every failure before reporting one: a canister failing must not
408
561
  // hide what the others did, and the error should name all of them so a CI
409
562
  // log shows the whole picture in one go.
410
563
  const failures = outcomes.flatMap((outcome, index) => {
411
- const name = canisters[index]?.name ?? `canister #${index}`
564
+ const canister = pending[index]
565
+ const name = canister?.name ?? `canister #${index}`
412
566
 
413
567
  if (outcome.status === "rejected") {
414
- return [{ name, detail: `${name}: ${describeError(outcome.reason)}` }]
568
+ // The shared-outDir error names the entry itself.
569
+ const detail =
570
+ outcome.reason instanceof SharedOutDirError
571
+ ? outcome.reason.message
572
+ : `${name}: ${describeError(outcome.reason)}`
573
+ return [{ canister, detail }]
415
574
  }
416
575
  if (!outcome.value.success) {
417
576
  return [
418
577
  {
419
- name,
578
+ canister,
420
579
  detail: `${name}: ${outcome.value.error ?? "unknown error"}`,
421
580
  },
422
581
  ]
@@ -429,7 +588,7 @@ export function icReactor(options: IcReactorPluginOptions): Plugin {
429
588
  }
430
589
 
431
590
  const message =
432
- `Failed to generate ${failures.length} of ${canisters.length} canisters:\n` +
591
+ `Failed to generate ${failures.length} of ${pending.length} canisters:\n` +
433
592
  failures.map(({ detail }) => ` - ${detail}`).join("\n")
434
593
 
435
594
  // Previously every failure here was a `console.error` and nothing more,
@@ -443,48 +602,30 @@ export function icReactor(options: IcReactorPluginOptions): Plugin {
443
602
 
444
603
  // One entry per canister, so fixing one of them removes only its own line
445
604
  // from the replay.
446
- for (const { name, detail } of failures) {
447
- pendingFailures.set(name, {
605
+ for (const { canister, detail } of failures) {
606
+ pendingFailures.set(canister, {
448
607
  message: `[ic-reactor] Failed to generate ${detail}`,
449
608
  stack: "",
450
609
  })
451
610
  }
452
611
  },
612
+ }
453
613
 
454
- handleHotUpdate({ file, server }) {
455
- // ── Hot Reload on .did changes ───────────────────────────────────────
456
- if (!file.endsWith(".did")) {
457
- return
458
- }
459
-
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
- )
467
-
468
- if (affectedCanisters.length === 0) {
469
- return
470
- }
614
+ return plugin
615
+ }
471
616
 
472
- console.log(
473
- `[ic-reactor] .did file changed: ${affectedCanisters
474
- .map((canister) => canister.name)
475
- .join(", ")}. Regenerating...`
476
- )
617
+ /** An entry refused because an earlier entry generates into its directory. */
618
+ class SharedOutDirError extends Error {}
477
619
 
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)
484
- },
620
+ /**
621
+ * Print what a canister's generation could not fix itself, such as an
622
+ * `index.ts` of the user's own that does not re-export the factories it
623
+ * generated. A warning never fails the run.
624
+ */
625
+ function reportWarnings(name: string, warnings: string[] | undefined): void {
626
+ for (const warning of warnings ?? []) {
627
+ console.warn(`[ic-reactor] ${name}: ${warning}`)
485
628
  }
486
-
487
- return plugin
488
629
  }
489
630
 
490
631
  /** One readable line for whatever the pipeline threw. */
@@ -501,8 +642,16 @@ function describeError(error: unknown): string {
501
642
  * pointed `/api` at icp-cli's port 8000 got 4943 instead, and nothing reported
502
643
  * the swap. Vite's merge skips an undefined value, so returning nothing leaves
503
644
  * the user's entry in place.
645
+ *
646
+ * `configure` receives the options object Vite builds the proxy from. Every
647
+ * supported Vite major copies it for each request, so the target can follow
648
+ * detection while the server runs.
504
649
  */
505
- function apiProxy(userConfig: UserConfig, target: string) {
650
+ function apiProxy(
651
+ userConfig: UserConfig,
652
+ target: string,
653
+ configure: (options: ProxyOptions) => void
654
+ ): Record<string, ProxyOptions> | undefined {
506
655
  if (userConfig.server?.proxy?.["/api"]) {
507
656
  debugLog(
508
657
  `The Vite config already proxies /api, so the plugin keeps that proxy instead of sending /api to ${target}.`
@@ -510,7 +659,101 @@ function apiProxy(userConfig: UserConfig, target: string) {
510
659
  return undefined
511
660
  }
512
661
 
513
- return { "/api": { target, changeOrigin: true } }
662
+ return {
663
+ "/api": {
664
+ target,
665
+ changeOrigin: true,
666
+ configure: (_proxy, options) => configure(options),
667
+ },
668
+ }
669
+ }
670
+
671
+ /** `"a"`, or `"a", "b"`: canister names as the warnings quote them. */
672
+ function quoteNames(names: string[]): string {
673
+ return names.map((name) => `"${name}"`).join(", ")
674
+ }
675
+
676
+ /**
677
+ * Warn at startup when detection is incomplete, and say what the plugin does
678
+ * about it: it asks `icp` again on each page load until detection completes.
679
+ *
680
+ * Failing detection used to be indistinguishable from success: no cookie was
681
+ * set, no warning was printed, and the app only broke later on an undefined
682
+ * canister id.
683
+ */
684
+ function warnAboutIncompleteDetection(
685
+ state: LocalEnvironmentState,
686
+ hasCanisters: boolean,
687
+ ownsApiProxy: boolean
688
+ ): void {
689
+ if (!state.environment) {
690
+ // Env-only mode (no canisters configured) has nothing to inject.
691
+ if (!hasCanisters) return
692
+ const proxyNote = ownsApiProxy
693
+ ? ` and /api goes to ${state.proxyTarget} for now`
694
+ : ""
695
+ console.warn(
696
+ `[ic-reactor] Could not detect the local IC environment, so no ic_env cookie is set${proxyNote}. ` +
697
+ `Is the local network running? The plugin asks \`icp\` again on each page load until it answers` +
698
+ `${ownsApiProxy ? ", then sends /api to the network it reports" : ""}: start the network ` +
699
+ `(\`icp network start\`) and reload the page. Re-run with DEBUG=ic-reactor to see the \`icp\` output.`
700
+ )
701
+ return
702
+ }
703
+
704
+ // The network can be up while a configured canister has never been
705
+ // deployed. Every `icp canister status <name>` then fails and that id is
706
+ // absent, so the cookie carries a root key and no PUBLIC_CANISTER_ID for it,
707
+ // which is the same silent failure. Only configured canisters count, and one
708
+ // with a configured `canisterId` is resolved.
709
+ const missing = state.missingCanisterIds
710
+ if (missing.length > 0) {
711
+ const it = missing.length === 1 ? "it" : "them"
712
+ console.warn(
713
+ `[ic-reactor] The local replica is running, but no canister ID could be resolved for ${quoteNames(missing)}. ` +
714
+ `Until one is, the ic_env cookie carries no PUBLIC_CANISTER_ID for ${it} and the app will see an ` +
715
+ `undefined canister id. Deploy ${it} (\`icp deploy\`) and reload the page: the plugin asks \`icp\` ` +
716
+ `again on each page load until every configured canister has an ID. ` +
717
+ `Re-run with DEBUG=ic-reactor to see the \`icp\` output.`
718
+ )
719
+ }
720
+ }
721
+
722
+ /**
723
+ * Report what a detection after startup found that the one before had not.
724
+ *
725
+ * @param followsApiProxy - Whether the `/api` proxy moves with detection. It
726
+ * does not when the Vite config or another plugin set its target.
727
+ */
728
+ function reportDetectionProgress(
729
+ previous: LocalEnvironmentState,
730
+ next: LocalEnvironmentState,
731
+ followsApiProxy: boolean
732
+ ): void {
733
+ if (!previous.environment && next.environment) {
734
+ console.log(
735
+ `[ic-reactor] Detected the local IC network: the ic_env cookie now carries its root key` +
736
+ (followsApiProxy ? ` and /api goes to ${next.proxyTarget}.` : ".")
737
+ )
738
+ }
739
+
740
+ const resolved = previous.missingCanisterIds.filter(
741
+ (name) => !next.missingCanisterIds.includes(name)
742
+ )
743
+ if (next.environment && resolved.length > 0) {
744
+ console.log(
745
+ `[ic-reactor] The ic_env cookie now carries the canister ID${
746
+ resolved.length === 1 ? "" : "s"
747
+ } for ${quoteNames(resolved)}.`
748
+ )
749
+ }
750
+
751
+ if (next.complete && !previous.complete) {
752
+ console.log(
753
+ "[ic-reactor] Every configured canister has an ID, so page loads no longer run `icp`. " +
754
+ "Restart the dev server after redeploying into a fresh network."
755
+ )
756
+ }
514
757
  }
515
758
 
516
759
  /**