@ic-reactor/vite-plugin 0.14.0 → 4.0.0-beta.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/README.md +173 -130
- package/dist/index.cjs +782 -218
- package/dist/index.d.cts +99 -29
- package/dist/index.d.ts +99 -29
- package/dist/index.js +783 -221
- package/package.json +6 -8
- package/src/dev-environment.ts +268 -0
- package/src/env.ts +54 -20
- package/src/generate.ts +594 -0
- package/src/index.ts +665 -396
- package/llms.txt +0 -55
package/src/index.ts
CHANGED
|
@@ -1,48 +1,91 @@
|
|
|
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
|
|
|
10
|
-
import
|
|
14
|
+
import fs from "node:fs"
|
|
11
15
|
import path from "node:path"
|
|
16
|
+
import type {
|
|
17
|
+
Logger,
|
|
18
|
+
Plugin,
|
|
19
|
+
ProxyOptions,
|
|
20
|
+
ResolvedConfig,
|
|
21
|
+
UserConfig,
|
|
22
|
+
ViteDevServer,
|
|
23
|
+
} from "vite"
|
|
12
24
|
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
type
|
|
16
|
-
type
|
|
17
|
-
} from "
|
|
18
|
-
import {
|
|
25
|
+
createLocalEnvironment,
|
|
26
|
+
icEnvMiddleware,
|
|
27
|
+
type LocalEnvironment,
|
|
28
|
+
type LocalEnvironmentState,
|
|
29
|
+
} from "./dev-environment.js"
|
|
30
|
+
import { GENERATE_TIMEOUT_MS, generate, resolveCliBin } from "./generate.js"
|
|
19
31
|
|
|
20
32
|
const PLUGIN_NAME = "ic-reactor-plugin"
|
|
21
|
-
const DEFAULT_LOCAL_REPLICA = "http://127.0.0.1:4943"
|
|
22
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
|
+
*/
|
|
23
46
|
export interface IcReactorPluginOptions {
|
|
24
47
|
/**
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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.
|
|
42
70
|
*/
|
|
43
|
-
|
|
71
|
+
canisters?: Record<
|
|
72
|
+
string,
|
|
73
|
+
{ didFile?: string; outDir?: string; canisterId?: string }
|
|
74
|
+
>
|
|
44
75
|
/**
|
|
45
|
-
*
|
|
76
|
+
* Inject the local IC environment under `vite dev` and `vite preview`: set
|
|
77
|
+
* the `ic_env` cookie on each response and proxy `/api` to the network the
|
|
78
|
+
* `icp` CLI reports.
|
|
79
|
+
*
|
|
80
|
+
* Until `icp` reports a network and every configured canister has an ID
|
|
81
|
+
* (a configured `canisterId` counts), each page load asks `icp` again, so a
|
|
82
|
+
* deploy after the server started needs only a reload. Once detection is
|
|
83
|
+
* complete, page loads run no `icp` command, and a redeploy into a fresh
|
|
84
|
+
* network needs a restart. An `/api` proxy that the Vite config or another
|
|
85
|
+
* plugin sets is left alone.
|
|
86
|
+
*
|
|
87
|
+
* Never injected in mode `"test"` (Vitest's), where `icp` is not run at all.
|
|
88
|
+
*
|
|
46
89
|
* Default: true
|
|
47
90
|
*/
|
|
48
91
|
injectEnvironment?: boolean
|
|
@@ -50,445 +93,569 @@ export interface IcReactorPluginOptions {
|
|
|
50
93
|
* Abort the Vite run when a canister fails to generate.
|
|
51
94
|
*
|
|
52
95
|
* 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
|
|
54
|
-
* than no build at all, while a dev server has to survive the broken
|
|
55
|
-
* intermediate states of a `.did` file being edited
|
|
56
|
-
*
|
|
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.
|
|
57
101
|
*/
|
|
58
102
|
failOnError?: boolean
|
|
59
103
|
}
|
|
60
104
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
+
})
|
|
80
183
|
|
|
81
184
|
// Vite resolves relative project paths against the resolved `config.root`,
|
|
82
185
|
// which only equals the process cwd when vite happens to be started from the
|
|
83
|
-
// project directory
|
|
84
|
-
// passed positionally (`vite build apps/web`).
|
|
85
|
-
//
|
|
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
|
|
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
|
|
88
189
|
// default root.
|
|
89
190
|
let projectRoot = process.cwd()
|
|
90
191
|
|
|
91
192
|
// `vite build` and `vite dev` want opposite failure behaviour, so remember
|
|
92
|
-
// which one we are in.
|
|
93
|
-
//
|
|
193
|
+
// which one we are in. Build is the safer default for the case where neither
|
|
194
|
+
// `config` nor `configResolved` has run.
|
|
94
195
|
let command: ResolvedConfig["command"] = "build"
|
|
95
196
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
197
|
+
let log: PluginLog = consoleLog
|
|
198
|
+
let announced = false
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The local IC environment `vite dev` and `vite preview` inject. The
|
|
202
|
+
* `config` hook creates it when `injectEnvironment` is on.
|
|
203
|
+
*/
|
|
204
|
+
let localEnvironment: LocalEnvironment | undefined
|
|
205
|
+
|
|
206
|
+
/** The options of the plugin's `/api` proxy, as Vite hands them over. */
|
|
207
|
+
const apiProxyOptions = new Set<ProxyOptions>()
|
|
208
|
+
|
|
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.
|
|
99
211
|
let devServer: ViteDevServer | null = null
|
|
100
212
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
)
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
.filter((canister) => !!canister.canisterId)
|
|
108
|
-
.map((canister) => [canister.name, canister.canisterId as string])
|
|
109
|
-
)
|
|
213
|
+
// ── Generation ──────────────────────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
const didPath = (canister: Generated) =>
|
|
216
|
+
path.resolve(projectRoot, canister.didFile)
|
|
217
|
+
const relativeToRoot = (file: string) =>
|
|
218
|
+
path.relative(projectRoot, file) || "."
|
|
110
219
|
|
|
111
220
|
/**
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
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.
|
|
116
225
|
*/
|
|
226
|
+
const generatedFrom = new Map<string, string>()
|
|
227
|
+
|
|
117
228
|
/**
|
|
118
|
-
* The failures
|
|
119
|
-
*
|
|
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
|
+
}
|
|
244
|
+
|
|
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 => {
|
|
257
|
+
try {
|
|
258
|
+
return fs.readFileSync(didPath(canister), "utf-8")
|
|
259
|
+
} catch {
|
|
260
|
+
return undefined
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
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.
|
|
120
269
|
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
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.
|
|
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.
|
|
126
272
|
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
* broken vanished from the overlay as soon as another canister regenerated
|
|
130
|
-
* and its reload reconnected every tab.
|
|
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.
|
|
131
275
|
*/
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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 []
|
|
146
293
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
294
|
+
let cli: string
|
|
295
|
+
try {
|
|
296
|
+
cli = resolveCliBin(projectRoot)
|
|
297
|
+
} catch (error) {
|
|
298
|
+
return stale.map((canister) => ({ canister, message: describe(error) }))
|
|
299
|
+
}
|
|
150
300
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
const regenerate = (
|
|
171
|
-
canisterConfig: CanisterConfig,
|
|
172
|
-
server: ViteDevServer
|
|
173
|
-
): Promise<void> => {
|
|
174
|
-
const { name } = canisterConfig
|
|
175
|
-
const running = inFlight.get(name)
|
|
176
|
-
|
|
177
|
-
if (running) {
|
|
178
|
-
rerunQueued.add(name)
|
|
179
|
-
return running
|
|
180
|
-
}
|
|
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
|
+
})
|
|
181
320
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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)}`
|
|
201
343
|
)
|
|
202
344
|
}
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
// rejection: invisible in the browser and, depending on the Node version,
|
|
207
|
-
// fatal to the dev server.
|
|
208
|
-
.catch((error: unknown) => {
|
|
209
|
-
pendingFailures.set(
|
|
210
|
-
name,
|
|
211
|
-
reportFailure(
|
|
212
|
-
server,
|
|
213
|
-
`Regeneration failed for ${name}: ${describeError(error)}`,
|
|
214
|
-
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}` : ""})`
|
|
215
348
|
)
|
|
216
|
-
)
|
|
217
|
-
})
|
|
218
|
-
.finally(() => {
|
|
219
|
-
inFlight.delete(name)
|
|
220
|
-
if (rerunQueued.delete(name)) {
|
|
221
|
-
void regenerate(canisterConfig, server)
|
|
222
349
|
}
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
350
|
+
}
|
|
351
|
+
return failures
|
|
352
|
+
} catch (error) {
|
|
353
|
+
if (signal.aborted) return undefined
|
|
354
|
+
return wanted.map((canister) => ({ canister, message: describe(error) }))
|
|
355
|
+
}
|
|
227
356
|
}
|
|
228
357
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
358
|
+
/**
|
|
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.
|
|
361
|
+
*/
|
|
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])
|
|
367
|
+
}
|
|
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")
|
|
376
|
+
)
|
|
377
|
+
}
|
|
232
378
|
|
|
233
|
-
|
|
234
|
-
|
|
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
|
+
}
|
|
235
408
|
|
|
236
|
-
|
|
237
|
-
|
|
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.
|
|
238
433
|
}
|
|
434
|
+
return
|
|
435
|
+
}
|
|
436
|
+
devServer?.ws.send(payload)
|
|
437
|
+
}
|
|
239
438
|
|
|
240
|
-
|
|
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(", ")}`
|
|
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
|
+
}
|
|
241
460
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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)}`)
|
|
248
493
|
}
|
|
494
|
+
})
|
|
495
|
+
}
|
|
249
496
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
// (no canisters configured), where there is nothing to inject anyway.
|
|
263
|
-
if (canisters.length > 0) {
|
|
264
|
-
console.warn(
|
|
265
|
-
`[ic-reactor] Could not detect the local IC environment, falling back to ${DEFAULT_LOCAL_REPLICA}. ` +
|
|
266
|
-
`Canister IDs and the root key will not be injected — is the local replica running? ` +
|
|
267
|
-
`Re-run with DEBUG=ic-reactor to see the \`icp\` output.`
|
|
268
|
-
)
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
for (const diagnostic of diagnostics) {
|
|
272
|
-
debugLog(diagnostic)
|
|
273
|
-
}
|
|
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)
|
|
507
|
+
}
|
|
508
|
+
}
|
|
274
509
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
{},
|
|
279
|
-
undefined,
|
|
280
|
-
"http://id.ai.localhost:8000/authorize"
|
|
281
|
-
)
|
|
282
|
-
: undefined
|
|
283
|
-
|
|
284
|
-
// Fallback: proxy /api to default local replica. In env-only mode,
|
|
285
|
-
// still provide the standard ICP CLI built-in local II URL.
|
|
286
|
-
return {
|
|
287
|
-
server: {
|
|
288
|
-
headers: envOnlyCookie
|
|
289
|
-
? {
|
|
290
|
-
"Set-Cookie": `ic_env=${envOnlyCookie}; Path=/; SameSite=Lax;`,
|
|
291
|
-
}
|
|
292
|
-
: undefined,
|
|
293
|
-
proxy: apiProxy(userConfig, DEFAULT_LOCAL_REPLICA),
|
|
294
|
-
},
|
|
295
|
-
}
|
|
296
|
-
}
|
|
510
|
+
const plugin: Plugin = {
|
|
511
|
+
name: PLUGIN_NAME,
|
|
512
|
+
enforce: "pre", // Run before other plugins
|
|
297
513
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
// and that id is simply absent, so the cookie goes out carrying a root key
|
|
302
|
-
// and no PUBLIC_CANISTER_ID for it. That is the same "indistinguishable
|
|
303
|
-
// from success until the app breaks on an undefined canister id" failure
|
|
304
|
-
// the branch above exists to prevent, and it is the more common one.
|
|
305
|
-
//
|
|
306
|
-
// Only configured canisters are reported: `internet_identity` is appended
|
|
307
|
-
// to canisterNames for convenience and is routinely not deployed.
|
|
308
|
-
// An explicitly configured `canisterId` counts as resolved: the cookie
|
|
309
|
-
// below merges configuredCanisterIds over the detected ones, so the app
|
|
310
|
-
// does receive a valid PUBLIC_CANISTER_ID. Warning on those told the user
|
|
311
|
-
// to deploy a canister whose id they had already supplied.
|
|
312
|
-
const missingCanisterIds = canisters
|
|
313
|
-
.map((canister) => canister.name)
|
|
314
|
-
.filter((name): name is string => !!name)
|
|
315
|
-
.filter(
|
|
316
|
-
(name) => !icEnv.canisterIds[name] && !configuredCanisterIds[name]
|
|
317
|
-
)
|
|
514
|
+
async config(userConfig, { command: viteCommand, mode }) {
|
|
515
|
+
announceGuide(userConfig)
|
|
516
|
+
command = viteCommand
|
|
318
517
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
`Deploy ${it} (\`icp deploy\`) — until then the injected ic_env carries no PUBLIC_CANISTER_ID ` +
|
|
325
|
-
`for ${it} and the app will see an undefined canister id. ` +
|
|
326
|
-
`Re-run with DEBUG=ic-reactor to see the \`icp\` output.`
|
|
327
|
-
)
|
|
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) {
|
|
522
|
+
return {}
|
|
328
523
|
}
|
|
329
524
|
|
|
330
|
-
|
|
331
|
-
debugLog(diagnostic)
|
|
332
|
-
}
|
|
525
|
+
// ── Local Development Proxy & Cookies ────────────────────────────────
|
|
333
526
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
527
|
+
// The plugin's own `/api` entry, unless the Vite config has one.
|
|
528
|
+
const ownsApiProxy = !userConfig.server?.proxy?.["/api"]
|
|
529
|
+
|
|
530
|
+
const environment = createLocalEnvironment({
|
|
531
|
+
canisterNames: names,
|
|
532
|
+
configuredCanisterIds,
|
|
533
|
+
// `configResolved` has not run yet, so resolve the root the way Vite
|
|
534
|
+
// will. icp finds the project from the directory it starts in, and
|
|
535
|
+
// with `vite apps/web` or a `root` option that is not the process cwd.
|
|
536
|
+
projectRoot: path.resolve(userConfig.root ?? process.cwd()),
|
|
537
|
+
onDiagnostic: debugLog,
|
|
538
|
+
onUpdate: (previous, next) => {
|
|
539
|
+
for (const proxyOptions of apiProxyOptions) {
|
|
540
|
+
proxyOptions.target = next.proxyTarget
|
|
541
|
+
}
|
|
542
|
+
if (previous) {
|
|
543
|
+
// Only a proxy the plugin kept following moves with detection.
|
|
544
|
+
reportDetectionProgress(previous, next, apiProxyOptions.size > 0)
|
|
545
|
+
}
|
|
338
546
|
},
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
547
|
+
})
|
|
548
|
+
localEnvironment = environment
|
|
549
|
+
|
|
550
|
+
const state = await environment.detect()
|
|
551
|
+
warnAboutIncompleteDetection(state, names.length > 0, ownsApiProxy)
|
|
342
552
|
|
|
343
553
|
return {
|
|
344
554
|
server: {
|
|
345
|
-
headers:
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
proxy: apiProxy(userConfig,
|
|
555
|
+
// The cookie is not a static `server.headers` entry: the middleware
|
|
556
|
+
// that configureServer adds sets it per response, from the latest
|
|
557
|
+
// detection. See dev-environment.ts.
|
|
558
|
+
proxy: apiProxy(userConfig, state.proxyTarget, (proxyOptions) => {
|
|
559
|
+
// A plugin whose config hook runs after this one can proxy /api
|
|
560
|
+
// as well. Vite merges its entry over the one returned here and
|
|
561
|
+
// keeps this `configure`, so a target other than the one returned
|
|
562
|
+
// here is that plugin's, and it stays where that plugin put it.
|
|
563
|
+
if (proxyOptions.target !== state.proxyTarget) {
|
|
564
|
+
debugLog(
|
|
565
|
+
"Another plugin changed the target of the /api proxy, so the plugin leaves that proxy alone."
|
|
566
|
+
)
|
|
567
|
+
return
|
|
568
|
+
}
|
|
569
|
+
// Vite hands the proxy these options on every request, so a new
|
|
570
|
+
// target set here takes effect on the next one.
|
|
571
|
+
apiProxyOptions.add(proxyOptions)
|
|
572
|
+
proxyOptions.target =
|
|
573
|
+
environment.state?.proxyTarget ?? state.proxyTarget
|
|
574
|
+
}),
|
|
349
575
|
},
|
|
350
576
|
}
|
|
351
577
|
},
|
|
352
578
|
|
|
353
579
|
configResolved(config) {
|
|
354
|
-
// Everything the plugin resolves
|
|
355
|
-
//
|
|
356
|
-
//
|
|
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.
|
|
357
583
|
projectRoot = config.root
|
|
358
584
|
command = config.command
|
|
585
|
+
log = config.logger
|
|
359
586
|
},
|
|
360
587
|
|
|
361
588
|
configureServer(server) {
|
|
362
589
|
devServer = server
|
|
363
590
|
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
// out of configureServer is not.
|
|
369
|
-
server.ws.on?.("connection", () => {
|
|
370
|
-
if (pendingFailures.size === 0) return
|
|
371
|
-
const failures = [...pendingFailures.values()]
|
|
372
|
-
server.ws.send({
|
|
373
|
-
type: "error",
|
|
374
|
-
err: {
|
|
375
|
-
message: failures.map((failure) => failure.message).join("\n"),
|
|
376
|
-
stack: failures
|
|
377
|
-
.map((failure) => failure.stack)
|
|
378
|
-
.filter(Boolean)
|
|
379
|
-
.join("\n"),
|
|
380
|
-
plugin: PLUGIN_NAME,
|
|
381
|
-
},
|
|
382
|
-
})
|
|
383
|
-
})
|
|
384
|
-
|
|
385
|
-
// Explicitly watch configured DID files so HMR works even when they are not in the module graph.
|
|
386
|
-
const didFiles = canisters.map((c) => resolveDidPath(c.didFile))
|
|
387
|
-
server.watcher.add(didFiles)
|
|
388
|
-
},
|
|
389
|
-
|
|
390
|
-
async buildStart() {
|
|
391
|
-
// ── Code Generation ──────────────────────────────────────────────────
|
|
392
|
-
|
|
393
|
-
console.log(
|
|
394
|
-
`[ic-reactor] Generating canister bindings for ${canisters.length} canisters...`
|
|
395
|
-
)
|
|
396
|
-
|
|
397
|
-
const outcomes = await Promise.allSettled(
|
|
398
|
-
canisters.map((canisterConfig) =>
|
|
399
|
-
runCanisterPipeline({
|
|
400
|
-
canisterConfig,
|
|
401
|
-
projectRoot,
|
|
402
|
-
globalConfig,
|
|
403
|
-
})
|
|
404
|
-
)
|
|
405
|
-
)
|
|
406
|
-
|
|
407
|
-
// Collect every failure before reporting one: a canister failing must not
|
|
408
|
-
// hide what the others did, and the error should name all of them so a CI
|
|
409
|
-
// log shows the whole picture in one go.
|
|
410
|
-
const failures = outcomes.flatMap((outcome, index) => {
|
|
411
|
-
const name = canisters[index]?.name ?? `canister #${index}`
|
|
412
|
-
|
|
413
|
-
if (outcome.status === "rejected") {
|
|
414
|
-
return [{ name, detail: `${name}: ${describeError(outcome.reason)}` }]
|
|
415
|
-
}
|
|
416
|
-
if (!outcome.value.success) {
|
|
417
|
-
return [
|
|
418
|
-
{
|
|
419
|
-
name,
|
|
420
|
-
detail: `${name}: ${outcome.value.error ?? "unknown error"}`,
|
|
421
|
-
},
|
|
422
|
-
]
|
|
423
|
-
}
|
|
424
|
-
return []
|
|
425
|
-
})
|
|
426
|
-
|
|
427
|
-
if (failures.length === 0) {
|
|
428
|
-
return
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
const message =
|
|
432
|
-
`Failed to generate ${failures.length} of ${canisters.length} canisters:\n` +
|
|
433
|
-
failures.map(({ detail }) => ` - ${detail}`).join("\n")
|
|
434
|
-
|
|
435
|
-
// Previously every failure here was a `console.error` and nothing more,
|
|
436
|
-
// so `vite build` exited 0 and CI shipped whatever stale bindings were
|
|
437
|
-
// still on disk — bindings that no longer match the deployed canister.
|
|
438
|
-
if (failOnError ?? command === "build") {
|
|
439
|
-
this.error(`[ic-reactor] ${message}`)
|
|
591
|
+
// Added here rather than returned as a post hook, so it runs before
|
|
592
|
+
// Vite's own middlewares, which serve the page.
|
|
593
|
+
if (localEnvironment) {
|
|
594
|
+
server.middlewares.use(icEnvMiddleware(localEnvironment))
|
|
440
595
|
}
|
|
441
596
|
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
//
|
|
445
|
-
//
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
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)
|
|
452
615
|
},
|
|
453
616
|
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
617
|
+
// `vite preview` resolves the config with the `serve` command too, and
|
|
618
|
+
// used to inherit the cookie from `server.headers`.
|
|
619
|
+
configurePreviewServer(server) {
|
|
620
|
+
if (localEnvironment) {
|
|
621
|
+
server.middlewares.use(icEnvMiddleware(localEnvironment))
|
|
458
622
|
}
|
|
623
|
+
},
|
|
459
624
|
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
// canister, such as two ledgers, share a .did file. Stopping at the first
|
|
463
|
-
// match left the others on stale bindings, and the full reload hid that.
|
|
464
|
-
const affectedCanisters = canisters.filter(
|
|
465
|
-
(canister) => resolveDidPath(canister.didFile) === changedPath
|
|
466
|
-
)
|
|
625
|
+
async buildStart() {
|
|
626
|
+
if (generated.length === 0) return
|
|
467
627
|
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
628
|
+
// `vite build --watch` rebuilds when a file it watches changes, and a
|
|
629
|
+
// `.did` file is never part of the module graph. Registered here, a save
|
|
630
|
+
// starts a rebuild, and the rebuild's buildStart regenerates.
|
|
631
|
+
for (const canister of generated) this.addWatchFile(didPath(canister))
|
|
471
632
|
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
.join(", ")}. Regenerating...`
|
|
633
|
+
const { signal } = stopper
|
|
634
|
+
const failures = await serially(() =>
|
|
635
|
+
generateNow(generated, false, signal)
|
|
476
636
|
)
|
|
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))
|
|
642
|
+
}
|
|
643
|
+
publish(generated, failures)
|
|
644
|
+
},
|
|
477
645
|
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
).then(() => undefined)
|
|
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()
|
|
484
651
|
},
|
|
485
652
|
}
|
|
486
653
|
|
|
487
654
|
return plugin
|
|
488
655
|
}
|
|
489
656
|
|
|
490
|
-
/** One readable line for whatever
|
|
491
|
-
function
|
|
657
|
+
/** One readable line for whatever was thrown. */
|
|
658
|
+
function describe(error: unknown): string {
|
|
492
659
|
return error instanceof Error ? error.message : String(error)
|
|
493
660
|
}
|
|
494
661
|
|
|
@@ -501,8 +668,16 @@ function describeError(error: unknown): string {
|
|
|
501
668
|
* pointed `/api` at icp-cli's port 8000 got 4943 instead, and nothing reported
|
|
502
669
|
* the swap. Vite's merge skips an undefined value, so returning nothing leaves
|
|
503
670
|
* the user's entry in place.
|
|
671
|
+
*
|
|
672
|
+
* `configure` receives the options object Vite builds the proxy from. Every
|
|
673
|
+
* supported Vite major copies it for each request, so the target can follow
|
|
674
|
+
* detection while the server runs.
|
|
504
675
|
*/
|
|
505
|
-
function apiProxy(
|
|
676
|
+
function apiProxy(
|
|
677
|
+
userConfig: UserConfig,
|
|
678
|
+
target: string,
|
|
679
|
+
configure: (options: ProxyOptions) => void
|
|
680
|
+
): Record<string, ProxyOptions> | undefined {
|
|
506
681
|
if (userConfig.server?.proxy?.["/api"]) {
|
|
507
682
|
debugLog(
|
|
508
683
|
`The Vite config already proxies /api, so the plugin keeps that proxy instead of sending /api to ${target}.`
|
|
@@ -510,7 +685,101 @@ function apiProxy(userConfig: UserConfig, target: string) {
|
|
|
510
685
|
return undefined
|
|
511
686
|
}
|
|
512
687
|
|
|
513
|
-
return {
|
|
688
|
+
return {
|
|
689
|
+
"/api": {
|
|
690
|
+
target,
|
|
691
|
+
changeOrigin: true,
|
|
692
|
+
configure: (_proxy, options) => configure(options),
|
|
693
|
+
},
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/** `"a"`, or `"a", "b"`: canister names as the warnings quote them. */
|
|
698
|
+
function quoteNames(names: string[]): string {
|
|
699
|
+
return names.map((name) => `"${name}"`).join(", ")
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* Warn at startup when detection is incomplete, and say what the plugin does
|
|
704
|
+
* about it: it asks `icp` again on each page load until detection completes.
|
|
705
|
+
*
|
|
706
|
+
* Failing detection used to be indistinguishable from success: no cookie was
|
|
707
|
+
* set, no warning was printed, and the app only broke later on an undefined
|
|
708
|
+
* canister id.
|
|
709
|
+
*/
|
|
710
|
+
function warnAboutIncompleteDetection(
|
|
711
|
+
state: LocalEnvironmentState,
|
|
712
|
+
hasCanisters: boolean,
|
|
713
|
+
ownsApiProxy: boolean
|
|
714
|
+
): void {
|
|
715
|
+
if (!state.environment) {
|
|
716
|
+
// Env-only mode (no canisters configured) has nothing to inject.
|
|
717
|
+
if (!hasCanisters) return
|
|
718
|
+
const proxyNote = ownsApiProxy
|
|
719
|
+
? ` and /api goes to ${state.proxyTarget} for now`
|
|
720
|
+
: ""
|
|
721
|
+
console.warn(
|
|
722
|
+
`[ic-reactor] Could not detect the local IC environment, so no ic_env cookie is set${proxyNote}. ` +
|
|
723
|
+
`Is the local network running? The plugin asks \`icp\` again on each page load until it answers` +
|
|
724
|
+
`${ownsApiProxy ? ", then sends /api to the network it reports" : ""}: start the network ` +
|
|
725
|
+
`(\`icp network start\`) and reload the page. Re-run with DEBUG=ic-reactor to see the \`icp\` output.`
|
|
726
|
+
)
|
|
727
|
+
return
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// The network can be up while a configured canister has never been
|
|
731
|
+
// deployed. Every `icp canister status <name>` then fails and that id is
|
|
732
|
+
// absent, so the cookie carries a root key and no PUBLIC_CANISTER_ID for it,
|
|
733
|
+
// which is the same silent failure. Only configured canisters count, and one
|
|
734
|
+
// with a configured `canisterId` is resolved.
|
|
735
|
+
const missing = state.missingCanisterIds
|
|
736
|
+
if (missing.length > 0) {
|
|
737
|
+
const it = missing.length === 1 ? "it" : "them"
|
|
738
|
+
console.warn(
|
|
739
|
+
`[ic-reactor] The local replica is running, but no canister ID could be resolved for ${quoteNames(missing)}. ` +
|
|
740
|
+
`Until one is, the ic_env cookie carries no PUBLIC_CANISTER_ID for ${it} and the app will see an ` +
|
|
741
|
+
`undefined canister id. Deploy ${it} (\`icp deploy\`) and reload the page: the plugin asks \`icp\` ` +
|
|
742
|
+
`again on each page load until every configured canister has an ID. ` +
|
|
743
|
+
`Re-run with DEBUG=ic-reactor to see the \`icp\` output.`
|
|
744
|
+
)
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* Report what a detection after startup found that the one before had not.
|
|
750
|
+
*
|
|
751
|
+
* @param followsApiProxy - Whether the `/api` proxy moves with detection. It
|
|
752
|
+
* does not when the Vite config or another plugin set its target.
|
|
753
|
+
*/
|
|
754
|
+
function reportDetectionProgress(
|
|
755
|
+
previous: LocalEnvironmentState,
|
|
756
|
+
next: LocalEnvironmentState,
|
|
757
|
+
followsApiProxy: boolean
|
|
758
|
+
): void {
|
|
759
|
+
if (!previous.environment && next.environment) {
|
|
760
|
+
console.log(
|
|
761
|
+
`[ic-reactor] Detected the local IC network: the ic_env cookie now carries its root key` +
|
|
762
|
+
(followsApiProxy ? ` and /api goes to ${next.proxyTarget}.` : ".")
|
|
763
|
+
)
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
const resolved = previous.missingCanisterIds.filter(
|
|
767
|
+
(name) => !next.missingCanisterIds.includes(name)
|
|
768
|
+
)
|
|
769
|
+
if (next.environment && resolved.length > 0) {
|
|
770
|
+
console.log(
|
|
771
|
+
`[ic-reactor] The ic_env cookie now carries the canister ID${
|
|
772
|
+
resolved.length === 1 ? "" : "s"
|
|
773
|
+
} for ${quoteNames(resolved)}.`
|
|
774
|
+
)
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
if (next.complete && !previous.complete) {
|
|
778
|
+
console.log(
|
|
779
|
+
"[ic-reactor] Every configured canister has an ID, so page loads no longer run `icp`. " +
|
|
780
|
+
"Restart the dev server after redeploying into a fresh network."
|
|
781
|
+
)
|
|
782
|
+
}
|
|
514
783
|
}
|
|
515
784
|
|
|
516
785
|
/**
|