@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/dist/index.js CHANGED
@@ -7,44 +7,59 @@ import {
7
7
  // src/env.ts
8
8
  import { execFileSync } from "child_process";
9
9
  function getIcEnvironmentInfo(canisterNames) {
10
- const environment = process.env.ICP_ENVIRONMENT || "local";
10
+ const networkName = process.env.ICP_ENVIRONMENT || "local";
11
+ const diagnostics = [];
11
12
  try {
12
13
  const networkStatus = JSON.parse(
13
- execFileSync("icp", ["network", "status", "-e", environment, "--json"], {
14
+ execFileSync("icp", ["network", "status", "-e", networkName, "--json"], {
14
15
  encoding: "utf-8",
15
- stdio: ["ignore", "pipe", "ignore"]
16
- // suppress stderr
16
+ // stderr is piped rather than ignored so a failure can explain itself.
17
+ // Piping still keeps it off the terminal — it only reaches the user if
18
+ // the caller decides to print the diagnostics we collect below.
19
+ stdio: ["ignore", "pipe", "pipe"]
17
20
  })
18
21
  );
19
22
  const rootKey = networkStatus.root_key;
20
23
  const proxyTarget = networkStatus.api_url || networkStatus.gateway_url || (networkStatus.port ? `http://127.0.0.1:${networkStatus.port}` : void 0);
21
24
  if (!proxyTarget) {
22
- return null;
25
+ diagnostics.push(
26
+ `\`icp network status -e ${networkName}\` reported no api_url, gateway_url or port`
27
+ );
28
+ return { environment: null, diagnostics };
23
29
  }
24
30
  const canisterIds = {};
25
31
  for (const name of canisterNames) {
26
32
  try {
27
33
  const canisterId = execFileSync(
28
34
  "icp",
29
- ["canister", "status", name, "-e", environment, "-i"],
30
- { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }
35
+ ["canister", "status", name, "-e", networkName, "-i"],
36
+ { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] }
31
37
  ).trim();
32
38
  if (canisterId) {
33
39
  canisterIds[name] = canisterId;
34
40
  }
35
- } catch {
41
+ } catch (error) {
42
+ diagnostics.push(
43
+ `\`icp canister status ${name} -e ${networkName}\` failed: ${describeExecError(error)}`
44
+ );
36
45
  }
37
46
  }
38
- const internetIdentityProvider = !canisterIds.internet_identity && isLocalhostGateway(proxyTarget) && environment !== "ic" ? localInternetIdentityProvider(proxyTarget) : void 0;
47
+ const internetIdentityProvider = !canisterIds.internet_identity && isLocalhostGateway(proxyTarget) && networkName !== "ic" ? localInternetIdentityProvider(proxyTarget) : void 0;
39
48
  return {
40
- environment,
41
- rootKey,
42
- proxyTarget,
43
- canisterIds,
44
- internetIdentityProvider
49
+ environment: {
50
+ environment: networkName,
51
+ rootKey,
52
+ proxyTarget,
53
+ canisterIds,
54
+ internetIdentityProvider
55
+ },
56
+ diagnostics
45
57
  };
46
58
  } catch (error) {
47
- return null;
59
+ diagnostics.push(
60
+ `\`icp network status -e ${networkName}\` failed: ${describeExecError(error)}`
61
+ );
62
+ return { environment: null, diagnostics };
48
63
  }
49
64
  }
50
65
  function buildIcEnvCookie(canisterIds, rootKey, internetIdentityProvider) {
@@ -57,6 +72,15 @@ function buildIcEnvCookie(canisterIds, rootKey, internetIdentityProvider) {
57
72
  }
58
73
  return encodeURIComponent(parts.join("&"));
59
74
  }
75
+ function describeExecError(error) {
76
+ const stderr = error?.stderr;
77
+ const text = typeof stderr === "string" ? stderr : stderr?.toString("utf-8");
78
+ const trimmed = text?.trim();
79
+ if (trimmed) {
80
+ return trimmed;
81
+ }
82
+ return error instanceof Error ? error.message : String(error);
83
+ }
60
84
  function isLocalhostGateway(proxyTarget) {
61
85
  try {
62
86
  const { hostname } = new URL(proxyTarget);
@@ -72,40 +96,103 @@ function localInternetIdentityProvider(proxyTarget) {
72
96
  }
73
97
 
74
98
  // src/index.ts
99
+ var PLUGIN_NAME = "ic-reactor-plugin";
100
+ var DEFAULT_LOCAL_REPLICA = "http://127.0.0.1:4943";
75
101
  function icReactor(options) {
76
102
  const {
77
103
  canisters,
78
104
  outDir = "src/declarations",
79
105
  clientManagerPath = "../../clients",
80
106
  target = "react",
81
- injectEnvironment = true
107
+ injectEnvironment = true,
108
+ failOnError
82
109
  } = options;
83
110
  const globalConfig = {
84
111
  outDir,
85
112
  clientManagerPath,
86
113
  target
87
114
  };
88
- const projectRoot = process.cwd();
115
+ let projectRoot = process.cwd();
116
+ let command = "build";
117
+ let devServer = null;
89
118
  const resolveDidPath = (didFile) => path.normalize(
90
119
  path.isAbsolute(didFile) ? didFile : path.resolve(projectRoot, didFile)
91
120
  );
92
121
  const configuredCanisterIds = Object.fromEntries(
93
122
  canisters.filter((canister) => !!canister.canisterId).map((canister) => [canister.name, canister.canisterId])
94
123
  );
124
+ let pendingFailure = null;
125
+ const reportFailure = (server, message, cause) => {
126
+ console.error(`[ic-reactor] ${message}`);
127
+ const err = {
128
+ message: `[ic-reactor] ${message}`,
129
+ stack: cause instanceof Error && cause.stack ? cause.stack : "",
130
+ plugin: PLUGIN_NAME
131
+ };
132
+ pendingFailure = { message: err.message, stack: err.stack };
133
+ server?.ws.send({ type: "error", err });
134
+ };
135
+ const inFlight = /* @__PURE__ */ new Map();
136
+ const rerunQueued = /* @__PURE__ */ new Set();
137
+ const regenerate = (canisterConfig, server) => {
138
+ const { name } = canisterConfig;
139
+ const running = inFlight.get(name);
140
+ if (running) {
141
+ rerunQueued.add(name);
142
+ return running;
143
+ }
144
+ const run = runCanisterPipeline({
145
+ canisterConfig,
146
+ projectRoot,
147
+ globalConfig
148
+ }).then((result) => {
149
+ if (result.success) {
150
+ pendingFailure = null;
151
+ server.ws.send({ type: "full-reload" });
152
+ } else {
153
+ reportFailure(
154
+ server,
155
+ `Regeneration failed for ${name}: ${result.error ?? "unknown error"}`
156
+ );
157
+ }
158
+ }).catch((error) => {
159
+ reportFailure(
160
+ server,
161
+ `Regeneration failed for ${name}: ${describeError(error)}`,
162
+ error
163
+ );
164
+ }).finally(() => {
165
+ inFlight.delete(name);
166
+ if (rerunQueued.delete(name)) {
167
+ void regenerate(canisterConfig, server);
168
+ }
169
+ });
170
+ inFlight.set(name, run);
171
+ return run;
172
+ };
95
173
  const plugin = {
96
- name: "ic-reactor-plugin",
174
+ name: PLUGIN_NAME,
97
175
  enforce: "pre",
98
176
  // Run before other plugins
99
- config(_config, { command }) {
100
- if (command !== "serve" || !injectEnvironment) {
177
+ config(_config, { command: viteCommand }) {
178
+ command = viteCommand;
179
+ if (viteCommand !== "serve" || !injectEnvironment) {
101
180
  return {};
102
181
  }
103
182
  const canisterNames = canisters.map((c) => c.name).filter((n) => !!n);
104
183
  if (!canisterNames.includes("internet_identity")) {
105
184
  canisterNames.push("internet_identity");
106
185
  }
107
- const icEnv = getIcEnvironmentInfo(canisterNames);
186
+ const { environment: icEnv, diagnostics } = getIcEnvironmentInfo(canisterNames);
108
187
  if (!icEnv) {
188
+ if (canisters.length > 0) {
189
+ console.warn(
190
+ `[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.`
191
+ );
192
+ }
193
+ for (const diagnostic of diagnostics) {
194
+ debugLog(diagnostic);
195
+ }
109
196
  const envOnlyCookie = canisters.length === 0 ? buildIcEnvCookie(
110
197
  {},
111
198
  void 0,
@@ -118,13 +205,26 @@ function icReactor(options) {
118
205
  } : void 0,
119
206
  proxy: {
120
207
  "/api": {
121
- target: "http://127.0.0.1:4943",
208
+ target: DEFAULT_LOCAL_REPLICA,
122
209
  changeOrigin: true
123
210
  }
124
211
  }
125
212
  }
126
213
  };
127
214
  }
215
+ const missingCanisterIds = canisters.map((canister) => canister.name).filter((name) => !!name).filter(
216
+ (name) => !icEnv.canisterIds[name] && !configuredCanisterIds[name]
217
+ );
218
+ if (missingCanisterIds.length > 0) {
219
+ const names = missingCanisterIds.map((name) => `"${name}"`).join(", ");
220
+ const it = missingCanisterIds.length === 1 ? "it" : "them";
221
+ console.warn(
222
+ `[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.`
223
+ );
224
+ }
225
+ for (const diagnostic of diagnostics) {
226
+ debugLog(diagnostic);
227
+ }
128
228
  const cookieValue = buildIcEnvCookie(
129
229
  {
130
230
  ...icEnv.canisterIds,
@@ -147,7 +247,19 @@ function icReactor(options) {
147
247
  }
148
248
  };
149
249
  },
250
+ configResolved(config) {
251
+ projectRoot = config.root;
252
+ command = config.command;
253
+ },
150
254
  configureServer(server) {
255
+ devServer = server;
256
+ server.ws.on?.("connection", () => {
257
+ if (!pendingFailure) return;
258
+ server.ws.send({
259
+ type: "error",
260
+ err: { ...pendingFailure, plugin: PLUGIN_NAME }
261
+ });
262
+ });
151
263
  const didFiles = canisters.map((c) => resolveDidPath(c.didFile));
152
264
  server.watcher.add(didFiles);
153
265
  },
@@ -155,55 +267,67 @@ function icReactor(options) {
155
267
  console.log(
156
268
  `[ic-reactor] Generating canister bindings for ${canisters.length} canisters...`
157
269
  );
158
- await Promise.allSettled(
159
- canisters.map(async (canisterConfig) => {
160
- try {
161
- const result = await runCanisterPipeline({
162
- canisterConfig,
163
- projectRoot,
164
- globalConfig
165
- });
166
- if (!result.success) {
167
- console.error(
168
- `[ic-reactor] Failed to generate ${canisterConfig.name}: ${result.error}`
169
- );
170
- }
171
- } catch (err) {
172
- console.error(
173
- `[ic-reactor] Error generating ${canisterConfig.name}:`,
174
- err
175
- );
176
- }
177
- })
178
- );
179
- },
180
- handleHotUpdate({ file, server }) {
181
- if (file.endsWith(".did")) {
182
- const affectedCanister = canisters.find((c) => {
183
- const configPath = resolveDidPath(c.didFile);
184
- return configPath === path.normalize(file);
185
- });
186
- if (affectedCanister) {
187
- console.log(
188
- `[ic-reactor] .did file changed: ${affectedCanister.name}. Regenerating...`
189
- );
190
- runCanisterPipeline({
191
- canisterConfig: affectedCanister,
270
+ const outcomes = await Promise.allSettled(
271
+ canisters.map(
272
+ (canisterConfig) => runCanisterPipeline({
273
+ canisterConfig,
192
274
  projectRoot,
193
275
  globalConfig
194
- }).then((result) => {
195
- if (result.success) {
196
- server.ws.send({ type: "full-reload" });
197
- } else {
198
- console.error(`[ic-reactor] Regeneration failed: ${result.error}`);
199
- }
200
- });
276
+ })
277
+ )
278
+ );
279
+ const failures = outcomes.flatMap((outcome, index) => {
280
+ const name = canisters[index]?.name ?? `canister #${index}`;
281
+ if (outcome.status === "rejected") {
282
+ return [`${name}: ${describeError(outcome.reason)}`];
201
283
  }
284
+ if (!outcome.value.success) {
285
+ return [`${name}: ${outcome.value.error ?? "unknown error"}`];
286
+ }
287
+ return [];
288
+ });
289
+ if (failures.length === 0) {
290
+ return;
291
+ }
292
+ const message = `Failed to generate ${failures.length} of ${canisters.length} canisters:
293
+ ` + failures.map((failure) => ` - ${failure}`).join("\n");
294
+ if (failOnError ?? command === "build") {
295
+ this.error(`[ic-reactor] ${message}`);
202
296
  }
297
+ reportFailure(devServer, message);
298
+ },
299
+ handleHotUpdate({ file, server }) {
300
+ if (!file.endsWith(".did")) {
301
+ return;
302
+ }
303
+ const changedPath = path.normalize(file);
304
+ const affectedCanister = canisters.find(
305
+ // Check if changed file matches configured didFile
306
+ (canister) => resolveDidPath(canister.didFile) === changedPath
307
+ );
308
+ if (!affectedCanister) {
309
+ return;
310
+ }
311
+ console.log(
312
+ `[ic-reactor] .did file changed: ${affectedCanister.name}. Regenerating...`
313
+ );
314
+ return regenerate(affectedCanister, server);
203
315
  }
204
316
  };
205
317
  return plugin;
206
318
  }
319
+ function describeError(error) {
320
+ return error instanceof Error ? error.message : String(error);
321
+ }
322
+ function debugLog(message) {
323
+ const enabled = (process.env.DEBUG ?? "").split(",").some((entry) => {
324
+ const scope = entry.trim();
325
+ return scope === "*" || scope === "ic-reactor" || scope.startsWith("ic-reactor:");
326
+ });
327
+ if (enabled) {
328
+ console.debug(`[ic-reactor:debug] ${message}`);
329
+ }
330
+ }
207
331
  export {
208
332
  icReactor
209
333
  };
package/llms.txt CHANGED
@@ -24,11 +24,18 @@ icReactor({
24
24
  - Runs codegen on startup and `.did` changes
25
25
  - Regenerates declarations + `index.generated.ts`
26
26
  - Preserves user-facing `index.ts` wrapper after initial creation
27
+ - Relative paths (`didFile`, `outDir`) resolve against Vite's `config.root`
28
+ - A failed canister fails `vite build`; in `vite dev` it goes to the terminal
29
+ and the browser error overlay instead
27
30
 
28
31
  ## Key Options
29
32
 
30
- - `canisters`, `outDir`, `clientManagerPath`, `target`, `mode`, `canisterId`
33
+ - Plugin level: `canisters`, `outDir`, `clientManagerPath`, `target`,
34
+ `injectEnvironment`, `failOnError`
35
+ - Per canister (inside `canisters[]`): `name`, `didFile`, `outDir`,
36
+ `clientManagerPath`, `target`, `mode`, `canisterId`
31
37
  - `injectEnvironment` for local `ic_env` cookie + `/api` proxy behavior
38
+ - `failOnError` overrides the default (`true` for build, `false` for dev)
32
39
 
33
40
  ## Best Practices
34
41
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ic-reactor/vite-plugin",
3
- "version": "0.12.0",
3
+ "version": "0.13.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",
@@ -22,6 +22,8 @@
22
22
  "files": [
23
23
  "dist",
24
24
  "src",
25
+ "!src/**/*.test.ts",
26
+ "!src/**/__snapshots__",
25
27
  "README.md",
26
28
  "llms.txt"
27
29
  ],
@@ -48,20 +50,19 @@
48
50
  },
49
51
  "homepage": "https://ic-reactor.b3pay.net/v3/packages/vite-plugin",
50
52
  "dependencies": {
51
- "@ic-reactor/codegen": "0.12.0"
53
+ "@ic-reactor/codegen": "0.13.0"
52
54
  },
53
55
  "peerDependencies": {
54
56
  "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
55
57
  },
56
58
  "devDependencies": {
57
- "@icp-sdk/bindgen": "^0.4.0",
58
- "@types/node": "^26.1.1",
59
+ "@types/node": "^26.2.0",
59
60
  "tsup": "^8.5.1",
60
61
  "typescript": "^6.0.3",
61
62
  "vitest": "^4.1.10"
62
63
  },
63
64
  "scripts": {
64
- "build": "tsup src/index.ts --format esm,cjs --dts --tsconfig tsconfig.json",
65
+ "build": "tsup src/index.ts --format esm,cjs --dts --clean --tsconfig tsconfig.json",
65
66
  "dev": "tsup src/index.ts --format esm,cjs --dts --watch --tsconfig tsconfig.json",
66
67
  "test": "vitest run",
67
68
  "typecheck": "tsc --noEmit -p tsconfig.typecheck.json"
package/src/env.ts CHANGED
@@ -15,19 +15,35 @@ export interface IcEnvironment {
15
15
  internetIdentityProvider?: string
16
16
  }
17
17
 
18
+ export interface IcEnvironmentDetection {
19
+ /** The detected environment, or `null` when `icp` could not answer. */
20
+ environment: IcEnvironment | null
21
+ /**
22
+ * Why detection came back empty or partial — the `icp` stderr where we have
23
+ * it. Detection failing is routine on a machine with no local replica, so
24
+ * these are handed back for the caller to log at its own level rather than
25
+ * printed here.
26
+ */
27
+ diagnostics: string[]
28
+ }
29
+
18
30
  /**
19
31
  * Detect the IC environment using the `icp` CLI.
20
32
  */
21
33
  export function getIcEnvironmentInfo(
22
34
  canisterNames: string[]
23
- ): IcEnvironment | null {
24
- const environment = process.env.ICP_ENVIRONMENT || "local"
35
+ ): IcEnvironmentDetection {
36
+ const networkName = process.env.ICP_ENVIRONMENT || "local"
37
+ const diagnostics: string[] = []
25
38
 
26
39
  try {
27
40
  const networkStatus = JSON.parse(
28
- execFileSync("icp", ["network", "status", "-e", environment, "--json"], {
41
+ execFileSync("icp", ["network", "status", "-e", networkName, "--json"], {
29
42
  encoding: "utf-8",
30
- stdio: ["ignore", "pipe", "ignore"], // suppress stderr
43
+ // stderr is piped rather than ignored so a failure can explain itself.
44
+ // Piping still keeps it off the terminal — it only reaches the user if
45
+ // the caller decides to print the diagnostics we collect below.
46
+ stdio: ["ignore", "pipe", "pipe"],
31
47
  })
32
48
  )
33
49
 
@@ -40,7 +56,10 @@ export function getIcEnvironmentInfo(
40
56
  : undefined)
41
57
 
42
58
  if (!proxyTarget) {
43
- return null
59
+ diagnostics.push(
60
+ `\`icp network status -e ${networkName}\` reported no api_url, gateway_url or port`
61
+ )
62
+ return { environment: null, diagnostics }
44
63
  }
45
64
 
46
65
  const canisterIds: Record<string, string> = {}
@@ -49,35 +68,45 @@ export function getIcEnvironmentInfo(
49
68
  try {
50
69
  const canisterId = execFileSync(
51
70
  "icp",
52
- ["canister", "status", name, "-e", environment, "-i"],
53
- { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }
71
+ ["canister", "status", name, "-e", networkName, "-i"],
72
+ { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] }
54
73
  ).trim()
55
74
 
56
75
  if (canisterId) {
57
76
  canisterIds[name] = canisterId
58
77
  }
59
- } catch {
60
- // Canister might not exist or be deployed yet
78
+ } catch (error) {
79
+ // Canister might not exist or be deployed yet — expected for
80
+ // internet_identity, which is added to the lookup list unconditionally.
81
+ diagnostics.push(
82
+ `\`icp canister status ${name} -e ${networkName}\` failed: ${describeExecError(error)}`
83
+ )
61
84
  }
62
85
  }
63
86
 
64
87
  const internetIdentityProvider =
65
88
  !canisterIds.internet_identity &&
66
89
  isLocalhostGateway(proxyTarget) &&
67
- environment !== "ic"
90
+ networkName !== "ic"
68
91
  ? localInternetIdentityProvider(proxyTarget)
69
92
  : undefined
70
93
 
71
94
  return {
72
- environment,
73
- rootKey,
74
- proxyTarget,
75
- canisterIds,
76
- internetIdentityProvider,
95
+ environment: {
96
+ environment: networkName,
97
+ rootKey,
98
+ proxyTarget,
99
+ canisterIds,
100
+ internetIdentityProvider,
101
+ },
102
+ diagnostics,
77
103
  }
78
104
  } catch (error) {
79
105
  // CLI not found or failed
80
- return null
106
+ diagnostics.push(
107
+ `\`icp network status -e ${networkName}\` failed: ${describeExecError(error)}`
108
+ )
109
+ return { environment: null, diagnostics }
81
110
  }
82
111
  }
83
112
 
@@ -103,6 +132,25 @@ export function buildIcEnvCookie(
103
132
  return encodeURIComponent(parts.join("&"))
104
133
  }
105
134
 
135
+ /**
136
+ * Turn whatever `execFileSync` threw into one readable line.
137
+ *
138
+ * The interesting part is almost always the captured stderr — the thrown
139
+ * Error's own message is just "Command failed: icp ..." — but stderr is absent
140
+ * when the binary itself is missing (ENOENT), so fall back to the message.
141
+ */
142
+ function describeExecError(error: unknown): string {
143
+ const stderr = (error as { stderr?: Buffer | string } | undefined)?.stderr
144
+ const text = typeof stderr === "string" ? stderr : stderr?.toString("utf-8")
145
+ const trimmed = text?.trim()
146
+
147
+ if (trimmed) {
148
+ return trimmed
149
+ }
150
+
151
+ return error instanceof Error ? error.message : String(error)
152
+ }
153
+
106
154
  function isLocalhostGateway(proxyTarget: string): boolean {
107
155
  try {
108
156
  const { hostname } = new URL(proxyTarget)