@ic-reactor/vite-plugin 0.14.0 → 4.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,24 +1,41 @@
1
1
  // src/index.ts
2
- import path from "path";
3
- import {
4
- runCanisterPipeline
5
- } from "@ic-reactor/codegen";
2
+ import fs2 from "fs";
3
+ import path2 from "path";
6
4
 
7
5
  // src/env.ts
8
- import { execFileSync } from "child_process";
9
- function getIcEnvironmentInfo(canisterNames, projectRoot = process.cwd()) {
6
+ import { execFile } from "child_process";
7
+ var ICP_TIMEOUT_MS = 1e4;
8
+ function runIcp(args, cwd) {
9
+ return new Promise((resolve, reject) => {
10
+ const child = execFile(
11
+ "icp",
12
+ args,
13
+ {
14
+ cwd,
15
+ encoding: "utf-8",
16
+ timeout: ICP_TIMEOUT_MS,
17
+ windowsHide: true
18
+ },
19
+ (error, stdout, stderr) => {
20
+ if (error) {
21
+ reject(Object.assign(error, { stderr }));
22
+ } else {
23
+ resolve(stdout);
24
+ }
25
+ }
26
+ );
27
+ child.stdin?.end();
28
+ });
29
+ }
30
+ async function getIcEnvironmentInfo(canisterNames, projectRoot = process.cwd()) {
10
31
  const networkName = process.env.ICP_ENVIRONMENT || "local";
11
32
  const diagnostics = [];
12
33
  try {
13
34
  const networkStatus = JSON.parse(
14
- execFileSync("icp", ["network", "status", "-e", networkName, "--json"], {
15
- cwd: projectRoot,
16
- encoding: "utf-8",
17
- // stderr is piped rather than ignored so a failure can explain itself.
18
- // Piping still keeps it off the terminal — it only reaches the user if
19
- // the caller decides to print the diagnostics we collect below.
20
- stdio: ["ignore", "pipe", "pipe"]
21
- })
35
+ await runIcp(
36
+ ["network", "status", "-e", networkName, "--json"],
37
+ projectRoot
38
+ )
22
39
  );
23
40
  const rootKey = networkStatus.root_key;
24
41
  const proxyTarget = networkStatus.api_url || networkStatus.gateway_url || (networkStatus.port ? `http://127.0.0.1:${networkStatus.port}` : void 0);
@@ -31,15 +48,10 @@ function getIcEnvironmentInfo(canisterNames, projectRoot = process.cwd()) {
31
48
  const canisterIds = {};
32
49
  for (const name of canisterNames) {
33
50
  try {
34
- const canisterId = execFileSync(
35
- "icp",
51
+ const canisterId = (await runIcp(
36
52
  ["canister", "status", name, "-e", networkName, "-i"],
37
- {
38
- cwd: projectRoot,
39
- encoding: "utf-8",
40
- stdio: ["ignore", "pipe", "pipe"]
41
- }
42
- ).trim();
53
+ projectRoot
54
+ )).trim();
43
55
  if (canisterId) {
44
56
  canisterIds[name] = canisterId;
45
57
  }
@@ -100,254 +112,804 @@ function localInternetIdentityProvider(proxyTarget) {
100
112
  return `${protocol}//id.ai.localhost${portSuffix}/authorize`;
101
113
  }
102
114
 
103
- // src/index.ts
104
- var PLUGIN_NAME = "ic-reactor-plugin";
115
+ // src/dev-environment.ts
105
116
  var DEFAULT_LOCAL_REPLICA = "http://127.0.0.1:4943";
106
- function icReactor(options) {
117
+ var DEFAULT_INTERNET_IDENTITY_PROVIDER = "http://id.ai.localhost:8000/authorize";
118
+ function createLocalEnvironment(options) {
107
119
  const {
108
- canisters,
109
- outDir = "src/declarations",
110
- clientManagerPath = "../../clients",
111
- target = "react",
112
- injectEnvironment = true,
113
- failOnError
120
+ canisterNames,
121
+ configuredCanisterIds,
122
+ projectRoot,
123
+ onDiagnostic,
124
+ onUpdate
114
125
  } = options;
115
- const globalConfig = {
116
- outDir,
117
- clientManagerPath,
118
- target
126
+ const requiredNames = [...new Set(canisterNames)];
127
+ const lookupNames = requiredNames.includes("internet_identity") ? requiredNames : [...requiredNames, "internet_identity"];
128
+ let state;
129
+ let running;
130
+ const toState = (environment) => {
131
+ const canisterIds = {
132
+ ...environment?.canisterIds,
133
+ ...configuredCanisterIds
134
+ };
135
+ const missingCanisterIds = requiredNames.filter(
136
+ (name) => !canisterIds[name]
137
+ );
138
+ let cookie;
139
+ if (environment) {
140
+ cookie = buildIcEnvCookie(
141
+ canisterIds,
142
+ environment.rootKey,
143
+ environment.internetIdentityProvider
144
+ );
145
+ } else if (requiredNames.length === 0) {
146
+ cookie = buildIcEnvCookie(
147
+ {},
148
+ void 0,
149
+ DEFAULT_INTERNET_IDENTITY_PROVIDER
150
+ );
151
+ }
152
+ return {
153
+ environment,
154
+ cookie,
155
+ proxyTarget: environment?.proxyTarget ?? DEFAULT_LOCAL_REPLICA,
156
+ missingCanisterIds,
157
+ complete: environment !== null && missingCanisterIds.length === 0
158
+ };
119
159
  };
120
- let projectRoot = process.cwd();
121
- let command = "build";
122
- let devServer = null;
123
- const resolveDidPath = (didFile) => path.normalize(
124
- path.isAbsolute(didFile) ? didFile : path.resolve(projectRoot, didFile)
160
+ const run = async () => {
161
+ const previous = state;
162
+ try {
163
+ const { environment, diagnostics } = await getIcEnvironmentInfo(
164
+ lookupNames,
165
+ projectRoot
166
+ );
167
+ diagnostics.forEach(onDiagnostic);
168
+ state = toState(
169
+ mergeDetections(previous?.environment ?? null, environment)
170
+ );
171
+ } catch (error) {
172
+ onDiagnostic(
173
+ `Detecting the local IC environment failed: ${error instanceof Error ? error.message : String(error)}`
174
+ );
175
+ state = previous ?? toState(null);
176
+ }
177
+ try {
178
+ onUpdate?.(previous, state);
179
+ } catch (error) {
180
+ onDiagnostic(
181
+ `Applying the detected IC environment failed: ${error instanceof Error ? error.message : String(error)}`
182
+ );
183
+ }
184
+ return state;
185
+ };
186
+ return {
187
+ get state() {
188
+ return state;
189
+ },
190
+ detect() {
191
+ running ?? (running = run().finally(() => {
192
+ running = void 0;
193
+ }));
194
+ return running;
195
+ }
196
+ };
197
+ }
198
+ function mergeDetections(previous, next) {
199
+ if (!next) return previous;
200
+ if (!previous || previous.rootKey !== next.rootKey) return next;
201
+ const canisterIds = { ...previous.canisterIds, ...next.canisterIds };
202
+ return {
203
+ ...next,
204
+ canisterIds,
205
+ // The built-in provider stands in only for a project that has no
206
+ // internet_identity canister, as getIcEnvironmentInfo decides it.
207
+ internetIdentityProvider: canisterIds.internet_identity ? void 0 : next.internetIdentityProvider
208
+ };
209
+ }
210
+ function isDocumentRequest(req) {
211
+ if (req.method !== "GET" && req.method !== "HEAD") return false;
212
+ const { accept } = req.headers;
213
+ return typeof accept === "string" && accept.includes("text/html");
214
+ }
215
+ function setIcEnvCookie(res, value) {
216
+ const existing = res.getHeader("Set-Cookie");
217
+ const others = (existing === void 0 ? [] : Array.isArray(existing) ? existing : [String(existing)]).filter((cookie2) => !cookie2.startsWith("ic_env="));
218
+ const cookie = `ic_env=${value}; Path=/; SameSite=Lax;`;
219
+ res.setHeader("Set-Cookie", others.length > 0 ? [...others, cookie] : cookie);
220
+ }
221
+ function icEnvMiddleware(environment) {
222
+ return (req, res, next) => {
223
+ const respond = (state2) => {
224
+ if (state2?.cookie !== void 0) setIcEnvCookie(res, state2.cookie);
225
+ next();
226
+ };
227
+ const { state } = environment;
228
+ if (state?.complete || !isDocumentRequest(req)) {
229
+ respond(state);
230
+ return;
231
+ }
232
+ void environment.detect().then(respond);
233
+ };
234
+ }
235
+
236
+ // src/generate.ts
237
+ import { spawn } from "child_process";
238
+ import fs from "fs";
239
+ import { createRequire } from "module";
240
+ import path from "path";
241
+ var CANDID_CORE_CLI_VERSION = "0.2.0-beta.1";
242
+ var REPORT_SCHEMA_VERSION = 1;
243
+ var GENERATE_TIMEOUT_MS = 6e4;
244
+ var MAX_STREAMED_LINES = 200;
245
+ var MAX_LINE_CHARS = 4096;
246
+ var MAX_OUTPUT_BYTES = 4 * 1024 * 1024;
247
+ function resolveCliBin(root) {
248
+ const requireFromApp = createRequire(path.join(root, "package.json"));
249
+ let manifestPath;
250
+ try {
251
+ manifestPath = requireFromApp.resolve("@candid-core/cli/package.json");
252
+ } catch {
253
+ throw new Error(
254
+ `cannot find @candid-core/cli from ${root}. Install the generator that pairs with this plugin next to @candid-core/schema: npm install --save-dev --save-exact @candid-core/cli@${CANDID_CORE_CLI_VERSION}`
255
+ );
256
+ }
257
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
258
+ const bin = typeof manifest.bin === "string" ? manifest.bin : manifest.bin?.["candid-core-cli"];
259
+ if (!bin) {
260
+ throw new Error(
261
+ `${manifestPath} declares no candid-core-cli bin, so it is not the generator this plugin runs`
262
+ );
263
+ }
264
+ return path.resolve(path.dirname(manifestPath), bin);
265
+ }
266
+ var keep = (text, chunk) => text.length >= MAX_OUTPUT_BYTES ? text : text + chunk;
267
+ function runProcess(bin, args, cwd, timeoutMs, signal, onStderrLine) {
268
+ return new Promise((resolve) => {
269
+ let stdout = "";
270
+ let stderr = "";
271
+ let timedOut = false;
272
+ let settled = false;
273
+ const stopped = {
274
+ code: null,
275
+ signal: null,
276
+ problem: "was stopped",
277
+ stopped: true
278
+ };
279
+ if (signal?.aborted) {
280
+ resolve({ stdout, stderr, ...stopped });
281
+ return;
282
+ }
283
+ let unfinished = "";
284
+ let cut = false;
285
+ let passed = 0;
286
+ const pass = (text) => {
287
+ if (!text.trim() || passed > MAX_STREAMED_LINES) return;
288
+ passed++;
289
+ onStderrLine?.(
290
+ passed > MAX_STREAMED_LINES ? `more than ${MAX_STREAMED_LINES} lines on stderr: the rest is not logged` : text.trimEnd()
291
+ );
292
+ };
293
+ const passUnfinished = () => {
294
+ pass(
295
+ cut ? `${unfinished} [line cut at ${MAX_LINE_CHARS} characters]` : unfinished
296
+ );
297
+ unfinished = "";
298
+ cut = false;
299
+ };
300
+ const settle = (result) => {
301
+ if (settled) return;
302
+ settled = true;
303
+ passUnfinished();
304
+ clearTimeout(timer);
305
+ signal?.removeEventListener("abort", stop);
306
+ resolve({ stdout, stderr, ...result });
307
+ };
308
+ const stop = () => {
309
+ unfinished = "";
310
+ cut = false;
311
+ child.kill("SIGKILL");
312
+ settle(stopped);
313
+ };
314
+ const child = spawn(process.execPath, [bin, ...args], {
315
+ cwd,
316
+ stdio: ["ignore", "pipe", "pipe"],
317
+ windowsHide: true
318
+ });
319
+ const timer = setTimeout(() => {
320
+ timedOut = true;
321
+ child.kill("SIGKILL");
322
+ }, timeoutMs);
323
+ signal?.addEventListener("abort", stop, { once: true });
324
+ child.stdout.setEncoding("utf-8");
325
+ child.stderr.setEncoding("utf-8");
326
+ child.stdout.on("data", (chunk) => stdout = keep(stdout, chunk));
327
+ child.stderr.on("data", (chunk) => {
328
+ stderr = keep(stderr, chunk);
329
+ if (settled || !onStderrLine || passed > MAX_STREAMED_LINES) return;
330
+ const pieces = chunk.split(/\r?\n/);
331
+ pieces.forEach((piece, index) => {
332
+ const room = MAX_LINE_CHARS - unfinished.length;
333
+ if (piece.length > room) cut = true;
334
+ unfinished += piece.slice(0, room);
335
+ if (index < pieces.length - 1) passUnfinished();
336
+ });
337
+ });
338
+ child.on(
339
+ "error",
340
+ (error) => settle({
341
+ code: null,
342
+ signal: null,
343
+ problem: `could not start: ${error.message}`,
344
+ unstartable: true
345
+ })
346
+ );
347
+ child.on(
348
+ "close",
349
+ (code, signal2) => settle({
350
+ code,
351
+ signal: signal2,
352
+ problem: timedOut ? `did not finish within ${timeoutMs / 1e3}s and was killed` : void 0
353
+ })
354
+ );
355
+ });
356
+ }
357
+ function describeProcess(run, reason) {
358
+ const ending = run.problem ?? (run.signal ? `was killed by ${run.signal}` : `exited with code ${run.code}`);
359
+ const lines = [`candid-core-cli ${ending}`];
360
+ if (reason) lines.push(reason);
361
+ const stderr = run.stderr.trim();
362
+ if (stderr) lines.push(stderr);
363
+ return lines.join("\n");
364
+ }
365
+ function parseReport(stdout, count) {
366
+ let report;
367
+ try {
368
+ report = JSON.parse(stdout);
369
+ } catch {
370
+ return void 0;
371
+ }
372
+ const { schemaVersion, entries } = report ?? {};
373
+ if (schemaVersion !== REPORT_SCHEMA_VERSION) {
374
+ throw new Error(
375
+ `its report has schemaVersion ${String(schemaVersion)}, and this plugin reads ${REPORT_SCHEMA_VERSION}; install @candid-core/cli@${CANDID_CORE_CLI_VERSION}`
376
+ );
377
+ }
378
+ if (!Array.isArray(entries) || entries.length !== count) {
379
+ throw new Error(`its report does not have one entry per .did (${count})`);
380
+ }
381
+ return entries.map((entry) => {
382
+ const { status, module, omitted, diagnostics } = entry;
383
+ return {
384
+ status: String(status),
385
+ module: typeof module === "string" ? module : void 0,
386
+ omitted: Array.isArray(omitted) ? omitted : [],
387
+ diagnostics: Array.isArray(diagnostics) ? diagnostics : []
388
+ };
389
+ });
390
+ }
391
+ function formatDiagnostic(diagnostic) {
392
+ const { code, message, notes } = diagnostic ?? {};
393
+ if (typeof message !== "string") return JSON.stringify(diagnostic);
394
+ const lead = typeof code === "string" ? `${code}: ${message}` : message;
395
+ const extra = Array.isArray(notes) ? notes.map((note) => ` ${note}`) : [];
396
+ return [lead, ...extra].join("\n");
397
+ }
398
+ async function runGroup(context, outDir, units) {
399
+ const run = await runProcess(
400
+ context.cli,
401
+ ["gen", ...units.map((unit) => unit.didFile), "-o", outDir, "--json"],
402
+ context.root,
403
+ context.timeoutMs,
404
+ context.signal,
405
+ (line) => context.onStderr?.(
406
+ line,
407
+ units.map((unit) => unit.didFile)
408
+ )
125
409
  );
126
- const configuredCanisterIds = Object.fromEntries(
127
- canisters.filter((canister) => !!canister.canisterId).map((canister) => [canister.name, canister.canisterId])
410
+ let entries;
411
+ let reason;
412
+ if (!run.problem) {
413
+ try {
414
+ entries = parseReport(run.stdout, units.length);
415
+ if (!entries && run.code === 0) reason = "it printed no report";
416
+ } catch (error) {
417
+ reason = error instanceof Error ? error.message : String(error);
418
+ }
419
+ }
420
+ if (!entries) {
421
+ if (units.length > 1 && !run.unstartable && !run.stopped) {
422
+ const results = await Promise.all(
423
+ units.map((unit) => runGroup(context, outDir, [unit]))
424
+ );
425
+ return { outcomes: results.flatMap((result) => result.outcomes) };
426
+ }
427
+ const failure = describeProcess(run, reason);
428
+ return {
429
+ outcomes: units.map(({ names }) => ({
430
+ names,
431
+ status: "failed",
432
+ omitted: [],
433
+ failure
434
+ }))
435
+ };
436
+ }
437
+ return {
438
+ outcomes: units.map((unit, index) => {
439
+ const entry = entries[index];
440
+ if (entry.status === "failed") {
441
+ return {
442
+ names: unit.names,
443
+ status: "failed",
444
+ omitted: [],
445
+ failure: entry.diagnostics.map(formatDiagnostic).join("\n") || "candid-core-cli reported the entry as failed"
446
+ };
447
+ }
448
+ return {
449
+ names: unit.names,
450
+ module: entry.module,
451
+ status: entry.status === "unchanged" ? "unchanged" : "written",
452
+ omitted: entry.omitted
453
+ };
454
+ })
455
+ };
456
+ }
457
+ function quoteNames(names) {
458
+ return names.map((name) => `"${name}"`).join(", ");
459
+ }
460
+ function foldsCase(dir) {
461
+ const swapCase = (name) => name.replace(
462
+ /[a-z]/gi,
463
+ (letter) => letter === letter.toLowerCase() ? letter.toUpperCase() : letter.toLowerCase()
128
464
  );
129
- const pendingFailures = /* @__PURE__ */ new Map();
130
- const reportFailure = (server, message, cause) => {
131
- console.error(`[ic-reactor] ${message}`);
132
- const err = {
133
- message: `[ic-reactor] ${message}`,
134
- stack: cause instanceof Error && cause.stack ? cause.stack : "",
135
- plugin: PLUGIN_NAME
465
+ const probe = (entry) => {
466
+ const swapped = path.join(
467
+ path.dirname(entry),
468
+ swapCase(path.basename(entry))
469
+ );
470
+ if (swapped === entry) return void 0;
471
+ try {
472
+ const found = fs.lstatSync(entry, { bigint: true });
473
+ if (found.ino === 0n) return void 0;
474
+ try {
475
+ const other = fs.lstatSync(swapped, { bigint: true });
476
+ return other.dev === found.dev && other.ino === found.ino;
477
+ } catch (error) {
478
+ return error.code === "ENOENT" ? false : void 0;
479
+ }
480
+ } catch {
481
+ return void 0;
482
+ }
483
+ };
484
+ for (let at = dir; ; at = path.dirname(at)) {
485
+ let names = [];
486
+ try {
487
+ names = fs.readdirSync(at);
488
+ } catch {
489
+ }
490
+ const named = names.find((name) => swapCase(name) !== name);
491
+ const verdict = (named === void 0 ? void 0 : probe(path.join(at, named))) ?? probe(at);
492
+ if (verdict !== void 0) return verdict;
493
+ if (path.dirname(at) === at) return void 0;
494
+ }
495
+ }
496
+ async function generate(request) {
497
+ const { root } = request;
498
+ const units = /* @__PURE__ */ new Map();
499
+ const claimed = /* @__PURE__ */ new Map();
500
+ const refused = /* @__PURE__ */ new Map();
501
+ const folding = /* @__PURE__ */ new Map();
502
+ const targetOf = (unit) => {
503
+ let folds = folding.get(unit.outDir);
504
+ if (folds === void 0) {
505
+ folds = (request.foldsCase ?? foldsCase)(unit.outDir) ?? false;
506
+ folding.set(unit.outDir, folds);
507
+ }
508
+ return folds ? moduleOf(unit).toLowerCase() : moduleOf(unit);
509
+ };
510
+ for (const canister of request.canisters) {
511
+ const key = JSON.stringify([canister.didFile, canister.outDir]);
512
+ const same = units.get(key);
513
+ if (same) {
514
+ same.names.push(canister.name);
515
+ continue;
516
+ }
517
+ const unit = {
518
+ names: [canister.name],
519
+ didFile: canister.didFile,
520
+ outDir: canister.outDir
136
521
  };
137
- server?.ws.send({ type: "error", err });
138
- return { message: err.message, stack: err.stack };
522
+ units.set(key, unit);
523
+ const target = targetOf(unit);
524
+ const owner = claimed.get(target);
525
+ if (owner) refused.set(unit, owner);
526
+ else claimed.set(target, unit);
527
+ }
528
+ const outcomes = [...refused].map(([unit, owner]) => ({
529
+ names: unit.names,
530
+ status: "failed",
531
+ omitted: [],
532
+ failure: `would write ${path.relative(root, moduleOf(unit))}, which ${quoteNames(owner.names)} already writes` + (moduleOf(unit) === moduleOf(owner) ? ". " : ` as ${path.relative(root, moduleOf(owner))}: the filesystem does not tell the two names apart. `) + `The generator names a module after its .did file: rename one file or give one canister its own outDir`
533
+ }));
534
+ const groups = /* @__PURE__ */ new Map();
535
+ for (const unit of units.values()) {
536
+ if (refused.has(unit)) continue;
537
+ groups.set(unit.outDir, [...groups.get(unit.outDir) ?? [], unit]);
538
+ }
539
+ const results = await Promise.all(
540
+ [...groups].map(([outDir, members]) => runGroup(request, outDir, members))
541
+ );
542
+ return {
543
+ outcomes: [...outcomes, ...results.flatMap((result) => result.outcomes)]
139
544
  };
140
- const inFlight = /* @__PURE__ */ new Map();
141
- const rerunQueued = /* @__PURE__ */ new Set();
142
- const regenerate = (canisterConfig, server) => {
143
- const { name } = canisterConfig;
144
- const running = inFlight.get(name);
145
- if (running) {
146
- rerunQueued.add(name);
147
- return running;
545
+ }
546
+ function moduleOf(unit) {
547
+ return path.join(
548
+ unit.outDir,
549
+ path.basename(unit.didFile, path.extname(unit.didFile)) + ".ts"
550
+ );
551
+ }
552
+
553
+ // src/index.ts
554
+ var PLUGIN_NAME = "ic-reactor-plugin";
555
+ var DEFAULT_OUT_DIR = "src/canisters";
556
+ var GUIDE_LINE = "ic-reactor: agent guide at node_modules/@ic-reactor/core/llms.txt";
557
+ var consoleLog = {
558
+ info: (message) => console.log(message),
559
+ warn: (message) => console.warn(message),
560
+ error: (message) => console.error(message)
561
+ };
562
+ function icReactor(options = {}) {
563
+ const { canisters = {}, injectEnvironment = true, failOnError } = options;
564
+ const names = Object.keys(canisters);
565
+ const configuredCanisterIds = Object.fromEntries(
566
+ names.flatMap((name) => {
567
+ const { canisterId } = canisters[name];
568
+ return canisterId ? [[name, canisterId]] : [];
569
+ })
570
+ );
571
+ const generated = names.flatMap((name) => {
572
+ const { didFile, outDir = DEFAULT_OUT_DIR } = canisters[name];
573
+ return didFile === void 0 ? [] : [{ name, didFile, outDir }];
574
+ });
575
+ let projectRoot = process.cwd();
576
+ let command = "build";
577
+ let log = consoleLog;
578
+ let announced = false;
579
+ let localEnvironment;
580
+ const apiProxyOptions = /* @__PURE__ */ new Set();
581
+ let devServer = null;
582
+ const didPath = (canister) => path2.resolve(projectRoot, canister.didFile);
583
+ const relativeToRoot = (file) => path2.relative(projectRoot, file) || ".";
584
+ const generatedFrom = /* @__PURE__ */ new Map();
585
+ const unfixed = /* @__PURE__ */ new Map();
586
+ let tail = Promise.resolve();
587
+ const serially = (job) => {
588
+ const result = tail.then(job);
589
+ tail = result.catch(() => void 0);
590
+ return result;
591
+ };
592
+ const queued = /* @__PURE__ */ new Set();
593
+ let stopper = new AbortController();
594
+ const readDid = (canister) => {
595
+ try {
596
+ return fs2.readFileSync(didPath(canister), "utf-8");
597
+ } catch {
598
+ return void 0;
148
599
  }
149
- const run = runCanisterPipeline({
150
- canisterConfig,
151
- projectRoot,
152
- globalConfig
153
- }).then((result) => {
154
- if (result.success) {
155
- pendingFailures.delete(name);
156
- server.ws.send({ type: "full-reload" });
157
- } else {
158
- pendingFailures.set(
159
- name,
160
- reportFailure(
161
- server,
162
- `Regeneration failed for ${name}: ${result.error ?? "unknown error"}`
163
- )
164
- );
165
- }
166
- }).catch((error) => {
167
- pendingFailures.set(
168
- name,
169
- reportFailure(
170
- server,
171
- `Regeneration failed for ${name}: ${describeError(error)}`,
172
- error
173
- )
600
+ };
601
+ const generateNow = async (wanted, force, signal) => {
602
+ if (signal.aborted) return void 0;
603
+ try {
604
+ const sources = new Map(
605
+ wanted.map((canister) => [canister.name, readDid(canister)])
174
606
  );
175
- }).finally(() => {
176
- inFlight.delete(name);
177
- if (rerunQueued.delete(name)) {
178
- void regenerate(canisterConfig, server);
607
+ const stale = wanted.filter(
608
+ ({ name }) => force || sources.get(name) === void 0 || generatedFrom.get(name) !== sources.get(name)
609
+ );
610
+ if (stale.length === 0) return [];
611
+ let cli;
612
+ try {
613
+ cli = resolveCliBin(projectRoot);
614
+ } catch (error) {
615
+ return stale.map((canister) => ({ canister, message: describe(error) }));
616
+ }
617
+ const result = await generate({
618
+ cli,
619
+ root: projectRoot,
620
+ timeoutMs: GENERATE_TIMEOUT_MS,
621
+ signal,
622
+ // Shown as it arrives: a run that takes long, or is killed at the
623
+ // timeout, would otherwise say nothing until it is over.
624
+ onStderr: (line, didFiles) => {
625
+ if (signal.aborted) return;
626
+ log.warn(
627
+ `ic-reactor: candid-core-cli (${didFiles.map((file) => relativeToRoot(file)).join(", ")}): ${line}`
628
+ );
629
+ },
630
+ canisters: stale.map((canister) => ({
631
+ name: canister.name,
632
+ didFile: didPath(canister),
633
+ outDir: path2.resolve(projectRoot, canister.outDir)
634
+ }))
635
+ });
636
+ if (signal.aborted) return void 0;
637
+ const failures = [];
638
+ for (const outcome of result.outcomes) {
639
+ const members = stale.filter(({ name }) => outcome.names.includes(name));
640
+ const names2 = members.map(({ name }) => name).join(", ");
641
+ if (outcome.status === "failed") {
642
+ for (const canister of members) {
643
+ generatedFrom.delete(canister.name);
644
+ failures.push({ canister, message: outcome.failure ?? "" });
645
+ }
646
+ continue;
647
+ }
648
+ for (const { name } of members) {
649
+ const source = sources.get(name);
650
+ if (source !== void 0) generatedFrom.set(name, source);
651
+ }
652
+ if (outcome.status === "written" && outcome.module) {
653
+ log.info(
654
+ `ic-reactor: generated ${names2} into ${relativeToRoot(outcome.module)}`
655
+ );
656
+ }
657
+ for (const { kind, name: what, reason, via } of outcome.omitted) {
658
+ log.warn(
659
+ `ic-reactor: ${names2}: omitted ${kind} ${what} (${reason}${via ? ` via ${via}` : ""})`
660
+ );
661
+ }
662
+ }
663
+ return failures;
664
+ } catch (error) {
665
+ if (signal.aborted) return void 0;
666
+ return wanted.map((canister) => ({ canister, message: describe(error) }));
667
+ }
668
+ };
669
+ const describeFailures = (failures) => {
670
+ const byMessage = /* @__PURE__ */ new Map();
671
+ for (const { canister, message } of failures) {
672
+ const label = `${canister.name} (${relativeToRoot(didPath(canister))})`;
673
+ byMessage.set(message, [...byMessage.get(message) ?? [], label]);
674
+ }
675
+ return `ic-reactor: could not generate ${failures.length} of ${generated.length} canisters:
676
+ ` + [...byMessage].map(
677
+ ([message, labels]) => ` - ${labels.join(", ")}: ${message.replace(/\n/g, "\n ")}`
678
+ ).join("\n");
679
+ };
680
+ const publish = (attempted, failures) => {
681
+ let wasFailing = false;
682
+ for (const { name } of attempted) {
683
+ if (unfixed.delete(name)) wasFailing = true;
684
+ }
685
+ for (const failure of failures) unfixed.set(failure.canister.name, failure);
686
+ if (failures.length > 0) {
687
+ log.error(describeFailures(failures));
688
+ showOverlay();
689
+ } else if (wasFailing && unfixed.size > 0) {
690
+ showOverlay();
691
+ } else if (wasFailing) {
692
+ devServer?.ws.send({ type: "full-reload" });
693
+ }
694
+ };
695
+ const showOverlay = (client) => {
696
+ if (unfixed.size === 0) return;
697
+ const payload = {
698
+ type: "error",
699
+ err: {
700
+ message: describeFailures([...unfixed.values()]),
701
+ stack: "",
702
+ plugin: PLUGIN_NAME
703
+ }
704
+ };
705
+ if (typeof client?.send === "function") {
706
+ try {
707
+ client.send(JSON.stringify(payload));
708
+ } catch {
709
+ }
710
+ return;
711
+ }
712
+ devServer?.ws.send(payload);
713
+ };
714
+ const onDidSaved = (file) => {
715
+ const saved = path2.normalize(file);
716
+ const affected = generated.filter((canister) => didPath(canister) === saved);
717
+ if (affected.length === 0 || queued.has(saved)) return;
718
+ queued.add(saved);
719
+ log.info(
720
+ `ic-reactor: ${relativeToRoot(saved)} changed, regenerating ${affected.map(({ name }) => name).join(", ")}`
721
+ );
722
+ const { signal } = stopper;
723
+ void serially(async () => {
724
+ queued.delete(saved);
725
+ const failures = await generateNow(affected, true, signal);
726
+ if (failures) publish(affected, failures);
727
+ });
728
+ };
729
+ const onDidRemoved = (file) => {
730
+ const removed = path2.normalize(file);
731
+ const affected = generated.filter(
732
+ (canister) => didPath(canister) === removed
733
+ );
734
+ if (affected.length === 0) return;
735
+ const { signal } = stopper;
736
+ void serially(async () => {
737
+ try {
738
+ if (signal.aborted || fs2.existsSync(removed)) return;
739
+ for (const { name } of affected) generatedFrom.delete(name);
740
+ publish(
741
+ affected,
742
+ affected.map((canister) => ({
743
+ canister,
744
+ message: "the .did file was deleted; its module is not regenerated until the file comes back"
745
+ }))
746
+ );
747
+ } catch (error) {
748
+ log.error(`ic-reactor: ${describe(error)}`);
179
749
  }
180
750
  });
181
- inFlight.set(name, run);
182
- return run;
751
+ };
752
+ const announceGuide = (userConfig) => {
753
+ if (announced) return;
754
+ announced = true;
755
+ if (userConfig.customLogger) {
756
+ userConfig.customLogger.info(GUIDE_LINE);
757
+ } else if (!["silent", "error", "warn"].includes(userConfig.logLevel ?? "")) {
758
+ console.log(GUIDE_LINE);
759
+ }
183
760
  };
184
761
  const plugin = {
185
762
  name: PLUGIN_NAME,
186
763
  enforce: "pre",
187
764
  // Run before other plugins
188
- config(userConfig, { command: viteCommand }) {
765
+ async config(userConfig, { command: viteCommand, mode }) {
766
+ announceGuide(userConfig);
189
767
  command = viteCommand;
190
- if (viteCommand !== "serve" || !injectEnvironment) {
768
+ if (viteCommand !== "serve" || mode === "test" || !injectEnvironment) {
191
769
  return {};
192
770
  }
193
- const canisterNames = canisters.map((c) => c.name).filter((n) => !!n);
194
- if (!canisterNames.includes("internet_identity")) {
195
- canisterNames.push("internet_identity");
196
- }
197
- const { environment: icEnv, diagnostics } = getIcEnvironmentInfo(
198
- canisterNames,
199
- path.resolve(userConfig.root ?? process.cwd())
200
- );
201
- if (!icEnv) {
202
- if (canisters.length > 0) {
203
- console.warn(
204
- `[ic-reactor] Could not detect the local IC environment, falling back to ${DEFAULT_LOCAL_REPLICA}. Canister IDs and the root key will not be injected \u2014 is the local replica running? Re-run with DEBUG=ic-reactor to see the \`icp\` output.`
205
- );
206
- }
207
- for (const diagnostic of diagnostics) {
208
- debugLog(diagnostic);
209
- }
210
- const envOnlyCookie = canisters.length === 0 ? buildIcEnvCookie(
211
- {},
212
- void 0,
213
- "http://id.ai.localhost:8000/authorize"
214
- ) : void 0;
215
- return {
216
- server: {
217
- headers: envOnlyCookie ? {
218
- "Set-Cookie": `ic_env=${envOnlyCookie}; Path=/; SameSite=Lax;`
219
- } : void 0,
220
- proxy: apiProxy(userConfig, DEFAULT_LOCAL_REPLICA)
771
+ const ownsApiProxy = !userConfig.server?.proxy?.["/api"];
772
+ const environment = createLocalEnvironment({
773
+ canisterNames: names,
774
+ configuredCanisterIds,
775
+ // `configResolved` has not run yet, so resolve the root the way Vite
776
+ // will. icp finds the project from the directory it starts in, and
777
+ // with `vite apps/web` or a `root` option that is not the process cwd.
778
+ projectRoot: path2.resolve(userConfig.root ?? process.cwd()),
779
+ onDiagnostic: debugLog,
780
+ onUpdate: (previous, next) => {
781
+ for (const proxyOptions of apiProxyOptions) {
782
+ proxyOptions.target = next.proxyTarget;
221
783
  }
222
- };
223
- }
224
- const missingCanisterIds = canisters.map((canister) => canister.name).filter((name) => !!name).filter(
225
- (name) => !icEnv.canisterIds[name] && !configuredCanisterIds[name]
226
- );
227
- if (missingCanisterIds.length > 0) {
228
- const names = missingCanisterIds.map((name) => `"${name}"`).join(", ");
229
- const it = missingCanisterIds.length === 1 ? "it" : "them";
230
- console.warn(
231
- `[ic-reactor] The local replica is running, but no canister ID could be resolved for ${names}. Deploy ${it} (\`icp deploy\`) \u2014 until then the injected ic_env carries no PUBLIC_CANISTER_ID for ${it} and the app will see an undefined canister id. Re-run with DEBUG=ic-reactor to see the \`icp\` output.`
232
- );
233
- }
234
- for (const diagnostic of diagnostics) {
235
- debugLog(diagnostic);
236
- }
237
- const cookieValue = buildIcEnvCookie(
238
- {
239
- ...icEnv.canisterIds,
240
- ...configuredCanisterIds
241
- },
242
- icEnv.rootKey,
243
- icEnv.internetIdentityProvider
244
- );
784
+ if (previous) {
785
+ reportDetectionProgress(previous, next, apiProxyOptions.size > 0);
786
+ }
787
+ }
788
+ });
789
+ localEnvironment = environment;
790
+ const state = await environment.detect();
791
+ warnAboutIncompleteDetection(state, names.length > 0, ownsApiProxy);
245
792
  return {
246
793
  server: {
247
- headers: {
248
- "Set-Cookie": `ic_env=${cookieValue}; Path=/; SameSite=Lax;`
249
- },
250
- proxy: apiProxy(userConfig, icEnv.proxyTarget)
794
+ // The cookie is not a static `server.headers` entry: the middleware
795
+ // that configureServer adds sets it per response, from the latest
796
+ // detection. See dev-environment.ts.
797
+ proxy: apiProxy(userConfig, state.proxyTarget, (proxyOptions) => {
798
+ if (proxyOptions.target !== state.proxyTarget) {
799
+ debugLog(
800
+ "Another plugin changed the target of the /api proxy, so the plugin leaves that proxy alone."
801
+ );
802
+ return;
803
+ }
804
+ apiProxyOptions.add(proxyOptions);
805
+ proxyOptions.target = environment.state?.proxyTarget ?? state.proxyTarget;
806
+ })
251
807
  }
252
808
  };
253
809
  },
254
810
  configResolved(config) {
255
811
  projectRoot = config.root;
256
812
  command = config.command;
813
+ log = config.logger;
257
814
  },
258
815
  configureServer(server) {
259
816
  devServer = server;
260
- server.ws.on?.("connection", () => {
261
- if (pendingFailures.size === 0) return;
262
- const failures = [...pendingFailures.values()];
263
- server.ws.send({
264
- type: "error",
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
- }
270
- });
271
- });
272
- const didFiles = canisters.map((c) => resolveDidPath(c.didFile));
273
- server.watcher.add(didFiles);
274
- },
275
- async buildStart() {
276
- console.log(
277
- `[ic-reactor] Generating canister bindings for ${canisters.length} canisters...`
278
- );
279
- const outcomes = await Promise.allSettled(
280
- canisters.map(
281
- (canisterConfig) => runCanisterPipeline({
282
- canisterConfig,
283
- projectRoot,
284
- globalConfig
285
- })
286
- )
287
- );
288
- const failures = outcomes.flatMap((outcome, index) => {
289
- const name = canisters[index]?.name ?? `canister #${index}`;
290
- if (outcome.status === "rejected") {
291
- return [{ name, detail: `${name}: ${describeError(outcome.reason)}` }];
292
- }
293
- if (!outcome.value.success) {
294
- return [
295
- {
296
- name,
297
- detail: `${name}: ${outcome.value.error ?? "unknown error"}`
298
- }
299
- ];
300
- }
301
- return [];
302
- });
303
- if (failures.length === 0) {
304
- return;
305
- }
306
- const message = `Failed to generate ${failures.length} of ${canisters.length} canisters:
307
- ` + failures.map(({ detail }) => ` - ${detail}`).join("\n");
308
- if (failOnError ?? command === "build") {
309
- this.error(`[ic-reactor] ${message}`);
310
- }
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
- });
817
+ if (localEnvironment) {
818
+ server.middlewares.use(icEnvMiddleware(localEnvironment));
317
819
  }
820
+ if (generated.length === 0) return;
821
+ server.ws.on?.("connection", showOverlay);
822
+ server.watcher.add(generated.map(didPath));
823
+ server.watcher.on("change", onDidSaved);
824
+ server.watcher.on("add", onDidSaved);
825
+ server.watcher.on("unlink", onDidRemoved);
318
826
  },
319
- handleHotUpdate({ file, server }) {
320
- if (!file.endsWith(".did")) {
321
- return;
827
+ // `vite preview` resolves the config with the `serve` command too, and
828
+ // used to inherit the cookie from `server.headers`.
829
+ configurePreviewServer(server) {
830
+ if (localEnvironment) {
831
+ server.middlewares.use(icEnvMiddleware(localEnvironment));
322
832
  }
323
- const changedPath = path.normalize(file);
324
- const affectedCanisters = canisters.filter(
325
- (canister) => resolveDidPath(canister.didFile) === changedPath
833
+ },
834
+ async buildStart() {
835
+ if (generated.length === 0) return;
836
+ for (const canister of generated) this.addWatchFile(didPath(canister));
837
+ const { signal } = stopper;
838
+ const failures = await serially(
839
+ () => generateNow(generated, false, signal)
326
840
  );
327
- if (affectedCanisters.length === 0) {
328
- return;
841
+ if (!failures) return;
842
+ if (failures.length > 0 && (failOnError ?? command === "build")) {
843
+ this.error(describeFailures(failures));
329
844
  }
330
- console.log(
331
- `[ic-reactor] .did file changed: ${affectedCanisters.map((canister) => canister.name).join(", ")}. Regenerating...`
332
- );
333
- return Promise.all(
334
- affectedCanisters.map((canister) => regenerate(canister, server))
335
- ).then(() => void 0);
845
+ publish(generated, failures);
846
+ },
847
+ // The end of a build, and the close of a dev server, which Vite reports
848
+ // here once for each of its environments.
849
+ closeBundle() {
850
+ stopper.abort();
851
+ stopper = new AbortController();
336
852
  }
337
853
  };
338
854
  return plugin;
339
855
  }
340
- function describeError(error) {
856
+ function describe(error) {
341
857
  return error instanceof Error ? error.message : String(error);
342
858
  }
343
- function apiProxy(userConfig, target) {
859
+ function apiProxy(userConfig, target, configure) {
344
860
  if (userConfig.server?.proxy?.["/api"]) {
345
861
  debugLog(
346
862
  `The Vite config already proxies /api, so the plugin keeps that proxy instead of sending /api to ${target}.`
347
863
  );
348
864
  return void 0;
349
865
  }
350
- return { "/api": { target, changeOrigin: true } };
866
+ return {
867
+ "/api": {
868
+ target,
869
+ changeOrigin: true,
870
+ configure: (_proxy, options) => configure(options)
871
+ }
872
+ };
873
+ }
874
+ function quoteNames2(names) {
875
+ return names.map((name) => `"${name}"`).join(", ");
876
+ }
877
+ function warnAboutIncompleteDetection(state, hasCanisters, ownsApiProxy) {
878
+ if (!state.environment) {
879
+ if (!hasCanisters) return;
880
+ const proxyNote = ownsApiProxy ? ` and /api goes to ${state.proxyTarget} for now` : "";
881
+ console.warn(
882
+ `[ic-reactor] Could not detect the local IC environment, so no ic_env cookie is set${proxyNote}. Is the local network running? The plugin asks \`icp\` again on each page load until it answers${ownsApiProxy ? ", then sends /api to the network it reports" : ""}: start the network (\`icp network start\`) and reload the page. Re-run with DEBUG=ic-reactor to see the \`icp\` output.`
883
+ );
884
+ return;
885
+ }
886
+ const missing = state.missingCanisterIds;
887
+ if (missing.length > 0) {
888
+ const it = missing.length === 1 ? "it" : "them";
889
+ console.warn(
890
+ `[ic-reactor] The local replica is running, but no canister ID could be resolved for ${quoteNames2(missing)}. Until one is, the ic_env cookie carries no PUBLIC_CANISTER_ID for ${it} and the app will see an undefined canister id. Deploy ${it} (\`icp deploy\`) and reload the page: the plugin asks \`icp\` again on each page load until every configured canister has an ID. Re-run with DEBUG=ic-reactor to see the \`icp\` output.`
891
+ );
892
+ }
893
+ }
894
+ function reportDetectionProgress(previous, next, followsApiProxy) {
895
+ if (!previous.environment && next.environment) {
896
+ console.log(
897
+ `[ic-reactor] Detected the local IC network: the ic_env cookie now carries its root key` + (followsApiProxy ? ` and /api goes to ${next.proxyTarget}.` : ".")
898
+ );
899
+ }
900
+ const resolved = previous.missingCanisterIds.filter(
901
+ (name) => !next.missingCanisterIds.includes(name)
902
+ );
903
+ if (next.environment && resolved.length > 0) {
904
+ console.log(
905
+ `[ic-reactor] The ic_env cookie now carries the canister ID${resolved.length === 1 ? "" : "s"} for ${quoteNames2(resolved)}.`
906
+ );
907
+ }
908
+ if (next.complete && !previous.complete) {
909
+ console.log(
910
+ "[ic-reactor] Every configured canister has an ID, so page loads no longer run `icp`. Restart the dev server after redeploying into a fresh network."
911
+ );
912
+ }
351
913
  }
352
914
  function debugLog(message) {
353
915
  const enabled = (process.env.DEBUG ?? "").split(",").some((entry) => {