@ic-reactor/vite-plugin 0.13.1 → 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, 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,16 +192,28 @@ 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 last failure, kept so a browser that was not connected when it happened
119
- * 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
123
201
  * broadcast when there are no WebSocket clients at all and the payload is
124
202
  * simply dropped. The terminal shows it; the overlay never appears — for
125
203
  * precisely the failures a developer is most likely to hit.
204
+ *
205
+ * A success only proves that the canister which regenerated is fixed. This
206
+ * used to be one slot that any success emptied, so a canister that was still
207
+ * broken vanished from the overlay as soon as another canister regenerated
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`.
126
212
  */
127
- let pendingFailure: { message: string; stack: string } | null = null
213
+ const pendingFailures = new Map<
214
+ CanisterConfig,
215
+ { message: string; stack: string }
216
+ >()
128
217
 
129
218
  const reportFailure = (
130
219
  server: ViteDevServer | null,
@@ -139,8 +228,8 @@ export function icReactor(options: IcReactorPluginOptions): Plugin {
139
228
  plugin: PLUGIN_NAME,
140
229
  }
141
230
 
142
- pendingFailure = { message: err.message, stack: err.stack }
143
231
  server?.ws.send({ type: "error", err })
232
+ return { message: err.message, stack: err.stack }
144
233
  }
145
234
 
146
235
  // Keep at most one regeneration per canister in flight and collapse every save
@@ -150,46 +239,65 @@ export function icReactor(options: IcReactorPluginOptions): Plugin {
150
239
  // Scope, precisely: this covers the watcher path only. `buildStart` calls the
151
240
  // pipeline directly and does not register here, so a save landing during the
152
241
  // initial generation can still run concurrently with it. That is deliberate
153
- // rather than an oversight -- since @ic-reactor/codegen generates into a
154
- // staging directory and swaps atomically, concurrent runs for one canister no
155
- // longer interleave inside a delete-then-write sequence; the loser is simply
156
- // 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.
157
247
  //
158
248
  // Note the coalesced promise resolves when the RUNNING pass finishes, not the
159
- // trailing rerun, so `handleHotUpdate` can return before the newest `.did` has
160
- // been written. The trailing run sends its own full-reload, so the browser
161
- // still converges.
162
- const inFlight = new Map<string, Promise<void>>()
163
- 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>()
164
259
 
165
260
  const regenerate = (
166
261
  canisterConfig: CanisterConfig,
167
262
  server: ViteDevServer
168
263
  ): Promise<void> => {
169
264
  const { name } = canisterConfig
170
- const running = inFlight.get(name)
265
+ const running = inFlight.get(canisterConfig)
171
266
 
172
267
  if (running) {
173
- rerunQueued.add(name)
268
+ rerunQueued.add(canisterConfig)
174
269
  return running
175
270
  }
176
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
+
177
281
  const run = runCanisterPipeline({
178
282
  canisterConfig,
179
283
  projectRoot,
180
284
  globalConfig,
181
285
  })
182
286
  .then((result) => {
287
+ reportWarnings(name, result.warnings)
183
288
  if (result.success) {
184
289
  // A later connection must not be handed a failure that has since been
185
290
  // fixed.
186
- pendingFailure = null
291
+ pendingFailures.delete(canisterConfig)
187
292
  // Reload page to reflect new types/hooks
188
293
  server.ws.send({ type: "full-reload" })
189
294
  } else {
190
- reportFailure(
191
- server,
192
- `Regeneration failed for ${name}: ${result.error ?? "unknown error"}`
295
+ pendingFailures.set(
296
+ canisterConfig,
297
+ reportFailure(
298
+ server,
299
+ `Regeneration failed for ${name}: ${result.error ?? "unknown error"}`
300
+ )
193
301
  )
194
302
  }
195
303
  })
@@ -198,28 +306,65 @@ export function icReactor(options: IcReactorPluginOptions): Plugin {
198
306
  // rejection: invisible in the browser and, depending on the Node version,
199
307
  // fatal to the dev server.
200
308
  .catch((error: unknown) => {
201
- reportFailure(
202
- server,
203
- `Regeneration failed for ${name}: ${describeError(error)}`,
204
- error
309
+ pendingFailures.set(
310
+ canisterConfig,
311
+ reportFailure(
312
+ server,
313
+ `Regeneration failed for ${name}: ${describeError(error)}`,
314
+ error
315
+ )
205
316
  )
206
317
  })
207
318
  .finally(() => {
208
- inFlight.delete(name)
209
- if (rerunQueued.delete(name)) {
319
+ inFlight.delete(canisterConfig)
320
+ if (rerunQueued.delete(canisterConfig)) {
210
321
  void regenerate(canisterConfig, server)
211
322
  }
212
323
  })
213
324
 
214
- inFlight.set(name, run)
325
+ inFlight.set(canisterConfig, run)
215
326
  return run
216
327
  }
217
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
+
218
363
  const plugin: Plugin = {
219
364
  name: PLUGIN_NAME,
220
365
  enforce: "pre", // Run before other plugins
221
366
 
222
- config(_config, { command: viteCommand }) {
367
+ async config(userConfig, { command: viteCommand }) {
223
368
  command = viteCommand
224
369
 
225
370
  if (viteCommand !== "serve" || !injectEnvironment) {
@@ -228,118 +373,56 @@ export function icReactor(options: IcReactorPluginOptions): Plugin {
228
373
 
229
374
  // ── Local Development Proxy & Cookies ────────────────────────────────
230
375
 
231
- // Always include internet_identity if not present (common need)
232
- const canisterNames = canisters
233
- .map((c) => c.name)
234
- .filter((n): n is string => !!n)
235
- if (!canisterNames.includes("internet_identity")) {
236
- canisterNames.push("internet_identity")
237
- }
238
-
239
- const { environment: icEnv, diagnostics } =
240
- getIcEnvironmentInfo(canisterNames)
241
-
242
- if (!icEnv) {
243
- // Failing detection used to be indistinguishable from success: no
244
- // cookie was set, no warning was printed, and the app only broke much
245
- // later on an undefined canister id. Stay quiet in env-only mode
246
- // (no canisters configured), where there is nothing to inject anyway.
247
- if (canisters.length > 0) {
248
- console.warn(
249
- `[ic-reactor] Could not detect the local IC environment, falling back to ${DEFAULT_LOCAL_REPLICA}. ` +
250
- `Canister IDs and the root key will not be injected — is the local replica running? ` +
251
- `Re-run with DEBUG=ic-reactor to see the \`icp\` output.`
252
- )
253
- }
254
-
255
- for (const diagnostic of diagnostics) {
256
- debugLog(diagnostic)
257
- }
258
-
259
- const envOnlyCookie =
260
- canisters.length === 0
261
- ? buildIcEnvCookie(
262
- {},
263
- undefined,
264
- "http://id.ai.localhost:8000/authorize"
265
- )
266
- : undefined
267
-
268
- // Fallback: proxy /api to default local replica. In env-only mode,
269
- // still provide the standard ICP CLI built-in local II URL.
270
- return {
271
- server: {
272
- headers: envOnlyCookie
273
- ? {
274
- "Set-Cookie": `ic_env=${envOnlyCookie}; Path=/; SameSite=Lax;`,
275
- }
276
- : undefined,
277
- proxy: {
278
- "/api": {
279
- target: DEFAULT_LOCAL_REPLICA,
280
- changeOrigin: true,
281
- },
282
- },
283
- },
284
- }
285
- }
286
-
287
- // The replica can be UP -- `icp network status` succeeds, so icEnv is
288
- // truthy and the check above never fires -- while a configured canister
289
- // has never been deployed. Every `icp canister status <name>` then fails
290
- // and that id is simply absent, so the cookie goes out carrying a root key
291
- // and no PUBLIC_CANISTER_ID for it. That is the same "indistinguishable
292
- // from success until the app breaks on an undefined canister id" failure
293
- // the branch above exists to prevent, and it is the more common one.
294
- //
295
- // Only configured canisters are reported: `internet_identity` is appended
296
- // to canisterNames for convenience and is routinely not deployed.
297
- // An explicitly configured `canisterId` counts as resolved: the cookie
298
- // below merges configuredCanisterIds over the detected ones, so the app
299
- // does receive a valid PUBLIC_CANISTER_ID. Warning on those told the user
300
- // to deploy a canister whose id they had already supplied.
301
- const missingCanisterIds = canisters
302
- .map((canister) => canister.name)
303
- .filter((name): name is string => !!name)
304
- .filter(
305
- (name) => !icEnv.canisterIds[name] && !configuredCanisterIds[name]
306
- )
307
-
308
- if (missingCanisterIds.length > 0) {
309
- const names = missingCanisterIds.map((name) => `"${name}"`).join(", ")
310
- const it = missingCanisterIds.length === 1 ? "it" : "them"
311
- console.warn(
312
- `[ic-reactor] The local replica is running, but no canister ID could be resolved for ${names}. ` +
313
- `Deploy ${it} (\`icp deploy\`) — until then the injected ic_env carries no PUBLIC_CANISTER_ID ` +
314
- `for ${it} and the app will see an undefined canister id. ` +
315
- `Re-run with DEBUG=ic-reactor to see the \`icp\` output.`
316
- )
317
- }
318
-
319
- for (const diagnostic of diagnostics) {
320
- debugLog(diagnostic)
321
- }
322
-
323
- const cookieValue = buildIcEnvCookie(
324
- {
325
- ...icEnv.canisterIds,
326
- ...configuredCanisterIds,
376
+ // The plugin's own `/api` entry, unless the Vite config has one.
377
+ const ownsApiProxy = !userConfig.server?.proxy?.["/api"]
378
+
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
+ }
327
397
  },
328
- icEnv.rootKey,
329
- icEnv.internetIdentityProvider
330
- )
398
+ })
399
+ localEnvironment = environment
400
+
401
+ const state = await environment.detect()
402
+ warnAboutIncompleteDetection(state, canisters.length > 0, ownsApiProxy)
331
403
 
332
404
  return {
333
405
  server: {
334
- headers: {
335
- "Set-Cookie": `ic_env=${cookieValue}; Path=/; SameSite=Lax;`,
336
- },
337
- proxy: {
338
- "/api": {
339
- target: icEnv.proxyTarget,
340
- changeOrigin: true,
341
- },
342
- },
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
+ }),
343
426
  },
344
427
  }
345
428
  },
@@ -355,52 +438,147 @@ export function icReactor(options: IcReactorPluginOptions): Plugin {
355
438
  configureServer(server) {
356
439
  devServer = server
357
440
 
358
- // Replay a startup failure to the first client that connects — see
359
- // pendingFailure. Cleared once generation succeeds.
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
+
447
+ // Replay the unfixed failures described at pendingFailures to each client
448
+ // that connects. A canister leaves the replay once it regenerates.
360
449
  // Guarded: the peer range spans several Vite majors and `ws.on` is not
361
450
  // present on every one of them. Losing the replay is acceptable; throwing
362
451
  // out of configureServer is not.
363
452
  server.ws.on?.("connection", () => {
364
- if (!pendingFailure) return
453
+ if (pendingFailures.size === 0) return
454
+ const failures = [...pendingFailures.values()]
365
455
  server.ws.send({
366
456
  type: "error",
367
- err: { ...pendingFailure, plugin: PLUGIN_NAME },
457
+ err: {
458
+ message: failures.map((failure) => failure.message).join("\n"),
459
+ stack: failures
460
+ .map((failure) => failure.stack)
461
+ .filter(Boolean)
462
+ .join("\n"),
463
+ plugin: PLUGIN_NAME,
464
+ },
368
465
  })
369
466
  })
370
467
 
371
- // 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.
372
469
  const didFiles = canisters.map((c) => resolveDidPath(c.didFile))
373
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
+ }
374
489
  },
375
490
 
376
491
  async buildStart() {
377
492
  // ── Code Generation ──────────────────────────────────────────────────
378
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
+
379
523
  console.log(
380
- `[ic-reactor] Generating canister bindings for ${canisters.length} canisters...`
524
+ `[ic-reactor] Generating canister bindings for ${pending.length} canisters...`
381
525
  )
382
526
 
527
+ // Each entry is checked just before its pipeline starts, so the check
528
+ // sees the directories the entries before it have claimed.
383
529
  const outcomes = await Promise.allSettled(
384
- canisters.map((canisterConfig) =>
385
- 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({
386
536
  canisterConfig,
387
537
  projectRoot,
388
538
  globalConfig,
389
539
  })
390
- )
540
+ })
391
541
  )
392
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
+
393
560
  // Collect every failure before reporting one: a canister failing must not
394
561
  // hide what the others did, and the error should name all of them so a CI
395
562
  // log shows the whole picture in one go.
396
563
  const failures = outcomes.flatMap((outcome, index) => {
397
- const name = canisters[index]?.name ?? `canister #${index}`
564
+ const canister = pending[index]
565
+ const name = canister?.name ?? `canister #${index}`
398
566
 
399
567
  if (outcome.status === "rejected") {
400
- return [`${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 }]
401
574
  }
402
575
  if (!outcome.value.success) {
403
- return [`${name}: ${outcome.value.error ?? "unknown error"}`]
576
+ return [
577
+ {
578
+ canister,
579
+ detail: `${name}: ${outcome.value.error ?? "unknown error"}`,
580
+ },
581
+ ]
404
582
  }
405
583
  return []
406
584
  })
@@ -410,8 +588,8 @@ export function icReactor(options: IcReactorPluginOptions): Plugin {
410
588
  }
411
589
 
412
590
  const message =
413
- `Failed to generate ${failures.length} of ${canisters.length} canisters:\n` +
414
- failures.map((failure) => ` - ${failure}`).join("\n")
591
+ `Failed to generate ${failures.length} of ${pending.length} canisters:\n` +
592
+ failures.map(({ detail }) => ` - ${detail}`).join("\n")
415
593
 
416
594
  // Previously every failure here was a `console.error` and nothing more,
417
595
  // so `vite build` exited 0 and CI shipped whatever stale bindings were
@@ -421,43 +599,163 @@ export function icReactor(options: IcReactorPluginOptions): Plugin {
421
599
  }
422
600
 
423
601
  reportFailure(devServer, message)
424
- },
425
-
426
- handleHotUpdate({ file, server }) {
427
- // ── Hot Reload on .did changes ───────────────────────────────────────
428
- if (!file.endsWith(".did")) {
429
- return
430
- }
431
602
 
432
- const changedPath = path.normalize(file)
433
- const affectedCanister = canisters.find(
434
- // Check if changed file matches configured didFile
435
- (canister) => resolveDidPath(canister.didFile) === changedPath
436
- )
437
-
438
- if (!affectedCanister) {
439
- return
603
+ // One entry per canister, so fixing one of them removes only its own line
604
+ // from the replay.
605
+ for (const { canister, detail } of failures) {
606
+ pendingFailures.set(canister, {
607
+ message: `[ic-reactor] Failed to generate ${detail}`,
608
+ stack: "",
609
+ })
440
610
  }
441
-
442
- console.log(
443
- `[ic-reactor] .did file changed: ${affectedCanister.name}. Regenerating...`
444
- )
445
-
446
- // Returned so Vite waits for the write to finish before applying the
447
- // update; it resolves to `undefined`, which leaves the affected module
448
- // list untouched.
449
- return regenerate(affectedCanister, server)
450
611
  },
451
612
  }
452
613
 
453
614
  return plugin
454
615
  }
455
616
 
617
+ /** An entry refused because an earlier entry generates into its directory. */
618
+ class SharedOutDirError extends Error {}
619
+
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}`)
628
+ }
629
+ }
630
+
456
631
  /** One readable line for whatever the pipeline threw. */
457
632
  function describeError(error: unknown): string {
458
633
  return error instanceof Error ? error.message : String(error)
459
634
  }
460
635
 
636
+ /**
637
+ * The `server.proxy` entry that sends `/api` to `target`, or nothing when the
638
+ * user's Vite config already proxies `/api`.
639
+ *
640
+ * Vite deep-merges the object a `config` hook returns over the user's config,
641
+ * so an `/api` entry returned here replaced the user's own. A project that
642
+ * pointed `/api` at icp-cli's port 8000 got 4943 instead, and nothing reported
643
+ * the swap. Vite's merge skips an undefined value, so returning nothing leaves
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.
649
+ */
650
+ function apiProxy(
651
+ userConfig: UserConfig,
652
+ target: string,
653
+ configure: (options: ProxyOptions) => void
654
+ ): Record<string, ProxyOptions> | undefined {
655
+ if (userConfig.server?.proxy?.["/api"]) {
656
+ debugLog(
657
+ `The Vite config already proxies /api, so the plugin keeps that proxy instead of sending /api to ${target}.`
658
+ )
659
+ return undefined
660
+ }
661
+
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
+ }
757
+ }
758
+
461
759
  /**
462
760
  * `icp` failing to answer is routine — no local replica, or a canister that has
463
761
  * never been deployed — so its stderr is noise until someone is actually