@ic-reactor/vite-plugin 0.12.1 → 0.13.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,7 +7,7 @@
7
7
  * 3. Hot-reloads when .did files change
8
8
  */
9
9
 
10
- import type { Plugin } from "vite"
10
+ import type { Plugin, ResolvedConfig, ViteDevServer } from "vite"
11
11
  import path from "node:path"
12
12
  import {
13
13
  runCanisterPipeline,
@@ -17,6 +17,9 @@ import {
17
17
  } from "@ic-reactor/codegen"
18
18
  import { getIcEnvironmentInfo, buildIcEnvCookie } from "./env.js"
19
19
 
20
+ const PLUGIN_NAME = "ic-reactor-plugin"
21
+ const DEFAULT_LOCAL_REPLICA = "http://127.0.0.1:4943"
22
+
20
23
  export interface IcReactorPluginOptions {
21
24
  /**
22
25
  * Canister configurations.
@@ -24,7 +27,7 @@ export interface IcReactorPluginOptions {
24
27
  */
25
28
  canisters: CanisterConfig[]
26
29
  /**
27
- * Default output directory (relative to project root).
30
+ * Default output directory (relative to the Vite project root).
28
31
  * Default: "src/declarations"
29
32
  */
30
33
  outDir?: string
@@ -43,15 +46,26 @@ export interface IcReactorPluginOptions {
43
46
  * Default: true
44
47
  */
45
48
  injectEnvironment?: boolean
49
+ /**
50
+ * Abort the Vite run when a canister fails to generate.
51
+ *
52
+ * Default: `true` under `vite build`, `false` under `vite dev`. A build that
53
+ * silently ships the bindings left over from the last successful run is worse
54
+ * than no build at all, while a dev server has to survive the broken
55
+ * intermediate states of a `.did` file being edited — there the failure is
56
+ * reported to the terminal and the browser error overlay instead.
57
+ */
58
+ failOnError?: boolean
46
59
  }
47
60
 
48
- export function icReactor(options: IcReactorPluginOptions): any {
61
+ export function icReactor(options: IcReactorPluginOptions): Plugin {
49
62
  const {
50
63
  canisters,
51
64
  outDir = "src/declarations",
52
65
  clientManagerPath = "../../clients",
53
66
  target = "react",
54
67
  injectEnvironment = true,
68
+ failOnError,
55
69
  } = options
56
70
 
57
71
  // Construct a partial CodegenConfig to pass to the pipeline
@@ -63,7 +77,27 @@ export function icReactor(options: IcReactorPluginOptions): any {
63
77
  clientManagerPath,
64
78
  target,
65
79
  }
66
- const projectRoot = process.cwd()
80
+
81
+ // Vite resolves relative project paths against the resolved `config.root`,
82
+ // which only equals the process cwd when vite happens to be started from the
83
+ // project directory — not for `root: "frontend"`, and not when the root is
84
+ // passed positionally (`vite build apps/web`). Note `--config` on its own does
85
+ // NOT move the root; it only selects the config file.
86
+ // `configResolved` overwrites this before any hook that resolves a path runs;
87
+ // the cwd is only the pre-resolution default, which is also Vite's own
88
+ // default root.
89
+ let projectRoot = process.cwd()
90
+
91
+ // `vite build` and `vite dev` want opposite failure behaviour, so remember
92
+ // which one we are in. Both `config` and `configResolved` carry the command;
93
+ // build is the safer default for the case where neither has run.
94
+ let command: ResolvedConfig["command"] = "build"
95
+
96
+ // Set once the dev server exists, so a `buildStart` failure in dev can reach
97
+ // the browser overlay too — in dev, `configureServer` runs before Vite calls
98
+ // `buildStart` on the plugin container.
99
+ let devServer: ViteDevServer | null = null
100
+
67
101
  const resolveDidPath = (didFile: string) =>
68
102
  path.normalize(
69
103
  path.isAbsolute(didFile) ? didFile : path.resolve(projectRoot, didFile)
@@ -74,12 +108,121 @@ export function icReactor(options: IcReactorPluginOptions): any {
74
108
  .map((canister) => [canister.name, canister.canisterId as string])
75
109
  )
76
110
 
111
+ /**
112
+ * Report a generation failure everywhere a developer might be looking.
113
+ *
114
+ * Terminal output scrolls away behind request logs and HMR chatter, so in dev
115
+ * the browser error overlay is the signal that actually gets noticed.
116
+ */
117
+ /**
118
+ * The last failure, kept so a browser that was not connected when it happened
119
+ * still gets the overlay.
120
+ *
121
+ * Vite awaits the plugin container's `buildStart` before the HTTP server
122
+ * starts listening, so a generation failure during `vite dev` startup is
123
+ * broadcast when there are no WebSocket clients at all and the payload is
124
+ * simply dropped. The terminal shows it; the overlay never appears — for
125
+ * precisely the failures a developer is most likely to hit.
126
+ */
127
+ let pendingFailure: { message: string; stack: string } | null = null
128
+
129
+ const reportFailure = (
130
+ server: ViteDevServer | null,
131
+ message: string,
132
+ cause?: unknown
133
+ ) => {
134
+ console.error(`[ic-reactor] ${message}`)
135
+
136
+ const err = {
137
+ message: `[ic-reactor] ${message}`,
138
+ stack: cause instanceof Error && cause.stack ? cause.stack : "",
139
+ plugin: PLUGIN_NAME,
140
+ }
141
+
142
+ pendingFailure = { message: err.message, stack: err.stack }
143
+ server?.ws.send({ type: "error", err })
144
+ }
145
+
146
+ // Keep at most one regeneration per canister in flight and collapse every save
147
+ // that arrives meanwhile into a single trailing rerun, so the last saved
148
+ // `.did` still wins without piling up concurrent runs.
149
+ //
150
+ // Scope, precisely: this covers the watcher path only. `buildStart` calls the
151
+ // pipeline directly and does not register here, so a save landing during the
152
+ // 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.
157
+ //
158
+ // 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>()
164
+
165
+ const regenerate = (
166
+ canisterConfig: CanisterConfig,
167
+ server: ViteDevServer
168
+ ): Promise<void> => {
169
+ const { name } = canisterConfig
170
+ const running = inFlight.get(name)
171
+
172
+ if (running) {
173
+ rerunQueued.add(name)
174
+ return running
175
+ }
176
+
177
+ const run = runCanisterPipeline({
178
+ canisterConfig,
179
+ projectRoot,
180
+ globalConfig,
181
+ })
182
+ .then((result) => {
183
+ if (result.success) {
184
+ // A later connection must not be handed a failure that has since been
185
+ // fixed.
186
+ pendingFailure = null
187
+ // Reload page to reflect new types/hooks
188
+ server.ws.send({ type: "full-reload" })
189
+ } else {
190
+ reportFailure(
191
+ server,
192
+ `Regeneration failed for ${name}: ${result.error ?? "unknown error"}`
193
+ )
194
+ }
195
+ })
196
+ // A throw from the pipeline (a malformed `.did` makes the parser throw
197
+ // rather than return a failed result) would otherwise be an unhandled
198
+ // rejection: invisible in the browser and, depending on the Node version,
199
+ // fatal to the dev server.
200
+ .catch((error: unknown) => {
201
+ reportFailure(
202
+ server,
203
+ `Regeneration failed for ${name}: ${describeError(error)}`,
204
+ error
205
+ )
206
+ })
207
+ .finally(() => {
208
+ inFlight.delete(name)
209
+ if (rerunQueued.delete(name)) {
210
+ void regenerate(canisterConfig, server)
211
+ }
212
+ })
213
+
214
+ inFlight.set(name, run)
215
+ return run
216
+ }
217
+
77
218
  const plugin: Plugin = {
78
- name: "ic-reactor-plugin",
219
+ name: PLUGIN_NAME,
79
220
  enforce: "pre", // Run before other plugins
80
221
 
81
- config(_config, { command }) {
82
- if (command !== "serve" || !injectEnvironment) {
222
+ config(_config, { command: viteCommand }) {
223
+ command = viteCommand
224
+
225
+ if (viteCommand !== "serve" || !injectEnvironment) {
83
226
  return {}
84
227
  }
85
228
 
@@ -93,9 +236,26 @@ export function icReactor(options: IcReactorPluginOptions): any {
93
236
  canisterNames.push("internet_identity")
94
237
  }
95
238
 
96
- const icEnv = getIcEnvironmentInfo(canisterNames)
239
+ const { environment: icEnv, diagnostics } =
240
+ getIcEnvironmentInfo(canisterNames)
97
241
 
98
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
+
99
259
  const envOnlyCookie =
100
260
  canisters.length === 0
101
261
  ? buildIcEnvCookie(
@@ -116,7 +276,7 @@ export function icReactor(options: IcReactorPluginOptions): any {
116
276
  : undefined,
117
277
  proxy: {
118
278
  "/api": {
119
- target: "http://127.0.0.1:4943",
279
+ target: DEFAULT_LOCAL_REPLICA,
120
280
  changeOrigin: true,
121
281
  },
122
282
  },
@@ -124,6 +284,42 @@ export function icReactor(options: IcReactorPluginOptions): any {
124
284
  }
125
285
  }
126
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
+
127
323
  const cookieValue = buildIcEnvCookie(
128
324
  {
129
325
  ...icEnv.canisterIds,
@@ -148,7 +344,30 @@ export function icReactor(options: IcReactorPluginOptions): any {
148
344
  }
149
345
  },
150
346
 
347
+ configResolved(config) {
348
+ // Everything the plugin resolves — `didFile`, `outDir`,
349
+ // `clientManagerPath` — is documented as relative to the project root, so
350
+ // it has to be Vite's resolved root and not wherever the process started.
351
+ projectRoot = config.root
352
+ command = config.command
353
+ },
354
+
151
355
  configureServer(server) {
356
+ devServer = server
357
+
358
+ // Replay a startup failure to the first client that connects — see
359
+ // pendingFailure. Cleared once generation succeeds.
360
+ // Guarded: the peer range spans several Vite majors and `ws.on` is not
361
+ // present on every one of them. Losing the replay is acceptable; throwing
362
+ // out of configureServer is not.
363
+ server.ws.on?.("connection", () => {
364
+ if (!pendingFailure) return
365
+ server.ws.send({
366
+ type: "error",
367
+ err: { ...pendingFailure, plugin: PLUGIN_NAME },
368
+ })
369
+ })
370
+
152
371
  // Explicitly watch configured DID files so HMR works even when they are not in the module graph.
153
372
  const didFiles = canisters.map((c) => resolveDidPath(c.didFile))
154
373
  server.watcher.add(didFiles)
@@ -161,66 +380,98 @@ export function icReactor(options: IcReactorPluginOptions): any {
161
380
  `[ic-reactor] Generating canister bindings for ${canisters.length} canisters...`
162
381
  )
163
382
 
164
- await Promise.allSettled(
165
- canisters.map(async (canisterConfig) => {
166
- try {
167
- // If .did file is missing, we might want to attempt pulling it?
168
- // For now, pipeline fails if missing. The old plugin logic to "download"
169
- // is omitted for simplicity unless requested, to keep "codegen" pure.
170
-
171
- const result = await runCanisterPipeline({
172
- canisterConfig,
173
- projectRoot,
174
- globalConfig,
175
- })
176
-
177
- if (!result.success) {
178
- console.error(
179
- `[ic-reactor] Failed to generate ${canisterConfig.name}: ${result.error}`
180
- )
181
- }
182
- } catch (err) {
183
- console.error(
184
- `[ic-reactor] Error generating ${canisterConfig.name}:`,
185
- err
186
- )
187
- }
188
- })
383
+ const outcomes = await Promise.allSettled(
384
+ canisters.map((canisterConfig) =>
385
+ runCanisterPipeline({
386
+ canisterConfig,
387
+ projectRoot,
388
+ globalConfig,
389
+ })
390
+ )
189
391
  )
392
+
393
+ // Collect every failure before reporting one: a canister failing must not
394
+ // hide what the others did, and the error should name all of them so a CI
395
+ // log shows the whole picture in one go.
396
+ const failures = outcomes.flatMap((outcome, index) => {
397
+ const name = canisters[index]?.name ?? `canister #${index}`
398
+
399
+ if (outcome.status === "rejected") {
400
+ return [`${name}: ${describeError(outcome.reason)}`]
401
+ }
402
+ if (!outcome.value.success) {
403
+ return [`${name}: ${outcome.value.error ?? "unknown error"}`]
404
+ }
405
+ return []
406
+ })
407
+
408
+ if (failures.length === 0) {
409
+ return
410
+ }
411
+
412
+ const message =
413
+ `Failed to generate ${failures.length} of ${canisters.length} canisters:\n` +
414
+ failures.map((failure) => ` - ${failure}`).join("\n")
415
+
416
+ // Previously every failure here was a `console.error` and nothing more,
417
+ // so `vite build` exited 0 and CI shipped whatever stale bindings were
418
+ // still on disk — bindings that no longer match the deployed canister.
419
+ if (failOnError ?? command === "build") {
420
+ this.error(`[ic-reactor] ${message}`)
421
+ }
422
+
423
+ reportFailure(devServer, message)
190
424
  },
191
425
 
192
426
  handleHotUpdate({ file, server }) {
193
427
  // ── Hot Reload on .did changes ───────────────────────────────────────
194
- if (file.endsWith(".did")) {
195
- const affectedCanister = canisters.find((c) => {
196
- // Check if changed file matches configured didFile
197
- // Cast is safe because didFile is required in CanisterConfig
198
- const configPath = resolveDidPath(c.didFile)
199
- return configPath === path.normalize(file)
200
- })
428
+ if (!file.endsWith(".did")) {
429
+ return
430
+ }
201
431
 
202
- if (affectedCanister) {
203
- console.log(
204
- `[ic-reactor] .did file changed: ${affectedCanister.name}. Regenerating...`
205
- )
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
+ )
206
437
 
207
- // Re-run pipeline for this canister
208
- runCanisterPipeline({
209
- canisterConfig: affectedCanister,
210
- projectRoot,
211
- globalConfig,
212
- }).then((result: import("@ic-reactor/codegen").PipelineResult) => {
213
- if (result.success) {
214
- // Reload page to reflect new types/hooks
215
- server.ws.send({ type: "full-reload" })
216
- } else {
217
- console.error(`[ic-reactor] Regeneration failed: ${result.error}`)
218
- }
219
- })
220
- }
438
+ if (!affectedCanister) {
439
+ return
221
440
  }
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)
222
450
  },
223
451
  }
224
452
 
225
453
  return plugin
226
454
  }
455
+
456
+ /** One readable line for whatever the pipeline threw. */
457
+ function describeError(error: unknown): string {
458
+ return error instanceof Error ? error.message : String(error)
459
+ }
460
+
461
+ /**
462
+ * `icp` failing to answer is routine — no local replica, or a canister that has
463
+ * never been deployed — so its stderr is noise until someone is actually
464
+ * debugging. Gate it the way Vite gates its own debug output.
465
+ */
466
+ function debugLog(message: string): void {
467
+ const enabled = (process.env.DEBUG ?? "").split(",").some((entry) => {
468
+ const scope = entry.trim()
469
+ return (
470
+ scope === "*" || scope === "ic-reactor" || scope.startsWith("ic-reactor:")
471
+ )
472
+ })
473
+
474
+ if (enabled) {
475
+ console.debug(`[ic-reactor:debug] ${message}`)
476
+ }
477
+ }