@ic-reactor/vite-plugin 0.14.0 → 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 +95 -20
- package/dist/index.cjs +350 -113
- package/dist/index.d.cts +14 -2
- package/dist/index.d.ts +14 -2
- package/dist/index.js +354 -115
- package/llms.txt +134 -33
- package/package.json +2 -2
- package/src/dev-environment.ts +268 -0
- package/src/env.ts +54 -20
- package/src/index.ts +415 -172
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,8 +192,9 @@ 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 failures that are still unfixed, one per canister,
|
|
119
|
-
* that was not connected when one happened 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
|
|
@@ -128,8 +206,14 @@ export function icReactor(options: IcReactorPluginOptions): Plugin {
|
|
|
128
206
|
* used to be one slot that any success emptied, so a canister that was still
|
|
129
207
|
* broken vanished from the overlay as soon as another canister regenerated
|
|
130
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`.
|
|
131
212
|
*/
|
|
132
|
-
const pendingFailures = new Map<
|
|
213
|
+
const pendingFailures = new Map<
|
|
214
|
+
CanisterConfig,
|
|
215
|
+
{ message: string; stack: string }
|
|
216
|
+
>()
|
|
133
217
|
|
|
134
218
|
const reportFailure = (
|
|
135
219
|
server: ViteDevServer | null,
|
|
@@ -155,45 +239,61 @@ export function icReactor(options: IcReactorPluginOptions): Plugin {
|
|
|
155
239
|
// Scope, precisely: this covers the watcher path only. `buildStart` calls the
|
|
156
240
|
// pipeline directly and does not register here, so a save landing during the
|
|
157
241
|
// initial generation can still run concurrently with it. That is deliberate
|
|
158
|
-
// rather than an oversight -- since @ic-reactor/codegen
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
//
|
|
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.
|
|
162
247
|
//
|
|
163
248
|
// Note the coalesced promise resolves when the RUNNING pass finishes, not the
|
|
164
|
-
// trailing rerun, so
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
|
|
168
|
-
|
|
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>()
|
|
169
259
|
|
|
170
260
|
const regenerate = (
|
|
171
261
|
canisterConfig: CanisterConfig,
|
|
172
262
|
server: ViteDevServer
|
|
173
263
|
): Promise<void> => {
|
|
174
264
|
const { name } = canisterConfig
|
|
175
|
-
const running = inFlight.get(
|
|
265
|
+
const running = inFlight.get(canisterConfig)
|
|
176
266
|
|
|
177
267
|
if (running) {
|
|
178
|
-
rerunQueued.add(
|
|
268
|
+
rerunQueued.add(canisterConfig)
|
|
179
269
|
return running
|
|
180
270
|
}
|
|
181
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
|
+
|
|
182
281
|
const run = runCanisterPipeline({
|
|
183
282
|
canisterConfig,
|
|
184
283
|
projectRoot,
|
|
185
284
|
globalConfig,
|
|
186
285
|
})
|
|
187
286
|
.then((result) => {
|
|
287
|
+
reportWarnings(name, result.warnings)
|
|
188
288
|
if (result.success) {
|
|
189
289
|
// A later connection must not be handed a failure that has since been
|
|
190
290
|
// fixed.
|
|
191
|
-
pendingFailures.delete(
|
|
291
|
+
pendingFailures.delete(canisterConfig)
|
|
192
292
|
// Reload page to reflect new types/hooks
|
|
193
293
|
server.ws.send({ type: "full-reload" })
|
|
194
294
|
} else {
|
|
195
295
|
pendingFailures.set(
|
|
196
|
-
|
|
296
|
+
canisterConfig,
|
|
197
297
|
reportFailure(
|
|
198
298
|
server,
|
|
199
299
|
`Regeneration failed for ${name}: ${result.error ?? "unknown error"}`
|
|
@@ -207,7 +307,7 @@ export function icReactor(options: IcReactorPluginOptions): Plugin {
|
|
|
207
307
|
// fatal to the dev server.
|
|
208
308
|
.catch((error: unknown) => {
|
|
209
309
|
pendingFailures.set(
|
|
210
|
-
|
|
310
|
+
canisterConfig,
|
|
211
311
|
reportFailure(
|
|
212
312
|
server,
|
|
213
313
|
`Regeneration failed for ${name}: ${describeError(error)}`,
|
|
@@ -216,21 +316,55 @@ export function icReactor(options: IcReactorPluginOptions): Plugin {
|
|
|
216
316
|
)
|
|
217
317
|
})
|
|
218
318
|
.finally(() => {
|
|
219
|
-
inFlight.delete(
|
|
220
|
-
if (rerunQueued.delete(
|
|
319
|
+
inFlight.delete(canisterConfig)
|
|
320
|
+
if (rerunQueued.delete(canisterConfig)) {
|
|
221
321
|
void regenerate(canisterConfig, server)
|
|
222
322
|
}
|
|
223
323
|
})
|
|
224
324
|
|
|
225
|
-
inFlight.set(
|
|
325
|
+
inFlight.set(canisterConfig, run)
|
|
226
326
|
return run
|
|
227
327
|
}
|
|
228
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
|
+
|
|
229
363
|
const plugin: Plugin = {
|
|
230
364
|
name: PLUGIN_NAME,
|
|
231
365
|
enforce: "pre", // Run before other plugins
|
|
232
366
|
|
|
233
|
-
config(userConfig, { command: viteCommand }) {
|
|
367
|
+
async config(userConfig, { command: viteCommand }) {
|
|
234
368
|
command = viteCommand
|
|
235
369
|
|
|
236
370
|
if (viteCommand !== "serve" || !injectEnvironment) {
|
|
@@ -239,113 +373,56 @@ export function icReactor(options: IcReactorPluginOptions): Plugin {
|
|
|
239
373
|
|
|
240
374
|
// ── Local Development Proxy & Cookies ────────────────────────────────
|
|
241
375
|
|
|
242
|
-
//
|
|
243
|
-
const
|
|
244
|
-
.map((c) => c.name)
|
|
245
|
-
.filter((n): n is string => !!n)
|
|
246
|
-
if (!canisterNames.includes("internet_identity")) {
|
|
247
|
-
canisterNames.push("internet_identity")
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
// `configResolved` has not run yet, so resolve the root the way Vite
|
|
251
|
-
// will. icp finds the project from the directory it starts in, and with
|
|
252
|
-
// `vite apps/web` or a `root` option that is not the process cwd.
|
|
253
|
-
const { environment: icEnv, diagnostics } = getIcEnvironmentInfo(
|
|
254
|
-
canisterNames,
|
|
255
|
-
path.resolve(userConfig.root ?? process.cwd())
|
|
256
|
-
)
|
|
376
|
+
// The plugin's own `/api` entry, unless the Vite config has one.
|
|
377
|
+
const ownsApiProxy = !userConfig.server?.proxy?.["/api"]
|
|
257
378
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
canisters.length === 0
|
|
277
|
-
? buildIcEnvCookie(
|
|
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
|
-
}
|
|
297
|
-
|
|
298
|
-
// The replica can be UP -- `icp network status` succeeds, so icEnv is
|
|
299
|
-
// truthy and the check above never fires -- while a configured canister
|
|
300
|
-
// has never been deployed. Every `icp canister status <name>` then fails
|
|
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
|
-
)
|
|
318
|
-
|
|
319
|
-
if (missingCanisterIds.length > 0) {
|
|
320
|
-
const names = missingCanisterIds.map((name) => `"${name}"`).join(", ")
|
|
321
|
-
const it = missingCanisterIds.length === 1 ? "it" : "them"
|
|
322
|
-
console.warn(
|
|
323
|
-
`[ic-reactor] The local replica is running, but no canister ID could be resolved for ${names}. ` +
|
|
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
|
-
)
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
for (const diagnostic of diagnostics) {
|
|
331
|
-
debugLog(diagnostic)
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
const cookieValue = buildIcEnvCookie(
|
|
335
|
-
{
|
|
336
|
-
...icEnv.canisterIds,
|
|
337
|
-
...configuredCanisterIds,
|
|
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
|
+
}
|
|
338
397
|
},
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
398
|
+
})
|
|
399
|
+
localEnvironment = environment
|
|
400
|
+
|
|
401
|
+
const state = await environment.detect()
|
|
402
|
+
warnAboutIncompleteDetection(state, canisters.length > 0, ownsApiProxy)
|
|
342
403
|
|
|
343
404
|
return {
|
|
344
405
|
server: {
|
|
345
|
-
headers:
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
proxy: apiProxy(userConfig,
|
|
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
|
+
}),
|
|
349
426
|
},
|
|
350
427
|
}
|
|
351
428
|
},
|
|
@@ -361,6 +438,12 @@ export function icReactor(options: IcReactorPluginOptions): Plugin {
|
|
|
361
438
|
configureServer(server) {
|
|
362
439
|
devServer = server
|
|
363
440
|
|
|
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
|
+
|
|
364
447
|
// Replay the unfixed failures described at pendingFailures to each client
|
|
365
448
|
// that connects. A canister leaves the replay once it regenerates.
|
|
366
449
|
// Guarded: the peer range spans several Vite majors and `ws.on` is not
|
|
@@ -382,41 +465,117 @@ export function icReactor(options: IcReactorPluginOptions): Plugin {
|
|
|
382
465
|
})
|
|
383
466
|
})
|
|
384
467
|
|
|
385
|
-
// Explicitly watch configured DID files
|
|
468
|
+
// Explicitly watch configured DID files, since they are not in the module graph.
|
|
386
469
|
const didFiles = canisters.map((c) => resolveDidPath(c.didFile))
|
|
387
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
|
+
}
|
|
388
489
|
},
|
|
389
490
|
|
|
390
491
|
async buildStart() {
|
|
391
492
|
// ── Code Generation ──────────────────────────────────────────────────
|
|
392
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
|
+
|
|
393
523
|
console.log(
|
|
394
|
-
`[ic-reactor] Generating canister bindings for ${
|
|
524
|
+
`[ic-reactor] Generating canister bindings for ${pending.length} canisters...`
|
|
395
525
|
)
|
|
396
526
|
|
|
527
|
+
// Each entry is checked just before its pipeline starts, so the check
|
|
528
|
+
// sees the directories the entries before it have claimed.
|
|
397
529
|
const outcomes = await Promise.allSettled(
|
|
398
|
-
|
|
399
|
-
|
|
530
|
+
pending.map((canisterConfig) => {
|
|
531
|
+
const sharedError = sharedOutDirError(canisterConfig)
|
|
532
|
+
if (sharedError !== undefined) {
|
|
533
|
+
return Promise.reject(new SharedOutDirError(sharedError))
|
|
534
|
+
}
|
|
535
|
+
return runCanisterPipeline({
|
|
400
536
|
canisterConfig,
|
|
401
537
|
projectRoot,
|
|
402
538
|
globalConfig,
|
|
403
539
|
})
|
|
404
|
-
)
|
|
540
|
+
})
|
|
405
541
|
)
|
|
406
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
|
+
|
|
407
560
|
// Collect every failure before reporting one: a canister failing must not
|
|
408
561
|
// hide what the others did, and the error should name all of them so a CI
|
|
409
562
|
// log shows the whole picture in one go.
|
|
410
563
|
const failures = outcomes.flatMap((outcome, index) => {
|
|
411
|
-
const
|
|
564
|
+
const canister = pending[index]
|
|
565
|
+
const name = canister?.name ?? `canister #${index}`
|
|
412
566
|
|
|
413
567
|
if (outcome.status === "rejected") {
|
|
414
|
-
|
|
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 }]
|
|
415
574
|
}
|
|
416
575
|
if (!outcome.value.success) {
|
|
417
576
|
return [
|
|
418
577
|
{
|
|
419
|
-
|
|
578
|
+
canister,
|
|
420
579
|
detail: `${name}: ${outcome.value.error ?? "unknown error"}`,
|
|
421
580
|
},
|
|
422
581
|
]
|
|
@@ -429,7 +588,7 @@ export function icReactor(options: IcReactorPluginOptions): Plugin {
|
|
|
429
588
|
}
|
|
430
589
|
|
|
431
590
|
const message =
|
|
432
|
-
`Failed to generate ${failures.length} of ${
|
|
591
|
+
`Failed to generate ${failures.length} of ${pending.length} canisters:\n` +
|
|
433
592
|
failures.map(({ detail }) => ` - ${detail}`).join("\n")
|
|
434
593
|
|
|
435
594
|
// Previously every failure here was a `console.error` and nothing more,
|
|
@@ -443,48 +602,30 @@ export function icReactor(options: IcReactorPluginOptions): Plugin {
|
|
|
443
602
|
|
|
444
603
|
// One entry per canister, so fixing one of them removes only its own line
|
|
445
604
|
// from the replay.
|
|
446
|
-
for (const {
|
|
447
|
-
pendingFailures.set(
|
|
605
|
+
for (const { canister, detail } of failures) {
|
|
606
|
+
pendingFailures.set(canister, {
|
|
448
607
|
message: `[ic-reactor] Failed to generate ${detail}`,
|
|
449
608
|
stack: "",
|
|
450
609
|
})
|
|
451
610
|
}
|
|
452
611
|
},
|
|
612
|
+
}
|
|
453
613
|
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
if (!file.endsWith(".did")) {
|
|
457
|
-
return
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
const changedPath = path.normalize(file)
|
|
461
|
-
// Every canister, not only the first match. Deployed instances of one
|
|
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
|
-
)
|
|
467
|
-
|
|
468
|
-
if (affectedCanisters.length === 0) {
|
|
469
|
-
return
|
|
470
|
-
}
|
|
614
|
+
return plugin
|
|
615
|
+
}
|
|
471
616
|
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
.map((canister) => canister.name)
|
|
475
|
-
.join(", ")}. Regenerating...`
|
|
476
|
-
)
|
|
617
|
+
/** An entry refused because an earlier entry generates into its directory. */
|
|
618
|
+
class SharedOutDirError extends Error {}
|
|
477
619
|
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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}`)
|
|
485
628
|
}
|
|
486
|
-
|
|
487
|
-
return plugin
|
|
488
629
|
}
|
|
489
630
|
|
|
490
631
|
/** One readable line for whatever the pipeline threw. */
|
|
@@ -501,8 +642,16 @@ function describeError(error: unknown): string {
|
|
|
501
642
|
* pointed `/api` at icp-cli's port 8000 got 4943 instead, and nothing reported
|
|
502
643
|
* the swap. Vite's merge skips an undefined value, so returning nothing leaves
|
|
503
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.
|
|
504
649
|
*/
|
|
505
|
-
function apiProxy(
|
|
650
|
+
function apiProxy(
|
|
651
|
+
userConfig: UserConfig,
|
|
652
|
+
target: string,
|
|
653
|
+
configure: (options: ProxyOptions) => void
|
|
654
|
+
): Record<string, ProxyOptions> | undefined {
|
|
506
655
|
if (userConfig.server?.proxy?.["/api"]) {
|
|
507
656
|
debugLog(
|
|
508
657
|
`The Vite config already proxies /api, so the plugin keeps that proxy instead of sending /api to ${target}.`
|
|
@@ -510,7 +659,101 @@ function apiProxy(userConfig: UserConfig, target: string) {
|
|
|
510
659
|
return undefined
|
|
511
660
|
}
|
|
512
661
|
|
|
513
|
-
return {
|
|
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
|
+
}
|
|
514
757
|
}
|
|
515
758
|
|
|
516
759
|
/**
|