@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/README.md +141 -173
- package/dist/index.cjs +534 -207
- package/dist/index.d.cts +88 -30
- package/dist/index.d.ts +88 -30
- package/dist/index.js +534 -211
- package/package.json +6 -8
- package/src/generate.ts +594 -0
- package/src/index.ts +456 -430
- package/llms.txt +0 -156
package/src/index.ts
CHANGED
|
@@ -1,61 +1,77 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @ic-reactor/vite-plugin
|
|
3
3
|
*
|
|
4
|
-
* Vite plugin
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
*
|
|
56
|
-
*
|
|
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
|
-
|
|
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
|
|
79
|
-
* than no build at all, while a dev server has to survive the broken
|
|
80
|
-
* intermediate states of a `.did` file being edited
|
|
81
|
-
*
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
|
109
|
-
// passed positionally (`vite build apps/web`).
|
|
110
|
-
//
|
|
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.
|
|
118
|
-
//
|
|
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
|
|
131
|
-
//
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
|
141
|
-
*
|
|
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<
|
|
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
|
|
146
|
-
const
|
|
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(
|
|
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
|
-
*
|
|
160
|
-
*
|
|
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
|
-
*
|
|
163
|
-
*
|
|
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
|
-
*
|
|
192
|
-
*
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
305
|
-
|
|
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
|
-
|
|
326
|
-
|
|
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
|
-
*
|
|
331
|
-
*
|
|
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
|
|
338
|
-
|
|
339
|
-
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
-
|
|
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:
|
|
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,
|
|
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
|
|
432
|
-
//
|
|
433
|
-
//
|
|
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
|
-
|
|
448
|
-
|
|
449
|
-
//
|
|
450
|
-
//
|
|
451
|
-
//
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
504
|
-
|
|
505
|
-
|
|
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 (
|
|
520
|
-
|
|
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
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
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
|
-
/**
|
|
618
|
-
|
|
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
|
|