@ic-reactor/vite-plugin 0.13.1 → 0.15.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 +97 -13
- package/dist/index.cjs +385 -118
- package/dist/index.d.cts +14 -2
- package/dist/index.d.ts +14 -2
- package/dist/index.js +389 -120
- package/llms.txt +134 -33
- package/package.json +4 -4
- package/src/dev-environment.ts +268 -0
- package/src/env.ts +60 -16
- package/src/index.ts +476 -178
package/src/index.ts
CHANGED
|
@@ -7,23 +7,38 @@
|
|
|
7
7
|
* 3. Hot-reloads when .did files change
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import type {
|
|
10
|
+
import type {
|
|
11
|
+
Plugin,
|
|
12
|
+
ProxyOptions,
|
|
13
|
+
ResolvedConfig,
|
|
14
|
+
UserConfig,
|
|
15
|
+
ViteDevServer,
|
|
16
|
+
} from "vite"
|
|
17
|
+
import fs from "node:fs"
|
|
11
18
|
import path from "node:path"
|
|
12
19
|
import {
|
|
20
|
+
findSharedOutDirs,
|
|
13
21
|
runCanisterPipeline,
|
|
22
|
+
sharedOutDirMessage,
|
|
14
23
|
type CanisterConfig,
|
|
15
24
|
type CodegenConfig,
|
|
16
25
|
type CodegenTarget,
|
|
17
26
|
} from "@ic-reactor/codegen"
|
|
18
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
createLocalEnvironment,
|
|
29
|
+
icEnvMiddleware,
|
|
30
|
+
type LocalEnvironment,
|
|
31
|
+
type LocalEnvironmentState,
|
|
32
|
+
} from "./dev-environment.js"
|
|
19
33
|
|
|
20
34
|
const PLUGIN_NAME = "ic-reactor-plugin"
|
|
21
|
-
const DEFAULT_LOCAL_REPLICA = "http://127.0.0.1:4943"
|
|
22
35
|
|
|
23
36
|
export interface IcReactorPluginOptions {
|
|
24
37
|
/**
|
|
25
38
|
* Canister configurations.
|
|
26
|
-
* `name` is required for each canister.
|
|
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`.
|
|
27
42
|
*/
|
|
28
43
|
canisters: CanisterConfig[]
|
|
29
44
|
/**
|
|
@@ -42,7 +57,17 @@ export interface IcReactorPluginOptions {
|
|
|
42
57
|
*/
|
|
43
58
|
target?: CodegenTarget
|
|
44
59
|
/**
|
|
45
|
-
*
|
|
60
|
+
* Inject the local IC environment under `vite dev` and `vite preview`: set
|
|
61
|
+
* the `ic_env` cookie on each response and proxy `/api` to the network the
|
|
62
|
+
* `icp` CLI reports.
|
|
63
|
+
*
|
|
64
|
+
* Until `icp` reports a network and every configured canister has an ID
|
|
65
|
+
* (a configured `canisterId` counts), each page load asks `icp` again, so a
|
|
66
|
+
* deploy after the server started needs only a reload. Once detection is
|
|
67
|
+
* complete, page loads run no `icp` command, and a redeploy into a fresh
|
|
68
|
+
* network needs a restart. An `/api` proxy that the Vite config or another
|
|
69
|
+
* plugin sets is left alone.
|
|
70
|
+
*
|
|
46
71
|
* Default: true
|
|
47
72
|
*/
|
|
48
73
|
injectEnvironment?: boolean
|
|
@@ -93,6 +118,15 @@ export function icReactor(options: IcReactorPluginOptions): Plugin {
|
|
|
93
118
|
// build is the safer default for the case where neither has run.
|
|
94
119
|
let command: ResolvedConfig["command"] = "build"
|
|
95
120
|
|
|
121
|
+
/**
|
|
122
|
+
* The local IC environment `vite dev` and `vite preview` inject. The
|
|
123
|
+
* `config` hook creates it when `injectEnvironment` is on.
|
|
124
|
+
*/
|
|
125
|
+
let localEnvironment: LocalEnvironment | undefined
|
|
126
|
+
|
|
127
|
+
/** The options of the plugin's `/api` proxy, as Vite hands them over. */
|
|
128
|
+
const apiProxyOptions = new Set<ProxyOptions>()
|
|
129
|
+
|
|
96
130
|
// Set once the dev server exists, so a `buildStart` failure in dev can reach
|
|
97
131
|
// the browser overlay too — in dev, `configureServer` runs before Vite calls
|
|
98
132
|
// `buildStart` on the plugin container.
|
|
@@ -102,6 +136,49 @@ export function icReactor(options: IcReactorPluginOptions): Plugin {
|
|
|
102
136
|
path.normalize(
|
|
103
137
|
path.isAbsolute(didFile) ? didFile : path.resolve(projectRoot, didFile)
|
|
104
138
|
)
|
|
139
|
+
/**
|
|
140
|
+
* The `.did` text each entry last generated from, so a watch rebuild can
|
|
141
|
+
* tell which entries need regenerating. See `buildStart`.
|
|
142
|
+
*/
|
|
143
|
+
const generatedFrom = new Map<CanisterConfig, string>()
|
|
144
|
+
|
|
145
|
+
/** The entry's `.did` text, or `undefined` when it cannot be read. */
|
|
146
|
+
const readDidSource = (canister: CanisterConfig): string | undefined => {
|
|
147
|
+
try {
|
|
148
|
+
return fs.readFileSync(resolveDidPath(canister.didFile), "utf-8")
|
|
149
|
+
} catch {
|
|
150
|
+
return undefined
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
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
|
+
/**
|
|
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.
|
|
161
|
+
*
|
|
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
|
+
|
|
105
182
|
const configuredCanisterIds = Object.fromEntries(
|
|
106
183
|
canisters
|
|
107
184
|
.filter((canister) => !!canister.canisterId)
|
|
@@ -115,16 +192,28 @@ export function icReactor(options: IcReactorPluginOptions): Plugin {
|
|
|
115
192
|
* the browser error overlay is the signal that actually gets noticed.
|
|
116
193
|
*/
|
|
117
194
|
/**
|
|
118
|
-
* The
|
|
119
|
-
* still gets the
|
|
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.
|
|
120
198
|
*
|
|
121
199
|
* Vite awaits the plugin container's `buildStart` before the HTTP server
|
|
122
200
|
* starts listening, so a generation failure during `vite dev` startup is
|
|
123
201
|
* broadcast when there are no WebSocket clients at all and the payload is
|
|
124
202
|
* simply dropped. The terminal shows it; the overlay never appears — for
|
|
125
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`.
|
|
126
212
|
*/
|
|
127
|
-
|
|
213
|
+
const pendingFailures = new Map<
|
|
214
|
+
CanisterConfig,
|
|
215
|
+
{ message: string; stack: string }
|
|
216
|
+
>()
|
|
128
217
|
|
|
129
218
|
const reportFailure = (
|
|
130
219
|
server: ViteDevServer | null,
|
|
@@ -139,8 +228,8 @@ export function icReactor(options: IcReactorPluginOptions): Plugin {
|
|
|
139
228
|
plugin: PLUGIN_NAME,
|
|
140
229
|
}
|
|
141
230
|
|
|
142
|
-
pendingFailure = { message: err.message, stack: err.stack }
|
|
143
231
|
server?.ws.send({ type: "error", err })
|
|
232
|
+
return { message: err.message, stack: err.stack }
|
|
144
233
|
}
|
|
145
234
|
|
|
146
235
|
// Keep at most one regeneration per canister in flight and collapse every save
|
|
@@ -150,46 +239,65 @@ export function icReactor(options: IcReactorPluginOptions): Plugin {
|
|
|
150
239
|
// Scope, precisely: this covers the watcher path only. `buildStart` calls the
|
|
151
240
|
// pipeline directly and does not register here, so a save landing during the
|
|
152
241
|
// initial generation can still run concurrently with it. That is deliberate
|
|
153
|
-
// rather than an oversight -- since @ic-reactor/codegen
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
//
|
|
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.
|
|
157
247
|
//
|
|
158
248
|
// Note the coalesced promise resolves when the RUNNING pass finishes, not the
|
|
159
|
-
// trailing rerun, so
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
|
|
163
|
-
|
|
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>()
|
|
164
259
|
|
|
165
260
|
const regenerate = (
|
|
166
261
|
canisterConfig: CanisterConfig,
|
|
167
262
|
server: ViteDevServer
|
|
168
263
|
): Promise<void> => {
|
|
169
264
|
const { name } = canisterConfig
|
|
170
|
-
const running = inFlight.get(
|
|
265
|
+
const running = inFlight.get(canisterConfig)
|
|
171
266
|
|
|
172
267
|
if (running) {
|
|
173
|
-
rerunQueued.add(
|
|
268
|
+
rerunQueued.add(canisterConfig)
|
|
174
269
|
return running
|
|
175
270
|
}
|
|
176
271
|
|
|
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
|
+
}
|
|
280
|
+
|
|
177
281
|
const run = runCanisterPipeline({
|
|
178
282
|
canisterConfig,
|
|
179
283
|
projectRoot,
|
|
180
284
|
globalConfig,
|
|
181
285
|
})
|
|
182
286
|
.then((result) => {
|
|
287
|
+
reportWarnings(name, result.warnings)
|
|
183
288
|
if (result.success) {
|
|
184
289
|
// A later connection must not be handed a failure that has since been
|
|
185
290
|
// fixed.
|
|
186
|
-
|
|
291
|
+
pendingFailures.delete(canisterConfig)
|
|
187
292
|
// Reload page to reflect new types/hooks
|
|
188
293
|
server.ws.send({ type: "full-reload" })
|
|
189
294
|
} else {
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
295
|
+
pendingFailures.set(
|
|
296
|
+
canisterConfig,
|
|
297
|
+
reportFailure(
|
|
298
|
+
server,
|
|
299
|
+
`Regeneration failed for ${name}: ${result.error ?? "unknown error"}`
|
|
300
|
+
)
|
|
193
301
|
)
|
|
194
302
|
}
|
|
195
303
|
})
|
|
@@ -198,28 +306,65 @@ export function icReactor(options: IcReactorPluginOptions): Plugin {
|
|
|
198
306
|
// rejection: invisible in the browser and, depending on the Node version,
|
|
199
307
|
// fatal to the dev server.
|
|
200
308
|
.catch((error: unknown) => {
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
309
|
+
pendingFailures.set(
|
|
310
|
+
canisterConfig,
|
|
311
|
+
reportFailure(
|
|
312
|
+
server,
|
|
313
|
+
`Regeneration failed for ${name}: ${describeError(error)}`,
|
|
314
|
+
error
|
|
315
|
+
)
|
|
205
316
|
)
|
|
206
317
|
})
|
|
207
318
|
.finally(() => {
|
|
208
|
-
inFlight.delete(
|
|
209
|
-
if (rerunQueued.delete(
|
|
319
|
+
inFlight.delete(canisterConfig)
|
|
320
|
+
if (rerunQueued.delete(canisterConfig)) {
|
|
210
321
|
void regenerate(canisterConfig, server)
|
|
211
322
|
}
|
|
212
323
|
})
|
|
213
324
|
|
|
214
|
-
inFlight.set(
|
|
325
|
+
inFlight.set(canisterConfig, run)
|
|
215
326
|
return run
|
|
216
327
|
}
|
|
217
328
|
|
|
329
|
+
/**
|
|
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.
|
|
336
|
+
*/
|
|
337
|
+
const regenerateForDid = (file: string, server: ViteDevServer): void => {
|
|
338
|
+
if (!file.endsWith(".did")) {
|
|
339
|
+
return
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const changedPath = path.normalize(file)
|
|
343
|
+
const affectedCanisters = canisters.filter(
|
|
344
|
+
(canister) => resolveDidPath(canister.didFile) === changedPath
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
if (affectedCanisters.length === 0) {
|
|
348
|
+
return
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
console.log(
|
|
352
|
+
`[ic-reactor] .did file changed: ${affectedCanisters
|
|
353
|
+
.map((canister) => canister.name)
|
|
354
|
+
.join(", ")}. Regenerating...`
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
// `regenerate` reports its own failures and never rejects.
|
|
358
|
+
for (const canister of affectedCanisters) {
|
|
359
|
+
void regenerate(canister, server)
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
218
363
|
const plugin: Plugin = {
|
|
219
364
|
name: PLUGIN_NAME,
|
|
220
365
|
enforce: "pre", // Run before other plugins
|
|
221
366
|
|
|
222
|
-
config(
|
|
367
|
+
async config(userConfig, { command: viteCommand }) {
|
|
223
368
|
command = viteCommand
|
|
224
369
|
|
|
225
370
|
if (viteCommand !== "serve" || !injectEnvironment) {
|
|
@@ -228,118 +373,56 @@ export function icReactor(options: IcReactorPluginOptions): Plugin {
|
|
|
228
373
|
|
|
229
374
|
// ── Local Development Proxy & Cookies ────────────────────────────────
|
|
230
375
|
|
|
231
|
-
//
|
|
232
|
-
const
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
)
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
for (const diagnostic of diagnostics) {
|
|
256
|
-
debugLog(diagnostic)
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
const envOnlyCookie =
|
|
260
|
-
canisters.length === 0
|
|
261
|
-
? buildIcEnvCookie(
|
|
262
|
-
{},
|
|
263
|
-
undefined,
|
|
264
|
-
"http://id.ai.localhost:8000/authorize"
|
|
265
|
-
)
|
|
266
|
-
: undefined
|
|
267
|
-
|
|
268
|
-
// Fallback: proxy /api to default local replica. In env-only mode,
|
|
269
|
-
// still provide the standard ICP CLI built-in local II URL.
|
|
270
|
-
return {
|
|
271
|
-
server: {
|
|
272
|
-
headers: envOnlyCookie
|
|
273
|
-
? {
|
|
274
|
-
"Set-Cookie": `ic_env=${envOnlyCookie}; Path=/; SameSite=Lax;`,
|
|
275
|
-
}
|
|
276
|
-
: undefined,
|
|
277
|
-
proxy: {
|
|
278
|
-
"/api": {
|
|
279
|
-
target: DEFAULT_LOCAL_REPLICA,
|
|
280
|
-
changeOrigin: true,
|
|
281
|
-
},
|
|
282
|
-
},
|
|
283
|
-
},
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
// The replica can be UP -- `icp network status` succeeds, so icEnv is
|
|
288
|
-
// truthy and the check above never fires -- while a configured canister
|
|
289
|
-
// has never been deployed. Every `icp canister status <name>` then fails
|
|
290
|
-
// and that id is simply absent, so the cookie goes out carrying a root key
|
|
291
|
-
// and no PUBLIC_CANISTER_ID for it. That is the same "indistinguishable
|
|
292
|
-
// from success until the app breaks on an undefined canister id" failure
|
|
293
|
-
// the branch above exists to prevent, and it is the more common one.
|
|
294
|
-
//
|
|
295
|
-
// Only configured canisters are reported: `internet_identity` is appended
|
|
296
|
-
// to canisterNames for convenience and is routinely not deployed.
|
|
297
|
-
// An explicitly configured `canisterId` counts as resolved: the cookie
|
|
298
|
-
// below merges configuredCanisterIds over the detected ones, so the app
|
|
299
|
-
// does receive a valid PUBLIC_CANISTER_ID. Warning on those told the user
|
|
300
|
-
// to deploy a canister whose id they had already supplied.
|
|
301
|
-
const missingCanisterIds = canisters
|
|
302
|
-
.map((canister) => canister.name)
|
|
303
|
-
.filter((name): name is string => !!name)
|
|
304
|
-
.filter(
|
|
305
|
-
(name) => !icEnv.canisterIds[name] && !configuredCanisterIds[name]
|
|
306
|
-
)
|
|
307
|
-
|
|
308
|
-
if (missingCanisterIds.length > 0) {
|
|
309
|
-
const names = missingCanisterIds.map((name) => `"${name}"`).join(", ")
|
|
310
|
-
const it = missingCanisterIds.length === 1 ? "it" : "them"
|
|
311
|
-
console.warn(
|
|
312
|
-
`[ic-reactor] The local replica is running, but no canister ID could be resolved for ${names}. ` +
|
|
313
|
-
`Deploy ${it} (\`icp deploy\`) — until then the injected ic_env carries no PUBLIC_CANISTER_ID ` +
|
|
314
|
-
`for ${it} and the app will see an undefined canister id. ` +
|
|
315
|
-
`Re-run with DEBUG=ic-reactor to see the \`icp\` output.`
|
|
316
|
-
)
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
for (const diagnostic of diagnostics) {
|
|
320
|
-
debugLog(diagnostic)
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
const cookieValue = buildIcEnvCookie(
|
|
324
|
-
{
|
|
325
|
-
...icEnv.canisterIds,
|
|
326
|
-
...configuredCanisterIds,
|
|
376
|
+
// The plugin's own `/api` entry, unless the Vite config has one.
|
|
377
|
+
const ownsApiProxy = !userConfig.server?.proxy?.["/api"]
|
|
378
|
+
|
|
379
|
+
const environment = createLocalEnvironment({
|
|
380
|
+
canisterNames: canisters
|
|
381
|
+
.map((canister) => canister.name)
|
|
382
|
+
.filter((name): name is string => !!name),
|
|
383
|
+
configuredCanisterIds,
|
|
384
|
+
// `configResolved` has not run yet, so resolve the root the way Vite
|
|
385
|
+
// will. icp finds the project from the directory it starts in, and
|
|
386
|
+
// with `vite apps/web` or a `root` option that is not the process cwd.
|
|
387
|
+
projectRoot: path.resolve(userConfig.root ?? process.cwd()),
|
|
388
|
+
onDiagnostic: debugLog,
|
|
389
|
+
onUpdate: (previous, next) => {
|
|
390
|
+
for (const proxyOptions of apiProxyOptions) {
|
|
391
|
+
proxyOptions.target = next.proxyTarget
|
|
392
|
+
}
|
|
393
|
+
if (previous) {
|
|
394
|
+
// Only a proxy the plugin kept following moves with detection.
|
|
395
|
+
reportDetectionProgress(previous, next, apiProxyOptions.size > 0)
|
|
396
|
+
}
|
|
327
397
|
},
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
398
|
+
})
|
|
399
|
+
localEnvironment = environment
|
|
400
|
+
|
|
401
|
+
const state = await environment.detect()
|
|
402
|
+
warnAboutIncompleteDetection(state, canisters.length > 0, ownsApiProxy)
|
|
331
403
|
|
|
332
404
|
return {
|
|
333
405
|
server: {
|
|
334
|
-
headers:
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
proxy: {
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
406
|
+
// The cookie is not a static `server.headers` entry: the middleware
|
|
407
|
+
// that configureServer adds sets it per response, from the latest
|
|
408
|
+
// detection. See dev-environment.ts.
|
|
409
|
+
proxy: apiProxy(userConfig, state.proxyTarget, (proxyOptions) => {
|
|
410
|
+
// A plugin whose config hook runs after this one can proxy /api
|
|
411
|
+
// as well. Vite merges its entry over the one returned here and
|
|
412
|
+
// keeps this `configure`, so a target other than the one returned
|
|
413
|
+
// here is that plugin's, and it stays where that plugin put it.
|
|
414
|
+
if (proxyOptions.target !== state.proxyTarget) {
|
|
415
|
+
debugLog(
|
|
416
|
+
"Another plugin changed the target of the /api proxy, so the plugin leaves that proxy alone."
|
|
417
|
+
)
|
|
418
|
+
return
|
|
419
|
+
}
|
|
420
|
+
// Vite hands the proxy these options on every request, so a new
|
|
421
|
+
// target set here takes effect on the next one.
|
|
422
|
+
apiProxyOptions.add(proxyOptions)
|
|
423
|
+
proxyOptions.target =
|
|
424
|
+
environment.state?.proxyTarget ?? state.proxyTarget
|
|
425
|
+
}),
|
|
343
426
|
},
|
|
344
427
|
}
|
|
345
428
|
},
|
|
@@ -355,52 +438,147 @@ export function icReactor(options: IcReactorPluginOptions): Plugin {
|
|
|
355
438
|
configureServer(server) {
|
|
356
439
|
devServer = server
|
|
357
440
|
|
|
358
|
-
//
|
|
359
|
-
//
|
|
441
|
+
// Added here rather than returned as a post hook, so it runs before
|
|
442
|
+
// Vite's own middlewares, which serve the page.
|
|
443
|
+
if (localEnvironment) {
|
|
444
|
+
server.middlewares.use(icEnvMiddleware(localEnvironment))
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Replay the unfixed failures described at pendingFailures to each client
|
|
448
|
+
// that connects. A canister leaves the replay once it regenerates.
|
|
360
449
|
// Guarded: the peer range spans several Vite majors and `ws.on` is not
|
|
361
450
|
// present on every one of them. Losing the replay is acceptable; throwing
|
|
362
451
|
// out of configureServer is not.
|
|
363
452
|
server.ws.on?.("connection", () => {
|
|
364
|
-
if (
|
|
453
|
+
if (pendingFailures.size === 0) return
|
|
454
|
+
const failures = [...pendingFailures.values()]
|
|
365
455
|
server.ws.send({
|
|
366
456
|
type: "error",
|
|
367
|
-
err: {
|
|
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
|
+
},
|
|
368
465
|
})
|
|
369
466
|
})
|
|
370
467
|
|
|
371
|
-
// Explicitly watch configured DID files
|
|
468
|
+
// Explicitly watch configured DID files, since they are not in the module graph.
|
|
372
469
|
const didFiles = canisters.map((c) => resolveDidPath(c.didFile))
|
|
373
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)
|
|
481
|
+
},
|
|
482
|
+
|
|
483
|
+
// `vite preview` resolves the config with the `serve` command too, and
|
|
484
|
+
// used to inherit the cookie from `server.headers`.
|
|
485
|
+
configurePreviewServer(server) {
|
|
486
|
+
if (localEnvironment) {
|
|
487
|
+
server.middlewares.use(icEnvMiddleware(localEnvironment))
|
|
488
|
+
}
|
|
374
489
|
},
|
|
375
490
|
|
|
376
491
|
async buildStart() {
|
|
377
492
|
// ── Code Generation ──────────────────────────────────────────────────
|
|
378
493
|
|
|
494
|
+
// `vite build --watch` rebuilds when a file it watches changes, and a
|
|
495
|
+
// `.did` file is never part of the module graph. Registered here, a save
|
|
496
|
+
// 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
|
+
}
|
|
502
|
+
|
|
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]
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
if (pending.length === 0) {
|
|
520
|
+
return
|
|
521
|
+
}
|
|
522
|
+
|
|
379
523
|
console.log(
|
|
380
|
-
`[ic-reactor] Generating canister bindings for ${
|
|
524
|
+
`[ic-reactor] Generating canister bindings for ${pending.length} canisters...`
|
|
381
525
|
)
|
|
382
526
|
|
|
527
|
+
// Each entry is checked just before its pipeline starts, so the check
|
|
528
|
+
// sees the directories the entries before it have claimed.
|
|
383
529
|
const outcomes = await Promise.allSettled(
|
|
384
|
-
|
|
385
|
-
|
|
530
|
+
pending.map((canisterConfig) => {
|
|
531
|
+
const sharedError = sharedOutDirError(canisterConfig)
|
|
532
|
+
if (sharedError !== undefined) {
|
|
533
|
+
return Promise.reject(new SharedOutDirError(sharedError))
|
|
534
|
+
}
|
|
535
|
+
return runCanisterPipeline({
|
|
386
536
|
canisterConfig,
|
|
387
537
|
projectRoot,
|
|
388
538
|
globalConfig,
|
|
389
539
|
})
|
|
390
|
-
)
|
|
540
|
+
})
|
|
391
541
|
)
|
|
392
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
|
+
|
|
393
560
|
// Collect every failure before reporting one: a canister failing must not
|
|
394
561
|
// hide what the others did, and the error should name all of them so a CI
|
|
395
562
|
// log shows the whole picture in one go.
|
|
396
563
|
const failures = outcomes.flatMap((outcome, index) => {
|
|
397
|
-
const
|
|
564
|
+
const canister = pending[index]
|
|
565
|
+
const name = canister?.name ?? `canister #${index}`
|
|
398
566
|
|
|
399
567
|
if (outcome.status === "rejected") {
|
|
400
|
-
|
|
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 }]
|
|
401
574
|
}
|
|
402
575
|
if (!outcome.value.success) {
|
|
403
|
-
return [
|
|
576
|
+
return [
|
|
577
|
+
{
|
|
578
|
+
canister,
|
|
579
|
+
detail: `${name}: ${outcome.value.error ?? "unknown error"}`,
|
|
580
|
+
},
|
|
581
|
+
]
|
|
404
582
|
}
|
|
405
583
|
return []
|
|
406
584
|
})
|
|
@@ -410,8 +588,8 @@ export function icReactor(options: IcReactorPluginOptions): Plugin {
|
|
|
410
588
|
}
|
|
411
589
|
|
|
412
590
|
const message =
|
|
413
|
-
`Failed to generate ${failures.length} of ${
|
|
414
|
-
failures.map((
|
|
591
|
+
`Failed to generate ${failures.length} of ${pending.length} canisters:\n` +
|
|
592
|
+
failures.map(({ detail }) => ` - ${detail}`).join("\n")
|
|
415
593
|
|
|
416
594
|
// Previously every failure here was a `console.error` and nothing more,
|
|
417
595
|
// so `vite build` exited 0 and CI shipped whatever stale bindings were
|
|
@@ -421,43 +599,163 @@ export function icReactor(options: IcReactorPluginOptions): Plugin {
|
|
|
421
599
|
}
|
|
422
600
|
|
|
423
601
|
reportFailure(devServer, message)
|
|
424
|
-
},
|
|
425
|
-
|
|
426
|
-
handleHotUpdate({ file, server }) {
|
|
427
|
-
// ── Hot Reload on .did changes ───────────────────────────────────────
|
|
428
|
-
if (!file.endsWith(".did")) {
|
|
429
|
-
return
|
|
430
|
-
}
|
|
431
602
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
(canister
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
return
|
|
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
|
+
})
|
|
440
610
|
}
|
|
441
|
-
|
|
442
|
-
console.log(
|
|
443
|
-
`[ic-reactor] .did file changed: ${affectedCanister.name}. Regenerating...`
|
|
444
|
-
)
|
|
445
|
-
|
|
446
|
-
// Returned so Vite waits for the write to finish before applying the
|
|
447
|
-
// update; it resolves to `undefined`, which leaves the affected module
|
|
448
|
-
// list untouched.
|
|
449
|
-
return regenerate(affectedCanister, server)
|
|
450
611
|
},
|
|
451
612
|
}
|
|
452
613
|
|
|
453
614
|
return plugin
|
|
454
615
|
}
|
|
455
616
|
|
|
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
|
+
|
|
456
631
|
/** One readable line for whatever the pipeline threw. */
|
|
457
632
|
function describeError(error: unknown): string {
|
|
458
633
|
return error instanceof Error ? error.message : String(error)
|
|
459
634
|
}
|
|
460
635
|
|
|
636
|
+
/**
|
|
637
|
+
* The `server.proxy` entry that sends `/api` to `target`, or nothing when the
|
|
638
|
+
* user's Vite config already proxies `/api`.
|
|
639
|
+
*
|
|
640
|
+
* Vite deep-merges the object a `config` hook returns over the user's config,
|
|
641
|
+
* so an `/api` entry returned here replaced the user's own. A project that
|
|
642
|
+
* pointed `/api` at icp-cli's port 8000 got 4943 instead, and nothing reported
|
|
643
|
+
* the swap. Vite's merge skips an undefined value, so returning nothing leaves
|
|
644
|
+
* the user's entry in place.
|
|
645
|
+
*
|
|
646
|
+
* `configure` receives the options object Vite builds the proxy from. Every
|
|
647
|
+
* supported Vite major copies it for each request, so the target can follow
|
|
648
|
+
* detection while the server runs.
|
|
649
|
+
*/
|
|
650
|
+
function apiProxy(
|
|
651
|
+
userConfig: UserConfig,
|
|
652
|
+
target: string,
|
|
653
|
+
configure: (options: ProxyOptions) => void
|
|
654
|
+
): Record<string, ProxyOptions> | undefined {
|
|
655
|
+
if (userConfig.server?.proxy?.["/api"]) {
|
|
656
|
+
debugLog(
|
|
657
|
+
`The Vite config already proxies /api, so the plugin keeps that proxy instead of sending /api to ${target}.`
|
|
658
|
+
)
|
|
659
|
+
return undefined
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
return {
|
|
663
|
+
"/api": {
|
|
664
|
+
target,
|
|
665
|
+
changeOrigin: true,
|
|
666
|
+
configure: (_proxy, options) => configure(options),
|
|
667
|
+
},
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/** `"a"`, or `"a", "b"`: canister names as the warnings quote them. */
|
|
672
|
+
function quoteNames(names: string[]): string {
|
|
673
|
+
return names.map((name) => `"${name}"`).join(", ")
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* Warn at startup when detection is incomplete, and say what the plugin does
|
|
678
|
+
* about it: it asks `icp` again on each page load until detection completes.
|
|
679
|
+
*
|
|
680
|
+
* Failing detection used to be indistinguishable from success: no cookie was
|
|
681
|
+
* set, no warning was printed, and the app only broke later on an undefined
|
|
682
|
+
* canister id.
|
|
683
|
+
*/
|
|
684
|
+
function warnAboutIncompleteDetection(
|
|
685
|
+
state: LocalEnvironmentState,
|
|
686
|
+
hasCanisters: boolean,
|
|
687
|
+
ownsApiProxy: boolean
|
|
688
|
+
): void {
|
|
689
|
+
if (!state.environment) {
|
|
690
|
+
// Env-only mode (no canisters configured) has nothing to inject.
|
|
691
|
+
if (!hasCanisters) return
|
|
692
|
+
const proxyNote = ownsApiProxy
|
|
693
|
+
? ` and /api goes to ${state.proxyTarget} for now`
|
|
694
|
+
: ""
|
|
695
|
+
console.warn(
|
|
696
|
+
`[ic-reactor] Could not detect the local IC environment, so no ic_env cookie is set${proxyNote}. ` +
|
|
697
|
+
`Is the local network running? The plugin asks \`icp\` again on each page load until it answers` +
|
|
698
|
+
`${ownsApiProxy ? ", then sends /api to the network it reports" : ""}: start the network ` +
|
|
699
|
+
`(\`icp network start\`) and reload the page. Re-run with DEBUG=ic-reactor to see the \`icp\` output.`
|
|
700
|
+
)
|
|
701
|
+
return
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// The network can be up while a configured canister has never been
|
|
705
|
+
// deployed. Every `icp canister status <name>` then fails and that id is
|
|
706
|
+
// absent, so the cookie carries a root key and no PUBLIC_CANISTER_ID for it,
|
|
707
|
+
// which is the same silent failure. Only configured canisters count, and one
|
|
708
|
+
// with a configured `canisterId` is resolved.
|
|
709
|
+
const missing = state.missingCanisterIds
|
|
710
|
+
if (missing.length > 0) {
|
|
711
|
+
const it = missing.length === 1 ? "it" : "them"
|
|
712
|
+
console.warn(
|
|
713
|
+
`[ic-reactor] The local replica is running, but no canister ID could be resolved for ${quoteNames(missing)}. ` +
|
|
714
|
+
`Until one is, the ic_env cookie carries no PUBLIC_CANISTER_ID for ${it} and the app will see an ` +
|
|
715
|
+
`undefined canister id. Deploy ${it} (\`icp deploy\`) and reload the page: the plugin asks \`icp\` ` +
|
|
716
|
+
`again on each page load until every configured canister has an ID. ` +
|
|
717
|
+
`Re-run with DEBUG=ic-reactor to see the \`icp\` output.`
|
|
718
|
+
)
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* Report what a detection after startup found that the one before had not.
|
|
724
|
+
*
|
|
725
|
+
* @param followsApiProxy - Whether the `/api` proxy moves with detection. It
|
|
726
|
+
* does not when the Vite config or another plugin set its target.
|
|
727
|
+
*/
|
|
728
|
+
function reportDetectionProgress(
|
|
729
|
+
previous: LocalEnvironmentState,
|
|
730
|
+
next: LocalEnvironmentState,
|
|
731
|
+
followsApiProxy: boolean
|
|
732
|
+
): void {
|
|
733
|
+
if (!previous.environment && next.environment) {
|
|
734
|
+
console.log(
|
|
735
|
+
`[ic-reactor] Detected the local IC network: the ic_env cookie now carries its root key` +
|
|
736
|
+
(followsApiProxy ? ` and /api goes to ${next.proxyTarget}.` : ".")
|
|
737
|
+
)
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
const resolved = previous.missingCanisterIds.filter(
|
|
741
|
+
(name) => !next.missingCanisterIds.includes(name)
|
|
742
|
+
)
|
|
743
|
+
if (next.environment && resolved.length > 0) {
|
|
744
|
+
console.log(
|
|
745
|
+
`[ic-reactor] The ic_env cookie now carries the canister ID${
|
|
746
|
+
resolved.length === 1 ? "" : "s"
|
|
747
|
+
} for ${quoteNames(resolved)}.`
|
|
748
|
+
)
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
if (next.complete && !previous.complete) {
|
|
752
|
+
console.log(
|
|
753
|
+
"[ic-reactor] Every configured canister has an ID, so page loads no longer run `icp`. " +
|
|
754
|
+
"Restart the dev server after redeploying into a fresh network."
|
|
755
|
+
)
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
461
759
|
/**
|
|
462
760
|
* `icp` failing to answer is routine — no local replica, or a canister that has
|
|
463
761
|
* never been deployed — so its stderr is noise until someone is actually
|