@ic-reactor/vite-plugin 0.15.1 → 4.0.0-beta.2

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
@@ -1,61 +1,77 @@
1
1
  /**
2
2
  * @ic-reactor/vite-plugin
3
3
  *
4
- * Vite plugin that:
5
- * 1. Generates hooks at build time (using @ic-reactor/codegen pipeline)
6
- * 2. Injects `ic_env` cookie for local development (via proxy)
7
- * 3. Hot-reloads when .did files change
4
+ * Vite plugin for an app built on a candid-core generated module.
5
+ *
6
+ * - Generation: at the start of a build or dev server, and when a `.did` file
7
+ * changes, it runs `candid-core-cli gen` in a child process (see
8
+ * generate.ts) and leaves candid-core's module as the generator wrote it. No
9
+ * wrapper files, hooks or reactors are generated.
10
+ * - Environment: under `vite dev` and `vite preview` it sets the `ic_env`
11
+ * cookie and proxies `/api` to the local IC network (see dev-environment.ts).
8
12
  */
9
13
 
14
+ import fs from "node:fs"
15
+ import path from "node:path"
10
16
  import type {
17
+ Logger,
11
18
  Plugin,
12
19
  ProxyOptions,
13
20
  ResolvedConfig,
14
21
  UserConfig,
15
22
  ViteDevServer,
16
23
  } from "vite"
17
- import fs from "node:fs"
18
- import path from "node:path"
19
- import {
20
- findSharedOutDirs,
21
- runCanisterPipeline,
22
- sharedOutDirMessage,
23
- type CanisterConfig,
24
- type CodegenConfig,
25
- type CodegenTarget,
26
- } from "@ic-reactor/codegen"
27
24
  import {
28
25
  createLocalEnvironment,
29
26
  icEnvMiddleware,
30
27
  type LocalEnvironment,
31
28
  type LocalEnvironmentState,
32
29
  } from "./dev-environment.js"
30
+ import { GENERATE_TIMEOUT_MS, generate, resolveCliBin } from "./generate.js"
33
31
 
34
32
  const PLUGIN_NAME = "ic-reactor-plugin"
35
33
 
34
+ /** Where a canister's module goes when it sets no `outDir`. */
35
+ const DEFAULT_OUT_DIR = "src/canisters"
36
+
37
+ /** The first line the plugin logs: where an agent reads how to use the library. */
38
+ const GUIDE_LINE =
39
+ "ic-reactor: agent guide at node_modules/@ic-reactor/core/llms.txt"
40
+
41
+ /**
42
+ * The options of {@link icReactor}. Every one is optional: with none, the
43
+ * plugin generates nothing and, under `vite dev`, only injects the local IC
44
+ * environment, for no canister of the app's.
45
+ */
36
46
  export interface IcReactorPluginOptions {
37
47
  /**
38
- * Canister configurations.
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`.
42
- */
43
- canisters: CanisterConfig[]
44
- /**
45
- * Default output directory (relative to the Vite project root).
46
- * Default: "src/declarations"
47
- */
48
- outDir?: string
49
- /**
50
- * Default client manager import path.
51
- * Default: "../../clients"
52
- */
53
- clientManagerPath?: string
54
- /**
55
- * Default generated runtime target.
56
- * Default: "react"
48
+ * The app's canisters, by name: the canister's name in the `icp` project,
49
+ * which is also the name the `ic_env` cookie carries its ID under.
50
+ *
51
+ * - `didFile`: the canister's Candid interface, relative to the Vite root.
52
+ * The plugin runs `candid-core-cli gen` on it at the start of a build or
53
+ * dev server and again each time the file changes, regenerating only the
54
+ * canisters that name the file that changed. The generator names its
55
+ * output after the file, so `didFile: "../backend/ledger.did"` writes `ledger.ts` (the
56
+ * module: it exports `actor` and the type `Actor`) and
57
+ * `ledger.envelope.json` into `outDir`. Canisters that name the same
58
+ * `didFile` and `outDir` share that one module, which is generated once.
59
+ * Different `.did` files that would write the same module are refused:
60
+ * give one an `outDir` of its own. A canister without a `didFile`
61
+ * generates nothing and is only named in the cookie.
62
+ * - `outDir`: where the generator writes, relative to the Vite root.
63
+ * Default: `"src/canisters"`.
64
+ * - `canisterId`: a fixed ID for the cookie, which wins over the ID `icp`
65
+ * reports for the canister.
66
+ *
67
+ * The generator is the `@candid-core/cli` the app has installed, run as a
68
+ * child process so that a failure on one `.did` stops that process and not
69
+ * the dev server.
57
70
  */
58
- target?: CodegenTarget
71
+ canisters?: Record<
72
+ string,
73
+ { didFile?: string; outDir?: string; canisterId?: string }
74
+ >
59
75
  /**
60
76
  * Inject the local IC environment under `vite dev` and `vite preview`: set
61
77
  * the `ic_env` cookie on each response and proxy `/api` to the network the
@@ -68,6 +84,8 @@ export interface IcReactorPluginOptions {
68
84
  * network needs a restart. An `/api` proxy that the Vite config or another
69
85
  * plugin sets is left alone.
70
86
  *
87
+ * Never injected in mode `"test"` (Vitest's), where `icp` is not run at all.
88
+ *
71
89
  * Default: true
72
90
  */
73
91
  injectEnvironment?: boolean
@@ -75,49 +93,110 @@ export interface IcReactorPluginOptions {
75
93
  * Abort the Vite run when a canister fails to generate.
76
94
  *
77
95
  * Default: `true` under `vite build`, `false` under `vite dev`. A build that
78
- * silently ships the bindings left over from the last successful run is worse
79
- * than no build at all, while a dev server has to survive the broken
80
- * intermediate states of a `.did` file being edited — there the failure is
81
- * reported to the terminal and the browser error overlay instead.
96
+ * silently ships the bindings left over from the last successful run is
97
+ * worse than no build at all, while a dev server has to survive the broken
98
+ * intermediate states of a `.did` file being edited: there the failure is
99
+ * logged and shown in the browser's error overlay, and the server keeps
100
+ * serving.
82
101
  */
83
102
  failOnError?: boolean
84
103
  }
85
104
 
86
- export function icReactor(options: IcReactorPluginOptions): Plugin {
87
- const {
88
- canisters,
89
- outDir = "src/declarations",
90
- clientManagerPath = "../../clients",
91
- target = "react",
92
- injectEnvironment = true,
93
- failOnError,
94
- } = options
95
-
96
- // Construct a partial CodegenConfig to pass to the pipeline
97
- const globalConfig: Pick<
98
- CodegenConfig,
99
- "outDir" | "clientManagerPath" | "target"
100
- > = {
101
- outDir,
102
- clientManagerPath,
103
- target,
104
- }
105
+ /** A canister with a `.did` to generate from. */
106
+ interface Generated {
107
+ name: string
108
+ didFile: string
109
+ outDir: string
110
+ }
111
+
112
+ /** A canister that did not generate, and why. */
113
+ interface Failure {
114
+ canister: Generated
115
+ message: string
116
+ }
117
+
118
+ type PluginLog = Pick<Logger, "info" | "warn" | "error">
119
+
120
+ /** What the hooks log through before Vite hands over its logger. */
121
+ const consoleLog: PluginLog = {
122
+ info: (message) => console.log(message),
123
+ warn: (message) => console.warn(message),
124
+ error: (message) => console.error(message),
125
+ }
126
+
127
+ /**
128
+ * The Vite plugin for an app built on a candid-core generated module.
129
+ *
130
+ * It does two things:
131
+ *
132
+ * - **Generates the module.** When a build or the dev server starts, and
133
+ * when a configured `.did` changes, it runs the app's `candid-core-cli gen`
134
+ * on each `didFile` and leaves the module as the generator wrote it: no
135
+ * wrapper files, hooks or reactors. The generator is WebAssembly, so it runs
136
+ * in a child process (the running Node binary on the CLI's bin script, never
137
+ * through a shell, killed after 60 seconds). A trap, a crash or a hang on a
138
+ * bad `.did` then ends that process and not the dev server: under
139
+ * `vite build` it fails the build with the CLI's own message, and under
140
+ * `vite dev` it is logged and shown in the error overlay while the server
141
+ * keeps serving. See {@link IcReactorPluginOptions.failOnError}.
142
+ * - **Injects the local IC environment.** Under `vite dev` and `vite preview`
143
+ * it sets the `ic_env` cookie, which carries the replica's root key and the
144
+ * canister IDs, and proxies `/api` to the local replica, so the app needs no
145
+ * configuration to find them. It asks the `icp` CLI for both, and is off in
146
+ * mode `"test"` (Vitest's), where `icp` is never run.
147
+ *
148
+ * The first thing it logs names where an agent reads how to use the library:
149
+ * `ic-reactor: agent guide at node_modules/@ic-reactor/core/llms.txt`.
150
+ *
151
+ * The plugin needs `@candid-core/cli`, at the exact release that pairs with
152
+ * the `@candid-core/schema` the generated modules import, installed in the
153
+ * app. It imports neither, and no `@ic-reactor` runtime package.
154
+ *
155
+ * @example
156
+ * ```ts
157
+ * // vite.config.ts
158
+ * export default defineConfig({
159
+ * plugins: [
160
+ * icReactor({
161
+ * canisters: { ledger: { didFile: "../backend/ledger.did" } },
162
+ * }),
163
+ * ],
164
+ * })
165
+ * ```
166
+ */
167
+ export function icReactor(options: IcReactorPluginOptions = {}): Plugin {
168
+ const { canisters = {}, injectEnvironment = true, failOnError } = options
169
+ const names = Object.keys(canisters)
170
+
171
+ const configuredCanisterIds = Object.fromEntries(
172
+ names.flatMap((name) => {
173
+ const { canisterId } = canisters[name]
174
+ return canisterId ? [[name, canisterId]] : []
175
+ })
176
+ )
177
+
178
+ /** The canisters that have a `.did` to generate from. */
179
+ const generated: Generated[] = names.flatMap((name) => {
180
+ const { didFile, outDir = DEFAULT_OUT_DIR } = canisters[name]
181
+ return didFile === undefined ? [] : [{ name, didFile, outDir }]
182
+ })
105
183
 
106
184
  // Vite resolves relative project paths against the resolved `config.root`,
107
185
  // which only equals the process cwd when vite happens to be started from the
108
- // project directory — not for `root: "frontend"`, and not when the root is
109
- // passed positionally (`vite build apps/web`). Note `--config` on its own does
110
- // NOT move the root; it only selects the config file.
111
- // `configResolved` overwrites this before any hook that resolves a path runs;
112
- // the cwd is only the pre-resolution default, which is also Vite's own
186
+ // project directory, not for `root: "frontend"` and not when the root is
187
+ // passed positionally (`vite build apps/web`). `configResolved` overwrites
188
+ // this before any hook that resolves a path runs; the cwd is only Vite's own
113
189
  // default root.
114
190
  let projectRoot = process.cwd()
115
191
 
116
192
  // `vite build` and `vite dev` want opposite failure behaviour, so remember
117
- // which one we are in. Both `config` and `configResolved` carry the command;
118
- // build is the safer default for the case where neither has run.
193
+ // which one we are in. Build is the safer default for the case where neither
194
+ // `config` nor `configResolved` has run.
119
195
  let command: ResolvedConfig["command"] = "build"
120
196
 
197
+ let log: PluginLog = consoleLog
198
+ let announced = false
199
+
121
200
  /**
122
201
  * The local IC environment `vite dev` and `vite preview` inject. The
123
202
  * `config` hook creates it when `injectEnvironment` is on.
@@ -127,236 +206,304 @@ export function icReactor(options: IcReactorPluginOptions): Plugin {
127
206
  /** The options of the plugin's `/api` proxy, as Vite hands them over. */
128
207
  const apiProxyOptions = new Set<ProxyOptions>()
129
208
 
130
- // Set once the dev server exists, so a `buildStart` failure in dev can reach
131
- // the browser overlay too — in dev, `configureServer` runs before Vite calls
132
- // `buildStart` on the plugin container.
209
+ // Set once the dev server exists. In dev, `configureServer` runs before Vite
210
+ // calls `buildStart`, so a startup failure can reach the overlay too.
133
211
  let devServer: ViteDevServer | null = null
134
212
 
135
- const resolveDidPath = (didFile: string) =>
136
- path.normalize(
137
- path.isAbsolute(didFile) ? didFile : path.resolve(projectRoot, didFile)
138
- )
213
+ // ── Generation ──────────────────────────────────────────────────────────
214
+
215
+ const didPath = (canister: Generated) =>
216
+ path.resolve(projectRoot, canister.didFile)
217
+ const relativeToRoot = (file: string) =>
218
+ path.relative(projectRoot, file) || "."
219
+
139
220
  /**
140
- * The `.did` text each entry last generated from, so a watch rebuild can
141
- * tell which entries need regenerating. See `buildStart`.
221
+ * The `.did` text each canister last generated from. A rebuild that finds
222
+ * the text unchanged skips the canister, which is every rebuild of
223
+ * `vite build --watch` that was not caused by a `.did`, and the second
224
+ * `buildStart` Vite 6 and later run for another environment.
142
225
  */
143
- const generatedFrom = new Map<CanisterConfig, string>()
226
+ const generatedFrom = new Map<string, string>()
227
+
228
+ /**
229
+ * The failures not yet fixed, by canister name. Vite awaits `buildStart`
230
+ * before the HTTP server listens, so a failure at startup is sent to no
231
+ * browser at all; each browser that connects later is handed these.
232
+ */
233
+ const unfixed = new Map<string, Failure>()
234
+
235
+ // Generation runs one job after another. Two `buildStart`s (one per Vite 6+
236
+ // environment) or a save during a run would otherwise start two generator
237
+ // processes that write the same files.
238
+ let tail: Promise<unknown> = Promise.resolve()
239
+ const serially = <T>(job: () => Promise<T>): Promise<T> => {
240
+ const result = tail.then(job)
241
+ tail = result.catch(() => undefined)
242
+ return result
243
+ }
144
244
 
145
- /** The entry's `.did` text, or `undefined` when it cannot be read. */
146
- const readDidSource = (canister: CanisterConfig): string | undefined => {
245
+ /** The `.did` files waiting for a run that has not started. See `onDidSaved`. */
246
+ const queued = new Set<string>()
247
+
248
+ // Aborted when the build or dev server ends (`closeBundle`), which kills
249
+ // the generator processes then running and drops the runs still queued. A
250
+ // generator that outlived its server would keep Node alive for up to its
251
+ // timeout, and after a restart (a `vite.config` edit) it would write the
252
+ // same outDir as the new server's first run. A fresh controller follows each
253
+ // abort, since the same plugin object serves a server that is started again.
254
+ let stopper = new AbortController()
255
+
256
+ const readDid = (canister: Generated): string | undefined => {
147
257
  try {
148
- return fs.readFileSync(resolveDidPath(canister.didFile), "utf-8")
258
+ return fs.readFileSync(didPath(canister), "utf-8")
149
259
  } catch {
150
260
  return undefined
151
261
  }
152
262
  }
153
263
 
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
264
  /**
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.
265
+ * Generate `wanted`, in one generator process for each output directory, and
266
+ * report what the generator said. Never rejects: an unexpected error fails
267
+ * the canisters, since a rejection from a watcher callback could end the dev
268
+ * server.
161
269
  *
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
-
182
- const configuredCanisterIds = Object.fromEntries(
183
- canisters
184
- .filter((canister) => !!canister.canisterId)
185
- .map((canister) => [canister.name, canister.canisterId as string])
186
- )
187
-
188
- /**
189
- * Report a generation failure everywhere a developer might be looking.
270
+ * Resolves `undefined` when `signal` was aborted before it finished: the
271
+ * server it belonged to is gone, so nothing is recorded, logged or shown.
190
272
  *
191
- * Terminal output scrolls away behind request logs and HMR chatter, so in dev
192
- * the browser error overlay is the signal that actually gets noticed.
273
+ * @param force - Generate even a canister whose `.did` text is unchanged.
274
+ * @param signal - The `stopper` signal current when the run was asked for.
193
275
  */
194
- /**
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.
198
- *
199
- * Vite awaits the plugin container's `buildStart` before the HTTP server
200
- * starts listening, so a generation failure during `vite dev` startup is
201
- * broadcast when there are no WebSocket clients at all and the payload is
202
- * simply dropped. The terminal shows it; the overlay never appears — for
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`.
212
- */
213
- const pendingFailures = new Map<
214
- CanisterConfig,
215
- { message: string; stack: string }
216
- >()
217
-
218
- const reportFailure = (
219
- server: ViteDevServer | null,
220
- message: string,
221
- cause?: unknown
222
- ) => {
223
- console.error(`[ic-reactor] ${message}`)
224
-
225
- const err = {
226
- message: `[ic-reactor] ${message}`,
227
- stack: cause instanceof Error && cause.stack ? cause.stack : "",
228
- plugin: PLUGIN_NAME,
229
- }
230
-
231
- server?.ws.send({ type: "error", err })
232
- return { message: err.message, stack: err.stack }
233
- }
276
+ const generateNow = async (
277
+ wanted: Generated[],
278
+ force: boolean,
279
+ signal: AbortSignal
280
+ ): Promise<Failure[] | undefined> => {
281
+ if (signal.aborted) return undefined
282
+ try {
283
+ const sources = new Map(
284
+ wanted.map((canister) => [canister.name, readDid(canister)])
285
+ )
286
+ const stale = wanted.filter(
287
+ ({ name }) =>
288
+ force ||
289
+ sources.get(name) === undefined ||
290
+ generatedFrom.get(name) !== sources.get(name)
291
+ )
292
+ if (stale.length === 0) return []
234
293
 
235
- // Keep at most one regeneration per canister in flight and collapse every save
236
- // that arrives meanwhile into a single trailing rerun, so the last saved
237
- // `.did` still wins without piling up concurrent runs.
238
- //
239
- // Scope, precisely: this covers the watcher path only. `buildStart` calls the
240
- // pipeline directly and does not register here, so a save landing during the
241
- // initial generation can still run concurrently with it. That is deliberate
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.
247
- //
248
- // Note the coalesced promise resolves when the RUNNING pass finishes, not the
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>()
259
-
260
- const regenerate = (
261
- canisterConfig: CanisterConfig,
262
- server: ViteDevServer
263
- ): Promise<void> => {
264
- const { name } = canisterConfig
265
- const running = inFlight.get(canisterConfig)
266
-
267
- if (running) {
268
- rerunQueued.add(canisterConfig)
269
- return running
270
- }
294
+ let cli: string
295
+ try {
296
+ cli = resolveCliBin(projectRoot)
297
+ } catch (error) {
298
+ return stale.map((canister) => ({ canister, message: describe(error) }))
299
+ }
271
300
 
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
- }
301
+ const result = await generate({
302
+ cli,
303
+ root: projectRoot,
304
+ timeoutMs: GENERATE_TIMEOUT_MS,
305
+ signal,
306
+ // Shown as it arrives: a run that takes long, or is killed at the
307
+ // timeout, would otherwise say nothing until it is over.
308
+ onStderr: (line, didFiles) => {
309
+ if (signal.aborted) return
310
+ log.warn(
311
+ `ic-reactor: candid-core-cli (${didFiles.map((file) => relativeToRoot(file)).join(", ")}): ${line}`
312
+ )
313
+ },
314
+ canisters: stale.map((canister) => ({
315
+ name: canister.name,
316
+ didFile: didPath(canister),
317
+ outDir: path.resolve(projectRoot, canister.outDir),
318
+ })),
319
+ })
280
320
 
281
- const run = runCanisterPipeline({
282
- canisterConfig,
283
- projectRoot,
284
- globalConfig,
285
- })
286
- .then((result) => {
287
- reportWarnings(name, result.warnings)
288
- if (result.success) {
289
- // A later connection must not be handed a failure that has since been
290
- // fixed.
291
- pendingFailures.delete(canisterConfig)
292
- // Reload page to reflect new types/hooks
293
- server.ws.send({ type: "full-reload" })
294
- } else {
295
- pendingFailures.set(
296
- canisterConfig,
297
- reportFailure(
298
- server,
299
- `Regeneration failed for ${name}: ${result.error ?? "unknown error"}`
300
- )
321
+ if (signal.aborted) return undefined
322
+
323
+ const failures: Failure[] = []
324
+ for (const outcome of result.outcomes) {
325
+ // The names of one outcome share a `.did`, so they succeed or fail
326
+ // together and are reported once.
327
+ const members = stale.filter(({ name }) => outcome.names.includes(name))
328
+ const names = members.map(({ name }) => name).join(", ")
329
+ if (outcome.status === "failed") {
330
+ for (const canister of members) {
331
+ generatedFrom.delete(canister.name)
332
+ failures.push({ canister, message: outcome.failure ?? "" })
333
+ }
334
+ continue
335
+ }
336
+ for (const { name } of members) {
337
+ const source = sources.get(name)
338
+ if (source !== undefined) generatedFrom.set(name, source)
339
+ }
340
+ if (outcome.status === "written" && outcome.module) {
341
+ log.info(
342
+ `ic-reactor: generated ${names} into ${relativeToRoot(outcome.module)}`
301
343
  )
302
344
  }
303
- })
304
- // A throw from the pipeline (a malformed `.did` makes the parser throw
305
- // rather than return a failed result) would otherwise be an unhandled
306
- // rejection: invisible in the browser and, depending on the Node version,
307
- // fatal to the dev server.
308
- .catch((error: unknown) => {
309
- pendingFailures.set(
310
- canisterConfig,
311
- reportFailure(
312
- server,
313
- `Regeneration failed for ${name}: ${describeError(error)}`,
314
- error
345
+ for (const { kind, name: what, reason, via } of outcome.omitted) {
346
+ log.warn(
347
+ `ic-reactor: ${names}: omitted ${kind} ${what} (${reason}${via ? ` via ${via}` : ""})`
315
348
  )
316
- )
317
- })
318
- .finally(() => {
319
- inFlight.delete(canisterConfig)
320
- if (rerunQueued.delete(canisterConfig)) {
321
- void regenerate(canisterConfig, server)
322
349
  }
323
- })
324
-
325
- inFlight.set(canisterConfig, run)
326
- return run
350
+ }
351
+ return failures
352
+ } catch (error) {
353
+ if (signal.aborted) return undefined
354
+ return wanted.map((canister) => ({ canister, message: describe(error) }))
355
+ }
327
356
  }
328
357
 
329
358
  /**
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.
359
+ * The error text for failed canisters. Canisters that failed for the same
360
+ * reason (no CLI installed, one crash) are listed under it once.
336
361
  */
337
- const regenerateForDid = (file: string, server: ViteDevServer): void => {
338
- if (!file.endsWith(".did")) {
339
- return
362
+ const describeFailures = (failures: Failure[]): string => {
363
+ const byMessage = new Map<string, string[]>()
364
+ for (const { canister, message } of failures) {
365
+ const label = `${canister.name} (${relativeToRoot(didPath(canister))})`
366
+ byMessage.set(message, [...(byMessage.get(message) ?? []), label])
340
367
  }
341
-
342
- const changedPath = path.normalize(file)
343
- const affectedCanisters = canisters.filter(
344
- (canister) => resolveDidPath(canister.didFile) === changedPath
368
+ return (
369
+ `ic-reactor: could not generate ${failures.length} of ${generated.length} canisters:\n` +
370
+ [...byMessage]
371
+ .map(
372
+ ([message, labels]) =>
373
+ ` - ${labels.join(", ")}: ${message.replace(/\n/g, "\n ")}`
374
+ )
375
+ .join("\n")
345
376
  )
377
+ }
378
+
379
+ /**
380
+ * Report the outcome of a run that does not end the Vite run (a save, or a
381
+ * build with `failOnError` off): log a failure and put every unfixed one in
382
+ * the browser's error overlay. When a canister is fixed, the overlay is
383
+ * replaced by one that lists only the canisters still failing, or cleared
384
+ * once there are none.
385
+ */
386
+ const publish = (attempted: Generated[], failures: Failure[]): void => {
387
+ // Every attempted canister leaves `unfixed`, not just the first one found
388
+ // there: a save regenerates all the canisters that name the `.did`, and
389
+ // `some` would stop at the first, leaving the others listed in an overlay
390
+ // nothing clears.
391
+ let wasFailing = false
392
+ for (const { name } of attempted) {
393
+ if (unfixed.delete(name)) wasFailing = true
394
+ }
395
+ for (const failure of failures) unfixed.set(failure.canister.name, failure)
396
+ if (failures.length > 0) {
397
+ log.error(describeFailures(failures))
398
+ showOverlay()
399
+ } else if (wasFailing && unfixed.size > 0) {
400
+ // The open overlay still lists the canister that was just fixed.
401
+ showOverlay()
402
+ } else if (wasFailing) {
403
+ // Nothing may have changed on disk when a canister is fixed back to what
404
+ // it generated before, so the page has nothing else to reload it.
405
+ devServer?.ws.send({ type: "full-reload" })
406
+ }
407
+ }
346
408
 
347
- if (affectedCanisters.length === 0) {
409
+ /**
410
+ * Put the unfixed failures in the error overlay: of the browser that just
411
+ * connected when the WebSocket hands it over (`ws.on("connection")` does),
412
+ * and of every browser otherwise. Vite's client clears the overlay on every
413
+ * hot update it applies, and nothing here sends it again, so the overlay can
414
+ * go while a canister is still broken. The terminal log keeps the error, and
415
+ * the replay on connection shows it after the next full reload.
416
+ */
417
+ const showOverlay = (client?: { send?: (data: string) => void }): void => {
418
+ if (unfixed.size === 0) return
419
+ const payload = {
420
+ type: "error" as const,
421
+ err: {
422
+ message: describeFailures([...unfixed.values()]),
423
+ stack: "",
424
+ plugin: PLUGIN_NAME,
425
+ },
426
+ }
427
+ if (typeof client?.send === "function") {
428
+ try {
429
+ client.send(JSON.stringify(payload))
430
+ } catch {
431
+ // A browser that is already gone has no use for the overlay, and a
432
+ // listener must not throw into the WebSocket server.
433
+ }
348
434
  return
349
435
  }
436
+ devServer?.ws.send(payload)
437
+ }
350
438
 
351
- console.log(
352
- `[ic-reactor] .did file changed: ${affectedCanisters
353
- .map((canister) => canister.name)
354
- .join(", ")}. Regenerating...`
439
+ /**
440
+ * A `.did` file was saved: regenerate the canisters that name it, and only
441
+ * those. Canisters with one interface share its file and its run. Saves that
442
+ * arrive while it runs collapse into one run after it, so the last saved
443
+ * file wins without runs piling up.
444
+ */
445
+ const onDidSaved = (file: string): void => {
446
+ const saved = path.normalize(file)
447
+ const affected = generated.filter((canister) => didPath(canister) === saved)
448
+ if (affected.length === 0 || queued.has(saved)) return
449
+ queued.add(saved)
450
+ log.info(
451
+ `ic-reactor: ${relativeToRoot(saved)} changed, regenerating ${affected.map(({ name }) => name).join(", ")}`
355
452
  )
453
+ const { signal } = stopper
454
+ void serially(async () => {
455
+ queued.delete(saved)
456
+ const failures = await generateNow(affected, true, signal)
457
+ if (failures) publish(affected, failures)
458
+ })
459
+ }
356
460
 
357
- // `regenerate` reports its own failures and never rejects.
358
- for (const canister of affectedCanisters) {
359
- void regenerate(canister, server)
461
+ /**
462
+ * A `.did` file was deleted: fail the canisters that name it, in the log and
463
+ * the overlay, until it comes back (an `add` regenerates them). The module
464
+ * generated from it is still on disk and would otherwise go on looking
465
+ * current. No generator runs, since there is nothing to read.
466
+ *
467
+ * It waits behind the run in flight, so that run cannot report the file as
468
+ * generated after this has reported it gone.
469
+ */
470
+ const onDidRemoved = (file: string): void => {
471
+ const removed = path.normalize(file)
472
+ const affected = generated.filter(
473
+ (canister) => didPath(canister) === removed
474
+ )
475
+ if (affected.length === 0) return
476
+ const { signal } = stopper
477
+ void serially(async () => {
478
+ try {
479
+ // Back already, and the run its `add` queued regenerates it.
480
+ if (signal.aborted || fs.existsSync(removed)) return
481
+ for (const { name } of affected) generatedFrom.delete(name)
482
+ publish(
483
+ affected,
484
+ affected.map((canister) => ({
485
+ canister,
486
+ message:
487
+ "the .did file was deleted; its module is not regenerated until the file comes back",
488
+ }))
489
+ )
490
+ } catch (error) {
491
+ // A listener must not throw into the watcher.
492
+ log.error(`ic-reactor: ${describe(error)}`)
493
+ }
494
+ })
495
+ }
496
+
497
+ /** Log the guide line once, before anything else the plugin says. */
498
+ const announceGuide = (userConfig: UserConfig): void => {
499
+ if (announced) return
500
+ announced = true
501
+ if (userConfig.customLogger) {
502
+ userConfig.customLogger.info(GUIDE_LINE)
503
+ } else if (
504
+ !["silent", "error", "warn"].includes(userConfig.logLevel ?? "")
505
+ ) {
506
+ console.log(GUIDE_LINE)
360
507
  }
361
508
  }
362
509
 
@@ -364,10 +511,14 @@ export function icReactor(options: IcReactorPluginOptions): Plugin {
364
511
  name: PLUGIN_NAME,
365
512
  enforce: "pre", // Run before other plugins
366
513
 
367
- async config(userConfig, { command: viteCommand }) {
514
+ async config(userConfig, { command: viteCommand, mode }) {
515
+ announceGuide(userConfig)
368
516
  command = viteCommand
369
517
 
370
- if (viteCommand !== "serve" || !injectEnvironment) {
518
+ // Vitest runs the plugin with the `serve` command and mode `test`, and
519
+ // a test run has no use for a cookie or a proxy, or for asking `icp`
520
+ // about a network.
521
+ if (viteCommand !== "serve" || mode === "test" || !injectEnvironment) {
371
522
  return {}
372
523
  }
373
524
 
@@ -377,9 +528,7 @@ export function icReactor(options: IcReactorPluginOptions): Plugin {
377
528
  const ownsApiProxy = !userConfig.server?.proxy?.["/api"]
378
529
 
379
530
  const environment = createLocalEnvironment({
380
- canisterNames: canisters
381
- .map((canister) => canister.name)
382
- .filter((name): name is string => !!name),
531
+ canisterNames: names,
383
532
  configuredCanisterIds,
384
533
  // `configResolved` has not run yet, so resolve the root the way Vite
385
534
  // will. icp finds the project from the directory it starts in, and
@@ -399,7 +548,7 @@ export function icReactor(options: IcReactorPluginOptions): Plugin {
399
548
  localEnvironment = environment
400
549
 
401
550
  const state = await environment.detect()
402
- warnAboutIncompleteDetection(state, canisters.length > 0, ownsApiProxy)
551
+ warnAboutIncompleteDetection(state, names.length > 0, ownsApiProxy)
403
552
 
404
553
  return {
405
554
  server: {
@@ -428,11 +577,12 @@ export function icReactor(options: IcReactorPluginOptions): Plugin {
428
577
  },
429
578
 
430
579
  configResolved(config) {
431
- // Everything the plugin resolves — `didFile`, `outDir`,
432
- // `clientManagerPath` — is documented as relative to the project root, so
433
- // it has to be Vite's resolved root and not wherever the process started.
580
+ // Everything the plugin resolves, `didFile` and `outDir`, is documented
581
+ // as relative to the project root, so it has to be Vite's resolved root
582
+ // and not wherever the process started.
434
583
  projectRoot = config.root
435
584
  command = config.command
585
+ log = config.logger
436
586
  },
437
587
 
438
588
  configureServer(server) {
@@ -444,40 +594,24 @@ export function icReactor(options: IcReactorPluginOptions): Plugin {
444
594
  server.middlewares.use(icEnvMiddleware(localEnvironment))
445
595
  }
446
596
 
447
- // Replay the unfixed failures described at pendingFailures to each client
448
- // that connects. A canister leaves the replay once it regenerates.
449
- // Guarded: the peer range spans several Vite majors and `ws.on` is not
450
- // present on every one of them. Losing the replay is acceptable; throwing
451
- // out of configureServer is not.
452
- server.ws.on?.("connection", () => {
453
- if (pendingFailures.size === 0) return
454
- const failures = [...pendingFailures.values()]
455
- server.ws.send({
456
- type: "error",
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
- },
465
- })
466
- })
467
-
468
- // Explicitly watch configured DID files, since they are not in the module graph.
469
- const didFiles = canisters.map((c) => resolveDidPath(c.didFile))
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)
597
+ if (generated.length === 0) return
598
+
599
+ // Hand each browser that connects the failures not yet fixed. Guarded:
600
+ // the peer range spans several Vite majors and `ws.on` is not present on
601
+ // every one of them. Losing the replay is acceptable; throwing out of
602
+ // configureServer is not.
603
+ server.ws.on?.("connection", showOverlay)
604
+
605
+ // `.did` files are not in the module graph, so the watcher is told about
606
+ // them. Regenerate from its own events and not from `handleHotUpdate`,
607
+ // which Vite calls only for a file changed in place and only while HMR
608
+ // is on: a `.did` created after startup, or written again by a build
609
+ // tool or `git checkout`, arrives as an `add` event, and a deleted one
610
+ // as `unlink`.
611
+ server.watcher.add(generated.map(didPath))
612
+ server.watcher.on("change", onDidSaved)
613
+ server.watcher.on("add", onDidSaved)
614
+ server.watcher.on("unlink", onDidRemoved)
481
615
  },
482
616
 
483
617
  // `vite preview` resolves the config with the `serve` command too, and
@@ -489,147 +623,39 @@ export function icReactor(options: IcReactorPluginOptions): Plugin {
489
623
  },
490
624
 
491
625
  async buildStart() {
492
- // ── Code Generation ──────────────────────────────────────────────────
626
+ if (generated.length === 0) return
493
627
 
494
628
  // `vite build --watch` rebuilds when a file it watches changes, and a
495
629
  // `.did` file is never part of the module graph. Registered here, a save
496
630
  // 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
- }
631
+ for (const canister of generated) this.addWatchFile(didPath(canister))
502
632
 
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]
633
+ const { signal } = stopper
634
+ const failures = await serially(() =>
635
+ generateNow(generated, false, signal)
517
636
  )
518
-
519
- if (pending.length === 0) {
520
- return
637
+ if (!failures) return
638
+ if (failures.length > 0 && (failOnError ?? command === "build")) {
639
+ // A build that exits 0 would ship whatever stale bindings are still on
640
+ // disk, which no longer match the canister.
641
+ this.error(describeFailures(failures))
521
642
  }
643
+ publish(generated, failures)
644
+ },
522
645
 
523
- console.log(
524
- `[ic-reactor] Generating canister bindings for ${pending.length} canisters...`
525
- )
526
-
527
- // Each entry is checked just before its pipeline starts, so the check
528
- // sees the directories the entries before it have claimed.
529
- const outcomes = await Promise.allSettled(
530
- pending.map((canisterConfig) => {
531
- const sharedError = sharedOutDirError(canisterConfig)
532
- if (sharedError !== undefined) {
533
- return Promise.reject(new SharedOutDirError(sharedError))
534
- }
535
- return runCanisterPipeline({
536
- canisterConfig,
537
- projectRoot,
538
- globalConfig,
539
- })
540
- })
541
- )
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
-
560
- // Collect every failure before reporting one: a canister failing must not
561
- // hide what the others did, and the error should name all of them so a CI
562
- // log shows the whole picture in one go.
563
- const failures = outcomes.flatMap((outcome, index) => {
564
- const canister = pending[index]
565
- const name = canister?.name ?? `canister #${index}`
566
-
567
- if (outcome.status === "rejected") {
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 }]
574
- }
575
- if (!outcome.value.success) {
576
- return [
577
- {
578
- canister,
579
- detail: `${name}: ${outcome.value.error ?? "unknown error"}`,
580
- },
581
- ]
582
- }
583
- return []
584
- })
585
-
586
- if (failures.length === 0) {
587
- return
588
- }
589
-
590
- const message =
591
- `Failed to generate ${failures.length} of ${pending.length} canisters:\n` +
592
- failures.map(({ detail }) => ` - ${detail}`).join("\n")
593
-
594
- // Previously every failure here was a `console.error` and nothing more,
595
- // so `vite build` exited 0 and CI shipped whatever stale bindings were
596
- // still on disk — bindings that no longer match the deployed canister.
597
- if (failOnError ?? command === "build") {
598
- this.error(`[ic-reactor] ${message}`)
599
- }
600
-
601
- reportFailure(devServer, message)
602
-
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
- })
610
- }
646
+ // The end of a build, and the close of a dev server, which Vite reports
647
+ // here once for each of its environments.
648
+ closeBundle() {
649
+ stopper.abort()
650
+ stopper = new AbortController()
611
651
  },
612
652
  }
613
653
 
614
654
  return plugin
615
655
  }
616
656
 
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
-
631
- /** One readable line for whatever the pipeline threw. */
632
- function describeError(error: unknown): string {
657
+ /** One readable line for whatever was thrown. */
658
+ function describe(error: unknown): string {
633
659
  return error instanceof Error ? error.message : String(error)
634
660
  }
635
661