@ic-reactor/vite-plugin 0.12.0 → 0.13.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
@@ -69,9 +69,26 @@ icReactor({
69
69
  clientManagerPath: "../../clients",
70
70
  target: "react",
71
71
  injectEnvironment: true,
72
+ failOnError: true,
72
73
  })
73
74
  ```
74
75
 
76
+ Relative paths — `didFile`, `outDir` — resolve against Vite's resolved
77
+ `config.root`, not the directory vite was started from. If you set
78
+ `root: "frontend"`, write the paths as the project itself sees them.
79
+
80
+ Note `--config` alone does **not** change the root: `vite build --config
81
+ apps/web/vite.config.ts` still leaves `root` at the directory vite was started
82
+ from, so app-relative paths resolve against the monorepo root. Set `root` in the
83
+ config file, or pass it positionally (`vite build apps/web --config …`), for
84
+ those paths to mean what the app expects.
85
+
86
+ `failOnError` decides what a failed canister does to the run. It defaults to
87
+ `true` under `vite build` and `false` under `vite dev`: a build that quietly
88
+ ships the bindings left over from the last successful run is worse than no
89
+ build at all, while a dev server has to survive the broken intermediate states
90
+ of a `.did` file being edited.
91
+
75
92
  ### Per-canister options
76
93
 
77
94
  - `name`
@@ -112,7 +129,10 @@ Set the `ICP_ENVIRONMENT` environment variable to target a non-default network
112
129
  (defaults to `"local"`).
113
130
 
114
131
  If environment detection fails, the plugin still falls back to proxying `/api`
115
- to `http://127.0.0.1:4943`, but it will not inject canister metadata.
132
+ to `http://127.0.0.1:4943`, but it will not inject canister metadata. It warns
133
+ when that happens with canisters configured, because the failure is otherwise
134
+ indistinguishable from success until the app breaks on an undefined canister
135
+ id. Run with `DEBUG=ic-reactor` to see the `icp` output behind the warning.
116
136
 
117
137
  ## File Regeneration
118
138
 
@@ -121,7 +141,11 @@ the managed `index.generated.ts` implementation. The user-facing `index.ts`
121
141
  entry is created once, then preserved unless it still matches the default
122
142
  wrapper or a legacy generated scaffold that can be migrated automatically.
123
143
  When a watched `.did` file changes, the plugin sends a full browser reload so
124
- the new declarations are picked up.
144
+ the new declarations are picked up. Regeneration is serialized per canister —
145
+ saves that land while a run is in flight collapse into a single rerun — so two
146
+ rapid saves cannot interleave inside the pipeline's delete-then-write sequence.
147
+ A regeneration that fails is reported to the terminal and to the browser error
148
+ overlay rather than leaving the page on stale bindings.
125
149
 
126
150
  ## When To Use It
127
151
 
package/dist/index.cjs CHANGED
@@ -39,44 +39,59 @@ var import_codegen = require("@ic-reactor/codegen");
39
39
  // src/env.ts
40
40
  var import_child_process = require("child_process");
41
41
  function getIcEnvironmentInfo(canisterNames) {
42
- const environment = process.env.ICP_ENVIRONMENT || "local";
42
+ const networkName = process.env.ICP_ENVIRONMENT || "local";
43
+ const diagnostics = [];
43
44
  try {
44
45
  const networkStatus = JSON.parse(
45
- (0, import_child_process.execFileSync)("icp", ["network", "status", "-e", environment, "--json"], {
46
+ (0, import_child_process.execFileSync)("icp", ["network", "status", "-e", networkName, "--json"], {
46
47
  encoding: "utf-8",
47
- stdio: ["ignore", "pipe", "ignore"]
48
- // suppress stderr
48
+ // stderr is piped rather than ignored so a failure can explain itself.
49
+ // Piping still keeps it off the terminal — it only reaches the user if
50
+ // the caller decides to print the diagnostics we collect below.
51
+ stdio: ["ignore", "pipe", "pipe"]
49
52
  })
50
53
  );
51
54
  const rootKey = networkStatus.root_key;
52
55
  const proxyTarget = networkStatus.api_url || networkStatus.gateway_url || (networkStatus.port ? `http://127.0.0.1:${networkStatus.port}` : void 0);
53
56
  if (!proxyTarget) {
54
- return null;
57
+ diagnostics.push(
58
+ `\`icp network status -e ${networkName}\` reported no api_url, gateway_url or port`
59
+ );
60
+ return { environment: null, diagnostics };
55
61
  }
56
62
  const canisterIds = {};
57
63
  for (const name of canisterNames) {
58
64
  try {
59
65
  const canisterId = (0, import_child_process.execFileSync)(
60
66
  "icp",
61
- ["canister", "status", name, "-e", environment, "-i"],
62
- { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }
67
+ ["canister", "status", name, "-e", networkName, "-i"],
68
+ { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] }
63
69
  ).trim();
64
70
  if (canisterId) {
65
71
  canisterIds[name] = canisterId;
66
72
  }
67
- } catch {
73
+ } catch (error) {
74
+ diagnostics.push(
75
+ `\`icp canister status ${name} -e ${networkName}\` failed: ${describeExecError(error)}`
76
+ );
68
77
  }
69
78
  }
70
- const internetIdentityProvider = !canisterIds.internet_identity && isLocalhostGateway(proxyTarget) && environment !== "ic" ? localInternetIdentityProvider(proxyTarget) : void 0;
79
+ const internetIdentityProvider = !canisterIds.internet_identity && isLocalhostGateway(proxyTarget) && networkName !== "ic" ? localInternetIdentityProvider(proxyTarget) : void 0;
71
80
  return {
72
- environment,
73
- rootKey,
74
- proxyTarget,
75
- canisterIds,
76
- internetIdentityProvider
81
+ environment: {
82
+ environment: networkName,
83
+ rootKey,
84
+ proxyTarget,
85
+ canisterIds,
86
+ internetIdentityProvider
87
+ },
88
+ diagnostics
77
89
  };
78
90
  } catch (error) {
79
- return null;
91
+ diagnostics.push(
92
+ `\`icp network status -e ${networkName}\` failed: ${describeExecError(error)}`
93
+ );
94
+ return { environment: null, diagnostics };
80
95
  }
81
96
  }
82
97
  function buildIcEnvCookie(canisterIds, rootKey, internetIdentityProvider) {
@@ -89,6 +104,15 @@ function buildIcEnvCookie(canisterIds, rootKey, internetIdentityProvider) {
89
104
  }
90
105
  return encodeURIComponent(parts.join("&"));
91
106
  }
107
+ function describeExecError(error) {
108
+ const stderr = error?.stderr;
109
+ const text = typeof stderr === "string" ? stderr : stderr?.toString("utf-8");
110
+ const trimmed = text?.trim();
111
+ if (trimmed) {
112
+ return trimmed;
113
+ }
114
+ return error instanceof Error ? error.message : String(error);
115
+ }
92
116
  function isLocalhostGateway(proxyTarget) {
93
117
  try {
94
118
  const { hostname } = new URL(proxyTarget);
@@ -104,40 +128,103 @@ function localInternetIdentityProvider(proxyTarget) {
104
128
  }
105
129
 
106
130
  // src/index.ts
131
+ var PLUGIN_NAME = "ic-reactor-plugin";
132
+ var DEFAULT_LOCAL_REPLICA = "http://127.0.0.1:4943";
107
133
  function icReactor(options) {
108
134
  const {
109
135
  canisters,
110
136
  outDir = "src/declarations",
111
137
  clientManagerPath = "../../clients",
112
138
  target = "react",
113
- injectEnvironment = true
139
+ injectEnvironment = true,
140
+ failOnError
114
141
  } = options;
115
142
  const globalConfig = {
116
143
  outDir,
117
144
  clientManagerPath,
118
145
  target
119
146
  };
120
- const projectRoot = process.cwd();
147
+ let projectRoot = process.cwd();
148
+ let command = "build";
149
+ let devServer = null;
121
150
  const resolveDidPath = (didFile) => import_node_path.default.normalize(
122
151
  import_node_path.default.isAbsolute(didFile) ? didFile : import_node_path.default.resolve(projectRoot, didFile)
123
152
  );
124
153
  const configuredCanisterIds = Object.fromEntries(
125
154
  canisters.filter((canister) => !!canister.canisterId).map((canister) => [canister.name, canister.canisterId])
126
155
  );
156
+ let pendingFailure = null;
157
+ const reportFailure = (server, message, cause) => {
158
+ console.error(`[ic-reactor] ${message}`);
159
+ const err = {
160
+ message: `[ic-reactor] ${message}`,
161
+ stack: cause instanceof Error && cause.stack ? cause.stack : "",
162
+ plugin: PLUGIN_NAME
163
+ };
164
+ pendingFailure = { message: err.message, stack: err.stack };
165
+ server?.ws.send({ type: "error", err });
166
+ };
167
+ const inFlight = /* @__PURE__ */ new Map();
168
+ const rerunQueued = /* @__PURE__ */ new Set();
169
+ const regenerate = (canisterConfig, server) => {
170
+ const { name } = canisterConfig;
171
+ const running = inFlight.get(name);
172
+ if (running) {
173
+ rerunQueued.add(name);
174
+ return running;
175
+ }
176
+ const run = (0, import_codegen.runCanisterPipeline)({
177
+ canisterConfig,
178
+ projectRoot,
179
+ globalConfig
180
+ }).then((result) => {
181
+ if (result.success) {
182
+ pendingFailure = null;
183
+ server.ws.send({ type: "full-reload" });
184
+ } else {
185
+ reportFailure(
186
+ server,
187
+ `Regeneration failed for ${name}: ${result.error ?? "unknown error"}`
188
+ );
189
+ }
190
+ }).catch((error) => {
191
+ reportFailure(
192
+ server,
193
+ `Regeneration failed for ${name}: ${describeError(error)}`,
194
+ error
195
+ );
196
+ }).finally(() => {
197
+ inFlight.delete(name);
198
+ if (rerunQueued.delete(name)) {
199
+ void regenerate(canisterConfig, server);
200
+ }
201
+ });
202
+ inFlight.set(name, run);
203
+ return run;
204
+ };
127
205
  const plugin = {
128
- name: "ic-reactor-plugin",
206
+ name: PLUGIN_NAME,
129
207
  enforce: "pre",
130
208
  // Run before other plugins
131
- config(_config, { command }) {
132
- if (command !== "serve" || !injectEnvironment) {
209
+ config(_config, { command: viteCommand }) {
210
+ command = viteCommand;
211
+ if (viteCommand !== "serve" || !injectEnvironment) {
133
212
  return {};
134
213
  }
135
214
  const canisterNames = canisters.map((c) => c.name).filter((n) => !!n);
136
215
  if (!canisterNames.includes("internet_identity")) {
137
216
  canisterNames.push("internet_identity");
138
217
  }
139
- const icEnv = getIcEnvironmentInfo(canisterNames);
218
+ const { environment: icEnv, diagnostics } = getIcEnvironmentInfo(canisterNames);
140
219
  if (!icEnv) {
220
+ if (canisters.length > 0) {
221
+ console.warn(
222
+ `[ic-reactor] Could not detect the local IC environment, falling back to ${DEFAULT_LOCAL_REPLICA}. Canister IDs and the root key will not be injected \u2014 is the local replica running? Re-run with DEBUG=ic-reactor to see the \`icp\` output.`
223
+ );
224
+ }
225
+ for (const diagnostic of diagnostics) {
226
+ debugLog(diagnostic);
227
+ }
141
228
  const envOnlyCookie = canisters.length === 0 ? buildIcEnvCookie(
142
229
  {},
143
230
  void 0,
@@ -150,13 +237,26 @@ function icReactor(options) {
150
237
  } : void 0,
151
238
  proxy: {
152
239
  "/api": {
153
- target: "http://127.0.0.1:4943",
240
+ target: DEFAULT_LOCAL_REPLICA,
154
241
  changeOrigin: true
155
242
  }
156
243
  }
157
244
  }
158
245
  };
159
246
  }
247
+ const missingCanisterIds = canisters.map((canister) => canister.name).filter((name) => !!name).filter(
248
+ (name) => !icEnv.canisterIds[name] && !configuredCanisterIds[name]
249
+ );
250
+ if (missingCanisterIds.length > 0) {
251
+ const names = missingCanisterIds.map((name) => `"${name}"`).join(", ");
252
+ const it = missingCanisterIds.length === 1 ? "it" : "them";
253
+ console.warn(
254
+ `[ic-reactor] The local replica is running, but no canister ID could be resolved for ${names}. Deploy ${it} (\`icp deploy\`) \u2014 until then the injected ic_env carries no PUBLIC_CANISTER_ID for ${it} and the app will see an undefined canister id. Re-run with DEBUG=ic-reactor to see the \`icp\` output.`
255
+ );
256
+ }
257
+ for (const diagnostic of diagnostics) {
258
+ debugLog(diagnostic);
259
+ }
160
260
  const cookieValue = buildIcEnvCookie(
161
261
  {
162
262
  ...icEnv.canisterIds,
@@ -179,7 +279,19 @@ function icReactor(options) {
179
279
  }
180
280
  };
181
281
  },
282
+ configResolved(config) {
283
+ projectRoot = config.root;
284
+ command = config.command;
285
+ },
182
286
  configureServer(server) {
287
+ devServer = server;
288
+ server.ws.on?.("connection", () => {
289
+ if (!pendingFailure) return;
290
+ server.ws.send({
291
+ type: "error",
292
+ err: { ...pendingFailure, plugin: PLUGIN_NAME }
293
+ });
294
+ });
183
295
  const didFiles = canisters.map((c) => resolveDidPath(c.didFile));
184
296
  server.watcher.add(didFiles);
185
297
  },
@@ -187,55 +299,67 @@ function icReactor(options) {
187
299
  console.log(
188
300
  `[ic-reactor] Generating canister bindings for ${canisters.length} canisters...`
189
301
  );
190
- await Promise.allSettled(
191
- canisters.map(async (canisterConfig) => {
192
- try {
193
- const result = await (0, import_codegen.runCanisterPipeline)({
194
- canisterConfig,
195
- projectRoot,
196
- globalConfig
197
- });
198
- if (!result.success) {
199
- console.error(
200
- `[ic-reactor] Failed to generate ${canisterConfig.name}: ${result.error}`
201
- );
202
- }
203
- } catch (err) {
204
- console.error(
205
- `[ic-reactor] Error generating ${canisterConfig.name}:`,
206
- err
207
- );
208
- }
209
- })
210
- );
211
- },
212
- handleHotUpdate({ file, server }) {
213
- if (file.endsWith(".did")) {
214
- const affectedCanister = canisters.find((c) => {
215
- const configPath = resolveDidPath(c.didFile);
216
- return configPath === import_node_path.default.normalize(file);
217
- });
218
- if (affectedCanister) {
219
- console.log(
220
- `[ic-reactor] .did file changed: ${affectedCanister.name}. Regenerating...`
221
- );
222
- (0, import_codegen.runCanisterPipeline)({
223
- canisterConfig: affectedCanister,
302
+ const outcomes = await Promise.allSettled(
303
+ canisters.map(
304
+ (canisterConfig) => (0, import_codegen.runCanisterPipeline)({
305
+ canisterConfig,
224
306
  projectRoot,
225
307
  globalConfig
226
- }).then((result) => {
227
- if (result.success) {
228
- server.ws.send({ type: "full-reload" });
229
- } else {
230
- console.error(`[ic-reactor] Regeneration failed: ${result.error}`);
231
- }
232
- });
308
+ })
309
+ )
310
+ );
311
+ const failures = outcomes.flatMap((outcome, index) => {
312
+ const name = canisters[index]?.name ?? `canister #${index}`;
313
+ if (outcome.status === "rejected") {
314
+ return [`${name}: ${describeError(outcome.reason)}`];
233
315
  }
316
+ if (!outcome.value.success) {
317
+ return [`${name}: ${outcome.value.error ?? "unknown error"}`];
318
+ }
319
+ return [];
320
+ });
321
+ if (failures.length === 0) {
322
+ return;
323
+ }
324
+ const message = `Failed to generate ${failures.length} of ${canisters.length} canisters:
325
+ ` + failures.map((failure) => ` - ${failure}`).join("\n");
326
+ if (failOnError ?? command === "build") {
327
+ this.error(`[ic-reactor] ${message}`);
234
328
  }
329
+ reportFailure(devServer, message);
330
+ },
331
+ handleHotUpdate({ file, server }) {
332
+ if (!file.endsWith(".did")) {
333
+ return;
334
+ }
335
+ const changedPath = import_node_path.default.normalize(file);
336
+ const affectedCanister = canisters.find(
337
+ // Check if changed file matches configured didFile
338
+ (canister) => resolveDidPath(canister.didFile) === changedPath
339
+ );
340
+ if (!affectedCanister) {
341
+ return;
342
+ }
343
+ console.log(
344
+ `[ic-reactor] .did file changed: ${affectedCanister.name}. Regenerating...`
345
+ );
346
+ return regenerate(affectedCanister, server);
235
347
  }
236
348
  };
237
349
  return plugin;
238
350
  }
351
+ function describeError(error) {
352
+ return error instanceof Error ? error.message : String(error);
353
+ }
354
+ function debugLog(message) {
355
+ const enabled = (process.env.DEBUG ?? "").split(",").some((entry) => {
356
+ const scope = entry.trim();
357
+ return scope === "*" || scope === "ic-reactor" || scope.startsWith("ic-reactor:");
358
+ });
359
+ if (enabled) {
360
+ console.debug(`[ic-reactor:debug] ${message}`);
361
+ }
362
+ }
239
363
  // Annotate the CommonJS export names for ESM import in node:
240
364
  0 && (module.exports = {
241
365
  icReactor
package/dist/index.d.cts CHANGED
@@ -1,3 +1,4 @@
1
+ import { Plugin } from 'vite';
1
2
  import { CanisterConfig, CodegenTarget } from '@ic-reactor/codegen';
2
3
 
3
4
  /**
@@ -16,7 +17,7 @@ interface IcReactorPluginOptions {
16
17
  */
17
18
  canisters: CanisterConfig[];
18
19
  /**
19
- * Default output directory (relative to project root).
20
+ * Default output directory (relative to the Vite project root).
20
21
  * Default: "src/declarations"
21
22
  */
22
23
  outDir?: string;
@@ -35,7 +36,17 @@ interface IcReactorPluginOptions {
35
36
  * Default: true
36
37
  */
37
38
  injectEnvironment?: boolean;
39
+ /**
40
+ * Abort the Vite run when a canister fails to generate.
41
+ *
42
+ * Default: `true` under `vite build`, `false` under `vite dev`. A build that
43
+ * silently ships the bindings left over from the last successful run is worse
44
+ * than no build at all, while a dev server has to survive the broken
45
+ * intermediate states of a `.did` file being edited — there the failure is
46
+ * reported to the terminal and the browser error overlay instead.
47
+ */
48
+ failOnError?: boolean;
38
49
  }
39
- declare function icReactor(options: IcReactorPluginOptions): any;
50
+ declare function icReactor(options: IcReactorPluginOptions): Plugin;
40
51
 
41
52
  export { type IcReactorPluginOptions, icReactor };
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { Plugin } from 'vite';
1
2
  import { CanisterConfig, CodegenTarget } from '@ic-reactor/codegen';
2
3
 
3
4
  /**
@@ -16,7 +17,7 @@ interface IcReactorPluginOptions {
16
17
  */
17
18
  canisters: CanisterConfig[];
18
19
  /**
19
- * Default output directory (relative to project root).
20
+ * Default output directory (relative to the Vite project root).
20
21
  * Default: "src/declarations"
21
22
  */
22
23
  outDir?: string;
@@ -35,7 +36,17 @@ interface IcReactorPluginOptions {
35
36
  * Default: true
36
37
  */
37
38
  injectEnvironment?: boolean;
39
+ /**
40
+ * Abort the Vite run when a canister fails to generate.
41
+ *
42
+ * Default: `true` under `vite build`, `false` under `vite dev`. A build that
43
+ * silently ships the bindings left over from the last successful run is worse
44
+ * than no build at all, while a dev server has to survive the broken
45
+ * intermediate states of a `.did` file being edited — there the failure is
46
+ * reported to the terminal and the browser error overlay instead.
47
+ */
48
+ failOnError?: boolean;
38
49
  }
39
- declare function icReactor(options: IcReactorPluginOptions): any;
50
+ declare function icReactor(options: IcReactorPluginOptions): Plugin;
40
51
 
41
52
  export { type IcReactorPluginOptions, icReactor };