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