@ic-reactor/vite-plugin 0.2.0 → 0.3.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,72 +1,140 @@
1
1
  // src/index.ts
2
2
  import fs from "fs";
3
3
  import path from "path";
4
+ import { execFileSync } from "child_process";
4
5
  import {
5
6
  generateDeclarations,
6
- generateReactorFile
7
+ generateReactorFile,
8
+ generateClientFile
7
9
  } from "@ic-reactor/codegen";
8
- var ICP_LOCAL_IDS_PATH = ".icp/cache/mappings/local.ids.json";
9
- var IC_ROOT_KEY_HEX = "308182301d060d2b0601040182dc7c0503010201060c2b0601040182dc7c050302010361008b52b4994f94c7ce4be1c1542d7c81dc79fea17d49efe8fa42e8566373581d4b969c4a59e96a0ef51b711fe5027ec01601182519d0a788f4bfe388e593b97cd1d7e44904de79422430bca686ac8c21305b3397b5ba4d7037d17877312fb7ee34";
10
- function loadLocalCanisterIds(rootDir) {
11
- const idsPath = path.resolve(rootDir, ICP_LOCAL_IDS_PATH);
10
+ function getIcEnvironmentInfo(canisterNames) {
11
+ const environment = process.env.ICP_ENVIRONMENT || "local";
12
12
  try {
13
- return JSON.parse(fs.readFileSync(idsPath, "utf-8"));
13
+ const networkStatus = JSON.parse(
14
+ execFileSync("icp", ["network", "status", "-e", environment, "--json"], {
15
+ encoding: "utf-8"
16
+ })
17
+ );
18
+ const rootKey = networkStatus.root_key;
19
+ const proxyTarget = `http://127.0.0.1:${networkStatus.port}`;
20
+ const canisterIds = {};
21
+ for (const name of canisterNames) {
22
+ try {
23
+ const canisterId = execFileSync(
24
+ "icp",
25
+ ["canister", "status", name, "-e", environment, "-i"],
26
+ {
27
+ encoding: "utf-8"
28
+ }
29
+ ).trim();
30
+ canisterIds[name] = canisterId;
31
+ } catch {
32
+ }
33
+ }
34
+ return { environment, rootKey, proxyTarget, canisterIds };
14
35
  } catch {
15
36
  return null;
16
37
  }
17
38
  }
18
- function buildIcEnvCookie(canisterIds) {
19
- const envParts = [`ic_root_key=${IC_ROOT_KEY_HEX}`];
39
+ function buildIcEnvCookie(canisterIds, rootKey) {
40
+ const envParts = [`ic_root_key=${rootKey}`];
20
41
  for (const [name, id] of Object.entries(canisterIds)) {
21
42
  envParts.push(`PUBLIC_CANISTER_ID:${name}=${id}`);
22
43
  }
23
44
  return encodeURIComponent(envParts.join("&"));
24
45
  }
25
- function addOrReplaceSetCookie(existing, cookie) {
26
- const cookieEntries = typeof existing === "string" ? [existing] : Array.isArray(existing) ? existing.filter((value) => typeof value === "string") : [];
27
- const nonIcEnvCookies = cookieEntries.filter(
28
- (entry) => !entry.trim().startsWith("ic_env=")
29
- );
30
- return [...nonIcEnvCookies, cookie];
31
- }
32
- function setupIcEnvMiddleware(server) {
33
- const rootDir = server.config.root || process.cwd();
34
- const idsPath = path.resolve(rootDir, ICP_LOCAL_IDS_PATH);
35
- let hasLoggedHint = false;
36
- server.middlewares.use((req, res, next) => {
37
- const canisterIds = loadLocalCanisterIds(rootDir);
38
- if (!canisterIds) {
39
- if (!hasLoggedHint) {
40
- server.config.logger.info(
41
- `[ic-reactor] icp-cli local IDs not found at ${idsPath}. Run \`icp deploy\` to enable automatic ic_env cookie injection.`
42
- );
43
- hasLoggedHint = true;
44
- }
45
- return next();
46
- }
47
- const cookie = `ic_env=${buildIcEnvCookie(canisterIds)}; Path=/; SameSite=Lax;`;
48
- const current = res.getHeader("Set-Cookie");
49
- res.setHeader("Set-Cookie", addOrReplaceSetCookie(current, cookie));
50
- next();
51
- });
52
- }
53
46
  function icReactorPlugin(options) {
54
- const baseOutDir = options.outDir ?? "./src/canisters";
47
+ const baseOutDir = options.outDir ?? "./src/lib/canisters";
55
48
  return {
56
49
  name: "ic-reactor-plugin",
57
- configureServer(server) {
58
- if (options.autoInjectIcEnv ?? true) {
59
- setupIcEnvMiddleware(server);
50
+ config(_config, { command }) {
51
+ if (command !== "serve" || !(options.injectEnvironment ?? true)) {
52
+ return {};
60
53
  }
54
+ const canisterNames = options.canisters.map((c) => c.name);
55
+ const icEnv = getIcEnvironmentInfo(canisterNames);
56
+ if (!icEnv) {
57
+ return {
58
+ server: {
59
+ proxy: {
60
+ "/api": {
61
+ target: "http://127.0.0.1:4943",
62
+ changeOrigin: true
63
+ }
64
+ }
65
+ }
66
+ };
67
+ }
68
+ const cookieValue = buildIcEnvCookie(icEnv.canisterIds, icEnv.rootKey);
69
+ return {
70
+ server: {
71
+ headers: {
72
+ "Set-Cookie": `ic_env=${cookieValue}; Path=/; SameSite=Lax;`
73
+ },
74
+ proxy: {
75
+ "/api": {
76
+ target: icEnv.proxyTarget,
77
+ changeOrigin: true
78
+ }
79
+ }
80
+ }
81
+ };
61
82
  },
62
83
  async buildStart() {
84
+ const defaultClientPath = path.resolve(
85
+ process.cwd(),
86
+ "src/lib/clients.ts"
87
+ );
88
+ if (!fs.existsSync(defaultClientPath)) {
89
+ console.log(
90
+ `[ic-reactor] Default client manager not found. Creating at ${defaultClientPath}`
91
+ );
92
+ const clientContent = generateClientFile();
93
+ fs.mkdirSync(path.dirname(defaultClientPath), { recursive: true });
94
+ fs.writeFileSync(defaultClientPath, clientContent);
95
+ }
63
96
  for (const canister of options.canisters) {
97
+ let didFile = canister.didFile;
64
98
  const outDir = canister.outDir ?? path.join(baseOutDir, canister.name);
99
+ if (!didFile) {
100
+ const environment = process.env.ICP_ENVIRONMENT || "local";
101
+ console.log(
102
+ `[ic-reactor] didFile not specified for "${canister.name}". Attempting to download from canister...`
103
+ );
104
+ try {
105
+ const candidContent = execFileSync(
106
+ "icp",
107
+ [
108
+ "canister",
109
+ "metadata",
110
+ canister.name,
111
+ "candid:service",
112
+ "-e",
113
+ environment
114
+ ],
115
+ { encoding: "utf-8" }
116
+ ).trim();
117
+ const declarationsDir = path.join(outDir, "declarations");
118
+ if (!fs.existsSync(declarationsDir)) {
119
+ fs.mkdirSync(declarationsDir, { recursive: true });
120
+ }
121
+ didFile = path.join(declarationsDir, `${canister.name}.did`);
122
+ fs.writeFileSync(didFile, candidContent);
123
+ console.log(
124
+ `[ic-reactor] Candid downloaded and saved to ${didFile}`
125
+ );
126
+ } catch (error) {
127
+ console.error(
128
+ `[ic-reactor] Failed to download candid for ${canister.name}: ${error}`
129
+ );
130
+ continue;
131
+ }
132
+ }
65
133
  console.log(
66
- `[ic-reactor] Generating hooks for ${canister.name} from ${canister.didFile}`
134
+ `[ic-reactor] Generating hooks for ${canister.name} from ${didFile}`
67
135
  );
68
136
  const result = await generateDeclarations({
69
- didFile: canister.didFile,
137
+ didFile,
70
138
  outDir,
71
139
  canisterName: canister.name
72
140
  });
@@ -76,28 +144,10 @@ function icReactorPlugin(options) {
76
144
  );
77
145
  continue;
78
146
  }
79
- console.log(
80
- `[ic-reactor] Declarations generated at ${result.declarationsDir}`
81
- );
82
- const useAdvanced = canister.advanced ?? options.advanced ?? false;
83
- let didContent;
84
- if (useAdvanced) {
85
- try {
86
- didContent = fs.readFileSync(canister.didFile, "utf-8");
87
- } catch (e) {
88
- console.warn(
89
- `[ic-reactor] Could not read DID file at ${canister.didFile}, skipping advanced hook generation for ${canister.name}.`
90
- );
91
- continue;
92
- }
93
- }
94
147
  const reactorContent = generateReactorFile({
95
148
  canisterName: canister.name,
96
- canisterConfig: canister,
97
- globalClientManagerPath: options.clientManagerPath,
98
- hasDeclarations: true,
99
- advanced: useAdvanced,
100
- didContent
149
+ didFile,
150
+ clientManagerPath: canister.clientManagerPath ?? options.clientManagerPath
101
151
  });
102
152
  const reactorPath = path.join(outDir, "index.ts");
103
153
  fs.mkdirSync(outDir, { recursive: true });
@@ -107,9 +157,10 @@ function icReactorPlugin(options) {
107
157
  },
108
158
  handleHotUpdate({ file, server }) {
109
159
  if (file.endsWith(".did")) {
110
- const canister = options.canisters.find(
111
- (c) => path.resolve(c.didFile) === file
112
- );
160
+ const canister = options.canisters.find((c) => {
161
+ if (!c.didFile) return false;
162
+ return path.resolve(c.didFile) === file;
163
+ });
113
164
  if (canister) {
114
165
  console.log(
115
166
  `[ic-reactor] Detected change in ${file}, regenerating...`
@@ -120,8 +171,6 @@ function icReactorPlugin(options) {
120
171
  }
121
172
  };
122
173
  }
123
- var index_default = icReactorPlugin;
124
174
  export {
125
- index_default as default,
126
175
  icReactorPlugin
127
176
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ic-reactor/vite-plugin",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Vite plugin for zero-config IC reactor generation from Candid files",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -37,20 +37,22 @@
37
37
  "url": "https://github.com/B3Pay/ic-reactor/issues"
38
38
  },
39
39
  "dependencies": {
40
- "@ic-reactor/codegen": "0.2.0"
40
+ "@ic-reactor/codegen": "0.3.1"
41
41
  },
42
42
  "peerDependencies": {
43
43
  "vite": "^5.0.0 || ^6.0.0 || ^7.0.0"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@icp-sdk/bindgen": "^0.2.1",
47
- "@types/node": "^25.2.2",
47
+ "@types/node": "^25.2.3",
48
48
  "tsup": "^8.5.1",
49
49
  "typescript": "^5.9.3",
50
- "vite": "^7.3.1"
50
+ "vite": "^7.3.1",
51
+ "vitest": "^4.0.18"
51
52
  },
52
53
  "scripts": {
53
54
  "build": "tsup src/index.ts --format esm,cjs --dts --tsconfig tsconfig.json",
54
- "dev": "tsup src/index.ts --format esm,cjs --dts --watch --tsconfig tsconfig.json"
55
+ "dev": "tsup src/index.ts --format esm,cjs --dts --watch --tsconfig tsconfig.json",
56
+ "test": "vitest run"
55
57
  }
56
58
  }
@@ -0,0 +1,273 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest"
2
+ import { icReactorPlugin, type IcReactorPluginOptions } from "./index"
3
+ import fs from "fs"
4
+ import path from "path"
5
+ import { execFileSync } from "child_process"
6
+ import {
7
+ generateDeclarations,
8
+ generateReactorFile,
9
+ generateClientFile,
10
+ } from "@ic-reactor/codegen"
11
+
12
+ // Mock internal dependencies
13
+ vi.mock("@ic-reactor/codegen", () => ({
14
+ generateDeclarations: vi.fn(),
15
+ generateReactorFile: vi.fn(),
16
+ generateClientFile: vi.fn(),
17
+ }))
18
+
19
+ // Mock child_process
20
+ vi.mock("child_process", () => ({
21
+ execFileSync: vi.fn(),
22
+ }))
23
+
24
+ // Mock fs
25
+ vi.mock("fs", () => ({
26
+ default: {
27
+ existsSync: vi.fn(() => true),
28
+ readFileSync: vi.fn(),
29
+ mkdirSync: vi.fn(),
30
+ writeFileSync: vi.fn(),
31
+ },
32
+ }))
33
+
34
+ describe("icReactorPlugin", () => {
35
+ const mockOptions: IcReactorPluginOptions = {
36
+ canisters: [
37
+ {
38
+ name: "test_canister",
39
+ didFile: "src/declarations/test.did",
40
+ outDir: "src/declarations/test_canister",
41
+ },
42
+ ],
43
+ outDir: "src/declarations",
44
+ }
45
+
46
+ const mockServer: any = {
47
+ config: {
48
+ root: "/mock/root",
49
+ logger: {
50
+ info: vi.fn(),
51
+ },
52
+ },
53
+ middlewares: {
54
+ use: vi.fn(),
55
+ },
56
+ restart: vi.fn(),
57
+ }
58
+
59
+ beforeEach(() => {
60
+ vi.resetAllMocks()
61
+ ;(generateDeclarations as any).mockResolvedValue({
62
+ success: true,
63
+ declarationsDir: "/mock/declarations",
64
+ })
65
+ ;(generateReactorFile as any).mockReturnValue("export const reactor = {}")
66
+ ;(generateClientFile as any).mockReturnValue("export const client = {}")
67
+ })
68
+
69
+ it("should return correct plugin structure", () => {
70
+ const plugin = icReactorPlugin(mockOptions)
71
+ expect(plugin.name).toBe("ic-reactor-plugin")
72
+ expect(plugin.buildStart).toBeDefined()
73
+ expect(plugin.handleHotUpdate).toBeDefined()
74
+ expect((plugin as any).config).toBeDefined()
75
+ // configureServer is no longer used for middleware
76
+ expect(plugin.configureServer).toBeUndefined()
77
+ })
78
+
79
+ describe("config", () => {
80
+ it("should set up API proxy and headers when icp-cli is available", () => {
81
+ ;(execFileSync as any).mockImplementation(
82
+ (command: string, args: string[], options: any) => {
83
+ if (
84
+ command === "icp" &&
85
+ args.includes("network") &&
86
+ args.includes("status")
87
+ ) {
88
+ return JSON.stringify({ root_key: "mock-root-key", port: 4943 })
89
+ }
90
+ if (
91
+ command === "icp" &&
92
+ args.includes("canister") &&
93
+ args.includes("status")
94
+ ) {
95
+ return "mock-canister-id"
96
+ }
97
+ return ""
98
+ }
99
+ )
100
+
101
+ const plugin = icReactorPlugin(mockOptions)
102
+ const config = (plugin as any).config({}, { command: "serve" })
103
+
104
+ expect(config.server.headers["Set-Cookie"]).toContain("ic_env=")
105
+ expect(config.server.headers["Set-Cookie"]).toContain(
106
+ "PUBLIC_CANISTER_ID%3Atest_canister%3Dmock-canister-id"
107
+ )
108
+ expect(config.server.headers["Set-Cookie"]).toContain(
109
+ "ic_root_key%3Dmock-root-key"
110
+ )
111
+ expect(config.server.proxy["/api"].target).toBe("http://127.0.0.1:4943")
112
+ })
113
+
114
+ it("should fallback to default proxy when icp-cli fails", () => {
115
+ ;(execFileSync as any).mockImplementation(() => {
116
+ throw new Error("Command not found")
117
+ })
118
+
119
+ const plugin = icReactorPlugin(mockOptions)
120
+ const config = (plugin as any).config({}, { command: "serve" })
121
+
122
+ expect(config.server.headers).toBeUndefined()
123
+ expect(config.server.proxy["/api"].target).toBe("http://127.0.0.1:4943")
124
+ })
125
+
126
+ it("should return empty config for build command", () => {
127
+ const plugin = icReactorPlugin(mockOptions)
128
+ const config = (plugin as any).config({}, { command: "build" })
129
+
130
+ expect(config).toEqual({})
131
+ })
132
+ })
133
+
134
+ describe("buildStart", () => {
135
+ it("should generate declarations and reactor file", async () => {
136
+ const plugin = icReactorPlugin(mockOptions)
137
+ await (plugin.buildStart as any)()
138
+
139
+ expect(generateDeclarations).toHaveBeenCalledWith({
140
+ didFile: "src/declarations/test.did",
141
+ outDir: "src/declarations/test_canister",
142
+ canisterName: "test_canister",
143
+ })
144
+
145
+ expect(generateReactorFile).toHaveBeenCalledWith({
146
+ canisterName: "test_canister",
147
+ didFile: "src/declarations/test.did",
148
+ clientManagerPath: undefined,
149
+ })
150
+
151
+ expect(fs.writeFileSync).toHaveBeenCalledWith(
152
+ path.join("src/declarations/test_canister", "index.ts"),
153
+ "export const reactor = {}"
154
+ )
155
+ })
156
+
157
+ it("should download candid if not specified", async () => {
158
+ const optionsWithMissingDid: IcReactorPluginOptions = {
159
+ canisters: [
160
+ {
161
+ name: "missing_did",
162
+ outDir: "src/declarations/missing_did",
163
+ },
164
+ ],
165
+ outDir: "src/declarations",
166
+ }
167
+
168
+ ;(execFileSync as any).mockImplementation(
169
+ (command: string, args: string[]) => {
170
+ if (command === "icp" && args.includes("metadata")) {
171
+ return "service : { greet: (text) -> (text) query }"
172
+ }
173
+ return ""
174
+ }
175
+ )
176
+
177
+ const plugin = icReactorPlugin(optionsWithMissingDid)
178
+ await (plugin.buildStart as any)()
179
+
180
+ expect(execFileSync).toHaveBeenCalledWith(
181
+ "icp",
182
+ expect.arrayContaining([
183
+ "canister",
184
+ "metadata",
185
+ "missing_did",
186
+ "candid:service",
187
+ ]),
188
+ expect.any(Object)
189
+ )
190
+
191
+ expect(fs.writeFileSync).toHaveBeenCalledWith(
192
+ path.join(
193
+ "src/declarations/missing_did/declarations",
194
+ "missing_did.did"
195
+ ),
196
+ "service : { greet: (text) -> (text) query }"
197
+ )
198
+
199
+ expect(generateDeclarations).toHaveBeenCalledWith({
200
+ didFile: path.join(
201
+ "src/declarations/missing_did/declarations",
202
+ "missing_did.did"
203
+ ),
204
+ outDir: "src/declarations/missing_did",
205
+ canisterName: "missing_did",
206
+ })
207
+
208
+ expect(generateReactorFile).toHaveBeenCalledWith({
209
+ canisterName: "missing_did",
210
+ didFile: path.join(
211
+ "src/declarations/missing_did/declarations",
212
+ "missing_did.did"
213
+ ),
214
+ clientManagerPath: undefined,
215
+ })
216
+ })
217
+
218
+ it("should handle generation errors", async () => {
219
+ const consoleErrorSpy = vi
220
+ .spyOn(console, "error")
221
+ .mockImplementation(() => {})
222
+
223
+ ;(generateDeclarations as any).mockResolvedValue({
224
+ success: false,
225
+ error: "Failed to generate",
226
+ })
227
+
228
+ const plugin = icReactorPlugin(mockOptions)
229
+ await (plugin.buildStart as any)()
230
+
231
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
232
+ expect.stringContaining("Failed to generate declarations")
233
+ )
234
+ expect(generateReactorFile).not.toHaveBeenCalled()
235
+
236
+ consoleErrorSpy.mockRestore()
237
+ })
238
+ })
239
+
240
+ describe("handleHotUpdate", () => {
241
+ it("should restart server when .did file changes", () => {
242
+ const plugin = icReactorPlugin(mockOptions)
243
+ const ctx = {
244
+ file: "/absolute/path/to/src/declarations/test.did",
245
+ server: mockServer,
246
+ }
247
+
248
+ // Mock path.resolve to match the test case
249
+ const originalResolve = path.resolve
250
+ vi.spyOn(path, "resolve").mockImplementation((...args) => {
251
+ if (args.some((a) => a.includes("test.did"))) {
252
+ return "/absolute/path/to/src/declarations/test.did"
253
+ }
254
+ return originalResolve(...args)
255
+ })
256
+ ;(plugin.handleHotUpdate as any)(ctx)
257
+
258
+ expect(mockServer.restart).toHaveBeenCalled()
259
+ })
260
+
261
+ it("should ignore other files", () => {
262
+ const plugin = icReactorPlugin(mockOptions)
263
+ const ctx = {
264
+ file: "/some/other/file.ts",
265
+ server: mockServer,
266
+ }
267
+
268
+ ;(plugin.handleHotUpdate as any)(ctx)
269
+
270
+ expect(mockServer.restart).not.toHaveBeenCalled()
271
+ })
272
+ })
273
+ })