@ic-reactor/codegen 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -17,6 +17,7 @@ import { runCanisterPipeline } from "@ic-reactor/codegen"
17
17
  await runCanisterPipeline({
18
18
  canisterConfig: {
19
19
  name: "backend",
20
+ mode: "DisplayReactor",
20
21
  didFile: "./backend.did",
21
22
  },
22
23
  projectRoot: process.cwd(),
@@ -27,12 +28,25 @@ await runCanisterPipeline({
27
28
  })
28
29
  ```
29
30
 
31
+ ## Reactor Class Configuration
32
+
33
+ Set `canisterConfig.mode` to choose the generated reactor class:
34
+
35
+ - `DisplayReactor` (default)
36
+ - `Reactor`
37
+ - `CandidReactor`
38
+ - `CandidDisplayReactor`
39
+ - `MetadataDisplayReactor`
40
+
41
+ Codegen now writes two files per canister: a managed `index.generated.ts` implementation that is regenerated on every run, and an `index.ts` entry wrapper. The wrapper is created once, then preserved unless it still matches the default generated wrapper or an older generated scaffold that can be migrated automatically.
42
+
30
43
  ## Generators
31
44
 
32
45
  You can also use individual generators if you need more granular control:
33
46
 
34
47
  - **`generateDeclarations`**: Generates `.js` (factory), `.d.ts` (types), and `.did` copy.
35
- - **`generateReactorFile`**: Generates the `index.ts` file with `DisplayReactor` and hooks.
48
+ - **`generateReactorFile`**: Generates the managed `index.generated.ts` implementation using either `DisplayReactor` or `Reactor`.
49
+ - **`generateReactorEntryFile`**: Generates the stable `index.ts` wrapper that re-exports from `index.generated.ts`.
36
50
  - **`generateClientFile`**: Generates a `ClientManager` boilerplate file.
37
51
 
38
52
  ## Utilities
package/dist/index.cjs CHANGED
@@ -34,6 +34,7 @@ __export(index_exports, {
34
34
  extractMethods: () => extractMethods,
35
35
  generateClientFile: () => generateClientFile,
36
36
  generateDeclarations: () => generateDeclarations,
37
+ generateReactorEntryFile: () => generateReactorEntryFile,
37
38
  generateReactorFile: () => generateReactorFile,
38
39
  getReactorName: () => getReactorName,
39
40
  getServiceTypeName: () => getServiceTypeName,
@@ -123,14 +124,35 @@ function getServiceTypeName(canisterName) {
123
124
  }
124
125
 
125
126
  // src/generators/reactor.ts
127
+ function getReactorClassImportSource(reactorClass) {
128
+ switch (reactorClass) {
129
+ case "Reactor":
130
+ case "DisplayReactor":
131
+ return "@ic-reactor/react";
132
+ case "CandidReactor":
133
+ case "CandidDisplayReactor":
134
+ case "MetadataDisplayReactor":
135
+ return "@ic-reactor/candid";
136
+ }
137
+ }
126
138
  function generateReactorFile(options) {
127
- const { canisterName, didFile, clientManagerPath = "../../clients" } = options;
139
+ const {
140
+ canisterName,
141
+ didFile,
142
+ clientManagerPath = "../../clients",
143
+ canisterId,
144
+ reactorClass = "DisplayReactor"
145
+ } = options;
128
146
  const pascalName = toPascalCase(canisterName);
129
147
  const reactorName = getReactorName(canisterName);
130
148
  const serviceName = getServiceTypeName(canisterName);
131
149
  const baseName = import_node_path2.default.basename(didFile, ".did");
132
150
  const declarationsPath = `./declarations/${baseName}`;
133
- return `import { DisplayReactor, createActorHooks } from "@ic-reactor/react"
151
+ const reactorImportSource = getReactorClassImportSource(reactorClass);
152
+ const canisterIdLine = canisterId ? ` canisterId: ${JSON.stringify(canisterId)},
153
+ ` : "";
154
+ return `import { createActorHooks } from "@ic-reactor/react"
155
+ import { ${reactorClass} } from "${reactorImportSource}"
134
156
  import { clientManager } from "${clientManagerPath}"
135
157
  import { idlFactory, type _SERVICE } from "${declarationsPath}"
136
158
 
@@ -140,12 +162,12 @@ export type ${serviceName} = _SERVICE
140
162
  * ${pascalName} Reactor
141
163
  *
142
164
  * Auto-generated by @ic-reactor/codegen \u2014 do not edit.
143
- * Re-run \`ic-reactor generate\` (or the Vite plugin) to regenerate.
165
+ * This file is overwritten whenever generation runs.
144
166
  */
145
- export const ${reactorName} = new DisplayReactor<${serviceName}>({
167
+ export const ${reactorName} = new ${reactorClass}<${serviceName}>({
146
168
  clientManager,
147
169
  idlFactory,
148
- name: "${canisterName}",
170
+ ${canisterIdLine} name: "${canisterName}",
149
171
  })
150
172
 
151
173
  export const {
@@ -158,8 +180,30 @@ export const {
158
180
  } = createActorHooks(${reactorName})
159
181
  `;
160
182
  }
183
+ function generateReactorEntryFile() {
184
+ return `/**
185
+ * Canister entrypoint.
186
+ *
187
+ * Created once by @ic-reactor/codegen and safe to customize.
188
+ * Keep the re-export below if you want generated hooks and types to stay in sync.
189
+ */
190
+ export * from "./index.generated"
191
+ `;
192
+ }
161
193
 
162
194
  // src/pipeline.ts
195
+ function resolveReactorClass(canisterConfig) {
196
+ return canisterConfig.mode ?? "DisplayReactor";
197
+ }
198
+ function normalizeFileContent(content) {
199
+ return content.replace(/\r\n/g, "\n").trim();
200
+ }
201
+ function isLegacyGeneratedIndexFile(content) {
202
+ return content.includes("Auto-generated by @ic-reactor/codegen") && content.includes("createActorHooks(");
203
+ }
204
+ function isManagedEntryWrapper(content, expectedEntryContent) {
205
+ return normalizeFileContent(content) === normalizeFileContent(expectedEntryContent);
206
+ }
163
207
  async function runCanisterPipeline(options) {
164
208
  const { canisterConfig, projectRoot, globalConfig } = options;
165
209
  const { name, didFile, clientManagerPath } = canisterConfig;
@@ -198,16 +242,33 @@ async function runCanisterPipeline(options) {
198
242
  error: `Declarations step failed: ${err instanceof Error ? err.message : String(err)}`
199
243
  };
200
244
  }
201
- const reactorPath = import_node_path3.default.join(canisterOutDir, "index.ts");
245
+ const reactorPath = import_node_path3.default.join(canisterOutDir, "index.generated.ts");
246
+ const entryPath = import_node_path3.default.join(canisterOutDir, "index.ts");
247
+ const reactorClass = resolveReactorClass(canisterConfig);
202
248
  try {
203
249
  const reactorContent = generateReactorFile({
204
250
  canisterName: name,
205
251
  didFile: resolvedDidFile,
206
- clientManagerPath: resolvedClientManagerPath
252
+ clientManagerPath: resolvedClientManagerPath,
253
+ canisterId: canisterConfig.canisterId,
254
+ reactorClass
207
255
  });
256
+ const entryContent = generateReactorEntryFile();
208
257
  import_node_fs2.default.mkdirSync(canisterOutDir, { recursive: true });
209
258
  import_node_fs2.default.writeFileSync(reactorPath, reactorContent);
210
259
  files.push({ success: true, filePath: reactorPath });
260
+ if (!import_node_fs2.default.existsSync(entryPath)) {
261
+ import_node_fs2.default.writeFileSync(entryPath, entryContent);
262
+ files.push({ success: true, filePath: entryPath });
263
+ } else {
264
+ const existingEntryContent = import_node_fs2.default.readFileSync(entryPath, "utf-8");
265
+ if (isLegacyGeneratedIndexFile(existingEntryContent) || isManagedEntryWrapper(existingEntryContent, entryContent)) {
266
+ import_node_fs2.default.writeFileSync(entryPath, entryContent);
267
+ files.push({ success: true, filePath: entryPath });
268
+ } else {
269
+ files.push({ success: true, filePath: entryPath, skipped: true });
270
+ }
271
+ }
211
272
  } catch (err) {
212
273
  files.push({
213
274
  success: false,
@@ -289,6 +350,7 @@ export const clientManager = new ClientManager({
289
350
  extractMethods,
290
351
  generateClientFile,
291
352
  generateDeclarations,
353
+ generateReactorEntryFile,
292
354
  generateReactorFile,
293
355
  getReactorName,
294
356
  getServiceTypeName,
package/dist/index.d.cts CHANGED
@@ -19,9 +19,15 @@ interface CanisterConfig {
19
19
  * Example: "../../clients" → `import { clientManager } from "../../clients"`
20
20
  */
21
21
  clientManagerPath?: string;
22
+ /**
23
+ * Reactor class used for generated hooks in this canister.
24
+ * Defaults to DisplayReactor for backward compatibility.
25
+ */
26
+ mode?: ReactorClassName;
22
27
  /** Optional fixed canister ID */
23
28
  canisterId?: string;
24
29
  }
30
+ type ReactorClassName = "Reactor" | "DisplayReactor" | "CandidReactor" | "CandidDisplayReactor" | "MetadataDisplayReactor";
25
31
  /**
26
32
  * Top-level codegen / CLI configuration (stored in `ic-reactor.json`).
27
33
  */
@@ -62,7 +68,8 @@ interface GeneratorResult {
62
68
  * Pipeline steps (in order):
63
69
  * 1. Resolve paths (didFile, outDir)
64
70
  * 2. Generate declarations (JS + .d.ts + .did copy)
65
- * 3. Generate reactor file (index.ts)
71
+ * 3. Generate reactor implementation (`index.generated.ts`)
72
+ * 4. Create or migrate the user entry (`index.ts`)
66
73
  */
67
74
 
68
75
  interface PipelineOptions {
@@ -186,7 +193,7 @@ declare function declarationsExist(outDir: string, canisterName: string): boolea
186
193
  /**
187
194
  * Reactor File Generator
188
195
  *
189
- * Generates the main `index.ts` for a canister — a DisplayReactor instance
196
+ * Generates the managed `index.generated.ts` implementation for a canister
190
197
  * plus the full set of typed hooks via `createActorHooks`.
191
198
  *
192
199
  * Generated output example (for canister "backend"):
@@ -204,6 +211,7 @@ declare function declarationsExist(outDir: string, canisterName: string): boolea
204
211
  * ...
205
212
  * } = createActorHooks(backendReactor)
206
213
  */
214
+
207
215
  interface ReactorGeneratorOptions {
208
216
  /** Canister name (e.g. "backend") */
209
217
  canisterName: string;
@@ -217,11 +225,22 @@ interface ReactorGeneratorOptions {
217
225
  * Default: "../../clients"
218
226
  */
219
227
  clientManagerPath?: string;
228
+ /** Optional fixed canister ID for the generated reactor */
229
+ canisterId?: string;
230
+ /**
231
+ * Which reactor class should back the generated hooks.
232
+ * Default: "DisplayReactor" (backward compatible)
233
+ */
234
+ reactorClass?: ReactorClassName;
220
235
  }
221
236
  /**
222
- * Generate the content of a canister's `index.ts` reactor file.
237
+ * Generate the content of a canister's managed implementation file.
223
238
  */
224
239
  declare function generateReactorFile(options: ReactorGeneratorOptions): string;
240
+ /**
241
+ * Generate the user-facing `index.ts` wrapper content.
242
+ */
243
+ declare function generateReactorEntryFile(): string;
225
244
 
226
245
  /**
227
246
  * Client Manager Generator
@@ -246,4 +265,4 @@ interface ClientGeneratorOptions {
246
265
  */
247
266
  declare function generateClientFile(options?: ClientGeneratorOptions): string;
248
267
 
249
- export { type CanisterConfig, type ClientGeneratorOptions, type CodegenConfig, type DeclarationsGeneratorOptions, type DeclarationsGeneratorResult, type GeneratorResult, type MethodInfo, type MethodType, type PipelineOptions, type PipelineResult, type ReactorGeneratorOptions, declarationsExist, extractMethods, generateClientFile, generateDeclarations, generateReactorFile, getReactorName, getServiceTypeName, parseDIDFile, runCanisterPipeline, toPascalCase };
268
+ export { type CanisterConfig, type ClientGeneratorOptions, type CodegenConfig, type DeclarationsGeneratorOptions, type DeclarationsGeneratorResult, type GeneratorResult, type MethodInfo, type MethodType, type PipelineOptions, type PipelineResult, type ReactorClassName, type ReactorGeneratorOptions, declarationsExist, extractMethods, generateClientFile, generateDeclarations, generateReactorEntryFile, generateReactorFile, getReactorName, getServiceTypeName, parseDIDFile, runCanisterPipeline, toPascalCase };
package/dist/index.d.ts CHANGED
@@ -19,9 +19,15 @@ interface CanisterConfig {
19
19
  * Example: "../../clients" → `import { clientManager } from "../../clients"`
20
20
  */
21
21
  clientManagerPath?: string;
22
+ /**
23
+ * Reactor class used for generated hooks in this canister.
24
+ * Defaults to DisplayReactor for backward compatibility.
25
+ */
26
+ mode?: ReactorClassName;
22
27
  /** Optional fixed canister ID */
23
28
  canisterId?: string;
24
29
  }
30
+ type ReactorClassName = "Reactor" | "DisplayReactor" | "CandidReactor" | "CandidDisplayReactor" | "MetadataDisplayReactor";
25
31
  /**
26
32
  * Top-level codegen / CLI configuration (stored in `ic-reactor.json`).
27
33
  */
@@ -62,7 +68,8 @@ interface GeneratorResult {
62
68
  * Pipeline steps (in order):
63
69
  * 1. Resolve paths (didFile, outDir)
64
70
  * 2. Generate declarations (JS + .d.ts + .did copy)
65
- * 3. Generate reactor file (index.ts)
71
+ * 3. Generate reactor implementation (`index.generated.ts`)
72
+ * 4. Create or migrate the user entry (`index.ts`)
66
73
  */
67
74
 
68
75
  interface PipelineOptions {
@@ -186,7 +193,7 @@ declare function declarationsExist(outDir: string, canisterName: string): boolea
186
193
  /**
187
194
  * Reactor File Generator
188
195
  *
189
- * Generates the main `index.ts` for a canister — a DisplayReactor instance
196
+ * Generates the managed `index.generated.ts` implementation for a canister
190
197
  * plus the full set of typed hooks via `createActorHooks`.
191
198
  *
192
199
  * Generated output example (for canister "backend"):
@@ -204,6 +211,7 @@ declare function declarationsExist(outDir: string, canisterName: string): boolea
204
211
  * ...
205
212
  * } = createActorHooks(backendReactor)
206
213
  */
214
+
207
215
  interface ReactorGeneratorOptions {
208
216
  /** Canister name (e.g. "backend") */
209
217
  canisterName: string;
@@ -217,11 +225,22 @@ interface ReactorGeneratorOptions {
217
225
  * Default: "../../clients"
218
226
  */
219
227
  clientManagerPath?: string;
228
+ /** Optional fixed canister ID for the generated reactor */
229
+ canisterId?: string;
230
+ /**
231
+ * Which reactor class should back the generated hooks.
232
+ * Default: "DisplayReactor" (backward compatible)
233
+ */
234
+ reactorClass?: ReactorClassName;
220
235
  }
221
236
  /**
222
- * Generate the content of a canister's `index.ts` reactor file.
237
+ * Generate the content of a canister's managed implementation file.
223
238
  */
224
239
  declare function generateReactorFile(options: ReactorGeneratorOptions): string;
240
+ /**
241
+ * Generate the user-facing `index.ts` wrapper content.
242
+ */
243
+ declare function generateReactorEntryFile(): string;
225
244
 
226
245
  /**
227
246
  * Client Manager Generator
@@ -246,4 +265,4 @@ interface ClientGeneratorOptions {
246
265
  */
247
266
  declare function generateClientFile(options?: ClientGeneratorOptions): string;
248
267
 
249
- export { type CanisterConfig, type ClientGeneratorOptions, type CodegenConfig, type DeclarationsGeneratorOptions, type DeclarationsGeneratorResult, type GeneratorResult, type MethodInfo, type MethodType, type PipelineOptions, type PipelineResult, type ReactorGeneratorOptions, declarationsExist, extractMethods, generateClientFile, generateDeclarations, generateReactorFile, getReactorName, getServiceTypeName, parseDIDFile, runCanisterPipeline, toPascalCase };
268
+ export { type CanisterConfig, type ClientGeneratorOptions, type CodegenConfig, type DeclarationsGeneratorOptions, type DeclarationsGeneratorResult, type GeneratorResult, type MethodInfo, type MethodType, type PipelineOptions, type PipelineResult, type ReactorClassName, type ReactorGeneratorOptions, declarationsExist, extractMethods, generateClientFile, generateDeclarations, generateReactorEntryFile, generateReactorFile, getReactorName, getServiceTypeName, parseDIDFile, runCanisterPipeline, toPascalCase };
package/dist/index.js CHANGED
@@ -78,14 +78,35 @@ function getServiceTypeName(canisterName) {
78
78
  }
79
79
 
80
80
  // src/generators/reactor.ts
81
+ function getReactorClassImportSource(reactorClass) {
82
+ switch (reactorClass) {
83
+ case "Reactor":
84
+ case "DisplayReactor":
85
+ return "@ic-reactor/react";
86
+ case "CandidReactor":
87
+ case "CandidDisplayReactor":
88
+ case "MetadataDisplayReactor":
89
+ return "@ic-reactor/candid";
90
+ }
91
+ }
81
92
  function generateReactorFile(options) {
82
- const { canisterName, didFile, clientManagerPath = "../../clients" } = options;
93
+ const {
94
+ canisterName,
95
+ didFile,
96
+ clientManagerPath = "../../clients",
97
+ canisterId,
98
+ reactorClass = "DisplayReactor"
99
+ } = options;
83
100
  const pascalName = toPascalCase(canisterName);
84
101
  const reactorName = getReactorName(canisterName);
85
102
  const serviceName = getServiceTypeName(canisterName);
86
103
  const baseName = path2.basename(didFile, ".did");
87
104
  const declarationsPath = `./declarations/${baseName}`;
88
- return `import { DisplayReactor, createActorHooks } from "@ic-reactor/react"
105
+ const reactorImportSource = getReactorClassImportSource(reactorClass);
106
+ const canisterIdLine = canisterId ? ` canisterId: ${JSON.stringify(canisterId)},
107
+ ` : "";
108
+ return `import { createActorHooks } from "@ic-reactor/react"
109
+ import { ${reactorClass} } from "${reactorImportSource}"
89
110
  import { clientManager } from "${clientManagerPath}"
90
111
  import { idlFactory, type _SERVICE } from "${declarationsPath}"
91
112
 
@@ -95,12 +116,12 @@ export type ${serviceName} = _SERVICE
95
116
  * ${pascalName} Reactor
96
117
  *
97
118
  * Auto-generated by @ic-reactor/codegen \u2014 do not edit.
98
- * Re-run \`ic-reactor generate\` (or the Vite plugin) to regenerate.
119
+ * This file is overwritten whenever generation runs.
99
120
  */
100
- export const ${reactorName} = new DisplayReactor<${serviceName}>({
121
+ export const ${reactorName} = new ${reactorClass}<${serviceName}>({
101
122
  clientManager,
102
123
  idlFactory,
103
- name: "${canisterName}",
124
+ ${canisterIdLine} name: "${canisterName}",
104
125
  })
105
126
 
106
127
  export const {
@@ -113,8 +134,30 @@ export const {
113
134
  } = createActorHooks(${reactorName})
114
135
  `;
115
136
  }
137
+ function generateReactorEntryFile() {
138
+ return `/**
139
+ * Canister entrypoint.
140
+ *
141
+ * Created once by @ic-reactor/codegen and safe to customize.
142
+ * Keep the re-export below if you want generated hooks and types to stay in sync.
143
+ */
144
+ export * from "./index.generated"
145
+ `;
146
+ }
116
147
 
117
148
  // src/pipeline.ts
149
+ function resolveReactorClass(canisterConfig) {
150
+ return canisterConfig.mode ?? "DisplayReactor";
151
+ }
152
+ function normalizeFileContent(content) {
153
+ return content.replace(/\r\n/g, "\n").trim();
154
+ }
155
+ function isLegacyGeneratedIndexFile(content) {
156
+ return content.includes("Auto-generated by @ic-reactor/codegen") && content.includes("createActorHooks(");
157
+ }
158
+ function isManagedEntryWrapper(content, expectedEntryContent) {
159
+ return normalizeFileContent(content) === normalizeFileContent(expectedEntryContent);
160
+ }
118
161
  async function runCanisterPipeline(options) {
119
162
  const { canisterConfig, projectRoot, globalConfig } = options;
120
163
  const { name, didFile, clientManagerPath } = canisterConfig;
@@ -153,16 +196,33 @@ async function runCanisterPipeline(options) {
153
196
  error: `Declarations step failed: ${err instanceof Error ? err.message : String(err)}`
154
197
  };
155
198
  }
156
- const reactorPath = path3.join(canisterOutDir, "index.ts");
199
+ const reactorPath = path3.join(canisterOutDir, "index.generated.ts");
200
+ const entryPath = path3.join(canisterOutDir, "index.ts");
201
+ const reactorClass = resolveReactorClass(canisterConfig);
157
202
  try {
158
203
  const reactorContent = generateReactorFile({
159
204
  canisterName: name,
160
205
  didFile: resolvedDidFile,
161
- clientManagerPath: resolvedClientManagerPath
206
+ clientManagerPath: resolvedClientManagerPath,
207
+ canisterId: canisterConfig.canisterId,
208
+ reactorClass
162
209
  });
210
+ const entryContent = generateReactorEntryFile();
163
211
  fs2.mkdirSync(canisterOutDir, { recursive: true });
164
212
  fs2.writeFileSync(reactorPath, reactorContent);
165
213
  files.push({ success: true, filePath: reactorPath });
214
+ if (!fs2.existsSync(entryPath)) {
215
+ fs2.writeFileSync(entryPath, entryContent);
216
+ files.push({ success: true, filePath: entryPath });
217
+ } else {
218
+ const existingEntryContent = fs2.readFileSync(entryPath, "utf-8");
219
+ if (isLegacyGeneratedIndexFile(existingEntryContent) || isManagedEntryWrapper(existingEntryContent, entryContent)) {
220
+ fs2.writeFileSync(entryPath, entryContent);
221
+ files.push({ success: true, filePath: entryPath });
222
+ } else {
223
+ files.push({ success: true, filePath: entryPath, skipped: true });
224
+ }
225
+ }
166
226
  } catch (err) {
167
227
  files.push({
168
228
  success: false,
@@ -243,6 +303,7 @@ export {
243
303
  extractMethods,
244
304
  generateClientFile,
245
305
  generateDeclarations,
306
+ generateReactorEntryFile,
246
307
  generateReactorFile,
247
308
  getReactorName,
248
309
  getServiceTypeName,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ic-reactor/codegen",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
4
4
  "description": "Shared code generation utilities for IC Reactor",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -37,7 +37,7 @@
37
37
  "@ic-reactor/parser": "0.4.6"
38
38
  },
39
39
  "devDependencies": {
40
- "@types/node": "^25.2.3",
40
+ "@types/node": "^25.3.5",
41
41
  "tsup": "^8.5.1",
42
42
  "typescript": "^5.9.3",
43
43
  "vitest": "^4.0.18"
@@ -0,0 +1,94 @@
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`Reactor generator > keeps default behavior as DisplayReactor > display-reactor-index 1`] = `
4
+ "import { createActorHooks } from "@ic-reactor/react"
5
+ import { DisplayReactor } from "@ic-reactor/react"
6
+ import { clientManager } from "../../clients"
7
+ import { idlFactory, type _SERVICE } from "./declarations/backend"
8
+
9
+ export type BackendService = _SERVICE
10
+
11
+ /**
12
+ * Backend Reactor
13
+ *
14
+ * Auto-generated by @ic-reactor/codegen — do not edit.
15
+ * This file is overwritten whenever generation runs.
16
+ */
17
+ export const backendReactor = new DisplayReactor<BackendService>({
18
+ clientManager,
19
+ idlFactory,
20
+ name: "backend",
21
+ })
22
+
23
+ export const {
24
+ useActorQuery: useBackendQuery,
25
+ useActorSuspenseQuery: useBackendSuspenseQuery,
26
+ useActorInfiniteQuery: useBackendInfiniteQuery,
27
+ useActorSuspenseInfiniteQuery: useBackendSuspenseInfiniteQuery,
28
+ useActorMutation: useBackendMutation,
29
+ useActorMethod: useBackendMethod,
30
+ } = createActorHooks(backendReactor)
31
+ "
32
+ `;
33
+
34
+ exports[`Reactor generator > supports Reactor mode generation > reactor-index 1`] = `
35
+ "import { createActorHooks } from "@ic-reactor/react"
36
+ import { Reactor } from "@ic-reactor/react"
37
+ import { clientManager } from "../../clients"
38
+ import { idlFactory, type _SERVICE } from "./declarations/workflow_engine"
39
+
40
+ export type WorkflowEngineService = _SERVICE
41
+
42
+ /**
43
+ * WorkflowEngine Reactor
44
+ *
45
+ * Auto-generated by @ic-reactor/codegen — do not edit.
46
+ * This file is overwritten whenever generation runs.
47
+ */
48
+ export const workflowEngineReactor = new Reactor<WorkflowEngineService>({
49
+ clientManager,
50
+ idlFactory,
51
+ name: "workflow_engine",
52
+ })
53
+
54
+ export const {
55
+ useActorQuery: useWorkflowEngineQuery,
56
+ useActorSuspenseQuery: useWorkflowEngineSuspenseQuery,
57
+ useActorInfiniteQuery: useWorkflowEngineInfiniteQuery,
58
+ useActorSuspenseInfiniteQuery: useWorkflowEngineSuspenseInfiniteQuery,
59
+ useActorMutation: useWorkflowEngineMutation,
60
+ useActorMethod: useWorkflowEngineMethod,
61
+ } = createActorHooks(workflowEngineReactor)
62
+ "
63
+ `;
64
+
65
+ exports[`Reactor generator > supports candid reactor subclasses > metadata-display-reactor-index 1`] = `
66
+ "import { createActorHooks } from "@ic-reactor/react"
67
+ import { MetadataDisplayReactor } from "@ic-reactor/candid"
68
+ import { clientManager } from "../../clients"
69
+ import { idlFactory, type _SERVICE } from "./declarations/ledger"
70
+
71
+ export type LedgerService = _SERVICE
72
+
73
+ /**
74
+ * Ledger Reactor
75
+ *
76
+ * Auto-generated by @ic-reactor/codegen — do not edit.
77
+ * This file is overwritten whenever generation runs.
78
+ */
79
+ export const ledgerReactor = new MetadataDisplayReactor<LedgerService>({
80
+ clientManager,
81
+ idlFactory,
82
+ name: "ledger",
83
+ })
84
+
85
+ export const {
86
+ useActorQuery: useLedgerQuery,
87
+ useActorSuspenseQuery: useLedgerSuspenseQuery,
88
+ useActorInfiniteQuery: useLedgerInfiniteQuery,
89
+ useActorSuspenseInfiniteQuery: useLedgerSuspenseInfiniteQuery,
90
+ useActorMutation: useLedgerMutation,
91
+ useActorMethod: useLedgerMethod,
92
+ } = createActorHooks(ledgerReactor)
93
+ "
94
+ `;
@@ -11,7 +11,7 @@ export type {
11
11
  DeclarationsGeneratorResult,
12
12
  } from "./declarations.js"
13
13
 
14
- export { generateReactorFile } from "./reactor.js"
14
+ export { generateReactorFile, generateReactorEntryFile } from "./reactor.js"
15
15
  export type { ReactorGeneratorOptions } from "./reactor.js"
16
16
 
17
17
  export { generateClientFile } from "./client.js"
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Reactor File Generator
3
3
  *
4
- * Generates the main `index.ts` for a canister — a DisplayReactor instance
4
+ * Generates the managed `index.generated.ts` implementation for a canister
5
5
  * plus the full set of typed hooks via `createActorHooks`.
6
6
  *
7
7
  * Generated output example (for canister "backend"):
@@ -22,6 +22,7 @@
22
22
 
23
23
  import path from "node:path"
24
24
  import { toPascalCase, getReactorName, getServiceTypeName } from "../naming.js"
25
+ import type { ReactorClassName } from "../types.js"
25
26
 
26
27
  export interface ReactorGeneratorOptions {
27
28
  /** Canister name (e.g. "backend") */
@@ -36,13 +37,40 @@ export interface ReactorGeneratorOptions {
36
37
  * Default: "../../clients"
37
38
  */
38
39
  clientManagerPath?: string
40
+ /** Optional fixed canister ID for the generated reactor */
41
+ canisterId?: string
42
+ /**
43
+ * Which reactor class should back the generated hooks.
44
+ * Default: "DisplayReactor" (backward compatible)
45
+ */
46
+ reactorClass?: ReactorClassName
47
+ }
48
+
49
+ function getReactorClassImportSource(
50
+ reactorClass: ReactorClassName
51
+ ): "@ic-reactor/react" | "@ic-reactor/candid" {
52
+ switch (reactorClass) {
53
+ case "Reactor":
54
+ case "DisplayReactor":
55
+ return "@ic-reactor/react"
56
+ case "CandidReactor":
57
+ case "CandidDisplayReactor":
58
+ case "MetadataDisplayReactor":
59
+ return "@ic-reactor/candid"
60
+ }
39
61
  }
40
62
 
41
63
  /**
42
- * Generate the content of a canister's `index.ts` reactor file.
64
+ * Generate the content of a canister's managed implementation file.
43
65
  */
44
66
  export function generateReactorFile(options: ReactorGeneratorOptions): string {
45
- const { canisterName, didFile, clientManagerPath = "../../clients" } = options
67
+ const {
68
+ canisterName,
69
+ didFile,
70
+ clientManagerPath = "../../clients",
71
+ canisterId,
72
+ reactorClass = "DisplayReactor",
73
+ } = options
46
74
 
47
75
  const pascalName = toPascalCase(canisterName)
48
76
  const reactorName = getReactorName(canisterName)
@@ -51,8 +79,13 @@ export function generateReactorFile(options: ReactorGeneratorOptions): string {
51
79
  // Derive the declarations import path from the .did filename
52
80
  const baseName = path.basename(didFile, ".did")
53
81
  const declarationsPath = `./declarations/${baseName}`
82
+ const reactorImportSource = getReactorClassImportSource(reactorClass)
83
+ const canisterIdLine = canisterId
84
+ ? ` canisterId: ${JSON.stringify(canisterId)},\n`
85
+ : ""
54
86
 
55
- return `import { DisplayReactor, createActorHooks } from "@ic-reactor/react"
87
+ return `import { createActorHooks } from "@ic-reactor/react"
88
+ import { ${reactorClass} } from "${reactorImportSource}"
56
89
  import { clientManager } from "${clientManagerPath}"
57
90
  import { idlFactory, type _SERVICE } from "${declarationsPath}"
58
91
 
@@ -62,12 +95,12 @@ export type ${serviceName} = _SERVICE
62
95
  * ${pascalName} Reactor
63
96
  *
64
97
  * Auto-generated by @ic-reactor/codegen — do not edit.
65
- * Re-run \`ic-reactor generate\` (or the Vite plugin) to regenerate.
98
+ * This file is overwritten whenever generation runs.
66
99
  */
67
- export const ${reactorName} = new DisplayReactor<${serviceName}>({
100
+ export const ${reactorName} = new ${reactorClass}<${serviceName}>({
68
101
  clientManager,
69
102
  idlFactory,
70
- name: "${canisterName}",
103
+ ${canisterIdLine} name: "${canisterName}",
71
104
  })
72
105
 
73
106
  export const {
@@ -80,3 +113,17 @@ export const {
80
113
  } = createActorHooks(${reactorName})
81
114
  `
82
115
  }
116
+
117
+ /**
118
+ * Generate the user-facing `index.ts` wrapper content.
119
+ */
120
+ export function generateReactorEntryFile(): string {
121
+ return `/**
122
+ * Canister entrypoint.
123
+ *
124
+ * Created once by @ic-reactor/codegen and safe to customize.
125
+ * Keep the re-export below if you want generated hooks and types to stay in sync.
126
+ */
127
+ export * from "./index.generated"
128
+ `
129
+ }
package/src/index.ts CHANGED
@@ -6,7 +6,12 @@
6
6
  */
7
7
 
8
8
  // Core Types
9
- export type { CanisterConfig, CodegenConfig, GeneratorResult } from "./types.js"
9
+ export type {
10
+ CanisterConfig,
11
+ CodegenConfig,
12
+ GeneratorResult,
13
+ ReactorClassName,
14
+ } from "./types.js"
10
15
 
11
16
  // Pipeline (Primary Entry Point)
12
17
  export { runCanisterPipeline } from "./pipeline.js"
@@ -0,0 +1,225 @@
1
+ import fs from "node:fs"
2
+ import os from "node:os"
3
+ import path from "node:path"
4
+ import { afterEach, describe, expect, it } from "vitest"
5
+ import { runCanisterPipeline } from "./pipeline"
6
+
7
+ describe("Codegen pipeline", () => {
8
+ const tempDirs: string[] = []
9
+
10
+ afterEach(() => {
11
+ for (const dir of tempDirs) {
12
+ fs.rmSync(dir, { recursive: true, force: true })
13
+ }
14
+ tempDirs.length = 0
15
+ })
16
+
17
+ function createTempProject() {
18
+ const projectRoot = fs.mkdtempSync(
19
+ path.join(os.tmpdir(), "ic-reactor-codegen-pipeline-")
20
+ )
21
+ tempDirs.push(projectRoot)
22
+ return projectRoot
23
+ }
24
+
25
+ function writeDid(projectRoot: string, fileName: string) {
26
+ fs.writeFileSync(
27
+ path.join(projectRoot, fileName),
28
+ `service : {
29
+ greet: (text) -> (text) query;
30
+ }`
31
+ )
32
+ }
33
+
34
+ it("uses per-canister mode to generate Reactor-based hooks", async () => {
35
+ const projectRoot = createTempProject()
36
+ writeDid(projectRoot, "workflow_engine.did")
37
+
38
+ const result = await runCanisterPipeline({
39
+ canisterConfig: {
40
+ name: "workflow_engine",
41
+ didFile: "workflow_engine.did",
42
+ mode: "Reactor",
43
+ },
44
+ projectRoot,
45
+ globalConfig: {
46
+ outDir: "src/declarations",
47
+ clientManagerPath: "../../clients",
48
+ },
49
+ })
50
+
51
+ expect(result.success).toBe(true)
52
+
53
+ const indexPath = path.join(
54
+ projectRoot,
55
+ "src/declarations/workflow_engine/index.generated.ts"
56
+ )
57
+ const generated = fs.readFileSync(indexPath, "utf-8")
58
+
59
+ expect(generated).toContain("new Reactor<WorkflowEngineService>")
60
+ expect(generated).not.toContain("new DisplayReactor<WorkflowEngineService>")
61
+ expect(
62
+ fs.readFileSync(
63
+ path.join(projectRoot, "src/declarations/workflow_engine/index.ts"),
64
+ "utf-8"
65
+ )
66
+ ).toContain('export * from "./index.generated"')
67
+ })
68
+
69
+ it("writes a configured canisterId into the generated reactor", async () => {
70
+ const projectRoot = createTempProject()
71
+ writeDid(projectRoot, "workflow.did")
72
+
73
+ const result = await runCanisterPipeline({
74
+ canisterConfig: {
75
+ name: "workflow",
76
+ didFile: "workflow.did",
77
+ canisterId: "yq4ns-hyaaa-aaaap-akbna-cai",
78
+ },
79
+ projectRoot,
80
+ globalConfig: {
81
+ outDir: "src/declarations",
82
+ clientManagerPath: "../../clients",
83
+ },
84
+ })
85
+
86
+ expect(result.success).toBe(true)
87
+
88
+ const indexPath = path.join(
89
+ projectRoot,
90
+ "src/declarations/workflow/index.generated.ts"
91
+ )
92
+ const generated = fs.readFileSync(indexPath, "utf-8")
93
+
94
+ expect(generated).toContain('canisterId: "yq4ns-hyaaa-aaaap-akbna-cai"')
95
+ expect(generated).toContain('name: "workflow"')
96
+ })
97
+
98
+ it("does not overwrite user-modified index.ts on regenerate", async () => {
99
+ const projectRoot = createTempProject()
100
+ writeDid(projectRoot, "backend.did")
101
+
102
+ const options = {
103
+ canisterConfig: {
104
+ name: "backend",
105
+ didFile: "backend.did",
106
+ },
107
+ projectRoot,
108
+ globalConfig: {
109
+ outDir: "src/declarations",
110
+ clientManagerPath: "../../clients",
111
+ },
112
+ } as const
113
+
114
+ const first = await runCanisterPipeline(options)
115
+ expect(first.success).toBe(true)
116
+
117
+ const indexPath = path.join(
118
+ projectRoot,
119
+ "src/declarations/backend/index.ts"
120
+ )
121
+ fs.writeFileSync(
122
+ indexPath,
123
+ `// user custom canister file
124
+ export const customBackendIndex = true
125
+ `
126
+ )
127
+
128
+ const second = await runCanisterPipeline({
129
+ ...options,
130
+ canisterConfig: {
131
+ ...options.canisterConfig,
132
+ canisterId: "yq4ns-hyaaa-aaaap-akbna-cai",
133
+ },
134
+ })
135
+ expect(second.success).toBe(true)
136
+
137
+ const wrapper = fs.readFileSync(indexPath, "utf-8")
138
+ expect(wrapper).toContain("customBackendIndex = true")
139
+ const generatedImpl = fs.readFileSync(
140
+ path.join(projectRoot, "src/declarations/backend/index.generated.ts"),
141
+ "utf-8"
142
+ )
143
+ expect(generatedImpl).toContain('canisterId: "yq4ns-hyaaa-aaaap-akbna-cai"')
144
+ expect(second.files).toEqual(
145
+ expect.arrayContaining([
146
+ expect.objectContaining({
147
+ filePath: indexPath,
148
+ skipped: true,
149
+ success: true,
150
+ }),
151
+ ])
152
+ )
153
+ })
154
+
155
+ it("migrates a legacy generated index.ts to the managed wrapper", async () => {
156
+ const projectRoot = createTempProject()
157
+ writeDid(projectRoot, "backend.did")
158
+
159
+ const canisterOutDir = path.join(projectRoot, "src/declarations/backend")
160
+ fs.mkdirSync(canisterOutDir, { recursive: true })
161
+
162
+ const existingGeneratedIndex = `import { DisplayReactor, createActorHooks } from "@ic-reactor/react"
163
+ import { clientManager } from "../../clients"
164
+ import { idlFactory, type _SERVICE } from "./declarations/backend"
165
+
166
+ export type BackendService = _SERVICE
167
+
168
+ /**
169
+ * Backend Reactor
170
+ *
171
+ * Auto-generated by @ic-reactor/codegen — do not edit.
172
+ */
173
+ export const backendReactor = new DisplayReactor<BackendService>({
174
+ clientManager,
175
+ idlFactory,
176
+ name: "backend",
177
+ })
178
+
179
+ export const {
180
+ useActorQuery: useBackendQuery,
181
+ } = createActorHooks(backendReactor)
182
+ `
183
+ fs.writeFileSync(
184
+ path.join(canisterOutDir, "index.ts"),
185
+ existingGeneratedIndex
186
+ )
187
+
188
+ const result = await runCanisterPipeline({
189
+ canisterConfig: {
190
+ name: "backend",
191
+ didFile: "backend.did",
192
+ },
193
+ projectRoot,
194
+ globalConfig: {
195
+ outDir: "src/declarations",
196
+ clientManagerPath: "../../clients",
197
+ },
198
+ })
199
+
200
+ expect(result.success).toBe(true)
201
+
202
+ const indexPath = path.join(canisterOutDir, "index.ts")
203
+ const entry = fs.readFileSync(indexPath, "utf-8")
204
+ const generated = fs.readFileSync(
205
+ path.join(canisterOutDir, "index.generated.ts"),
206
+ "utf-8"
207
+ )
208
+ expect(entry).toContain('export * from "./index.generated"')
209
+ expect(entry).not.toBe(existingGeneratedIndex)
210
+ expect(generated).toContain("new DisplayReactor<BackendService>")
211
+ expect(generated).toContain("useBackendMutation")
212
+ expect(result.files).toEqual(
213
+ expect.arrayContaining([
214
+ expect.objectContaining({
215
+ filePath: path.join(canisterOutDir, "index.generated.ts"),
216
+ success: true,
217
+ }),
218
+ expect.objectContaining({
219
+ filePath: indexPath,
220
+ success: true,
221
+ }),
222
+ ])
223
+ )
224
+ })
225
+ })
package/src/pipeline.ts CHANGED
@@ -7,14 +7,23 @@
7
7
  * Pipeline steps (in order):
8
8
  * 1. Resolve paths (didFile, outDir)
9
9
  * 2. Generate declarations (JS + .d.ts + .did copy)
10
- * 3. Generate reactor file (index.ts)
10
+ * 3. Generate reactor implementation (`index.generated.ts`)
11
+ * 4. Create or migrate the user entry (`index.ts`)
11
12
  */
12
13
 
13
14
  import fs from "node:fs"
14
15
  import path from "node:path"
15
- import type { CanisterConfig, CodegenConfig, GeneratorResult } from "./types.js"
16
+ import type {
17
+ CanisterConfig,
18
+ CodegenConfig,
19
+ GeneratorResult,
20
+ ReactorClassName,
21
+ } from "./types.js"
16
22
  import { generateDeclarations } from "./generators/declarations.js"
17
- import { generateReactorFile } from "./generators/reactor.js"
23
+ import {
24
+ generateReactorEntryFile,
25
+ generateReactorFile,
26
+ } from "./generators/reactor.js"
18
27
 
19
28
  export interface PipelineOptions {
20
29
  /** Canister name and config */
@@ -30,6 +39,30 @@ export interface PipelineOptions {
30
39
  globalConfig: Pick<CodegenConfig, "outDir" | "clientManagerPath">
31
40
  }
32
41
 
42
+ function resolveReactorClass(canisterConfig: CanisterConfig): ReactorClassName {
43
+ return canisterConfig.mode ?? "DisplayReactor"
44
+ }
45
+
46
+ function normalizeFileContent(content: string): string {
47
+ return content.replace(/\r\n/g, "\n").trim()
48
+ }
49
+
50
+ function isLegacyGeneratedIndexFile(content: string): boolean {
51
+ return (
52
+ content.includes("Auto-generated by @ic-reactor/codegen") &&
53
+ content.includes("createActorHooks(")
54
+ )
55
+ }
56
+
57
+ function isManagedEntryWrapper(
58
+ content: string,
59
+ expectedEntryContent: string
60
+ ): boolean {
61
+ return (
62
+ normalizeFileContent(content) === normalizeFileContent(expectedEntryContent)
63
+ )
64
+ }
65
+
33
66
  export interface PipelineResult {
34
67
  canisterName: string
35
68
  success: boolean
@@ -108,19 +141,40 @@ export async function runCanisterPipeline(
108
141
 
109
142
  // ── Step 2: Reactor file ───────────────────────────────────────────────────
110
143
 
111
- const reactorPath = path.join(canisterOutDir, "index.ts")
144
+ const reactorPath = path.join(canisterOutDir, "index.generated.ts")
145
+ const entryPath = path.join(canisterOutDir, "index.ts")
146
+ const reactorClass = resolveReactorClass(canisterConfig)
112
147
 
113
148
  try {
114
149
  const reactorContent = generateReactorFile({
115
150
  canisterName: name,
116
151
  didFile: resolvedDidFile,
117
152
  clientManagerPath: resolvedClientManagerPath,
153
+ canisterId: canisterConfig.canisterId,
154
+ reactorClass,
118
155
  })
156
+ const entryContent = generateReactorEntryFile()
119
157
 
120
158
  fs.mkdirSync(canisterOutDir, { recursive: true })
121
159
  fs.writeFileSync(reactorPath, reactorContent)
122
-
123
160
  files.push({ success: true, filePath: reactorPath })
161
+
162
+ if (!fs.existsSync(entryPath)) {
163
+ fs.writeFileSync(entryPath, entryContent)
164
+ files.push({ success: true, filePath: entryPath })
165
+ } else {
166
+ const existingEntryContent = fs.readFileSync(entryPath, "utf-8")
167
+
168
+ if (
169
+ isLegacyGeneratedIndexFile(existingEntryContent) ||
170
+ isManagedEntryWrapper(existingEntryContent, entryContent)
171
+ ) {
172
+ fs.writeFileSync(entryPath, entryContent)
173
+ files.push({ success: true, filePath: entryPath })
174
+ } else {
175
+ files.push({ success: true, filePath: entryPath, skipped: true })
176
+ }
177
+ }
124
178
  } catch (err) {
125
179
  files.push({
126
180
  success: false,
@@ -0,0 +1,63 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { generateReactorEntryFile, generateReactorFile } from "./generators"
3
+
4
+ describe("Reactor generator", () => {
5
+ it("keeps default behavior as DisplayReactor", () => {
6
+ const content = generateReactorFile({
7
+ canisterName: "backend",
8
+ didFile: "mock/backend.did",
9
+ clientManagerPath: "../../clients",
10
+ })
11
+
12
+ expect(content).toMatchSnapshot("display-reactor-index")
13
+ expect(content).toContain("new DisplayReactor<BackendService>")
14
+ expect(content).not.toContain("export const BackendReactorMode")
15
+ })
16
+
17
+ it("supports Reactor mode generation", () => {
18
+ const content = generateReactorFile({
19
+ canisterName: "workflow_engine",
20
+ didFile: "mock/workflow_engine.did",
21
+ reactorClass: "Reactor",
22
+ })
23
+
24
+ expect(content).toMatchSnapshot("reactor-index")
25
+ expect(content).toContain("new Reactor<WorkflowEngineService>")
26
+ expect(content).not.toContain("createWorkflowEngineDisplayReactor")
27
+ })
28
+
29
+ it("supports candid reactor subclasses", () => {
30
+ const content = generateReactorFile({
31
+ canisterName: "ledger",
32
+ didFile: "mock/ledger.did",
33
+ reactorClass: "MetadataDisplayReactor",
34
+ })
35
+
36
+ expect(content).toMatchSnapshot("metadata-display-reactor-index")
37
+ expect(content).toContain(
38
+ 'import { createActorHooks } from "@ic-reactor/react"'
39
+ )
40
+ expect(content).toContain(
41
+ 'import { MetadataDisplayReactor } from "@ic-reactor/candid"'
42
+ )
43
+ expect(content).toContain("new MetadataDisplayReactor<LedgerService>")
44
+ })
45
+
46
+ it("writes a fixed canisterId when configured", () => {
47
+ const content = generateReactorFile({
48
+ canisterName: "workflow",
49
+ didFile: "mock/workflow.did",
50
+ canisterId: "yq4ns-hyaaa-aaaap-akbna-cai",
51
+ })
52
+
53
+ expect(content).toContain('canisterId: "yq4ns-hyaaa-aaaap-akbna-cai"')
54
+ expect(content).toContain('name: "workflow"')
55
+ })
56
+
57
+ it("generates a stable entry wrapper", () => {
58
+ const content = generateReactorEntryFile()
59
+
60
+ expect(content).toContain('export * from "./index.generated"')
61
+ expect(content).toContain("safe to customize")
62
+ })
63
+ })
package/src/types.ts CHANGED
@@ -24,10 +24,22 @@ export interface CanisterConfig {
24
24
  * Example: "../../clients" → `import { clientManager } from "../../clients"`
25
25
  */
26
26
  clientManagerPath?: string
27
+ /**
28
+ * Reactor class used for generated hooks in this canister.
29
+ * Defaults to DisplayReactor for backward compatibility.
30
+ */
31
+ mode?: ReactorClassName
27
32
  /** Optional fixed canister ID */
28
33
  canisterId?: string
29
34
  }
30
35
 
36
+ export type ReactorClassName =
37
+ | "Reactor"
38
+ | "DisplayReactor"
39
+ | "CandidReactor"
40
+ | "CandidDisplayReactor"
41
+ | "MetadataDisplayReactor"
42
+
31
43
  /**
32
44
  * Top-level codegen / CLI configuration (stored in `ic-reactor.json`).
33
45
  */