@ic-reactor/vite-plugin 0.13.1 → 0.14.0

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/README.md CHANGED
@@ -57,6 +57,12 @@ exports the reactor and six hooks named after the canister
57
57
  `use<Canister>InfiniteQuery`, `use<Canister>SuspenseInfiniteQuery`,
58
58
  `use<Canister>Mutation`, `use<Canister>Method`).
59
59
 
60
+ If Prettier resolves from Vite's `config.root`, the plugin formats the
61
+ generated `.js` and `.d.ts` with it and your Prettier config, so a rebuild
62
+ leaves formatted, committed declarations unchanged. Without Prettier they hold
63
+ the Candid parser's output followed by a newline, and a formatting error never
64
+ fails the build.
65
+
60
66
  If you want non-React output, set `target: "core"` and install the matching
61
67
  runtime package instead of `@ic-reactor/react`.
62
68
 
@@ -140,6 +146,9 @@ when that happens with canisters configured, because the failure is otherwise
140
146
  indistinguishable from success until the app breaks on an undefined canister
141
147
  id. Run with `DEBUG=ic-reactor` to see the `icp` output behind the warning.
142
148
 
149
+ If your Vite config already sets `server.proxy["/api"]`, the plugin leaves that
150
+ entry alone, whether detection succeeds or not.
151
+
143
152
  ## File Regeneration
144
153
 
145
154
  On startup and on `.did` file changes, the plugin regenerates declarations and
package/dist/index.cjs CHANGED
@@ -38,12 +38,13 @@ var import_codegen = require("@ic-reactor/codegen");
38
38
 
39
39
  // src/env.ts
40
40
  var import_child_process = require("child_process");
41
- function getIcEnvironmentInfo(canisterNames) {
41
+ function getIcEnvironmentInfo(canisterNames, projectRoot = process.cwd()) {
42
42
  const networkName = process.env.ICP_ENVIRONMENT || "local";
43
43
  const diagnostics = [];
44
44
  try {
45
45
  const networkStatus = JSON.parse(
46
46
  (0, import_child_process.execFileSync)("icp", ["network", "status", "-e", networkName, "--json"], {
47
+ cwd: projectRoot,
47
48
  encoding: "utf-8",
48
49
  // stderr is piped rather than ignored so a failure can explain itself.
49
50
  // Piping still keeps it off the terminal — it only reaches the user if
@@ -65,7 +66,11 @@ function getIcEnvironmentInfo(canisterNames) {
65
66
  const canisterId = (0, import_child_process.execFileSync)(
66
67
  "icp",
67
68
  ["canister", "status", name, "-e", networkName, "-i"],
68
- { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] }
69
+ {
70
+ cwd: projectRoot,
71
+ encoding: "utf-8",
72
+ stdio: ["ignore", "pipe", "pipe"]
73
+ }
69
74
  ).trim();
70
75
  if (canisterId) {
71
76
  canisterIds[name] = canisterId;
@@ -153,7 +158,7 @@ function icReactor(options) {
153
158
  const configuredCanisterIds = Object.fromEntries(
154
159
  canisters.filter((canister) => !!canister.canisterId).map((canister) => [canister.name, canister.canisterId])
155
160
  );
156
- let pendingFailure = null;
161
+ const pendingFailures = /* @__PURE__ */ new Map();
157
162
  const reportFailure = (server, message, cause) => {
158
163
  console.error(`[ic-reactor] ${message}`);
159
164
  const err = {
@@ -161,8 +166,8 @@ function icReactor(options) {
161
166
  stack: cause instanceof Error && cause.stack ? cause.stack : "",
162
167
  plugin: PLUGIN_NAME
163
168
  };
164
- pendingFailure = { message: err.message, stack: err.stack };
165
169
  server?.ws.send({ type: "error", err });
170
+ return { message: err.message, stack: err.stack };
166
171
  };
167
172
  const inFlight = /* @__PURE__ */ new Map();
168
173
  const rerunQueued = /* @__PURE__ */ new Set();
@@ -179,19 +184,25 @@ function icReactor(options) {
179
184
  globalConfig
180
185
  }).then((result) => {
181
186
  if (result.success) {
182
- pendingFailure = null;
187
+ pendingFailures.delete(name);
183
188
  server.ws.send({ type: "full-reload" });
184
189
  } else {
185
- reportFailure(
186
- server,
187
- `Regeneration failed for ${name}: ${result.error ?? "unknown error"}`
190
+ pendingFailures.set(
191
+ name,
192
+ reportFailure(
193
+ server,
194
+ `Regeneration failed for ${name}: ${result.error ?? "unknown error"}`
195
+ )
188
196
  );
189
197
  }
190
198
  }).catch((error) => {
191
- reportFailure(
192
- server,
193
- `Regeneration failed for ${name}: ${describeError(error)}`,
194
- error
199
+ pendingFailures.set(
200
+ name,
201
+ reportFailure(
202
+ server,
203
+ `Regeneration failed for ${name}: ${describeError(error)}`,
204
+ error
205
+ )
195
206
  );
196
207
  }).finally(() => {
197
208
  inFlight.delete(name);
@@ -206,7 +217,7 @@ function icReactor(options) {
206
217
  name: PLUGIN_NAME,
207
218
  enforce: "pre",
208
219
  // Run before other plugins
209
- config(_config, { command: viteCommand }) {
220
+ config(userConfig, { command: viteCommand }) {
210
221
  command = viteCommand;
211
222
  if (viteCommand !== "serve" || !injectEnvironment) {
212
223
  return {};
@@ -215,7 +226,10 @@ function icReactor(options) {
215
226
  if (!canisterNames.includes("internet_identity")) {
216
227
  canisterNames.push("internet_identity");
217
228
  }
218
- const { environment: icEnv, diagnostics } = getIcEnvironmentInfo(canisterNames);
229
+ const { environment: icEnv, diagnostics } = getIcEnvironmentInfo(
230
+ canisterNames,
231
+ import_node_path.default.resolve(userConfig.root ?? process.cwd())
232
+ );
219
233
  if (!icEnv) {
220
234
  if (canisters.length > 0) {
221
235
  console.warn(
@@ -235,12 +249,7 @@ function icReactor(options) {
235
249
  headers: envOnlyCookie ? {
236
250
  "Set-Cookie": `ic_env=${envOnlyCookie}; Path=/; SameSite=Lax;`
237
251
  } : void 0,
238
- proxy: {
239
- "/api": {
240
- target: DEFAULT_LOCAL_REPLICA,
241
- changeOrigin: true
242
- }
243
- }
252
+ proxy: apiProxy(userConfig, DEFAULT_LOCAL_REPLICA)
244
253
  }
245
254
  };
246
255
  }
@@ -270,12 +279,7 @@ function icReactor(options) {
270
279
  headers: {
271
280
  "Set-Cookie": `ic_env=${cookieValue}; Path=/; SameSite=Lax;`
272
281
  },
273
- proxy: {
274
- "/api": {
275
- target: icEnv.proxyTarget,
276
- changeOrigin: true
277
- }
278
- }
282
+ proxy: apiProxy(userConfig, icEnv.proxyTarget)
279
283
  }
280
284
  };
281
285
  },
@@ -286,10 +290,15 @@ function icReactor(options) {
286
290
  configureServer(server) {
287
291
  devServer = server;
288
292
  server.ws.on?.("connection", () => {
289
- if (!pendingFailure) return;
293
+ if (pendingFailures.size === 0) return;
294
+ const failures = [...pendingFailures.values()];
290
295
  server.ws.send({
291
296
  type: "error",
292
- err: { ...pendingFailure, plugin: PLUGIN_NAME }
297
+ err: {
298
+ message: failures.map((failure) => failure.message).join("\n"),
299
+ stack: failures.map((failure) => failure.stack).filter(Boolean).join("\n"),
300
+ plugin: PLUGIN_NAME
301
+ }
293
302
  });
294
303
  });
295
304
  const didFiles = canisters.map((c) => resolveDidPath(c.didFile));
@@ -311,10 +320,15 @@ function icReactor(options) {
311
320
  const failures = outcomes.flatMap((outcome, index) => {
312
321
  const name = canisters[index]?.name ?? `canister #${index}`;
313
322
  if (outcome.status === "rejected") {
314
- return [`${name}: ${describeError(outcome.reason)}`];
323
+ return [{ name, detail: `${name}: ${describeError(outcome.reason)}` }];
315
324
  }
316
325
  if (!outcome.value.success) {
317
- return [`${name}: ${outcome.value.error ?? "unknown error"}`];
326
+ return [
327
+ {
328
+ name,
329
+ detail: `${name}: ${outcome.value.error ?? "unknown error"}`
330
+ }
331
+ ];
318
332
  }
319
333
  return [];
320
334
  });
@@ -322,28 +336,35 @@ function icReactor(options) {
322
336
  return;
323
337
  }
324
338
  const message = `Failed to generate ${failures.length} of ${canisters.length} canisters:
325
- ` + failures.map((failure) => ` - ${failure}`).join("\n");
339
+ ` + failures.map(({ detail }) => ` - ${detail}`).join("\n");
326
340
  if (failOnError ?? command === "build") {
327
341
  this.error(`[ic-reactor] ${message}`);
328
342
  }
329
343
  reportFailure(devServer, message);
344
+ for (const { name, detail } of failures) {
345
+ pendingFailures.set(name, {
346
+ message: `[ic-reactor] Failed to generate ${detail}`,
347
+ stack: ""
348
+ });
349
+ }
330
350
  },
331
351
  handleHotUpdate({ file, server }) {
332
352
  if (!file.endsWith(".did")) {
333
353
  return;
334
354
  }
335
355
  const changedPath = import_node_path.default.normalize(file);
336
- const affectedCanister = canisters.find(
337
- // Check if changed file matches configured didFile
356
+ const affectedCanisters = canisters.filter(
338
357
  (canister) => resolveDidPath(canister.didFile) === changedPath
339
358
  );
340
- if (!affectedCanister) {
359
+ if (affectedCanisters.length === 0) {
341
360
  return;
342
361
  }
343
362
  console.log(
344
- `[ic-reactor] .did file changed: ${affectedCanister.name}. Regenerating...`
363
+ `[ic-reactor] .did file changed: ${affectedCanisters.map((canister) => canister.name).join(", ")}. Regenerating...`
345
364
  );
346
- return regenerate(affectedCanister, server);
365
+ return Promise.all(
366
+ affectedCanisters.map((canister) => regenerate(canister, server))
367
+ ).then(() => void 0);
347
368
  }
348
369
  };
349
370
  return plugin;
@@ -351,6 +372,15 @@ function icReactor(options) {
351
372
  function describeError(error) {
352
373
  return error instanceof Error ? error.message : String(error);
353
374
  }
375
+ function apiProxy(userConfig, target) {
376
+ if (userConfig.server?.proxy?.["/api"]) {
377
+ debugLog(
378
+ `The Vite config already proxies /api, so the plugin keeps that proxy instead of sending /api to ${target}.`
379
+ );
380
+ return void 0;
381
+ }
382
+ return { "/api": { target, changeOrigin: true } };
383
+ }
354
384
  function debugLog(message) {
355
385
  const enabled = (process.env.DEBUG ?? "").split(",").some((entry) => {
356
386
  const scope = entry.trim();
package/dist/index.js CHANGED
@@ -6,12 +6,13 @@ import {
6
6
 
7
7
  // src/env.ts
8
8
  import { execFileSync } from "child_process";
9
- function getIcEnvironmentInfo(canisterNames) {
9
+ function getIcEnvironmentInfo(canisterNames, projectRoot = process.cwd()) {
10
10
  const networkName = process.env.ICP_ENVIRONMENT || "local";
11
11
  const diagnostics = [];
12
12
  try {
13
13
  const networkStatus = JSON.parse(
14
14
  execFileSync("icp", ["network", "status", "-e", networkName, "--json"], {
15
+ cwd: projectRoot,
15
16
  encoding: "utf-8",
16
17
  // stderr is piped rather than ignored so a failure can explain itself.
17
18
  // Piping still keeps it off the terminal — it only reaches the user if
@@ -33,7 +34,11 @@ function getIcEnvironmentInfo(canisterNames) {
33
34
  const canisterId = execFileSync(
34
35
  "icp",
35
36
  ["canister", "status", name, "-e", networkName, "-i"],
36
- { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] }
37
+ {
38
+ cwd: projectRoot,
39
+ encoding: "utf-8",
40
+ stdio: ["ignore", "pipe", "pipe"]
41
+ }
37
42
  ).trim();
38
43
  if (canisterId) {
39
44
  canisterIds[name] = canisterId;
@@ -121,7 +126,7 @@ function icReactor(options) {
121
126
  const configuredCanisterIds = Object.fromEntries(
122
127
  canisters.filter((canister) => !!canister.canisterId).map((canister) => [canister.name, canister.canisterId])
123
128
  );
124
- let pendingFailure = null;
129
+ const pendingFailures = /* @__PURE__ */ new Map();
125
130
  const reportFailure = (server, message, cause) => {
126
131
  console.error(`[ic-reactor] ${message}`);
127
132
  const err = {
@@ -129,8 +134,8 @@ function icReactor(options) {
129
134
  stack: cause instanceof Error && cause.stack ? cause.stack : "",
130
135
  plugin: PLUGIN_NAME
131
136
  };
132
- pendingFailure = { message: err.message, stack: err.stack };
133
137
  server?.ws.send({ type: "error", err });
138
+ return { message: err.message, stack: err.stack };
134
139
  };
135
140
  const inFlight = /* @__PURE__ */ new Map();
136
141
  const rerunQueued = /* @__PURE__ */ new Set();
@@ -147,19 +152,25 @@ function icReactor(options) {
147
152
  globalConfig
148
153
  }).then((result) => {
149
154
  if (result.success) {
150
- pendingFailure = null;
155
+ pendingFailures.delete(name);
151
156
  server.ws.send({ type: "full-reload" });
152
157
  } else {
153
- reportFailure(
154
- server,
155
- `Regeneration failed for ${name}: ${result.error ?? "unknown error"}`
158
+ pendingFailures.set(
159
+ name,
160
+ reportFailure(
161
+ server,
162
+ `Regeneration failed for ${name}: ${result.error ?? "unknown error"}`
163
+ )
156
164
  );
157
165
  }
158
166
  }).catch((error) => {
159
- reportFailure(
160
- server,
161
- `Regeneration failed for ${name}: ${describeError(error)}`,
162
- error
167
+ pendingFailures.set(
168
+ name,
169
+ reportFailure(
170
+ server,
171
+ `Regeneration failed for ${name}: ${describeError(error)}`,
172
+ error
173
+ )
163
174
  );
164
175
  }).finally(() => {
165
176
  inFlight.delete(name);
@@ -174,7 +185,7 @@ function icReactor(options) {
174
185
  name: PLUGIN_NAME,
175
186
  enforce: "pre",
176
187
  // Run before other plugins
177
- config(_config, { command: viteCommand }) {
188
+ config(userConfig, { command: viteCommand }) {
178
189
  command = viteCommand;
179
190
  if (viteCommand !== "serve" || !injectEnvironment) {
180
191
  return {};
@@ -183,7 +194,10 @@ function icReactor(options) {
183
194
  if (!canisterNames.includes("internet_identity")) {
184
195
  canisterNames.push("internet_identity");
185
196
  }
186
- const { environment: icEnv, diagnostics } = getIcEnvironmentInfo(canisterNames);
197
+ const { environment: icEnv, diagnostics } = getIcEnvironmentInfo(
198
+ canisterNames,
199
+ path.resolve(userConfig.root ?? process.cwd())
200
+ );
187
201
  if (!icEnv) {
188
202
  if (canisters.length > 0) {
189
203
  console.warn(
@@ -203,12 +217,7 @@ function icReactor(options) {
203
217
  headers: envOnlyCookie ? {
204
218
  "Set-Cookie": `ic_env=${envOnlyCookie}; Path=/; SameSite=Lax;`
205
219
  } : void 0,
206
- proxy: {
207
- "/api": {
208
- target: DEFAULT_LOCAL_REPLICA,
209
- changeOrigin: true
210
- }
211
- }
220
+ proxy: apiProxy(userConfig, DEFAULT_LOCAL_REPLICA)
212
221
  }
213
222
  };
214
223
  }
@@ -238,12 +247,7 @@ function icReactor(options) {
238
247
  headers: {
239
248
  "Set-Cookie": `ic_env=${cookieValue}; Path=/; SameSite=Lax;`
240
249
  },
241
- proxy: {
242
- "/api": {
243
- target: icEnv.proxyTarget,
244
- changeOrigin: true
245
- }
246
- }
250
+ proxy: apiProxy(userConfig, icEnv.proxyTarget)
247
251
  }
248
252
  };
249
253
  },
@@ -254,10 +258,15 @@ function icReactor(options) {
254
258
  configureServer(server) {
255
259
  devServer = server;
256
260
  server.ws.on?.("connection", () => {
257
- if (!pendingFailure) return;
261
+ if (pendingFailures.size === 0) return;
262
+ const failures = [...pendingFailures.values()];
258
263
  server.ws.send({
259
264
  type: "error",
260
- err: { ...pendingFailure, plugin: PLUGIN_NAME }
265
+ err: {
266
+ message: failures.map((failure) => failure.message).join("\n"),
267
+ stack: failures.map((failure) => failure.stack).filter(Boolean).join("\n"),
268
+ plugin: PLUGIN_NAME
269
+ }
261
270
  });
262
271
  });
263
272
  const didFiles = canisters.map((c) => resolveDidPath(c.didFile));
@@ -279,10 +288,15 @@ function icReactor(options) {
279
288
  const failures = outcomes.flatMap((outcome, index) => {
280
289
  const name = canisters[index]?.name ?? `canister #${index}`;
281
290
  if (outcome.status === "rejected") {
282
- return [`${name}: ${describeError(outcome.reason)}`];
291
+ return [{ name, detail: `${name}: ${describeError(outcome.reason)}` }];
283
292
  }
284
293
  if (!outcome.value.success) {
285
- return [`${name}: ${outcome.value.error ?? "unknown error"}`];
294
+ return [
295
+ {
296
+ name,
297
+ detail: `${name}: ${outcome.value.error ?? "unknown error"}`
298
+ }
299
+ ];
286
300
  }
287
301
  return [];
288
302
  });
@@ -290,28 +304,35 @@ function icReactor(options) {
290
304
  return;
291
305
  }
292
306
  const message = `Failed to generate ${failures.length} of ${canisters.length} canisters:
293
- ` + failures.map((failure) => ` - ${failure}`).join("\n");
307
+ ` + failures.map(({ detail }) => ` - ${detail}`).join("\n");
294
308
  if (failOnError ?? command === "build") {
295
309
  this.error(`[ic-reactor] ${message}`);
296
310
  }
297
311
  reportFailure(devServer, message);
312
+ for (const { name, detail } of failures) {
313
+ pendingFailures.set(name, {
314
+ message: `[ic-reactor] Failed to generate ${detail}`,
315
+ stack: ""
316
+ });
317
+ }
298
318
  },
299
319
  handleHotUpdate({ file, server }) {
300
320
  if (!file.endsWith(".did")) {
301
321
  return;
302
322
  }
303
323
  const changedPath = path.normalize(file);
304
- const affectedCanister = canisters.find(
305
- // Check if changed file matches configured didFile
324
+ const affectedCanisters = canisters.filter(
306
325
  (canister) => resolveDidPath(canister.didFile) === changedPath
307
326
  );
308
- if (!affectedCanister) {
327
+ if (affectedCanisters.length === 0) {
309
328
  return;
310
329
  }
311
330
  console.log(
312
- `[ic-reactor] .did file changed: ${affectedCanister.name}. Regenerating...`
331
+ `[ic-reactor] .did file changed: ${affectedCanisters.map((canister) => canister.name).join(", ")}. Regenerating...`
313
332
  );
314
- return regenerate(affectedCanister, server);
333
+ return Promise.all(
334
+ affectedCanisters.map((canister) => regenerate(canister, server))
335
+ ).then(() => void 0);
315
336
  }
316
337
  };
317
338
  return plugin;
@@ -319,6 +340,15 @@ function icReactor(options) {
319
340
  function describeError(error) {
320
341
  return error instanceof Error ? error.message : String(error);
321
342
  }
343
+ function apiProxy(userConfig, target) {
344
+ if (userConfig.server?.proxy?.["/api"]) {
345
+ debugLog(
346
+ `The Vite config already proxies /api, so the plugin keeps that proxy instead of sending /api to ${target}.`
347
+ );
348
+ return void 0;
349
+ }
350
+ return { "/api": { target, changeOrigin: true } };
351
+ }
322
352
  function debugLog(message) {
323
353
  const enabled = (process.env.DEBUG ?? "").split(",").some((entry) => {
324
354
  const scope = entry.trim();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ic-reactor/vite-plugin",
3
- "version": "0.13.1",
3
+ "version": "0.14.0",
4
4
  "description": "Vite plugin for zero-config IC reactor generation from Candid files",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -50,16 +50,16 @@
50
50
  },
51
51
  "homepage": "https://ic-reactor.b3pay.net/v3/packages/vite-plugin",
52
52
  "dependencies": {
53
- "@ic-reactor/codegen": "0.13.1"
53
+ "@ic-reactor/codegen": "0.14.0"
54
54
  },
55
55
  "peerDependencies": {
56
56
  "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
57
57
  },
58
58
  "devDependencies": {
59
- "@types/node": "^26.4.0",
59
+ "@types/node": "^26.5.1",
60
60
  "tsup": "^8.5.1",
61
61
  "typescript": "^6.0.3",
62
- "vitest": "^4.1.11"
62
+ "vitest": "^5.0.0"
63
63
  },
64
64
  "scripts": {
65
65
  "build": "tsup src/index.ts --format esm,cjs --dts --clean --tsconfig tsconfig.json",
package/src/env.ts CHANGED
@@ -29,9 +29,14 @@ export interface IcEnvironmentDetection {
29
29
 
30
30
  /**
31
31
  * Detect the IC environment using the `icp` CLI.
32
+ *
33
+ * @param projectRoot - Directory `icp` runs in. `icp` finds its project by
34
+ * looking for `icp.yaml` there and in each parent directory, so this has to be
35
+ * the app's root and not wherever the process happened to start.
32
36
  */
33
37
  export function getIcEnvironmentInfo(
34
- canisterNames: string[]
38
+ canisterNames: string[],
39
+ projectRoot: string = process.cwd()
35
40
  ): IcEnvironmentDetection {
36
41
  const networkName = process.env.ICP_ENVIRONMENT || "local"
37
42
  const diagnostics: string[] = []
@@ -39,6 +44,7 @@ export function getIcEnvironmentInfo(
39
44
  try {
40
45
  const networkStatus = JSON.parse(
41
46
  execFileSync("icp", ["network", "status", "-e", networkName, "--json"], {
47
+ cwd: projectRoot,
42
48
  encoding: "utf-8",
43
49
  // stderr is piped rather than ignored so a failure can explain itself.
44
50
  // Piping still keeps it off the terminal — it only reaches the user if
@@ -69,7 +75,11 @@ export function getIcEnvironmentInfo(
69
75
  const canisterId = execFileSync(
70
76
  "icp",
71
77
  ["canister", "status", name, "-e", networkName, "-i"],
72
- { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] }
78
+ {
79
+ cwd: projectRoot,
80
+ encoding: "utf-8",
81
+ stdio: ["ignore", "pipe", "pipe"],
82
+ }
73
83
  ).trim()
74
84
 
75
85
  if (canisterId) {
package/src/index.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  * 3. Hot-reloads when .did files change
8
8
  */
9
9
 
10
- import type { Plugin, ResolvedConfig, ViteDevServer } from "vite"
10
+ import type { Plugin, ResolvedConfig, UserConfig, ViteDevServer } from "vite"
11
11
  import path from "node:path"
12
12
  import {
13
13
  runCanisterPipeline,
@@ -115,16 +115,21 @@ export function icReactor(options: IcReactorPluginOptions): Plugin {
115
115
  * the browser error overlay is the signal that actually gets noticed.
116
116
  */
117
117
  /**
118
- * The last failure, kept so a browser that was not connected when it happened
119
- * still gets the overlay.
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.
120
120
  *
121
121
  * Vite awaits the plugin container's `buildStart` before the HTTP server
122
122
  * starts listening, so a generation failure during `vite dev` startup is
123
123
  * broadcast when there are no WebSocket clients at all and the payload is
124
124
  * simply dropped. The terminal shows it; the overlay never appears — for
125
125
  * precisely the failures a developer is most likely to hit.
126
+ *
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.
126
131
  */
127
- let pendingFailure: { message: string; stack: string } | null = null
132
+ const pendingFailures = new Map<string, { message: string; stack: string }>()
128
133
 
129
134
  const reportFailure = (
130
135
  server: ViteDevServer | null,
@@ -139,8 +144,8 @@ export function icReactor(options: IcReactorPluginOptions): Plugin {
139
144
  plugin: PLUGIN_NAME,
140
145
  }
141
146
 
142
- pendingFailure = { message: err.message, stack: err.stack }
143
147
  server?.ws.send({ type: "error", err })
148
+ return { message: err.message, stack: err.stack }
144
149
  }
145
150
 
146
151
  // Keep at most one regeneration per canister in flight and collapse every save
@@ -183,13 +188,16 @@ export function icReactor(options: IcReactorPluginOptions): Plugin {
183
188
  if (result.success) {
184
189
  // A later connection must not be handed a failure that has since been
185
190
  // fixed.
186
- pendingFailure = null
191
+ pendingFailures.delete(name)
187
192
  // Reload page to reflect new types/hooks
188
193
  server.ws.send({ type: "full-reload" })
189
194
  } else {
190
- reportFailure(
191
- server,
192
- `Regeneration failed for ${name}: ${result.error ?? "unknown error"}`
195
+ pendingFailures.set(
196
+ name,
197
+ reportFailure(
198
+ server,
199
+ `Regeneration failed for ${name}: ${result.error ?? "unknown error"}`
200
+ )
193
201
  )
194
202
  }
195
203
  })
@@ -198,10 +206,13 @@ export function icReactor(options: IcReactorPluginOptions): Plugin {
198
206
  // rejection: invisible in the browser and, depending on the Node version,
199
207
  // fatal to the dev server.
200
208
  .catch((error: unknown) => {
201
- reportFailure(
202
- server,
203
- `Regeneration failed for ${name}: ${describeError(error)}`,
204
- error
209
+ pendingFailures.set(
210
+ name,
211
+ reportFailure(
212
+ server,
213
+ `Regeneration failed for ${name}: ${describeError(error)}`,
214
+ error
215
+ )
205
216
  )
206
217
  })
207
218
  .finally(() => {
@@ -219,7 +230,7 @@ export function icReactor(options: IcReactorPluginOptions): Plugin {
219
230
  name: PLUGIN_NAME,
220
231
  enforce: "pre", // Run before other plugins
221
232
 
222
- config(_config, { command: viteCommand }) {
233
+ config(userConfig, { command: viteCommand }) {
223
234
  command = viteCommand
224
235
 
225
236
  if (viteCommand !== "serve" || !injectEnvironment) {
@@ -236,8 +247,13 @@ export function icReactor(options: IcReactorPluginOptions): Plugin {
236
247
  canisterNames.push("internet_identity")
237
248
  }
238
249
 
239
- const { environment: icEnv, diagnostics } =
240
- getIcEnvironmentInfo(canisterNames)
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
+ )
241
257
 
242
258
  if (!icEnv) {
243
259
  // Failing detection used to be indistinguishable from success: no
@@ -274,12 +290,7 @@ export function icReactor(options: IcReactorPluginOptions): Plugin {
274
290
  "Set-Cookie": `ic_env=${envOnlyCookie}; Path=/; SameSite=Lax;`,
275
291
  }
276
292
  : undefined,
277
- proxy: {
278
- "/api": {
279
- target: DEFAULT_LOCAL_REPLICA,
280
- changeOrigin: true,
281
- },
282
- },
293
+ proxy: apiProxy(userConfig, DEFAULT_LOCAL_REPLICA),
283
294
  },
284
295
  }
285
296
  }
@@ -334,12 +345,7 @@ export function icReactor(options: IcReactorPluginOptions): Plugin {
334
345
  headers: {
335
346
  "Set-Cookie": `ic_env=${cookieValue}; Path=/; SameSite=Lax;`,
336
347
  },
337
- proxy: {
338
- "/api": {
339
- target: icEnv.proxyTarget,
340
- changeOrigin: true,
341
- },
342
- },
348
+ proxy: apiProxy(userConfig, icEnv.proxyTarget),
343
349
  },
344
350
  }
345
351
  },
@@ -355,16 +361,24 @@ export function icReactor(options: IcReactorPluginOptions): Plugin {
355
361
  configureServer(server) {
356
362
  devServer = server
357
363
 
358
- // Replay a startup failure to the first client that connects — see
359
- // pendingFailure. Cleared once generation succeeds.
364
+ // Replay the unfixed failures described at pendingFailures to each client
365
+ // that connects. A canister leaves the replay once it regenerates.
360
366
  // Guarded: the peer range spans several Vite majors and `ws.on` is not
361
367
  // present on every one of them. Losing the replay is acceptable; throwing
362
368
  // out of configureServer is not.
363
369
  server.ws.on?.("connection", () => {
364
- if (!pendingFailure) return
370
+ if (pendingFailures.size === 0) return
371
+ const failures = [...pendingFailures.values()]
365
372
  server.ws.send({
366
373
  type: "error",
367
- err: { ...pendingFailure, plugin: PLUGIN_NAME },
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
+ },
368
382
  })
369
383
  })
370
384
 
@@ -397,10 +411,15 @@ export function icReactor(options: IcReactorPluginOptions): Plugin {
397
411
  const name = canisters[index]?.name ?? `canister #${index}`
398
412
 
399
413
  if (outcome.status === "rejected") {
400
- return [`${name}: ${describeError(outcome.reason)}`]
414
+ return [{ name, detail: `${name}: ${describeError(outcome.reason)}` }]
401
415
  }
402
416
  if (!outcome.value.success) {
403
- return [`${name}: ${outcome.value.error ?? "unknown error"}`]
417
+ return [
418
+ {
419
+ name,
420
+ detail: `${name}: ${outcome.value.error ?? "unknown error"}`,
421
+ },
422
+ ]
404
423
  }
405
424
  return []
406
425
  })
@@ -411,7 +430,7 @@ export function icReactor(options: IcReactorPluginOptions): Plugin {
411
430
 
412
431
  const message =
413
432
  `Failed to generate ${failures.length} of ${canisters.length} canisters:\n` +
414
- failures.map((failure) => ` - ${failure}`).join("\n")
433
+ failures.map(({ detail }) => ` - ${detail}`).join("\n")
415
434
 
416
435
  // Previously every failure here was a `console.error` and nothing more,
417
436
  // so `vite build` exited 0 and CI shipped whatever stale bindings were
@@ -421,6 +440,15 @@ export function icReactor(options: IcReactorPluginOptions): Plugin {
421
440
  }
422
441
 
423
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
+ }
424
452
  },
425
453
 
426
454
  handleHotUpdate({ file, server }) {
@@ -430,23 +458,29 @@ export function icReactor(options: IcReactorPluginOptions): Plugin {
430
458
  }
431
459
 
432
460
  const changedPath = path.normalize(file)
433
- const affectedCanister = canisters.find(
434
- // Check if changed file matches configured didFile
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(
435
465
  (canister) => resolveDidPath(canister.didFile) === changedPath
436
466
  )
437
467
 
438
- if (!affectedCanister) {
468
+ if (affectedCanisters.length === 0) {
439
469
  return
440
470
  }
441
471
 
442
472
  console.log(
443
- `[ic-reactor] .did file changed: ${affectedCanister.name}. Regenerating...`
473
+ `[ic-reactor] .did file changed: ${affectedCanisters
474
+ .map((canister) => canister.name)
475
+ .join(", ")}. Regenerating...`
444
476
  )
445
477
 
446
478
  // Returned so Vite waits for the write to finish before applying the
447
479
  // update; it resolves to `undefined`, which leaves the affected module
448
480
  // list untouched.
449
- return regenerate(affectedCanister, server)
481
+ return Promise.all(
482
+ affectedCanisters.map((canister) => regenerate(canister, server))
483
+ ).then(() => undefined)
450
484
  },
451
485
  }
452
486
 
@@ -458,6 +492,27 @@ function describeError(error: unknown): string {
458
492
  return error instanceof Error ? error.message : String(error)
459
493
  }
460
494
 
495
+ /**
496
+ * The `server.proxy` entry that sends `/api` to `target`, or nothing when the
497
+ * user's Vite config already proxies `/api`.
498
+ *
499
+ * Vite deep-merges the object a `config` hook returns over the user's config,
500
+ * so an `/api` entry returned here replaced the user's own. A project that
501
+ * pointed `/api` at icp-cli's port 8000 got 4943 instead, and nothing reported
502
+ * the swap. Vite's merge skips an undefined value, so returning nothing leaves
503
+ * the user's entry in place.
504
+ */
505
+ function apiProxy(userConfig: UserConfig, target: string) {
506
+ if (userConfig.server?.proxy?.["/api"]) {
507
+ debugLog(
508
+ `The Vite config already proxies /api, so the plugin keeps that proxy instead of sending /api to ${target}.`
509
+ )
510
+ return undefined
511
+ }
512
+
513
+ return { "/api": { target, changeOrigin: true } }
514
+ }
515
+
461
516
  /**
462
517
  * `icp` failing to answer is routine — no local replica, or a canister that has
463
518
  * never been deployed — so its stderr is noise until someone is actually