@ic-reactor/vite-plugin 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ })
package/src/index.ts ADDED
@@ -0,0 +1,267 @@
1
+ /**
2
+ * IC-Reactor Vite Plugin
3
+ *
4
+ * A Vite plugin that generates ic-reactor hooks from Candid .did files.
5
+ * Uses @ic-reactor/codegen for all code generation logic.
6
+ *
7
+ * Usage:
8
+ * ```ts
9
+ * import { icReactorPlugin } from "@ic-reactor/vite-plugin"
10
+ *
11
+ * export default defineConfig({
12
+ * plugins: [
13
+ * icReactorPlugin({
14
+ * canisters: [
15
+ * {
16
+ * name: "backend",
17
+ * didFile: "../backend/backend.did",
18
+ * clientManagerPath: "../lib/client"
19
+ * }
20
+ * ]
21
+ * })
22
+ * ]
23
+ * })
24
+ * ```
25
+ */
26
+
27
+ import type { Plugin } from "vite"
28
+ import fs from "fs"
29
+ import path from "path"
30
+ import { execFileSync } from "child_process"
31
+ import {
32
+ generateDeclarations,
33
+ generateReactorFile,
34
+ generateClientFile,
35
+ } from "@ic-reactor/codegen"
36
+
37
+ // ═══════════════════════════════════════════════════════════════════════════
38
+ // TYPES
39
+ // ═══════════════════════════════════════════════════════════════════════════
40
+ export interface CanisterConfig {
41
+ name: string
42
+ outDir?: string
43
+ didFile?: string
44
+ clientManagerPath?: string
45
+ }
46
+
47
+ export interface IcReactorPluginOptions {
48
+ /** List of canisters to generate hooks for */
49
+ canisters: CanisterConfig[]
50
+ /** Base output directory (default: ./src/lib/canisters) */
51
+ outDir?: string
52
+ /**
53
+ * Path to import ClientManager from (relative to generated file).
54
+ * Default: "../../clients"
55
+ */
56
+ clientManagerPath?: string
57
+ /**
58
+ * Automatically inject the IC environment (canister IDs and root key)
59
+ * into the browser using an `ic_env` cookie. (default: true)
60
+ *
61
+ * This is useful for local development with `icp`.
62
+ */
63
+ injectEnvironment?: boolean
64
+ }
65
+
66
+ function getIcEnvironmentInfo(canisterNames: string[]) {
67
+ const environment = process.env.ICP_ENVIRONMENT || "local"
68
+
69
+ try {
70
+ const networkStatus = JSON.parse(
71
+ execFileSync("icp", ["network", "status", "-e", environment, "--json"], {
72
+ encoding: "utf-8",
73
+ })
74
+ )
75
+ const rootKey = networkStatus.root_key
76
+ const proxyTarget = `http://127.0.0.1:${networkStatus.port}`
77
+
78
+ const canisterIds: Record<string, string> = {}
79
+ for (const name of canisterNames) {
80
+ try {
81
+ const canisterId = execFileSync(
82
+ "icp",
83
+ ["canister", "status", name, "-e", environment, "-i"],
84
+ {
85
+ encoding: "utf-8",
86
+ }
87
+ ).trim()
88
+ canisterIds[name] = canisterId
89
+ } catch {
90
+ // Skip if canister not found
91
+ }
92
+ }
93
+
94
+ return { environment, rootKey, proxyTarget, canisterIds }
95
+ } catch {
96
+ return null
97
+ }
98
+ }
99
+
100
+ function buildIcEnvCookie(
101
+ canisterIds: Record<string, string>,
102
+ rootKey: string
103
+ ): string {
104
+ const envParts = [`ic_root_key=${rootKey}`]
105
+
106
+ for (const [name, id] of Object.entries(canisterIds)) {
107
+ envParts.push(`PUBLIC_CANISTER_ID:${name}=${id}`)
108
+ }
109
+
110
+ return encodeURIComponent(envParts.join("&"))
111
+ }
112
+
113
+ // ═══════════════════════════════════════════════════════════════════════════
114
+ // VITE PLUGIN
115
+ // ═══════════════════════════════════════════════════════════════════════════
116
+
117
+ export function icReactorPlugin(options: IcReactorPluginOptions): Plugin {
118
+ const baseOutDir = options.outDir ?? "./src/lib/canisters"
119
+
120
+ return {
121
+ name: "ic-reactor-plugin",
122
+
123
+ config(_config, { command }) {
124
+ if (command !== "serve" || !(options.injectEnvironment ?? true)) {
125
+ return {}
126
+ }
127
+
128
+ const canisterNames = options.canisters.map((c) => c.name)
129
+ const icEnv = getIcEnvironmentInfo(canisterNames)
130
+
131
+ if (!icEnv) {
132
+ return {
133
+ server: {
134
+ proxy: {
135
+ "/api": {
136
+ target: "http://127.0.0.1:4943",
137
+ changeOrigin: true,
138
+ },
139
+ },
140
+ },
141
+ }
142
+ }
143
+
144
+ const cookieValue = buildIcEnvCookie(icEnv.canisterIds, icEnv.rootKey)
145
+
146
+ return {
147
+ server: {
148
+ headers: {
149
+ "Set-Cookie": `ic_env=${cookieValue}; Path=/; SameSite=Lax;`,
150
+ },
151
+ proxy: {
152
+ "/api": {
153
+ target: icEnv.proxyTarget,
154
+ changeOrigin: true,
155
+ },
156
+ },
157
+ },
158
+ }
159
+ },
160
+
161
+ async buildStart() {
162
+ // Step 0: Ensure central client manager exists (default: src/lib/clients.ts)
163
+ const defaultClientPath = path.resolve(
164
+ process.cwd(),
165
+ "src/lib/clients.ts"
166
+ )
167
+ if (!fs.existsSync(defaultClientPath)) {
168
+ console.log(
169
+ `[ic-reactor] Default client manager not found. Creating at ${defaultClientPath}`
170
+ )
171
+ const clientContent = generateClientFile()
172
+ fs.mkdirSync(path.dirname(defaultClientPath), { recursive: true })
173
+ fs.writeFileSync(defaultClientPath, clientContent)
174
+ }
175
+
176
+ for (const canister of options.canisters) {
177
+ let didFile = canister.didFile
178
+ const outDir = canister.outDir ?? path.join(baseOutDir, canister.name)
179
+
180
+ if (!didFile) {
181
+ const environment = process.env.ICP_ENVIRONMENT || "local"
182
+
183
+ console.log(
184
+ `[ic-reactor] didFile not specified for "${canister.name}". Attempting to download from canister...`
185
+ )
186
+ try {
187
+ const candidContent = execFileSync(
188
+ "icp",
189
+ [
190
+ "canister",
191
+ "metadata",
192
+ canister.name,
193
+ "candid:service",
194
+ "-e",
195
+ environment,
196
+ ],
197
+ { encoding: "utf-8" }
198
+ ).trim()
199
+
200
+ const declarationsDir = path.join(outDir, "declarations")
201
+ if (!fs.existsSync(declarationsDir)) {
202
+ fs.mkdirSync(declarationsDir, { recursive: true })
203
+ }
204
+ didFile = path.join(declarationsDir, `${canister.name}.did`)
205
+ fs.writeFileSync(didFile, candidContent)
206
+ console.log(
207
+ `[ic-reactor] Candid downloaded and saved to ${didFile}`
208
+ )
209
+ } catch (error) {
210
+ console.error(
211
+ `[ic-reactor] Failed to download candid for ${canister.name}: ${error}`
212
+ )
213
+ continue
214
+ }
215
+ }
216
+
217
+ console.log(
218
+ `[ic-reactor] Generating hooks for ${canister.name} from ${didFile}`
219
+ )
220
+
221
+ // Step 1: Generate declarations via @ic-reactor/codegen
222
+ const result = await generateDeclarations({
223
+ didFile: didFile,
224
+ outDir,
225
+ canisterName: canister.name,
226
+ })
227
+
228
+ if (!result.success) {
229
+ console.error(
230
+ `[ic-reactor] Failed to generate declarations: ${result.error}`
231
+ )
232
+ continue
233
+ }
234
+
235
+ // Step 2: Generate the reactor file using shared codegen
236
+ const reactorContent = generateReactorFile({
237
+ canisterName: canister.name,
238
+ didFile: didFile,
239
+ clientManagerPath:
240
+ canister.clientManagerPath ?? options.clientManagerPath,
241
+ })
242
+
243
+ const reactorPath = path.join(outDir, "index.ts")
244
+ fs.mkdirSync(outDir, { recursive: true })
245
+ fs.writeFileSync(reactorPath, reactorContent)
246
+
247
+ console.log(`[ic-reactor] Reactor hooks generated at ${reactorPath}`)
248
+ }
249
+ },
250
+
251
+ handleHotUpdate({ file, server }) {
252
+ // Watch for .did file changes and regenerate
253
+ if (file.endsWith(".did")) {
254
+ const canister = options.canisters.find((c) => {
255
+ if (!c.didFile) return false
256
+ return path.resolve(c.didFile) === file
257
+ })
258
+ if (canister) {
259
+ console.log(
260
+ `[ic-reactor] Detected change in ${file}, regenerating...`
261
+ )
262
+ server.restart()
263
+ }
264
+ }
265
+ },
266
+ }
267
+ }