@ic-reactor/codegen 0.5.1 → 0.6.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
@@ -23,16 +23,75 @@ await runCanisterPipeline({
23
23
  globalConfig: {
24
24
  outDir: "src/declarations",
25
25
  clientManagerPath: "../../clients",
26
+ reactor: {
27
+ defaultMode: "display",
28
+ canisters: {
29
+ workflow_engine: "raw",
30
+ },
31
+ },
26
32
  },
27
33
  })
28
34
  ```
29
35
 
36
+ ## Reactor Mode Configuration
37
+
38
+ `reactor.defaultMode` controls whether generated default hook exports use:
39
+
40
+ - `display` → `DisplayReactor` (current/default behavior)
41
+ - `raw` → `Reactor` (raw Candid shapes)
42
+
43
+ `reactor.canisters` lets you override specific canisters without editing generated files.
44
+
45
+ ## Generated File Layout (Stable Wrapper)
46
+
47
+ Each canister now generates:
48
+
49
+ - `index.generated.ts` (regenerated on every run)
50
+ - `index.ts` (created only if missing; not overwritten)
51
+
52
+ The stable wrapper defaults to:
53
+
54
+ ```ts
55
+ export * from "./index.generated"
56
+ ```
57
+
58
+ This lets applications customize exports once and keep them across regenerations.
59
+
60
+ ## Generated Output Examples
61
+
62
+ Display default:
63
+
64
+ ```ts
65
+ export function createBackendRawReactor() {
66
+ /* ... */
67
+ }
68
+ export function createBackendDisplayReactor() {
69
+ /* ... */
70
+ }
71
+ export const backendReactor = createBackendDisplayReactor()
72
+ export const BackendReactorMode = "display" as const
73
+ ```
74
+
75
+ Raw default:
76
+
77
+ ```ts
78
+ export function createWorkflowEngineRawReactor() {
79
+ /* ... */
80
+ }
81
+ export function createWorkflowEngineDisplayReactor() {
82
+ /* ... */
83
+ }
84
+ export const workflowEngineReactor = createWorkflowEngineRawReactor()
85
+ export const WorkflowEngineReactorMode = "raw" as const
86
+ ```
87
+
30
88
  ## Generators
31
89
 
32
90
  You can also use individual generators if you need more granular control:
33
91
 
34
92
  - **`generateDeclarations`**: Generates `.js` (factory), `.d.ts` (types), and `.did` copy.
35
- - **`generateReactorFile`**: Generates the `index.ts` file with `DisplayReactor` and hooks.
93
+ - **`generateReactorFile`**: Generates the `index.generated.ts` implementation with raw/display factories and typed hooks.
94
+ - **`generateReactorWrapperFile`**: Generates the stable `index.ts` wrapper (create-once, preserve-on-regenerate).
36
95
  - **`generateClientFile`**: Generates a `ClientManager` boilerplate file.
37
96
 
38
97
  ## Utilities
package/dist/index.cjs CHANGED
@@ -35,6 +35,7 @@ __export(index_exports, {
35
35
  generateClientFile: () => generateClientFile,
36
36
  generateDeclarations: () => generateDeclarations,
37
37
  generateReactorFile: () => generateReactorFile,
38
+ generateReactorWrapperFile: () => generateReactorWrapperFile,
38
39
  getReactorName: () => getReactorName,
39
40
  getServiceTypeName: () => getServiceTypeName,
40
41
  parseDIDFile: () => parseDIDFile,
@@ -124,13 +125,21 @@ function getServiceTypeName(canisterName) {
124
125
 
125
126
  // src/generators/reactor.ts
126
127
  function generateReactorFile(options) {
127
- const { canisterName, didFile, clientManagerPath = "../../clients" } = options;
128
+ const {
129
+ canisterName,
130
+ didFile,
131
+ clientManagerPath = "../../clients",
132
+ reactorMode = "display"
133
+ } = options;
128
134
  const pascalName = toPascalCase(canisterName);
129
135
  const reactorName = getReactorName(canisterName);
130
136
  const serviceName = getServiceTypeName(canisterName);
137
+ const rawFactoryName = `create${pascalName}RawReactor`;
138
+ const displayFactoryName = `create${pascalName}DisplayReactor`;
139
+ const defaultFactoryName = reactorMode === "raw" ? rawFactoryName : displayFactoryName;
131
140
  const baseName = import_node_path2.default.basename(didFile, ".did");
132
141
  const declarationsPath = `./declarations/${baseName}`;
133
- return `import { DisplayReactor, createActorHooks } from "@ic-reactor/react"
142
+ return `import { DisplayReactor, Reactor, createActorHooks } from "@ic-reactor/react"
134
143
  import { clientManager } from "${clientManagerPath}"
135
144
  import { idlFactory, type _SERVICE } from "${declarationsPath}"
136
145
 
@@ -142,11 +151,25 @@ export type ${serviceName} = _SERVICE
142
151
  * Auto-generated by @ic-reactor/codegen \u2014 do not edit.
143
152
  * Re-run \`ic-reactor generate\` (or the Vite plugin) to regenerate.
144
153
  */
145
- export const ${reactorName} = new DisplayReactor<${serviceName}>({
146
- clientManager,
147
- idlFactory,
148
- name: "${canisterName}",
149
- })
154
+ export function ${rawFactoryName}() {
155
+ return new Reactor<${serviceName}>({
156
+ clientManager,
157
+ idlFactory,
158
+ name: "${canisterName}",
159
+ })
160
+ }
161
+
162
+ export function ${displayFactoryName}() {
163
+ return new DisplayReactor<${serviceName}>({
164
+ clientManager,
165
+ idlFactory,
166
+ name: "${canisterName}",
167
+ })
168
+ }
169
+
170
+ export const ${reactorName} = ${defaultFactoryName}()
171
+
172
+ export const ${pascalName}ReactorMode = "${reactorMode}" as const
150
173
 
151
174
  export const {
152
175
  useActorQuery: use${pascalName}Query,
@@ -158,8 +181,29 @@ export const {
158
181
  } = createActorHooks(${reactorName})
159
182
  `;
160
183
  }
184
+ function generateReactorWrapperFile(options) {
185
+ const { canisterName } = options;
186
+ const pascalName = toPascalCase(canisterName);
187
+ return `/**
188
+ * ${pascalName} canister exports
189
+ *
190
+ * This wrapper is created once by @ic-reactor/codegen and is not overwritten.
191
+ * Customize it if you need to swap reactor implementations or compose custom exports.
192
+ */
193
+ export * from "./index.generated"
194
+ `;
195
+ }
161
196
 
162
197
  // src/pipeline.ts
198
+ function resolveReactorMode(canisterName, globalConfig) {
199
+ return globalConfig.reactor?.canisters?.[canisterName] ?? globalConfig.reactor?.defaultMode ?? "display";
200
+ }
201
+ function isStableWrapperFile(content) {
202
+ return content.includes('export * from "./index.generated"');
203
+ }
204
+ function isLegacyGeneratedIndexFile(content) {
205
+ return content.includes("Auto-generated by @ic-reactor/codegen") && content.includes("createActorHooks(") && !isStableWrapperFile(content);
206
+ }
163
207
  async function runCanisterPipeline(options) {
164
208
  const { canisterConfig, projectRoot, globalConfig } = options;
165
209
  const { name, didFile, clientManagerPath } = canisterConfig;
@@ -198,20 +242,41 @@ 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 generatedReactorPath = import_node_path3.default.join(canisterOutDir, "index.generated.ts");
246
+ const wrapperPath = import_node_path3.default.join(canisterOutDir, "index.ts");
247
+ const reactorMode = resolveReactorMode(name, globalConfig);
202
248
  try {
203
249
  const reactorContent = generateReactorFile({
204
250
  canisterName: name,
205
251
  didFile: resolvedDidFile,
206
- clientManagerPath: resolvedClientManagerPath
252
+ clientManagerPath: resolvedClientManagerPath,
253
+ reactorMode
254
+ });
255
+ const wrapperContent = generateReactorWrapperFile({
256
+ canisterName: name,
257
+ didFile: resolvedDidFile,
258
+ clientManagerPath: resolvedClientManagerPath,
259
+ reactorMode
207
260
  });
208
261
  import_node_fs2.default.mkdirSync(canisterOutDir, { recursive: true });
209
- import_node_fs2.default.writeFileSync(reactorPath, reactorContent);
210
- files.push({ success: true, filePath: reactorPath });
262
+ import_node_fs2.default.writeFileSync(generatedReactorPath, reactorContent);
263
+ files.push({ success: true, filePath: generatedReactorPath });
264
+ if (!import_node_fs2.default.existsSync(wrapperPath)) {
265
+ import_node_fs2.default.writeFileSync(wrapperPath, wrapperContent);
266
+ files.push({ success: true, filePath: wrapperPath });
267
+ } else {
268
+ const existingWrapperContent = import_node_fs2.default.readFileSync(wrapperPath, "utf-8");
269
+ if (isLegacyGeneratedIndexFile(existingWrapperContent)) {
270
+ import_node_fs2.default.writeFileSync(wrapperPath, wrapperContent);
271
+ files.push({ success: true, filePath: wrapperPath });
272
+ } else {
273
+ files.push({ success: true, filePath: wrapperPath, skipped: true });
274
+ }
275
+ }
211
276
  } catch (err) {
212
277
  files.push({
213
278
  success: false,
214
- filePath: reactorPath,
279
+ filePath: generatedReactorPath,
215
280
  error: `Reactor generation failed: ${err instanceof Error ? err.message : String(err)}`
216
281
  });
217
282
  return {
@@ -290,6 +355,7 @@ export const clientManager = new ClientManager({
290
355
  generateClientFile,
291
356
  generateDeclarations,
292
357
  generateReactorFile,
358
+ generateReactorWrapperFile,
293
359
  getReactorName,
294
360
  getServiceTypeName,
295
361
  parseDIDFile,
package/dist/index.d.cts CHANGED
@@ -22,6 +22,19 @@ interface CanisterConfig {
22
22
  /** Optional fixed canister ID */
23
23
  canisterId?: string;
24
24
  }
25
+ type ReactorMode = "raw" | "display";
26
+ interface ReactorGenerationConfig {
27
+ /**
28
+ * Default reactor mode used for generated hook exports.
29
+ * `display` preserves current behavior.
30
+ */
31
+ defaultMode?: ReactorMode;
32
+ /**
33
+ * Optional per-canister overrides keyed by canister name.
34
+ * Example: { workflow_engine: "raw" }
35
+ */
36
+ canisters?: Record<string, ReactorMode>;
37
+ }
25
38
  /**
26
39
  * Top-level codegen / CLI configuration (stored in `ic-reactor.json`).
27
40
  */
@@ -38,6 +51,8 @@ interface CodegenConfig {
38
51
  * Individual canisters can override via `CanisterConfig.clientManagerPath`.
39
52
  */
40
53
  clientManagerPath?: string;
54
+ /** Optional reactor mode generation settings */
55
+ reactor?: ReactorGenerationConfig;
41
56
  /** Canister configurations, keyed by canister name */
42
57
  canisters: Record<string, CanisterConfig>;
43
58
  }
@@ -62,7 +77,7 @@ interface GeneratorResult {
62
77
  * Pipeline steps (in order):
63
78
  * 1. Resolve paths (didFile, outDir)
64
79
  * 2. Generate declarations (JS + .d.ts + .did copy)
65
- * 3. Generate reactor file (index.ts)
80
+ * 3. Generate reactor implementation + stable wrapper (`index.generated.ts` + `index.ts`)
66
81
  */
67
82
 
68
83
  interface PipelineOptions {
@@ -76,7 +91,7 @@ interface PipelineOptions {
76
91
  /**
77
92
  * Global codegen config (for fallback outDir and clientManagerPath).
78
93
  */
79
- globalConfig: Pick<CodegenConfig, "outDir" | "clientManagerPath">;
94
+ globalConfig: Pick<CodegenConfig, "outDir" | "clientManagerPath" | "reactor">;
80
95
  }
81
96
  interface PipelineResult {
82
97
  canisterName: string;
@@ -204,6 +219,7 @@ declare function declarationsExist(outDir: string, canisterName: string): boolea
204
219
  * ...
205
220
  * } = createActorHooks(backendReactor)
206
221
  */
222
+
207
223
  interface ReactorGeneratorOptions {
208
224
  /** Canister name (e.g. "backend") */
209
225
  canisterName: string;
@@ -217,11 +233,21 @@ interface ReactorGeneratorOptions {
217
233
  * Default: "../../clients"
218
234
  */
219
235
  clientManagerPath?: string;
236
+ /**
237
+ * Which reactor implementation should back the default exported hooks.
238
+ * Default: "display" (backward compatible)
239
+ */
240
+ reactorMode?: ReactorMode;
220
241
  }
221
242
  /**
222
- * Generate the content of a canister's `index.ts` reactor file.
243
+ * Generate the content of a canister's `index.generated.ts` implementation file.
223
244
  */
224
245
  declare function generateReactorFile(options: ReactorGeneratorOptions): string;
246
+ /**
247
+ * Generate the stable user wrapper `index.ts` for a canister.
248
+ * This file is intended to be created once and preserved across regenerations.
249
+ */
250
+ declare function generateReactorWrapperFile(options: ReactorGeneratorOptions): string;
225
251
 
226
252
  /**
227
253
  * Client Manager Generator
@@ -246,4 +272,4 @@ interface ClientGeneratorOptions {
246
272
  */
247
273
  declare function generateClientFile(options?: ClientGeneratorOptions): string;
248
274
 
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 };
275
+ export { type CanisterConfig, type ClientGeneratorOptions, type CodegenConfig, type DeclarationsGeneratorOptions, type DeclarationsGeneratorResult, type GeneratorResult, type MethodInfo, type MethodType, type PipelineOptions, type PipelineResult, type ReactorGenerationConfig, type ReactorGeneratorOptions, type ReactorMode, declarationsExist, extractMethods, generateClientFile, generateDeclarations, generateReactorFile, generateReactorWrapperFile, getReactorName, getServiceTypeName, parseDIDFile, runCanisterPipeline, toPascalCase };
package/dist/index.d.ts CHANGED
@@ -22,6 +22,19 @@ interface CanisterConfig {
22
22
  /** Optional fixed canister ID */
23
23
  canisterId?: string;
24
24
  }
25
+ type ReactorMode = "raw" | "display";
26
+ interface ReactorGenerationConfig {
27
+ /**
28
+ * Default reactor mode used for generated hook exports.
29
+ * `display` preserves current behavior.
30
+ */
31
+ defaultMode?: ReactorMode;
32
+ /**
33
+ * Optional per-canister overrides keyed by canister name.
34
+ * Example: { workflow_engine: "raw" }
35
+ */
36
+ canisters?: Record<string, ReactorMode>;
37
+ }
25
38
  /**
26
39
  * Top-level codegen / CLI configuration (stored in `ic-reactor.json`).
27
40
  */
@@ -38,6 +51,8 @@ interface CodegenConfig {
38
51
  * Individual canisters can override via `CanisterConfig.clientManagerPath`.
39
52
  */
40
53
  clientManagerPath?: string;
54
+ /** Optional reactor mode generation settings */
55
+ reactor?: ReactorGenerationConfig;
41
56
  /** Canister configurations, keyed by canister name */
42
57
  canisters: Record<string, CanisterConfig>;
43
58
  }
@@ -62,7 +77,7 @@ interface GeneratorResult {
62
77
  * Pipeline steps (in order):
63
78
  * 1. Resolve paths (didFile, outDir)
64
79
  * 2. Generate declarations (JS + .d.ts + .did copy)
65
- * 3. Generate reactor file (index.ts)
80
+ * 3. Generate reactor implementation + stable wrapper (`index.generated.ts` + `index.ts`)
66
81
  */
67
82
 
68
83
  interface PipelineOptions {
@@ -76,7 +91,7 @@ interface PipelineOptions {
76
91
  /**
77
92
  * Global codegen config (for fallback outDir and clientManagerPath).
78
93
  */
79
- globalConfig: Pick<CodegenConfig, "outDir" | "clientManagerPath">;
94
+ globalConfig: Pick<CodegenConfig, "outDir" | "clientManagerPath" | "reactor">;
80
95
  }
81
96
  interface PipelineResult {
82
97
  canisterName: string;
@@ -204,6 +219,7 @@ declare function declarationsExist(outDir: string, canisterName: string): boolea
204
219
  * ...
205
220
  * } = createActorHooks(backendReactor)
206
221
  */
222
+
207
223
  interface ReactorGeneratorOptions {
208
224
  /** Canister name (e.g. "backend") */
209
225
  canisterName: string;
@@ -217,11 +233,21 @@ interface ReactorGeneratorOptions {
217
233
  * Default: "../../clients"
218
234
  */
219
235
  clientManagerPath?: string;
236
+ /**
237
+ * Which reactor implementation should back the default exported hooks.
238
+ * Default: "display" (backward compatible)
239
+ */
240
+ reactorMode?: ReactorMode;
220
241
  }
221
242
  /**
222
- * Generate the content of a canister's `index.ts` reactor file.
243
+ * Generate the content of a canister's `index.generated.ts` implementation file.
223
244
  */
224
245
  declare function generateReactorFile(options: ReactorGeneratorOptions): string;
246
+ /**
247
+ * Generate the stable user wrapper `index.ts` for a canister.
248
+ * This file is intended to be created once and preserved across regenerations.
249
+ */
250
+ declare function generateReactorWrapperFile(options: ReactorGeneratorOptions): string;
225
251
 
226
252
  /**
227
253
  * Client Manager Generator
@@ -246,4 +272,4 @@ interface ClientGeneratorOptions {
246
272
  */
247
273
  declare function generateClientFile(options?: ClientGeneratorOptions): string;
248
274
 
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 };
275
+ export { type CanisterConfig, type ClientGeneratorOptions, type CodegenConfig, type DeclarationsGeneratorOptions, type DeclarationsGeneratorResult, type GeneratorResult, type MethodInfo, type MethodType, type PipelineOptions, type PipelineResult, type ReactorGenerationConfig, type ReactorGeneratorOptions, type ReactorMode, declarationsExist, extractMethods, generateClientFile, generateDeclarations, generateReactorFile, generateReactorWrapperFile, getReactorName, getServiceTypeName, parseDIDFile, runCanisterPipeline, toPascalCase };
package/dist/index.js CHANGED
@@ -79,13 +79,21 @@ function getServiceTypeName(canisterName) {
79
79
 
80
80
  // src/generators/reactor.ts
81
81
  function generateReactorFile(options) {
82
- const { canisterName, didFile, clientManagerPath = "../../clients" } = options;
82
+ const {
83
+ canisterName,
84
+ didFile,
85
+ clientManagerPath = "../../clients",
86
+ reactorMode = "display"
87
+ } = options;
83
88
  const pascalName = toPascalCase(canisterName);
84
89
  const reactorName = getReactorName(canisterName);
85
90
  const serviceName = getServiceTypeName(canisterName);
91
+ const rawFactoryName = `create${pascalName}RawReactor`;
92
+ const displayFactoryName = `create${pascalName}DisplayReactor`;
93
+ const defaultFactoryName = reactorMode === "raw" ? rawFactoryName : displayFactoryName;
86
94
  const baseName = path2.basename(didFile, ".did");
87
95
  const declarationsPath = `./declarations/${baseName}`;
88
- return `import { DisplayReactor, createActorHooks } from "@ic-reactor/react"
96
+ return `import { DisplayReactor, Reactor, createActorHooks } from "@ic-reactor/react"
89
97
  import { clientManager } from "${clientManagerPath}"
90
98
  import { idlFactory, type _SERVICE } from "${declarationsPath}"
91
99
 
@@ -97,11 +105,25 @@ export type ${serviceName} = _SERVICE
97
105
  * Auto-generated by @ic-reactor/codegen \u2014 do not edit.
98
106
  * Re-run \`ic-reactor generate\` (or the Vite plugin) to regenerate.
99
107
  */
100
- export const ${reactorName} = new DisplayReactor<${serviceName}>({
101
- clientManager,
102
- idlFactory,
103
- name: "${canisterName}",
104
- })
108
+ export function ${rawFactoryName}() {
109
+ return new Reactor<${serviceName}>({
110
+ clientManager,
111
+ idlFactory,
112
+ name: "${canisterName}",
113
+ })
114
+ }
115
+
116
+ export function ${displayFactoryName}() {
117
+ return new DisplayReactor<${serviceName}>({
118
+ clientManager,
119
+ idlFactory,
120
+ name: "${canisterName}",
121
+ })
122
+ }
123
+
124
+ export const ${reactorName} = ${defaultFactoryName}()
125
+
126
+ export const ${pascalName}ReactorMode = "${reactorMode}" as const
105
127
 
106
128
  export const {
107
129
  useActorQuery: use${pascalName}Query,
@@ -113,8 +135,29 @@ export const {
113
135
  } = createActorHooks(${reactorName})
114
136
  `;
115
137
  }
138
+ function generateReactorWrapperFile(options) {
139
+ const { canisterName } = options;
140
+ const pascalName = toPascalCase(canisterName);
141
+ return `/**
142
+ * ${pascalName} canister exports
143
+ *
144
+ * This wrapper is created once by @ic-reactor/codegen and is not overwritten.
145
+ * Customize it if you need to swap reactor implementations or compose custom exports.
146
+ */
147
+ export * from "./index.generated"
148
+ `;
149
+ }
116
150
 
117
151
  // src/pipeline.ts
152
+ function resolveReactorMode(canisterName, globalConfig) {
153
+ return globalConfig.reactor?.canisters?.[canisterName] ?? globalConfig.reactor?.defaultMode ?? "display";
154
+ }
155
+ function isStableWrapperFile(content) {
156
+ return content.includes('export * from "./index.generated"');
157
+ }
158
+ function isLegacyGeneratedIndexFile(content) {
159
+ return content.includes("Auto-generated by @ic-reactor/codegen") && content.includes("createActorHooks(") && !isStableWrapperFile(content);
160
+ }
118
161
  async function runCanisterPipeline(options) {
119
162
  const { canisterConfig, projectRoot, globalConfig } = options;
120
163
  const { name, didFile, clientManagerPath } = canisterConfig;
@@ -153,20 +196,41 @@ 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 generatedReactorPath = path3.join(canisterOutDir, "index.generated.ts");
200
+ const wrapperPath = path3.join(canisterOutDir, "index.ts");
201
+ const reactorMode = resolveReactorMode(name, globalConfig);
157
202
  try {
158
203
  const reactorContent = generateReactorFile({
159
204
  canisterName: name,
160
205
  didFile: resolvedDidFile,
161
- clientManagerPath: resolvedClientManagerPath
206
+ clientManagerPath: resolvedClientManagerPath,
207
+ reactorMode
208
+ });
209
+ const wrapperContent = generateReactorWrapperFile({
210
+ canisterName: name,
211
+ didFile: resolvedDidFile,
212
+ clientManagerPath: resolvedClientManagerPath,
213
+ reactorMode
162
214
  });
163
215
  fs2.mkdirSync(canisterOutDir, { recursive: true });
164
- fs2.writeFileSync(reactorPath, reactorContent);
165
- files.push({ success: true, filePath: reactorPath });
216
+ fs2.writeFileSync(generatedReactorPath, reactorContent);
217
+ files.push({ success: true, filePath: generatedReactorPath });
218
+ if (!fs2.existsSync(wrapperPath)) {
219
+ fs2.writeFileSync(wrapperPath, wrapperContent);
220
+ files.push({ success: true, filePath: wrapperPath });
221
+ } else {
222
+ const existingWrapperContent = fs2.readFileSync(wrapperPath, "utf-8");
223
+ if (isLegacyGeneratedIndexFile(existingWrapperContent)) {
224
+ fs2.writeFileSync(wrapperPath, wrapperContent);
225
+ files.push({ success: true, filePath: wrapperPath });
226
+ } else {
227
+ files.push({ success: true, filePath: wrapperPath, skipped: true });
228
+ }
229
+ }
166
230
  } catch (err) {
167
231
  files.push({
168
232
  success: false,
169
- filePath: reactorPath,
233
+ filePath: generatedReactorPath,
170
234
  error: `Reactor generation failed: ${err instanceof Error ? err.message : String(err)}`
171
235
  });
172
236
  return {
@@ -244,6 +308,7 @@ export {
244
308
  generateClientFile,
245
309
  generateDeclarations,
246
310
  generateReactorFile,
311
+ generateReactorWrapperFile,
247
312
  getReactorName,
248
313
  getServiceTypeName,
249
314
  parseDIDFile,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ic-reactor/codegen",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "Shared code generation utilities for IC Reactor",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -0,0 +1,100 @@
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`Reactor generator > generates a stable wrapper file > stable-wrapper-index 1`] = `
4
+ "/**
5
+ * Backend canister exports
6
+ *
7
+ * This wrapper is created once by @ic-reactor/codegen and is not overwritten.
8
+ * Customize it if you need to swap reactor implementations or compose custom exports.
9
+ */
10
+ export * from "./index.generated"
11
+ "
12
+ `;
13
+
14
+ exports[`Reactor generator > keeps default behavior as display mode > display-mode-index-generated 1`] = `
15
+ "import { DisplayReactor, Reactor, createActorHooks } from "@ic-reactor/react"
16
+ import { clientManager } from "../../clients"
17
+ import { idlFactory, type _SERVICE } from "./declarations/backend"
18
+
19
+ export type BackendService = _SERVICE
20
+
21
+ /**
22
+ * Backend Reactor
23
+ *
24
+ * Auto-generated by @ic-reactor/codegen — do not edit.
25
+ * Re-run \`ic-reactor generate\` (or the Vite plugin) to regenerate.
26
+ */
27
+ export function createBackendRawReactor() {
28
+ return new Reactor<BackendService>({
29
+ clientManager,
30
+ idlFactory,
31
+ name: "backend",
32
+ })
33
+ }
34
+
35
+ export function createBackendDisplayReactor() {
36
+ return new DisplayReactor<BackendService>({
37
+ clientManager,
38
+ idlFactory,
39
+ name: "backend",
40
+ })
41
+ }
42
+
43
+ export const backendReactor = createBackendDisplayReactor()
44
+
45
+ export const BackendReactorMode = "display" as const
46
+
47
+ export const {
48
+ useActorQuery: useBackendQuery,
49
+ useActorSuspenseQuery: useBackendSuspenseQuery,
50
+ useActorInfiniteQuery: useBackendInfiniteQuery,
51
+ useActorSuspenseInfiniteQuery: useBackendSuspenseInfiniteQuery,
52
+ useActorMutation: useBackendMutation,
53
+ useActorMethod: useBackendMethod,
54
+ } = createActorHooks(backendReactor)
55
+ "
56
+ `;
57
+
58
+ exports[`Reactor generator > supports raw mode generation > raw-mode-index-generated 1`] = `
59
+ "import { DisplayReactor, Reactor, createActorHooks } from "@ic-reactor/react"
60
+ import { clientManager } from "../../clients"
61
+ import { idlFactory, type _SERVICE } from "./declarations/workflow_engine"
62
+
63
+ export type WorkflowEngineService = _SERVICE
64
+
65
+ /**
66
+ * WorkflowEngine Reactor
67
+ *
68
+ * Auto-generated by @ic-reactor/codegen — do not edit.
69
+ * Re-run \`ic-reactor generate\` (or the Vite plugin) to regenerate.
70
+ */
71
+ export function createWorkflowEngineRawReactor() {
72
+ return new Reactor<WorkflowEngineService>({
73
+ clientManager,
74
+ idlFactory,
75
+ name: "workflow_engine",
76
+ })
77
+ }
78
+
79
+ export function createWorkflowEngineDisplayReactor() {
80
+ return new DisplayReactor<WorkflowEngineService>({
81
+ clientManager,
82
+ idlFactory,
83
+ name: "workflow_engine",
84
+ })
85
+ }
86
+
87
+ export const workflowEngineReactor = createWorkflowEngineRawReactor()
88
+
89
+ export const WorkflowEngineReactorMode = "raw" as const
90
+
91
+ export const {
92
+ useActorQuery: useWorkflowEngineQuery,
93
+ useActorSuspenseQuery: useWorkflowEngineSuspenseQuery,
94
+ useActorInfiniteQuery: useWorkflowEngineInfiniteQuery,
95
+ useActorSuspenseInfiniteQuery: useWorkflowEngineSuspenseInfiniteQuery,
96
+ useActorMutation: useWorkflowEngineMutation,
97
+ useActorMethod: useWorkflowEngineMethod,
98
+ } = createActorHooks(workflowEngineReactor)
99
+ "
100
+ `;
@@ -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, generateReactorWrapperFile } from "./reactor.js"
15
15
  export type { ReactorGeneratorOptions } from "./reactor.js"
16
16
 
17
17
  export { generateClientFile } from "./client.js"
@@ -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 { ReactorMode } from "../types.js"
25
26
 
26
27
  export interface ReactorGeneratorOptions {
27
28
  /** Canister name (e.g. "backend") */
@@ -36,23 +37,37 @@ export interface ReactorGeneratorOptions {
36
37
  * Default: "../../clients"
37
38
  */
38
39
  clientManagerPath?: string
40
+ /**
41
+ * Which reactor implementation should back the default exported hooks.
42
+ * Default: "display" (backward compatible)
43
+ */
44
+ reactorMode?: ReactorMode
39
45
  }
40
46
 
41
47
  /**
42
- * Generate the content of a canister's `index.ts` reactor file.
48
+ * Generate the content of a canister's `index.generated.ts` implementation file.
43
49
  */
44
50
  export function generateReactorFile(options: ReactorGeneratorOptions): string {
45
- const { canisterName, didFile, clientManagerPath = "../../clients" } = options
51
+ const {
52
+ canisterName,
53
+ didFile,
54
+ clientManagerPath = "../../clients",
55
+ reactorMode = "display",
56
+ } = options
46
57
 
47
58
  const pascalName = toPascalCase(canisterName)
48
59
  const reactorName = getReactorName(canisterName)
49
60
  const serviceName = getServiceTypeName(canisterName)
61
+ const rawFactoryName = `create${pascalName}RawReactor`
62
+ const displayFactoryName = `create${pascalName}DisplayReactor`
63
+ const defaultFactoryName =
64
+ reactorMode === "raw" ? rawFactoryName : displayFactoryName
50
65
 
51
66
  // Derive the declarations import path from the .did filename
52
67
  const baseName = path.basename(didFile, ".did")
53
68
  const declarationsPath = `./declarations/${baseName}`
54
69
 
55
- return `import { DisplayReactor, createActorHooks } from "@ic-reactor/react"
70
+ return `import { DisplayReactor, Reactor, createActorHooks } from "@ic-reactor/react"
56
71
  import { clientManager } from "${clientManagerPath}"
57
72
  import { idlFactory, type _SERVICE } from "${declarationsPath}"
58
73
 
@@ -64,11 +79,25 @@ export type ${serviceName} = _SERVICE
64
79
  * Auto-generated by @ic-reactor/codegen — do not edit.
65
80
  * Re-run \`ic-reactor generate\` (or the Vite plugin) to regenerate.
66
81
  */
67
- export const ${reactorName} = new DisplayReactor<${serviceName}>({
68
- clientManager,
69
- idlFactory,
70
- name: "${canisterName}",
71
- })
82
+ export function ${rawFactoryName}() {
83
+ return new Reactor<${serviceName}>({
84
+ clientManager,
85
+ idlFactory,
86
+ name: "${canisterName}",
87
+ })
88
+ }
89
+
90
+ export function ${displayFactoryName}() {
91
+ return new DisplayReactor<${serviceName}>({
92
+ clientManager,
93
+ idlFactory,
94
+ name: "${canisterName}",
95
+ })
96
+ }
97
+
98
+ export const ${reactorName} = ${defaultFactoryName}()
99
+
100
+ export const ${pascalName}ReactorMode = "${reactorMode}" as const
72
101
 
73
102
  export const {
74
103
  useActorQuery: use${pascalName}Query,
@@ -80,3 +109,23 @@ export const {
80
109
  } = createActorHooks(${reactorName})
81
110
  `
82
111
  }
112
+
113
+ /**
114
+ * Generate the stable user wrapper `index.ts` for a canister.
115
+ * This file is intended to be created once and preserved across regenerations.
116
+ */
117
+ export function generateReactorWrapperFile(
118
+ options: ReactorGeneratorOptions
119
+ ): string {
120
+ const { canisterName } = options
121
+ const pascalName = toPascalCase(canisterName)
122
+
123
+ return `/**
124
+ * ${pascalName} canister exports
125
+ *
126
+ * This wrapper is created once by @ic-reactor/codegen and is not overwritten.
127
+ * Customize it if you need to swap reactor implementations or compose custom exports.
128
+ */
129
+ export * from "./index.generated"
130
+ `
131
+ }
package/src/index.ts CHANGED
@@ -6,7 +6,13 @@
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
+ ReactorMode,
14
+ ReactorGenerationConfig,
15
+ } from "./types.js"
10
16
 
11
17
  // Pipeline (Primary Entry Point)
12
18
  export { runCanisterPipeline } from "./pipeline.js"
@@ -0,0 +1,178 @@
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("applies per-canister reactor mode overrides over the global default", 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
+ },
43
+ projectRoot,
44
+ globalConfig: {
45
+ outDir: "src/declarations",
46
+ clientManagerPath: "../../clients",
47
+ reactor: {
48
+ defaultMode: "display",
49
+ canisters: {
50
+ workflow_engine: "raw",
51
+ },
52
+ },
53
+ },
54
+ })
55
+
56
+ expect(result.success).toBe(true)
57
+
58
+ const generatedPath = path.join(
59
+ projectRoot,
60
+ "src/declarations/workflow_engine/index.generated.ts"
61
+ )
62
+ const generated = fs.readFileSync(generatedPath, "utf-8")
63
+
64
+ expect(generated).toContain("new Reactor<WorkflowEngineService>")
65
+ expect(generated).toContain(
66
+ 'export const WorkflowEngineReactorMode = "raw" as const'
67
+ )
68
+ })
69
+
70
+ it("does not overwrite the wrapper file on regenerate", async () => {
71
+ const projectRoot = createTempProject()
72
+ writeDid(projectRoot, "backend.did")
73
+
74
+ const options = {
75
+ canisterConfig: {
76
+ name: "backend",
77
+ didFile: "backend.did",
78
+ },
79
+ projectRoot,
80
+ globalConfig: {
81
+ outDir: "src/declarations",
82
+ clientManagerPath: "../../clients",
83
+ },
84
+ } as const
85
+
86
+ const first = await runCanisterPipeline(options)
87
+ expect(first.success).toBe(true)
88
+
89
+ const wrapperPath = path.join(
90
+ projectRoot,
91
+ "src/declarations/backend/index.ts"
92
+ )
93
+ fs.writeFileSync(
94
+ wrapperPath,
95
+ `// user wrapper
96
+ export const customBackendWrapper = true
97
+ `
98
+ )
99
+
100
+ const second = await runCanisterPipeline(options)
101
+ expect(second.success).toBe(true)
102
+
103
+ const wrapper = fs.readFileSync(wrapperPath, "utf-8")
104
+ expect(wrapper).toContain("customBackendWrapper = true")
105
+ expect(second.files).toEqual(
106
+ expect.arrayContaining([
107
+ expect.objectContaining({
108
+ filePath: wrapperPath,
109
+ skipped: true,
110
+ success: true,
111
+ }),
112
+ ])
113
+ )
114
+ })
115
+
116
+ it("migrates a legacy generated index.ts into a stable wrapper", async () => {
117
+ const projectRoot = createTempProject()
118
+ writeDid(projectRoot, "backend.did")
119
+
120
+ const canisterOutDir = path.join(projectRoot, "src/declarations/backend")
121
+ fs.mkdirSync(canisterOutDir, { recursive: true })
122
+
123
+ // Simulate the pre-wrapper generated index.ts content from older versions.
124
+ fs.writeFileSync(
125
+ path.join(canisterOutDir, "index.ts"),
126
+ `import { DisplayReactor, createActorHooks } from "@ic-reactor/react"
127
+ import { clientManager } from "../../clients"
128
+ import { idlFactory, type _SERVICE } from "./declarations/backend"
129
+
130
+ export type BackendService = _SERVICE
131
+
132
+ /**
133
+ * Backend Reactor
134
+ *
135
+ * Auto-generated by @ic-reactor/codegen — do not edit.
136
+ * Re-run \`ic-reactor generate\` (or the Vite plugin) to regenerate.
137
+ */
138
+ export const backendReactor = new DisplayReactor<BackendService>({
139
+ clientManager,
140
+ idlFactory,
141
+ name: "backend",
142
+ })
143
+
144
+ export const {
145
+ useActorQuery: useBackendQuery,
146
+ } = createActorHooks(backendReactor)
147
+ `
148
+ )
149
+
150
+ const result = await runCanisterPipeline({
151
+ canisterConfig: {
152
+ name: "backend",
153
+ didFile: "backend.did",
154
+ },
155
+ projectRoot,
156
+ globalConfig: {
157
+ outDir: "src/declarations",
158
+ clientManagerPath: "../../clients",
159
+ },
160
+ })
161
+
162
+ expect(result.success).toBe(true)
163
+
164
+ const wrapperPath = path.join(canisterOutDir, "index.ts")
165
+ const wrapper = fs.readFileSync(wrapperPath, "utf-8")
166
+ expect(wrapper).toContain('export * from "./index.generated"')
167
+ expect(wrapper).not.toContain("createActorHooks(")
168
+
169
+ const generated = fs.readFileSync(
170
+ path.join(canisterOutDir, "index.generated.ts"),
171
+ "utf-8"
172
+ )
173
+ expect(generated).toContain("createBackendDisplayReactor")
174
+ expect(generated).toContain(
175
+ 'export const BackendReactorMode = "display" as const'
176
+ )
177
+ })
178
+ })
package/src/pipeline.ts CHANGED
@@ -7,14 +7,22 @@
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 + stable wrapper (`index.generated.ts` + `index.ts`)
11
11
  */
12
12
 
13
13
  import fs from "node:fs"
14
14
  import path from "node:path"
15
- import type { CanisterConfig, CodegenConfig, GeneratorResult } from "./types.js"
15
+ import type {
16
+ CanisterConfig,
17
+ CodegenConfig,
18
+ GeneratorResult,
19
+ ReactorMode,
20
+ } from "./types.js"
16
21
  import { generateDeclarations } from "./generators/declarations.js"
17
- import { generateReactorFile } from "./generators/reactor.js"
22
+ import {
23
+ generateReactorFile,
24
+ generateReactorWrapperFile,
25
+ } from "./generators/reactor.js"
18
26
 
19
27
  export interface PipelineOptions {
20
28
  /** Canister name and config */
@@ -27,7 +35,30 @@ export interface PipelineOptions {
27
35
  /**
28
36
  * Global codegen config (for fallback outDir and clientManagerPath).
29
37
  */
30
- globalConfig: Pick<CodegenConfig, "outDir" | "clientManagerPath">
38
+ globalConfig: Pick<CodegenConfig, "outDir" | "clientManagerPath" | "reactor">
39
+ }
40
+
41
+ function resolveReactorMode(
42
+ canisterName: string,
43
+ globalConfig: Pick<CodegenConfig, "reactor">
44
+ ): ReactorMode {
45
+ return (
46
+ globalConfig.reactor?.canisters?.[canisterName] ??
47
+ globalConfig.reactor?.defaultMode ??
48
+ "display"
49
+ )
50
+ }
51
+
52
+ function isStableWrapperFile(content: string): boolean {
53
+ return content.includes('export * from "./index.generated"')
54
+ }
55
+
56
+ function isLegacyGeneratedIndexFile(content: string): boolean {
57
+ return (
58
+ content.includes("Auto-generated by @ic-reactor/codegen") &&
59
+ content.includes("createActorHooks(") &&
60
+ !isStableWrapperFile(content)
61
+ )
31
62
  }
32
63
 
33
64
  export interface PipelineResult {
@@ -106,25 +137,48 @@ export async function runCanisterPipeline(
106
137
  }
107
138
  }
108
139
 
109
- // ── Step 2: Reactor file ───────────────────────────────────────────────────
140
+ // ── Step 2: Reactor implementation + stable wrapper ───────────────────────
110
141
 
111
- const reactorPath = path.join(canisterOutDir, "index.ts")
142
+ const generatedReactorPath = path.join(canisterOutDir, "index.generated.ts")
143
+ const wrapperPath = path.join(canisterOutDir, "index.ts")
144
+ const reactorMode = resolveReactorMode(name, globalConfig)
112
145
 
113
146
  try {
114
147
  const reactorContent = generateReactorFile({
115
148
  canisterName: name,
116
149
  didFile: resolvedDidFile,
117
150
  clientManagerPath: resolvedClientManagerPath,
151
+ reactorMode,
152
+ })
153
+ const wrapperContent = generateReactorWrapperFile({
154
+ canisterName: name,
155
+ didFile: resolvedDidFile,
156
+ clientManagerPath: resolvedClientManagerPath,
157
+ reactorMode,
118
158
  })
119
159
 
120
160
  fs.mkdirSync(canisterOutDir, { recursive: true })
121
- fs.writeFileSync(reactorPath, reactorContent)
161
+ fs.writeFileSync(generatedReactorPath, reactorContent)
162
+
163
+ files.push({ success: true, filePath: generatedReactorPath })
122
164
 
123
- files.push({ success: true, filePath: reactorPath })
165
+ if (!fs.existsSync(wrapperPath)) {
166
+ fs.writeFileSync(wrapperPath, wrapperContent)
167
+ files.push({ success: true, filePath: wrapperPath })
168
+ } else {
169
+ const existingWrapperContent = fs.readFileSync(wrapperPath, "utf-8")
170
+
171
+ if (isLegacyGeneratedIndexFile(existingWrapperContent)) {
172
+ fs.writeFileSync(wrapperPath, wrapperContent)
173
+ files.push({ success: true, filePath: wrapperPath })
174
+ } else {
175
+ files.push({ success: true, filePath: wrapperPath, skipped: true })
176
+ }
177
+ }
124
178
  } catch (err) {
125
179
  files.push({
126
180
  success: false,
127
- filePath: reactorPath,
181
+ filePath: generatedReactorPath,
128
182
  error: `Reactor generation failed: ${err instanceof Error ? err.message : String(err)}`,
129
183
  })
130
184
 
@@ -0,0 +1,42 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { generateReactorFile, generateReactorWrapperFile } from "./generators"
3
+
4
+ describe("Reactor generator", () => {
5
+ it("keeps default behavior as display mode", () => {
6
+ const content = generateReactorFile({
7
+ canisterName: "backend",
8
+ didFile: "mock/backend.did",
9
+ clientManagerPath: "../../clients",
10
+ })
11
+
12
+ expect(content).toMatchSnapshot("display-mode-index-generated")
13
+ expect(content).toContain("new DisplayReactor<BackendService>")
14
+ expect(content).toContain(
15
+ 'export const BackendReactorMode = "display" as const'
16
+ )
17
+ })
18
+
19
+ it("supports raw mode generation", () => {
20
+ const content = generateReactorFile({
21
+ canisterName: "workflow_engine",
22
+ didFile: "mock/workflow_engine.did",
23
+ reactorMode: "raw",
24
+ })
25
+
26
+ expect(content).toMatchSnapshot("raw-mode-index-generated")
27
+ expect(content).toContain("new Reactor<WorkflowEngineService>")
28
+ expect(content).toContain(
29
+ 'export const WorkflowEngineReactorMode = "raw" as const'
30
+ )
31
+ })
32
+
33
+ it("generates a stable wrapper file", () => {
34
+ const content = generateReactorWrapperFile({
35
+ canisterName: "backend",
36
+ didFile: "mock/backend.did",
37
+ })
38
+
39
+ expect(content).toMatchSnapshot("stable-wrapper-index")
40
+ expect(content).toContain('export * from "./index.generated"')
41
+ })
42
+ })
package/src/types.ts CHANGED
@@ -28,6 +28,21 @@ export interface CanisterConfig {
28
28
  canisterId?: string
29
29
  }
30
30
 
31
+ export type ReactorMode = "raw" | "display"
32
+
33
+ export interface ReactorGenerationConfig {
34
+ /**
35
+ * Default reactor mode used for generated hook exports.
36
+ * `display` preserves current behavior.
37
+ */
38
+ defaultMode?: ReactorMode
39
+ /**
40
+ * Optional per-canister overrides keyed by canister name.
41
+ * Example: { workflow_engine: "raw" }
42
+ */
43
+ canisters?: Record<string, ReactorMode>
44
+ }
45
+
31
46
  /**
32
47
  * Top-level codegen / CLI configuration (stored in `ic-reactor.json`).
33
48
  */
@@ -44,6 +59,8 @@ export interface CodegenConfig {
44
59
  * Individual canisters can override via `CanisterConfig.clientManagerPath`.
45
60
  */
46
61
  clientManagerPath?: string
62
+ /** Optional reactor mode generation settings */
63
+ reactor?: ReactorGenerationConfig
47
64
  /** Canister configurations, keyed by canister name */
48
65
  canisters: Record<string, CanisterConfig>
49
66
  }