@ic-reactor/vite-plugin 0.13.0 → 0.14.0
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 +17 -2
- package/dist/index.cjs +66 -36
- package/dist/index.js +66 -36
- package/llms.txt +8 -1
- package/package.json +4 -4
- package/src/env.ts +12 -2
- package/src/index.ts +95 -40
package/README.md
CHANGED
|
@@ -40,8 +40,14 @@ export const clientManager = new ClientManager({
|
|
|
40
40
|
})
|
|
41
41
|
```
|
|
42
42
|
|
|
43
|
-
No opt-in flag is needed to pick up the plugin's environment
|
|
44
|
-
the `ic_env` cookie and `ClientManager` reads it automatically
|
|
43
|
+
No opt-in flag is needed to pick up the plugin's environment in development:
|
|
44
|
+
the plugin sets the `ic_env` cookie and `ClientManager` reads it automatically
|
|
45
|
+
in the browser. That trust stops at the local replica — cookies are not
|
|
46
|
+
origin-isolated, so on a custom domain or mainnet the cookie is ignored and a
|
|
47
|
+
reactor with no `canisterId` throws. Set the per-canister `canisterId` in the
|
|
48
|
+
plugin config to bake it into the generated output for those builds, or pass
|
|
49
|
+
`allowEnvConfig: true` to `ClientManager` if you trust every subdomain of the
|
|
50
|
+
domain you serve from.
|
|
45
51
|
|
|
46
52
|
The plugin generates files under `src/declarations/<canister>/` by default —
|
|
47
53
|
`declarations/<did-basename>.{js,d.ts,did}` plus a managed `index.generated.ts`
|
|
@@ -51,6 +57,12 @@ exports the reactor and six hooks named after the canister
|
|
|
51
57
|
`use<Canister>InfiniteQuery`, `use<Canister>SuspenseInfiniteQuery`,
|
|
52
58
|
`use<Canister>Mutation`, `use<Canister>Method`).
|
|
53
59
|
|
|
60
|
+
If Prettier resolves from Vite's `config.root`, the plugin formats the
|
|
61
|
+
generated `.js` and `.d.ts` with it and your Prettier config, so a rebuild
|
|
62
|
+
leaves formatted, committed declarations unchanged. Without Prettier they hold
|
|
63
|
+
the Candid parser's output followed by a newline, and a formatting error never
|
|
64
|
+
fails the build.
|
|
65
|
+
|
|
54
66
|
If you want non-React output, set `target: "core"` and install the matching
|
|
55
67
|
runtime package instead of `@ic-reactor/react`.
|
|
56
68
|
|
|
@@ -134,6 +146,9 @@ when that happens with canisters configured, because the failure is otherwise
|
|
|
134
146
|
indistinguishable from success until the app breaks on an undefined canister
|
|
135
147
|
id. Run with `DEBUG=ic-reactor` to see the `icp` output behind the warning.
|
|
136
148
|
|
|
149
|
+
If your Vite config already sets `server.proxy["/api"]`, the plugin leaves that
|
|
150
|
+
entry alone, whether detection succeeds or not.
|
|
151
|
+
|
|
137
152
|
## File Regeneration
|
|
138
153
|
|
|
139
154
|
On startup and on `.did` file changes, the plugin regenerates declarations and
|
package/dist/index.cjs
CHANGED
|
@@ -38,12 +38,13 @@ var import_codegen = require("@ic-reactor/codegen");
|
|
|
38
38
|
|
|
39
39
|
// src/env.ts
|
|
40
40
|
var import_child_process = require("child_process");
|
|
41
|
-
function getIcEnvironmentInfo(canisterNames) {
|
|
41
|
+
function getIcEnvironmentInfo(canisterNames, projectRoot = process.cwd()) {
|
|
42
42
|
const networkName = process.env.ICP_ENVIRONMENT || "local";
|
|
43
43
|
const diagnostics = [];
|
|
44
44
|
try {
|
|
45
45
|
const networkStatus = JSON.parse(
|
|
46
46
|
(0, import_child_process.execFileSync)("icp", ["network", "status", "-e", networkName, "--json"], {
|
|
47
|
+
cwd: projectRoot,
|
|
47
48
|
encoding: "utf-8",
|
|
48
49
|
// stderr is piped rather than ignored so a failure can explain itself.
|
|
49
50
|
// Piping still keeps it off the terminal — it only reaches the user if
|
|
@@ -65,7 +66,11 @@ function getIcEnvironmentInfo(canisterNames) {
|
|
|
65
66
|
const canisterId = (0, import_child_process.execFileSync)(
|
|
66
67
|
"icp",
|
|
67
68
|
["canister", "status", name, "-e", networkName, "-i"],
|
|
68
|
-
{
|
|
69
|
+
{
|
|
70
|
+
cwd: projectRoot,
|
|
71
|
+
encoding: "utf-8",
|
|
72
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
73
|
+
}
|
|
69
74
|
).trim();
|
|
70
75
|
if (canisterId) {
|
|
71
76
|
canisterIds[name] = canisterId;
|
|
@@ -153,7 +158,7 @@ function icReactor(options) {
|
|
|
153
158
|
const configuredCanisterIds = Object.fromEntries(
|
|
154
159
|
canisters.filter((canister) => !!canister.canisterId).map((canister) => [canister.name, canister.canisterId])
|
|
155
160
|
);
|
|
156
|
-
|
|
161
|
+
const pendingFailures = /* @__PURE__ */ new Map();
|
|
157
162
|
const reportFailure = (server, message, cause) => {
|
|
158
163
|
console.error(`[ic-reactor] ${message}`);
|
|
159
164
|
const err = {
|
|
@@ -161,8 +166,8 @@ function icReactor(options) {
|
|
|
161
166
|
stack: cause instanceof Error && cause.stack ? cause.stack : "",
|
|
162
167
|
plugin: PLUGIN_NAME
|
|
163
168
|
};
|
|
164
|
-
pendingFailure = { message: err.message, stack: err.stack };
|
|
165
169
|
server?.ws.send({ type: "error", err });
|
|
170
|
+
return { message: err.message, stack: err.stack };
|
|
166
171
|
};
|
|
167
172
|
const inFlight = /* @__PURE__ */ new Map();
|
|
168
173
|
const rerunQueued = /* @__PURE__ */ new Set();
|
|
@@ -179,19 +184,25 @@ function icReactor(options) {
|
|
|
179
184
|
globalConfig
|
|
180
185
|
}).then((result) => {
|
|
181
186
|
if (result.success) {
|
|
182
|
-
|
|
187
|
+
pendingFailures.delete(name);
|
|
183
188
|
server.ws.send({ type: "full-reload" });
|
|
184
189
|
} else {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
190
|
+
pendingFailures.set(
|
|
191
|
+
name,
|
|
192
|
+
reportFailure(
|
|
193
|
+
server,
|
|
194
|
+
`Regeneration failed for ${name}: ${result.error ?? "unknown error"}`
|
|
195
|
+
)
|
|
188
196
|
);
|
|
189
197
|
}
|
|
190
198
|
}).catch((error) => {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
199
|
+
pendingFailures.set(
|
|
200
|
+
name,
|
|
201
|
+
reportFailure(
|
|
202
|
+
server,
|
|
203
|
+
`Regeneration failed for ${name}: ${describeError(error)}`,
|
|
204
|
+
error
|
|
205
|
+
)
|
|
195
206
|
);
|
|
196
207
|
}).finally(() => {
|
|
197
208
|
inFlight.delete(name);
|
|
@@ -206,7 +217,7 @@ function icReactor(options) {
|
|
|
206
217
|
name: PLUGIN_NAME,
|
|
207
218
|
enforce: "pre",
|
|
208
219
|
// Run before other plugins
|
|
209
|
-
config(
|
|
220
|
+
config(userConfig, { command: viteCommand }) {
|
|
210
221
|
command = viteCommand;
|
|
211
222
|
if (viteCommand !== "serve" || !injectEnvironment) {
|
|
212
223
|
return {};
|
|
@@ -215,7 +226,10 @@ function icReactor(options) {
|
|
|
215
226
|
if (!canisterNames.includes("internet_identity")) {
|
|
216
227
|
canisterNames.push("internet_identity");
|
|
217
228
|
}
|
|
218
|
-
const { environment: icEnv, diagnostics } = getIcEnvironmentInfo(
|
|
229
|
+
const { environment: icEnv, diagnostics } = getIcEnvironmentInfo(
|
|
230
|
+
canisterNames,
|
|
231
|
+
import_node_path.default.resolve(userConfig.root ?? process.cwd())
|
|
232
|
+
);
|
|
219
233
|
if (!icEnv) {
|
|
220
234
|
if (canisters.length > 0) {
|
|
221
235
|
console.warn(
|
|
@@ -235,12 +249,7 @@ function icReactor(options) {
|
|
|
235
249
|
headers: envOnlyCookie ? {
|
|
236
250
|
"Set-Cookie": `ic_env=${envOnlyCookie}; Path=/; SameSite=Lax;`
|
|
237
251
|
} : void 0,
|
|
238
|
-
proxy:
|
|
239
|
-
"/api": {
|
|
240
|
-
target: DEFAULT_LOCAL_REPLICA,
|
|
241
|
-
changeOrigin: true
|
|
242
|
-
}
|
|
243
|
-
}
|
|
252
|
+
proxy: apiProxy(userConfig, DEFAULT_LOCAL_REPLICA)
|
|
244
253
|
}
|
|
245
254
|
};
|
|
246
255
|
}
|
|
@@ -270,12 +279,7 @@ function icReactor(options) {
|
|
|
270
279
|
headers: {
|
|
271
280
|
"Set-Cookie": `ic_env=${cookieValue}; Path=/; SameSite=Lax;`
|
|
272
281
|
},
|
|
273
|
-
proxy:
|
|
274
|
-
"/api": {
|
|
275
|
-
target: icEnv.proxyTarget,
|
|
276
|
-
changeOrigin: true
|
|
277
|
-
}
|
|
278
|
-
}
|
|
282
|
+
proxy: apiProxy(userConfig, icEnv.proxyTarget)
|
|
279
283
|
}
|
|
280
284
|
};
|
|
281
285
|
},
|
|
@@ -286,10 +290,15 @@ function icReactor(options) {
|
|
|
286
290
|
configureServer(server) {
|
|
287
291
|
devServer = server;
|
|
288
292
|
server.ws.on?.("connection", () => {
|
|
289
|
-
if (
|
|
293
|
+
if (pendingFailures.size === 0) return;
|
|
294
|
+
const failures = [...pendingFailures.values()];
|
|
290
295
|
server.ws.send({
|
|
291
296
|
type: "error",
|
|
292
|
-
err: {
|
|
297
|
+
err: {
|
|
298
|
+
message: failures.map((failure) => failure.message).join("\n"),
|
|
299
|
+
stack: failures.map((failure) => failure.stack).filter(Boolean).join("\n"),
|
|
300
|
+
plugin: PLUGIN_NAME
|
|
301
|
+
}
|
|
293
302
|
});
|
|
294
303
|
});
|
|
295
304
|
const didFiles = canisters.map((c) => resolveDidPath(c.didFile));
|
|
@@ -311,10 +320,15 @@ function icReactor(options) {
|
|
|
311
320
|
const failures = outcomes.flatMap((outcome, index) => {
|
|
312
321
|
const name = canisters[index]?.name ?? `canister #${index}`;
|
|
313
322
|
if (outcome.status === "rejected") {
|
|
314
|
-
return [`${name}: ${describeError(outcome.reason)}`];
|
|
323
|
+
return [{ name, detail: `${name}: ${describeError(outcome.reason)}` }];
|
|
315
324
|
}
|
|
316
325
|
if (!outcome.value.success) {
|
|
317
|
-
return [
|
|
326
|
+
return [
|
|
327
|
+
{
|
|
328
|
+
name,
|
|
329
|
+
detail: `${name}: ${outcome.value.error ?? "unknown error"}`
|
|
330
|
+
}
|
|
331
|
+
];
|
|
318
332
|
}
|
|
319
333
|
return [];
|
|
320
334
|
});
|
|
@@ -322,28 +336,35 @@ function icReactor(options) {
|
|
|
322
336
|
return;
|
|
323
337
|
}
|
|
324
338
|
const message = `Failed to generate ${failures.length} of ${canisters.length} canisters:
|
|
325
|
-
` + failures.map((
|
|
339
|
+
` + failures.map(({ detail }) => ` - ${detail}`).join("\n");
|
|
326
340
|
if (failOnError ?? command === "build") {
|
|
327
341
|
this.error(`[ic-reactor] ${message}`);
|
|
328
342
|
}
|
|
329
343
|
reportFailure(devServer, message);
|
|
344
|
+
for (const { name, detail } of failures) {
|
|
345
|
+
pendingFailures.set(name, {
|
|
346
|
+
message: `[ic-reactor] Failed to generate ${detail}`,
|
|
347
|
+
stack: ""
|
|
348
|
+
});
|
|
349
|
+
}
|
|
330
350
|
},
|
|
331
351
|
handleHotUpdate({ file, server }) {
|
|
332
352
|
if (!file.endsWith(".did")) {
|
|
333
353
|
return;
|
|
334
354
|
}
|
|
335
355
|
const changedPath = import_node_path.default.normalize(file);
|
|
336
|
-
const
|
|
337
|
-
// Check if changed file matches configured didFile
|
|
356
|
+
const affectedCanisters = canisters.filter(
|
|
338
357
|
(canister) => resolveDidPath(canister.didFile) === changedPath
|
|
339
358
|
);
|
|
340
|
-
if (
|
|
359
|
+
if (affectedCanisters.length === 0) {
|
|
341
360
|
return;
|
|
342
361
|
}
|
|
343
362
|
console.log(
|
|
344
|
-
`[ic-reactor] .did file changed: ${
|
|
363
|
+
`[ic-reactor] .did file changed: ${affectedCanisters.map((canister) => canister.name).join(", ")}. Regenerating...`
|
|
345
364
|
);
|
|
346
|
-
return
|
|
365
|
+
return Promise.all(
|
|
366
|
+
affectedCanisters.map((canister) => regenerate(canister, server))
|
|
367
|
+
).then(() => void 0);
|
|
347
368
|
}
|
|
348
369
|
};
|
|
349
370
|
return plugin;
|
|
@@ -351,6 +372,15 @@ function icReactor(options) {
|
|
|
351
372
|
function describeError(error) {
|
|
352
373
|
return error instanceof Error ? error.message : String(error);
|
|
353
374
|
}
|
|
375
|
+
function apiProxy(userConfig, target) {
|
|
376
|
+
if (userConfig.server?.proxy?.["/api"]) {
|
|
377
|
+
debugLog(
|
|
378
|
+
`The Vite config already proxies /api, so the plugin keeps that proxy instead of sending /api to ${target}.`
|
|
379
|
+
);
|
|
380
|
+
return void 0;
|
|
381
|
+
}
|
|
382
|
+
return { "/api": { target, changeOrigin: true } };
|
|
383
|
+
}
|
|
354
384
|
function debugLog(message) {
|
|
355
385
|
const enabled = (process.env.DEBUG ?? "").split(",").some((entry) => {
|
|
356
386
|
const scope = entry.trim();
|
package/dist/index.js
CHANGED
|
@@ -6,12 +6,13 @@ import {
|
|
|
6
6
|
|
|
7
7
|
// src/env.ts
|
|
8
8
|
import { execFileSync } from "child_process";
|
|
9
|
-
function getIcEnvironmentInfo(canisterNames) {
|
|
9
|
+
function getIcEnvironmentInfo(canisterNames, projectRoot = process.cwd()) {
|
|
10
10
|
const networkName = process.env.ICP_ENVIRONMENT || "local";
|
|
11
11
|
const diagnostics = [];
|
|
12
12
|
try {
|
|
13
13
|
const networkStatus = JSON.parse(
|
|
14
14
|
execFileSync("icp", ["network", "status", "-e", networkName, "--json"], {
|
|
15
|
+
cwd: projectRoot,
|
|
15
16
|
encoding: "utf-8",
|
|
16
17
|
// stderr is piped rather than ignored so a failure can explain itself.
|
|
17
18
|
// Piping still keeps it off the terminal — it only reaches the user if
|
|
@@ -33,7 +34,11 @@ function getIcEnvironmentInfo(canisterNames) {
|
|
|
33
34
|
const canisterId = execFileSync(
|
|
34
35
|
"icp",
|
|
35
36
|
["canister", "status", name, "-e", networkName, "-i"],
|
|
36
|
-
{
|
|
37
|
+
{
|
|
38
|
+
cwd: projectRoot,
|
|
39
|
+
encoding: "utf-8",
|
|
40
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
41
|
+
}
|
|
37
42
|
).trim();
|
|
38
43
|
if (canisterId) {
|
|
39
44
|
canisterIds[name] = canisterId;
|
|
@@ -121,7 +126,7 @@ function icReactor(options) {
|
|
|
121
126
|
const configuredCanisterIds = Object.fromEntries(
|
|
122
127
|
canisters.filter((canister) => !!canister.canisterId).map((canister) => [canister.name, canister.canisterId])
|
|
123
128
|
);
|
|
124
|
-
|
|
129
|
+
const pendingFailures = /* @__PURE__ */ new Map();
|
|
125
130
|
const reportFailure = (server, message, cause) => {
|
|
126
131
|
console.error(`[ic-reactor] ${message}`);
|
|
127
132
|
const err = {
|
|
@@ -129,8 +134,8 @@ function icReactor(options) {
|
|
|
129
134
|
stack: cause instanceof Error && cause.stack ? cause.stack : "",
|
|
130
135
|
plugin: PLUGIN_NAME
|
|
131
136
|
};
|
|
132
|
-
pendingFailure = { message: err.message, stack: err.stack };
|
|
133
137
|
server?.ws.send({ type: "error", err });
|
|
138
|
+
return { message: err.message, stack: err.stack };
|
|
134
139
|
};
|
|
135
140
|
const inFlight = /* @__PURE__ */ new Map();
|
|
136
141
|
const rerunQueued = /* @__PURE__ */ new Set();
|
|
@@ -147,19 +152,25 @@ function icReactor(options) {
|
|
|
147
152
|
globalConfig
|
|
148
153
|
}).then((result) => {
|
|
149
154
|
if (result.success) {
|
|
150
|
-
|
|
155
|
+
pendingFailures.delete(name);
|
|
151
156
|
server.ws.send({ type: "full-reload" });
|
|
152
157
|
} else {
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
158
|
+
pendingFailures.set(
|
|
159
|
+
name,
|
|
160
|
+
reportFailure(
|
|
161
|
+
server,
|
|
162
|
+
`Regeneration failed for ${name}: ${result.error ?? "unknown error"}`
|
|
163
|
+
)
|
|
156
164
|
);
|
|
157
165
|
}
|
|
158
166
|
}).catch((error) => {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
167
|
+
pendingFailures.set(
|
|
168
|
+
name,
|
|
169
|
+
reportFailure(
|
|
170
|
+
server,
|
|
171
|
+
`Regeneration failed for ${name}: ${describeError(error)}`,
|
|
172
|
+
error
|
|
173
|
+
)
|
|
163
174
|
);
|
|
164
175
|
}).finally(() => {
|
|
165
176
|
inFlight.delete(name);
|
|
@@ -174,7 +185,7 @@ function icReactor(options) {
|
|
|
174
185
|
name: PLUGIN_NAME,
|
|
175
186
|
enforce: "pre",
|
|
176
187
|
// Run before other plugins
|
|
177
|
-
config(
|
|
188
|
+
config(userConfig, { command: viteCommand }) {
|
|
178
189
|
command = viteCommand;
|
|
179
190
|
if (viteCommand !== "serve" || !injectEnvironment) {
|
|
180
191
|
return {};
|
|
@@ -183,7 +194,10 @@ function icReactor(options) {
|
|
|
183
194
|
if (!canisterNames.includes("internet_identity")) {
|
|
184
195
|
canisterNames.push("internet_identity");
|
|
185
196
|
}
|
|
186
|
-
const { environment: icEnv, diagnostics } = getIcEnvironmentInfo(
|
|
197
|
+
const { environment: icEnv, diagnostics } = getIcEnvironmentInfo(
|
|
198
|
+
canisterNames,
|
|
199
|
+
path.resolve(userConfig.root ?? process.cwd())
|
|
200
|
+
);
|
|
187
201
|
if (!icEnv) {
|
|
188
202
|
if (canisters.length > 0) {
|
|
189
203
|
console.warn(
|
|
@@ -203,12 +217,7 @@ function icReactor(options) {
|
|
|
203
217
|
headers: envOnlyCookie ? {
|
|
204
218
|
"Set-Cookie": `ic_env=${envOnlyCookie}; Path=/; SameSite=Lax;`
|
|
205
219
|
} : void 0,
|
|
206
|
-
proxy:
|
|
207
|
-
"/api": {
|
|
208
|
-
target: DEFAULT_LOCAL_REPLICA,
|
|
209
|
-
changeOrigin: true
|
|
210
|
-
}
|
|
211
|
-
}
|
|
220
|
+
proxy: apiProxy(userConfig, DEFAULT_LOCAL_REPLICA)
|
|
212
221
|
}
|
|
213
222
|
};
|
|
214
223
|
}
|
|
@@ -238,12 +247,7 @@ function icReactor(options) {
|
|
|
238
247
|
headers: {
|
|
239
248
|
"Set-Cookie": `ic_env=${cookieValue}; Path=/; SameSite=Lax;`
|
|
240
249
|
},
|
|
241
|
-
proxy:
|
|
242
|
-
"/api": {
|
|
243
|
-
target: icEnv.proxyTarget,
|
|
244
|
-
changeOrigin: true
|
|
245
|
-
}
|
|
246
|
-
}
|
|
250
|
+
proxy: apiProxy(userConfig, icEnv.proxyTarget)
|
|
247
251
|
}
|
|
248
252
|
};
|
|
249
253
|
},
|
|
@@ -254,10 +258,15 @@ function icReactor(options) {
|
|
|
254
258
|
configureServer(server) {
|
|
255
259
|
devServer = server;
|
|
256
260
|
server.ws.on?.("connection", () => {
|
|
257
|
-
if (
|
|
261
|
+
if (pendingFailures.size === 0) return;
|
|
262
|
+
const failures = [...pendingFailures.values()];
|
|
258
263
|
server.ws.send({
|
|
259
264
|
type: "error",
|
|
260
|
-
err: {
|
|
265
|
+
err: {
|
|
266
|
+
message: failures.map((failure) => failure.message).join("\n"),
|
|
267
|
+
stack: failures.map((failure) => failure.stack).filter(Boolean).join("\n"),
|
|
268
|
+
plugin: PLUGIN_NAME
|
|
269
|
+
}
|
|
261
270
|
});
|
|
262
271
|
});
|
|
263
272
|
const didFiles = canisters.map((c) => resolveDidPath(c.didFile));
|
|
@@ -279,10 +288,15 @@ function icReactor(options) {
|
|
|
279
288
|
const failures = outcomes.flatMap((outcome, index) => {
|
|
280
289
|
const name = canisters[index]?.name ?? `canister #${index}`;
|
|
281
290
|
if (outcome.status === "rejected") {
|
|
282
|
-
return [`${name}: ${describeError(outcome.reason)}`];
|
|
291
|
+
return [{ name, detail: `${name}: ${describeError(outcome.reason)}` }];
|
|
283
292
|
}
|
|
284
293
|
if (!outcome.value.success) {
|
|
285
|
-
return [
|
|
294
|
+
return [
|
|
295
|
+
{
|
|
296
|
+
name,
|
|
297
|
+
detail: `${name}: ${outcome.value.error ?? "unknown error"}`
|
|
298
|
+
}
|
|
299
|
+
];
|
|
286
300
|
}
|
|
287
301
|
return [];
|
|
288
302
|
});
|
|
@@ -290,28 +304,35 @@ function icReactor(options) {
|
|
|
290
304
|
return;
|
|
291
305
|
}
|
|
292
306
|
const message = `Failed to generate ${failures.length} of ${canisters.length} canisters:
|
|
293
|
-
` + failures.map((
|
|
307
|
+
` + failures.map(({ detail }) => ` - ${detail}`).join("\n");
|
|
294
308
|
if (failOnError ?? command === "build") {
|
|
295
309
|
this.error(`[ic-reactor] ${message}`);
|
|
296
310
|
}
|
|
297
311
|
reportFailure(devServer, message);
|
|
312
|
+
for (const { name, detail } of failures) {
|
|
313
|
+
pendingFailures.set(name, {
|
|
314
|
+
message: `[ic-reactor] Failed to generate ${detail}`,
|
|
315
|
+
stack: ""
|
|
316
|
+
});
|
|
317
|
+
}
|
|
298
318
|
},
|
|
299
319
|
handleHotUpdate({ file, server }) {
|
|
300
320
|
if (!file.endsWith(".did")) {
|
|
301
321
|
return;
|
|
302
322
|
}
|
|
303
323
|
const changedPath = path.normalize(file);
|
|
304
|
-
const
|
|
305
|
-
// Check if changed file matches configured didFile
|
|
324
|
+
const affectedCanisters = canisters.filter(
|
|
306
325
|
(canister) => resolveDidPath(canister.didFile) === changedPath
|
|
307
326
|
);
|
|
308
|
-
if (
|
|
327
|
+
if (affectedCanisters.length === 0) {
|
|
309
328
|
return;
|
|
310
329
|
}
|
|
311
330
|
console.log(
|
|
312
|
-
`[ic-reactor] .did file changed: ${
|
|
331
|
+
`[ic-reactor] .did file changed: ${affectedCanisters.map((canister) => canister.name).join(", ")}. Regenerating...`
|
|
313
332
|
);
|
|
314
|
-
return
|
|
333
|
+
return Promise.all(
|
|
334
|
+
affectedCanisters.map((canister) => regenerate(canister, server))
|
|
335
|
+
).then(() => void 0);
|
|
315
336
|
}
|
|
316
337
|
};
|
|
317
338
|
return plugin;
|
|
@@ -319,6 +340,15 @@ function icReactor(options) {
|
|
|
319
340
|
function describeError(error) {
|
|
320
341
|
return error instanceof Error ? error.message : String(error);
|
|
321
342
|
}
|
|
343
|
+
function apiProxy(userConfig, target) {
|
|
344
|
+
if (userConfig.server?.proxy?.["/api"]) {
|
|
345
|
+
debugLog(
|
|
346
|
+
`The Vite config already proxies /api, so the plugin keeps that proxy instead of sending /api to ${target}.`
|
|
347
|
+
);
|
|
348
|
+
return void 0;
|
|
349
|
+
}
|
|
350
|
+
return { "/api": { target, changeOrigin: true } };
|
|
351
|
+
}
|
|
322
352
|
function debugLog(message) {
|
|
323
353
|
const enabled = (process.env.DEBUG ?? "").split(",").some((entry) => {
|
|
324
354
|
const scope = entry.trim();
|
package/llms.txt
CHANGED
|
@@ -34,7 +34,14 @@ icReactor({
|
|
|
34
34
|
`injectEnvironment`, `failOnError`
|
|
35
35
|
- Per canister (inside `canisters[]`): `name`, `didFile`, `outDir`,
|
|
36
36
|
`clientManagerPath`, `target`, `mode`, `canisterId`
|
|
37
|
-
- `injectEnvironment` for local `ic_env` cookie + `/api` proxy behavior
|
|
37
|
+
- `injectEnvironment` for local `ic_env` cookie + `/api` proxy behavior. The
|
|
38
|
+
consumer side is a positive allowlist: `ClientManager` trusts the cookie — its
|
|
39
|
+
root key, its Internet Identity provider, and the canister IDs a reactor
|
|
40
|
+
resolves by name — only for loopback, `localhost`, and dev-container hosts.
|
|
41
|
+
Serving a dev build over a tunnel or custom domain needs
|
|
42
|
+
`allowEnvConfig: true` (formerly `allowEnvRootKey`); without it a generated
|
|
43
|
+
reactor that carries no `canisterId` throws instead of reading one from the
|
|
44
|
+
cookie — see `packages/core/llms.txt`
|
|
38
45
|
- `failOnError` overrides the default (`true` for build, `false` for dev)
|
|
39
46
|
|
|
40
47
|
## Best Practices
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ic-reactor/vite-plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "Vite plugin for zero-config IC reactor generation from Candid files",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.cjs",
|
|
@@ -50,16 +50,16 @@
|
|
|
50
50
|
},
|
|
51
51
|
"homepage": "https://ic-reactor.b3pay.net/v3/packages/vite-plugin",
|
|
52
52
|
"dependencies": {
|
|
53
|
-
"@ic-reactor/codegen": "0.
|
|
53
|
+
"@ic-reactor/codegen": "0.14.0"
|
|
54
54
|
},
|
|
55
55
|
"peerDependencies": {
|
|
56
56
|
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
|
-
"@types/node": "^26.
|
|
59
|
+
"@types/node": "^26.5.1",
|
|
60
60
|
"tsup": "^8.5.1",
|
|
61
61
|
"typescript": "^6.0.3",
|
|
62
|
-
"vitest": "^
|
|
62
|
+
"vitest": "^5.0.0"
|
|
63
63
|
},
|
|
64
64
|
"scripts": {
|
|
65
65
|
"build": "tsup src/index.ts --format esm,cjs --dts --clean --tsconfig tsconfig.json",
|
package/src/env.ts
CHANGED
|
@@ -29,9 +29,14 @@ export interface IcEnvironmentDetection {
|
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
31
|
* Detect the IC environment using the `icp` CLI.
|
|
32
|
+
*
|
|
33
|
+
* @param projectRoot - Directory `icp` runs in. `icp` finds its project by
|
|
34
|
+
* looking for `icp.yaml` there and in each parent directory, so this has to be
|
|
35
|
+
* the app's root and not wherever the process happened to start.
|
|
32
36
|
*/
|
|
33
37
|
export function getIcEnvironmentInfo(
|
|
34
|
-
canisterNames: string[]
|
|
38
|
+
canisterNames: string[],
|
|
39
|
+
projectRoot: string = process.cwd()
|
|
35
40
|
): IcEnvironmentDetection {
|
|
36
41
|
const networkName = process.env.ICP_ENVIRONMENT || "local"
|
|
37
42
|
const diagnostics: string[] = []
|
|
@@ -39,6 +44,7 @@ export function getIcEnvironmentInfo(
|
|
|
39
44
|
try {
|
|
40
45
|
const networkStatus = JSON.parse(
|
|
41
46
|
execFileSync("icp", ["network", "status", "-e", networkName, "--json"], {
|
|
47
|
+
cwd: projectRoot,
|
|
42
48
|
encoding: "utf-8",
|
|
43
49
|
// stderr is piped rather than ignored so a failure can explain itself.
|
|
44
50
|
// Piping still keeps it off the terminal — it only reaches the user if
|
|
@@ -69,7 +75,11 @@ export function getIcEnvironmentInfo(
|
|
|
69
75
|
const canisterId = execFileSync(
|
|
70
76
|
"icp",
|
|
71
77
|
["canister", "status", name, "-e", networkName, "-i"],
|
|
72
|
-
{
|
|
78
|
+
{
|
|
79
|
+
cwd: projectRoot,
|
|
80
|
+
encoding: "utf-8",
|
|
81
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
82
|
+
}
|
|
73
83
|
).trim()
|
|
74
84
|
|
|
75
85
|
if (canisterId) {
|
package/src/index.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* 3. Hot-reloads when .did files change
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import type { Plugin, ResolvedConfig, ViteDevServer } from "vite"
|
|
10
|
+
import type { Plugin, ResolvedConfig, UserConfig, ViteDevServer } from "vite"
|
|
11
11
|
import path from "node:path"
|
|
12
12
|
import {
|
|
13
13
|
runCanisterPipeline,
|
|
@@ -115,16 +115,21 @@ export function icReactor(options: IcReactorPluginOptions): Plugin {
|
|
|
115
115
|
* the browser error overlay is the signal that actually gets noticed.
|
|
116
116
|
*/
|
|
117
117
|
/**
|
|
118
|
-
* The
|
|
119
|
-
* still gets the overlay.
|
|
118
|
+
* The failures that are still unfixed, one per canister, kept so a browser
|
|
119
|
+
* that was not connected when one happened still gets the overlay.
|
|
120
120
|
*
|
|
121
121
|
* Vite awaits the plugin container's `buildStart` before the HTTP server
|
|
122
122
|
* starts listening, so a generation failure during `vite dev` startup is
|
|
123
123
|
* broadcast when there are no WebSocket clients at all and the payload is
|
|
124
124
|
* simply dropped. The terminal shows it; the overlay never appears — for
|
|
125
125
|
* precisely the failures a developer is most likely to hit.
|
|
126
|
+
*
|
|
127
|
+
* A success only proves that the canister which regenerated is fixed. This
|
|
128
|
+
* used to be one slot that any success emptied, so a canister that was still
|
|
129
|
+
* broken vanished from the overlay as soon as another canister regenerated
|
|
130
|
+
* and its reload reconnected every tab.
|
|
126
131
|
*/
|
|
127
|
-
|
|
132
|
+
const pendingFailures = new Map<string, { message: string; stack: string }>()
|
|
128
133
|
|
|
129
134
|
const reportFailure = (
|
|
130
135
|
server: ViteDevServer | null,
|
|
@@ -139,8 +144,8 @@ export function icReactor(options: IcReactorPluginOptions): Plugin {
|
|
|
139
144
|
plugin: PLUGIN_NAME,
|
|
140
145
|
}
|
|
141
146
|
|
|
142
|
-
pendingFailure = { message: err.message, stack: err.stack }
|
|
143
147
|
server?.ws.send({ type: "error", err })
|
|
148
|
+
return { message: err.message, stack: err.stack }
|
|
144
149
|
}
|
|
145
150
|
|
|
146
151
|
// Keep at most one regeneration per canister in flight and collapse every save
|
|
@@ -183,13 +188,16 @@ export function icReactor(options: IcReactorPluginOptions): Plugin {
|
|
|
183
188
|
if (result.success) {
|
|
184
189
|
// A later connection must not be handed a failure that has since been
|
|
185
190
|
// fixed.
|
|
186
|
-
|
|
191
|
+
pendingFailures.delete(name)
|
|
187
192
|
// Reload page to reflect new types/hooks
|
|
188
193
|
server.ws.send({ type: "full-reload" })
|
|
189
194
|
} else {
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
195
|
+
pendingFailures.set(
|
|
196
|
+
name,
|
|
197
|
+
reportFailure(
|
|
198
|
+
server,
|
|
199
|
+
`Regeneration failed for ${name}: ${result.error ?? "unknown error"}`
|
|
200
|
+
)
|
|
193
201
|
)
|
|
194
202
|
}
|
|
195
203
|
})
|
|
@@ -198,10 +206,13 @@ export function icReactor(options: IcReactorPluginOptions): Plugin {
|
|
|
198
206
|
// rejection: invisible in the browser and, depending on the Node version,
|
|
199
207
|
// fatal to the dev server.
|
|
200
208
|
.catch((error: unknown) => {
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
209
|
+
pendingFailures.set(
|
|
210
|
+
name,
|
|
211
|
+
reportFailure(
|
|
212
|
+
server,
|
|
213
|
+
`Regeneration failed for ${name}: ${describeError(error)}`,
|
|
214
|
+
error
|
|
215
|
+
)
|
|
205
216
|
)
|
|
206
217
|
})
|
|
207
218
|
.finally(() => {
|
|
@@ -219,7 +230,7 @@ export function icReactor(options: IcReactorPluginOptions): Plugin {
|
|
|
219
230
|
name: PLUGIN_NAME,
|
|
220
231
|
enforce: "pre", // Run before other plugins
|
|
221
232
|
|
|
222
|
-
config(
|
|
233
|
+
config(userConfig, { command: viteCommand }) {
|
|
223
234
|
command = viteCommand
|
|
224
235
|
|
|
225
236
|
if (viteCommand !== "serve" || !injectEnvironment) {
|
|
@@ -236,8 +247,13 @@ export function icReactor(options: IcReactorPluginOptions): Plugin {
|
|
|
236
247
|
canisterNames.push("internet_identity")
|
|
237
248
|
}
|
|
238
249
|
|
|
239
|
-
|
|
240
|
-
|
|
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
|
+
)
|
|
241
257
|
|
|
242
258
|
if (!icEnv) {
|
|
243
259
|
// Failing detection used to be indistinguishable from success: no
|
|
@@ -274,12 +290,7 @@ export function icReactor(options: IcReactorPluginOptions): Plugin {
|
|
|
274
290
|
"Set-Cookie": `ic_env=${envOnlyCookie}; Path=/; SameSite=Lax;`,
|
|
275
291
|
}
|
|
276
292
|
: undefined,
|
|
277
|
-
proxy:
|
|
278
|
-
"/api": {
|
|
279
|
-
target: DEFAULT_LOCAL_REPLICA,
|
|
280
|
-
changeOrigin: true,
|
|
281
|
-
},
|
|
282
|
-
},
|
|
293
|
+
proxy: apiProxy(userConfig, DEFAULT_LOCAL_REPLICA),
|
|
283
294
|
},
|
|
284
295
|
}
|
|
285
296
|
}
|
|
@@ -334,12 +345,7 @@ export function icReactor(options: IcReactorPluginOptions): Plugin {
|
|
|
334
345
|
headers: {
|
|
335
346
|
"Set-Cookie": `ic_env=${cookieValue}; Path=/; SameSite=Lax;`,
|
|
336
347
|
},
|
|
337
|
-
proxy:
|
|
338
|
-
"/api": {
|
|
339
|
-
target: icEnv.proxyTarget,
|
|
340
|
-
changeOrigin: true,
|
|
341
|
-
},
|
|
342
|
-
},
|
|
348
|
+
proxy: apiProxy(userConfig, icEnv.proxyTarget),
|
|
343
349
|
},
|
|
344
350
|
}
|
|
345
351
|
},
|
|
@@ -355,16 +361,24 @@ export function icReactor(options: IcReactorPluginOptions): Plugin {
|
|
|
355
361
|
configureServer(server) {
|
|
356
362
|
devServer = server
|
|
357
363
|
|
|
358
|
-
// Replay
|
|
359
|
-
//
|
|
364
|
+
// Replay the unfixed failures described at pendingFailures to each client
|
|
365
|
+
// that connects. A canister leaves the replay once it regenerates.
|
|
360
366
|
// Guarded: the peer range spans several Vite majors and `ws.on` is not
|
|
361
367
|
// present on every one of them. Losing the replay is acceptable; throwing
|
|
362
368
|
// out of configureServer is not.
|
|
363
369
|
server.ws.on?.("connection", () => {
|
|
364
|
-
if (
|
|
370
|
+
if (pendingFailures.size === 0) return
|
|
371
|
+
const failures = [...pendingFailures.values()]
|
|
365
372
|
server.ws.send({
|
|
366
373
|
type: "error",
|
|
367
|
-
err: {
|
|
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
|
+
},
|
|
368
382
|
})
|
|
369
383
|
})
|
|
370
384
|
|
|
@@ -397,10 +411,15 @@ export function icReactor(options: IcReactorPluginOptions): Plugin {
|
|
|
397
411
|
const name = canisters[index]?.name ?? `canister #${index}`
|
|
398
412
|
|
|
399
413
|
if (outcome.status === "rejected") {
|
|
400
|
-
return [`${name}: ${describeError(outcome.reason)}`]
|
|
414
|
+
return [{ name, detail: `${name}: ${describeError(outcome.reason)}` }]
|
|
401
415
|
}
|
|
402
416
|
if (!outcome.value.success) {
|
|
403
|
-
return [
|
|
417
|
+
return [
|
|
418
|
+
{
|
|
419
|
+
name,
|
|
420
|
+
detail: `${name}: ${outcome.value.error ?? "unknown error"}`,
|
|
421
|
+
},
|
|
422
|
+
]
|
|
404
423
|
}
|
|
405
424
|
return []
|
|
406
425
|
})
|
|
@@ -411,7 +430,7 @@ export function icReactor(options: IcReactorPluginOptions): Plugin {
|
|
|
411
430
|
|
|
412
431
|
const message =
|
|
413
432
|
`Failed to generate ${failures.length} of ${canisters.length} canisters:\n` +
|
|
414
|
-
failures.map((
|
|
433
|
+
failures.map(({ detail }) => ` - ${detail}`).join("\n")
|
|
415
434
|
|
|
416
435
|
// Previously every failure here was a `console.error` and nothing more,
|
|
417
436
|
// so `vite build` exited 0 and CI shipped whatever stale bindings were
|
|
@@ -421,6 +440,15 @@ export function icReactor(options: IcReactorPluginOptions): Plugin {
|
|
|
421
440
|
}
|
|
422
441
|
|
|
423
442
|
reportFailure(devServer, message)
|
|
443
|
+
|
|
444
|
+
// One entry per canister, so fixing one of them removes only its own line
|
|
445
|
+
// from the replay.
|
|
446
|
+
for (const { name, detail } of failures) {
|
|
447
|
+
pendingFailures.set(name, {
|
|
448
|
+
message: `[ic-reactor] Failed to generate ${detail}`,
|
|
449
|
+
stack: "",
|
|
450
|
+
})
|
|
451
|
+
}
|
|
424
452
|
},
|
|
425
453
|
|
|
426
454
|
handleHotUpdate({ file, server }) {
|
|
@@ -430,23 +458,29 @@ export function icReactor(options: IcReactorPluginOptions): Plugin {
|
|
|
430
458
|
}
|
|
431
459
|
|
|
432
460
|
const changedPath = path.normalize(file)
|
|
433
|
-
|
|
434
|
-
|
|
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(
|
|
435
465
|
(canister) => resolveDidPath(canister.didFile) === changedPath
|
|
436
466
|
)
|
|
437
467
|
|
|
438
|
-
if (
|
|
468
|
+
if (affectedCanisters.length === 0) {
|
|
439
469
|
return
|
|
440
470
|
}
|
|
441
471
|
|
|
442
472
|
console.log(
|
|
443
|
-
`[ic-reactor] .did file changed: ${
|
|
473
|
+
`[ic-reactor] .did file changed: ${affectedCanisters
|
|
474
|
+
.map((canister) => canister.name)
|
|
475
|
+
.join(", ")}. Regenerating...`
|
|
444
476
|
)
|
|
445
477
|
|
|
446
478
|
// Returned so Vite waits for the write to finish before applying the
|
|
447
479
|
// update; it resolves to `undefined`, which leaves the affected module
|
|
448
480
|
// list untouched.
|
|
449
|
-
return
|
|
481
|
+
return Promise.all(
|
|
482
|
+
affectedCanisters.map((canister) => regenerate(canister, server))
|
|
483
|
+
).then(() => undefined)
|
|
450
484
|
},
|
|
451
485
|
}
|
|
452
486
|
|
|
@@ -458,6 +492,27 @@ function describeError(error: unknown): string {
|
|
|
458
492
|
return error instanceof Error ? error.message : String(error)
|
|
459
493
|
}
|
|
460
494
|
|
|
495
|
+
/**
|
|
496
|
+
* The `server.proxy` entry that sends `/api` to `target`, or nothing when the
|
|
497
|
+
* user's Vite config already proxies `/api`.
|
|
498
|
+
*
|
|
499
|
+
* Vite deep-merges the object a `config` hook returns over the user's config,
|
|
500
|
+
* so an `/api` entry returned here replaced the user's own. A project that
|
|
501
|
+
* pointed `/api` at icp-cli's port 8000 got 4943 instead, and nothing reported
|
|
502
|
+
* the swap. Vite's merge skips an undefined value, so returning nothing leaves
|
|
503
|
+
* the user's entry in place.
|
|
504
|
+
*/
|
|
505
|
+
function apiProxy(userConfig: UserConfig, target: string) {
|
|
506
|
+
if (userConfig.server?.proxy?.["/api"]) {
|
|
507
|
+
debugLog(
|
|
508
|
+
`The Vite config already proxies /api, so the plugin keeps that proxy instead of sending /api to ${target}.`
|
|
509
|
+
)
|
|
510
|
+
return undefined
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
return { "/api": { target, changeOrigin: true } }
|
|
514
|
+
}
|
|
515
|
+
|
|
461
516
|
/**
|
|
462
517
|
* `icp` failing to answer is routine — no local replica, or a canister that has
|
|
463
518
|
* never been deployed — so its stderr is noise until someone is actually
|