@chainlink/cre-sdk 1.0.1 → 1.0.2

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.
@@ -186,7 +186,7 @@ export class RuntimeImpl extends BaseRuntimeImpl {
186
186
  // Step 3: Execute node function and capture result/error
187
187
  try {
188
188
  const observation = fn(nodeRuntime, ...args);
189
- this.captureObservation(consensusInput, observation);
189
+ this.captureObservation(consensusInput, observation, consensusAggregation.descriptor);
190
190
  }
191
191
  catch (e) {
192
192
  this.captureError(consensusInput, e);
@@ -205,15 +205,19 @@ export class RuntimeImpl extends BaseRuntimeImpl {
205
205
  });
206
206
  if (consensusAggregation.defaultValue) {
207
207
  // Safe cast: ConsensusAggregation<T, true> implies T extends CreSerializable
208
- consensusInput.default = Value.from(consensusAggregation.defaultValue).proto();
208
+ const defaultValue = Value.from(consensusAggregation.defaultValue).proto();
209
+ clearIgnoredFields(defaultValue, consensusAggregation.descriptor);
210
+ consensusInput.default = defaultValue;
209
211
  }
210
212
  return consensusInput;
211
213
  }
212
- captureObservation(consensusInput, observation) {
214
+ captureObservation(consensusInput, observation, descriptor) {
213
215
  // Safe cast: ConsensusAggregation<T, true> implies T extends CreSerializable
216
+ const observationValue = Value.from(observation).proto();
217
+ clearIgnoredFields(observationValue, descriptor);
214
218
  consensusInput.observation = {
215
219
  case: 'value',
216
- value: Value.from(observation).proto(),
220
+ value: observationValue,
217
221
  };
218
222
  }
219
223
  captureError(consensusInput, e) {
@@ -305,3 +309,31 @@ export class RuntimeImpl extends BaseRuntimeImpl {
305
309
  };
306
310
  }
307
311
  }
312
+ function clearIgnoredFields(value, descriptor) {
313
+ if (!descriptor || !value) {
314
+ return;
315
+ }
316
+ const fieldsMap = descriptor.descriptor?.case === 'fieldsMap' ? descriptor.descriptor.value : undefined;
317
+ if (!fieldsMap) {
318
+ return;
319
+ }
320
+ if (value.value?.case === 'mapValue') {
321
+ const mapValue = value.value.value;
322
+ if (!mapValue || !mapValue.fields) {
323
+ return;
324
+ }
325
+ for (const [key, val] of Object.entries(mapValue.fields)) {
326
+ const nestedDescriptor = fieldsMap.fields[key];
327
+ if (!nestedDescriptor) {
328
+ delete mapValue.fields[key];
329
+ continue;
330
+ }
331
+ const nestedFieldsMap = nestedDescriptor.descriptor?.case === 'fieldsMap'
332
+ ? nestedDescriptor.descriptor.value
333
+ : undefined;
334
+ if (nestedFieldsMap && val.value?.case === 'mapValue') {
335
+ clearIgnoredFields(val, nestedDescriptor);
336
+ }
337
+ }
338
+ }
339
+ }
@@ -68,7 +68,7 @@ export declare function text(responseFn: () => {
68
68
  * @returns The parsed JSON
69
69
  * @throws Error if the body is not valid JSON
70
70
  */
71
- export declare function json(response: Response): unknown;
71
+ export declare function json(response: Response | ResponseTemplate): unknown;
72
72
  /**
73
73
  * Parses the response body as JSON
74
74
  * @param responseFn - Function that returns an object with result function that returns Response
@@ -76,7 +76,7 @@ export declare function json(response: Response): unknown;
76
76
  * @throws Error if the body is not valid JSON
77
77
  */
78
78
  export declare function json(responseFn: () => {
79
- result: Response;
79
+ result: Response | ResponseTemplate;
80
80
  }): {
81
81
  result: () => unknown;
82
82
  };
@@ -7,7 +7,8 @@ async function standardValidate(schema, input) {
7
7
  * @see https://github.com/standard-schema/standard-schema?tab=readme-ov-file#how-do-i-accept-standard-schemas-in-my-library
8
8
  */
9
9
  if (result.issues) {
10
- throw new Error(JSON.stringify(result.issues, null, 2));
10
+ const errorDetails = JSON.stringify(result.issues, null, 2);
11
+ throw new Error(`Config validation failed. Expectations were not matched:\n\n${errorDetails}`);
11
12
  }
12
13
  return result.value;
13
14
  }
@@ -1 +1,2 @@
1
1
  export { Runner } from './runner';
2
+ export { sendErrorResponse } from './send-error-response';
@@ -1 +1,2 @@
1
1
  export { Runner } from './runner';
2
+ export { sendErrorResponse } from './send-error-response';
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Prepares an error response payload from an error.
3
+ * This is a pure function that converts an error to a serialized ExecutionResult.
4
+ *
5
+ * @param error - The error to prepare
6
+ * @returns The serialized error response payload, or null if the error cannot be converted to a string
7
+ */
8
+ export declare const prepareErrorResponse: (error: unknown) => Uint8Array | null;
9
+ /**
10
+ * Sends an error response through the Javy bridge.
11
+ * This is used internally by the SDK as an error handler for the main() function,
12
+ * catching exceptions that bubbled up to the top level.
13
+ *
14
+ * @param error - The error to send
15
+ */
16
+ export declare const sendErrorResponse: (error: unknown) => void;
@@ -0,0 +1,43 @@
1
+ import { create, toBinary } from '@bufbuild/protobuf';
2
+ import { ExecutionResultSchema } from '../../generated/sdk/v1alpha/sdk_pb';
3
+ import { hostBindings } from './host-bindings';
4
+ /**
5
+ * Prepares an error response payload from an error.
6
+ * This is a pure function that converts an error to a serialized ExecutionResult.
7
+ *
8
+ * @param error - The error to prepare
9
+ * @returns The serialized error response payload, or null if the error cannot be converted to a string
10
+ */
11
+ export const prepareErrorResponse = (error) => {
12
+ let errorMessage = null;
13
+ if (error instanceof Error) {
14
+ errorMessage = error.message;
15
+ }
16
+ else if (typeof error === 'string') {
17
+ errorMessage = error;
18
+ }
19
+ else {
20
+ errorMessage = String(error) || null;
21
+ }
22
+ if (typeof errorMessage !== 'string') {
23
+ return null;
24
+ }
25
+ const result = create(ExecutionResultSchema, {
26
+ result: { case: 'error', value: errorMessage },
27
+ });
28
+ return toBinary(ExecutionResultSchema, result);
29
+ };
30
+ /**
31
+ * Sends an error response through the Javy bridge.
32
+ * This is used internally by the SDK as an error handler for the main() function,
33
+ * catching exceptions that bubbled up to the top level.
34
+ *
35
+ * @param error - The error to send
36
+ */
37
+ export const sendErrorResponse = (error) => {
38
+ const payload = prepareErrorResponse(error);
39
+ if (payload === null) {
40
+ return;
41
+ }
42
+ hostBindings.sendResponse(payload);
43
+ };
@@ -0,0 +1,32 @@
1
+ import { BasicActionCapability } from '@cre/generated-sdk/capabilities/internal/basicaction/v1/basicaction_sdk_gen'
2
+ import { BasicCapability as BasicTriggerCapability } from '@cre/generated-sdk/capabilities/internal/basictrigger/v1/basic_sdk_gen'
3
+ import { cre, type Runtime } from '@cre/sdk/cre'
4
+ import { Runner } from '@cre/sdk/wasm'
5
+
6
+ const asyncCalls = (runtime: Runtime<Uint8Array>) => {
7
+ const basicAction = new BasicActionCapability()
8
+ const input = { inputThing: true }
9
+ const p = basicAction.performAction(runtime, input)
10
+
11
+ p.result()
12
+
13
+ return `We should not get here, result should throw an error`
14
+ }
15
+
16
+ const initWorkflow = () => {
17
+ const basicTrigger = new BasicTriggerCapability()
18
+ return [cre.handler(basicTrigger.trigger({}), asyncCalls)]
19
+ }
20
+
21
+ export async function main() {
22
+ console.log(
23
+ `TS workflow: standard test: capability calls are async [${new Date().toISOString()}]`,
24
+ )
25
+
26
+ const runner = await Runner.newRunner<Uint8Array>({
27
+ configParser: (c) => c,
28
+ })
29
+ runner.run(initWorkflow)
30
+ }
31
+
32
+ await main()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chainlink/cre-sdk",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -55,7 +55,7 @@
55
55
  "dependencies": {
56
56
  "@bufbuild/protobuf": "2.6.3",
57
57
  "@bufbuild/protoc-gen-es": "2.6.3",
58
- "@chainlink/cre-sdk-javy-plugin": "1.0.1",
58
+ "@chainlink/cre-sdk-javy-plugin": "1.0.2",
59
59
  "@standard-schema/spec": "1.0.0",
60
60
  "viem": "2.34.0",
61
61
  "zod": "3.25.76"
@@ -67,7 +67,7 @@
67
67
  "chain-selectors": "https://github.com/smartcontractkit/chain-selectors.git#8b963095ae797a3024c8e55822cced7bf618176f",
68
68
  "fast-glob": "3.3.3",
69
69
  "ts-proto": "2.7.5",
70
- "typescript": "5.9.2",
70
+ "typescript": "5.9.3",
71
71
  "yaml": "2.8.1"
72
72
  },
73
73
  "publishConfig": {
@@ -75,6 +75,11 @@
75
75
  },
76
76
  "author": "SmartContract Chainlink Limited SEZC",
77
77
  "license": "BUSL-1.1",
78
+ "repository": {
79
+ "type": "git",
80
+ "url": "https://github.com/smartcontractkit/cre-sdk-typescript",
81
+ "directory": "packages/cre-sdk"
82
+ },
78
83
  "engines": {
79
84
  "bun": ">=1.2.21"
80
85
  }
@@ -1,7 +1,8 @@
1
- import { existsSync } from 'node:fs'
1
+ import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
2
2
  import { mkdir } from 'node:fs/promises'
3
3
  import path from 'node:path'
4
4
  import { $ } from 'bun'
5
+ import { wrapWorkflowCode } from './workflow-wrapper'
5
6
 
6
7
  export const main = async (tsFilePath?: string, outputFilePath?: string) => {
7
8
  const cliArgs = process.argv.slice(3)
@@ -32,26 +33,44 @@ export const main = async (tsFilePath?: string, outputFilePath?: string) => {
32
33
  // Ensure the output directory exists
33
34
  await mkdir(path.dirname(resolvedOutput), { recursive: true })
34
35
 
35
- // Build step (emit next to output file, then overwrite)
36
- await Bun.build({
37
- entrypoints: [resolvedInput],
38
- outdir: path.dirname(resolvedOutput),
39
- target: 'browser',
40
- format: 'esm',
41
- naming: path.basename(resolvedOutput),
42
- })
36
+ // Wrap workflow code with automatic error handling
37
+ const originalCode = readFileSync(resolvedInput, 'utf-8')
38
+ const wrappedCode = wrapWorkflowCode(originalCode, resolvedInput)
43
39
 
44
- // The file Bun will emit before bundling
45
- const builtFile = path.join(path.dirname(resolvedOutput), path.basename(resolvedOutput))
40
+ // Write wrapped code to temp file (in the same directory as input for module resolution)
41
+ const tempFile = path.join(
42
+ path.dirname(resolvedInput),
43
+ `.workflow-temp-${Date.now()}-${Math.random().toString(36).slice(2)}.ts`,
44
+ )
45
+ writeFileSync(tempFile, wrappedCode, 'utf-8')
46
46
 
47
- if (!existsSync(builtFile)) {
48
- console.error(`❌ Expected file not found: ${builtFile}`)
49
- process.exit(1)
50
- }
47
+ try {
48
+ // Build step (emit next to output file, then overwrite)
49
+ await Bun.build({
50
+ entrypoints: [tempFile],
51
+ outdir: path.dirname(resolvedOutput),
52
+ target: 'browser',
53
+ format: 'esm',
54
+ naming: path.basename(resolvedOutput),
55
+ })
56
+
57
+ // The file Bun will emit before bundling
58
+ const builtFile = path.join(path.dirname(resolvedOutput), path.basename(resolvedOutput))
51
59
 
52
- // Bundle into the final file (overwrite)
53
- await $`bun build ${builtFile} --bundle --outfile=${resolvedOutput}`
60
+ if (!existsSync(builtFile)) {
61
+ console.error(`❌ Expected file not found: ${builtFile}`)
62
+ process.exit(1)
63
+ }
54
64
 
55
- console.info(`✅ Built: ${resolvedOutput}`)
56
- return resolvedOutput
65
+ // Bundle into the final file (overwrite)
66
+ await $`bun build ${builtFile} --bundle --outfile=${resolvedOutput}`
67
+
68
+ console.info(`✅ Built: ${resolvedOutput}`)
69
+ return resolvedOutput
70
+ } finally {
71
+ // Clean up temp file
72
+ if (existsSync(tempFile)) {
73
+ unlinkSync(tempFile)
74
+ }
75
+ }
57
76
  }
@@ -0,0 +1,321 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { wrapWorkflowCode } from './workflow-wrapper'
3
+
4
+ describe('wrapWorkflowCode', () => {
5
+ describe('import handling', () => {
6
+ test('adds sendErrorResponse import when no @chainlink/cre-sdk import exists', () => {
7
+ const input = `export async function main() {
8
+ console.log('hello')
9
+ }`
10
+ const result = wrapWorkflowCode(input, 'test.ts')
11
+
12
+ expect(result).toContain("import { sendErrorResponse } from '@chainlink/cre-sdk'")
13
+ expect(result).toContain('main().catch(sendErrorResponse)')
14
+ })
15
+
16
+ test('adds sendErrorResponse to existing @chainlink/cre-sdk import', () => {
17
+ const input = `import { Runner } from '@chainlink/cre-sdk'
18
+
19
+ export async function main() {
20
+ const runner = await Runner.newRunner()
21
+ }`
22
+ const result = wrapWorkflowCode(input, 'test.ts')
23
+
24
+ expect(result).toContain('Runner, sendErrorResponse')
25
+ expect(result).toContain('main().catch(sendErrorResponse)')
26
+ // Should not add a separate import
27
+ expect(result.match(/import.*from.*@chainlink\/cre-sdk/g)?.length).toBe(1)
28
+ })
29
+
30
+ test('does not duplicate sendErrorResponse if already imported', () => {
31
+ const input = `import { Runner, sendErrorResponse } from '@chainlink/cre-sdk'
32
+
33
+ export async function main() {
34
+ const runner = await Runner.newRunner()
35
+ }`
36
+ const result = wrapWorkflowCode(input, 'test.ts')
37
+
38
+ // Count occurrences of sendErrorResponse in imports
39
+ const importMatch = result.match(/import.*{[^}]*}.*from.*@chainlink\/cre-sdk/)?.[0]
40
+ expect(importMatch?.match(/sendErrorResponse/g)?.length).toBe(1)
41
+ expect(result).toContain('main().catch(sendErrorResponse)')
42
+ })
43
+
44
+ test('handles multiple named imports correctly', () => {
45
+ const input = `import { cre, Runner, Config } from '@chainlink/cre-sdk'
46
+
47
+ export async function main() {
48
+ const runner = await Runner.newRunner()
49
+ }`
50
+ const result = wrapWorkflowCode(input, 'test.ts')
51
+
52
+ expect(result).toContain('Config, sendErrorResponse')
53
+ expect(result).toContain('main().catch(sendErrorResponse)')
54
+ })
55
+
56
+ test('adds import after existing imports from other modules', () => {
57
+ const input = `import { something } from 'other-module'
58
+ import { another } from 'another-module'
59
+
60
+ export async function main() {
61
+ console.log('hello')
62
+ }`
63
+ const result = wrapWorkflowCode(input, 'test.ts')
64
+
65
+ expect(result).toContain("import { sendErrorResponse } from '@chainlink/cre-sdk'")
66
+ // The new import should be after the existing imports
67
+ const lines = result.split('\n')
68
+ const otherModuleIndex = lines.findIndex((l) => l.includes('other-module'))
69
+ const creImportIndex = lines.findIndex((l) => l.includes('@chainlink/cre-sdk'))
70
+ expect(creImportIndex).toBeGreaterThan(otherModuleIndex)
71
+ })
72
+ })
73
+
74
+ describe('error handling detection', () => {
75
+ test('does not add wrapper when main().catch() already exists', () => {
76
+ const input = `import { Runner, sendErrorResponse } from '@chainlink/cre-sdk'
77
+
78
+ export async function main() {
79
+ const runner = await Runner.newRunner()
80
+ }
81
+
82
+ main().catch(sendErrorResponse)`
83
+ const result = wrapWorkflowCode(input, 'test.ts')
84
+
85
+ // Should only have one main().catch() call
86
+ expect(result.match(/main\(\)\.catch/g)?.length).toBe(1)
87
+ })
88
+
89
+ test('does not add wrapper when main().catch() with custom handler exists', () => {
90
+ const input = `import { Runner } from '@chainlink/cre-sdk'
91
+
92
+ export async function main() {
93
+ const runner = await Runner.newRunner()
94
+ }
95
+
96
+ main().catch((e) => {
97
+ console.error('Custom error:', e)
98
+ })`
99
+ const result = wrapWorkflowCode(input, 'test.ts')
100
+
101
+ // Should still add sendErrorResponse import but not add another .catch()
102
+ expect(result).toContain('sendErrorResponse')
103
+ // Should only have one .catch() call
104
+ expect(result.match(/\.catch\(/g)?.length).toBe(1)
105
+ })
106
+
107
+ test('does not add wrapper when main().then().catch() exists', () => {
108
+ const input = `import { Runner, sendErrorResponse } from '@chainlink/cre-sdk'
109
+
110
+ export async function main() {
111
+ const runner = await Runner.newRunner()
112
+ }
113
+
114
+ main().then(() => console.log('done')).catch(sendErrorResponse)`
115
+ const result = wrapWorkflowCode(input, 'test.ts')
116
+
117
+ // Should only have one .catch() call
118
+ expect(result.match(/\.catch\(/g)?.length).toBe(1)
119
+ })
120
+
121
+ test('adds wrapper when main() is called without .catch()', () => {
122
+ const input = `import { Runner } from '@chainlink/cre-sdk'
123
+
124
+ export async function main() {
125
+ const runner = await Runner.newRunner()
126
+ }
127
+
128
+ main()`
129
+ const result = wrapWorkflowCode(input, 'test.ts')
130
+
131
+ // Should have two main() calls - original and wrapper
132
+ expect(result).toContain('main()')
133
+ expect(result).toContain('main().catch(sendErrorResponse)')
134
+ })
135
+ })
136
+
137
+ describe('main function detection', () => {
138
+ test('handles exported async main function', () => {
139
+ const input = `export async function main() {
140
+ console.log('hello')
141
+ }`
142
+ const result = wrapWorkflowCode(input, 'test.ts')
143
+
144
+ expect(result).toContain('main().catch(sendErrorResponse)')
145
+ })
146
+
147
+ test('handles exported sync main function', () => {
148
+ const input = `export function main() {
149
+ console.log('hello')
150
+ }`
151
+ const result = wrapWorkflowCode(input, 'test.ts')
152
+
153
+ expect(result).toContain('main().catch(sendErrorResponse)')
154
+ })
155
+ })
156
+
157
+ describe('edge cases', () => {
158
+ test('handles empty file', () => {
159
+ const input = ''
160
+ const result = wrapWorkflowCode(input, 'test.ts')
161
+
162
+ expect(result).toContain("import { sendErrorResponse } from '@chainlink/cre-sdk'")
163
+ expect(result).toContain('main().catch(sendErrorResponse)')
164
+ })
165
+
166
+ test('handles file with only imports', () => {
167
+ const input = `import { Runner } from '@chainlink/cre-sdk'`
168
+ const result = wrapWorkflowCode(input, 'test.ts')
169
+
170
+ expect(result).toContain('Runner, sendErrorResponse')
171
+ expect(result).toContain('main().catch(sendErrorResponse)')
172
+ })
173
+
174
+ test('ignores main() in comments', () => {
175
+ const input = `import { Runner } from '@chainlink/cre-sdk'
176
+
177
+ // main() is called automatically
178
+ /* main().catch(sendErrorResponse) */
179
+
180
+ export async function main() {
181
+ console.log('hello')
182
+ }`
183
+ const result = wrapWorkflowCode(input, 'test.ts')
184
+
185
+ // Should add wrapper because the main() calls in comments don't count
186
+ expect(result).toContain('main().catch(sendErrorResponse)')
187
+ })
188
+
189
+ test('ignores main() in string literals', () => {
190
+ const input = `import { Runner } from '@chainlink/cre-sdk'
191
+
192
+ export async function main() {
193
+ const code = "main().catch(handler)"
194
+ console.log(code)
195
+ }`
196
+ const result = wrapWorkflowCode(input, 'test.ts')
197
+
198
+ // Should add wrapper because the main() in string doesn't count
199
+ expect(result).toContain('main().catch(sendErrorResponse)')
200
+ })
201
+
202
+ test('preserves original code structure', () => {
203
+ const input = `import { Runner } from '@chainlink/cre-sdk'
204
+
205
+ const config = {
206
+ name: 'test'
207
+ }
208
+
209
+ export async function main() {
210
+ const runner = await Runner.newRunner()
211
+ await runner.run()
212
+ }`
213
+ const result = wrapWorkflowCode(input, 'test.ts')
214
+
215
+ expect(result).toContain('const config = {')
216
+ expect(result).toContain("name: 'test'")
217
+ expect(result).toContain('export async function main()')
218
+ })
219
+
220
+ test('handles Windows-style line endings', () => {
221
+ const input =
222
+ "import { Runner } from '@chainlink/cre-sdk'\r\n\r\nexport async function main() {\r\n console.log('hello')\r\n}"
223
+ const result = wrapWorkflowCode(input, 'test.ts')
224
+
225
+ expect(result).toContain('sendErrorResponse')
226
+ expect(result).toContain('main().catch(sendErrorResponse)')
227
+ })
228
+
229
+ test('handles file ending without newline', () => {
230
+ const input = `import { Runner } from '@chainlink/cre-sdk'
231
+
232
+ export async function main() {
233
+ console.log('hello')
234
+ }`
235
+ const result = wrapWorkflowCode(input, 'test.ts')
236
+
237
+ expect(result).toContain('main().catch(sendErrorResponse)')
238
+ expect(result.endsWith('\n')).toBe(true)
239
+ })
240
+ })
241
+
242
+ describe('real-world workflow examples', () => {
243
+ test('wraps typical hello-world workflow', () => {
244
+ const input = `import { cre, Runner, type Config } from '@chainlink/cre-sdk'
245
+
246
+ interface WorkflowConfig extends Config {
247
+ message: string
248
+ }
249
+
250
+ async function initWorkflow(runtime: cre.Runtime<WorkflowConfig>) {
251
+ const config = runtime.getConfig()
252
+ console.log(config.message)
253
+ }
254
+
255
+ export async function main() {
256
+ const runner = await Runner.newRunner<WorkflowConfig>()
257
+ await runner.run(initWorkflow)
258
+ }`
259
+ const result = wrapWorkflowCode(input, 'test.ts')
260
+
261
+ expect(result).toContain('cre, Runner, type Config, sendErrorResponse')
262
+ expect(result).toContain('main().catch(sendErrorResponse)')
263
+ // Original code should be preserved
264
+ expect(result).toContain('interface WorkflowConfig extends Config')
265
+ expect(result).toContain('async function initWorkflow')
266
+ })
267
+
268
+ test('wraps workflow with http capability', () => {
269
+ const input = `import { cre, Runner, type Config } from '@chainlink/cre-sdk'
270
+ import { http } from '@chainlink/cre-sdk/capabilities'
271
+
272
+ interface WorkflowConfig extends Config {
273
+ apiUrl: string
274
+ }
275
+
276
+ async function initWorkflow(runtime: cre.Runtime<WorkflowConfig>) {
277
+ const config = runtime.getConfig()
278
+ const response = await http.fetch(runtime, { url: config.apiUrl })
279
+ console.log(response)
280
+ }
281
+
282
+ export async function main() {
283
+ const runner = await Runner.newRunner<WorkflowConfig>()
284
+ await runner.run(initWorkflow)
285
+ }`
286
+ const result = wrapWorkflowCode(input, 'test.ts')
287
+
288
+ expect(result).toContain('sendErrorResponse')
289
+ expect(result).toContain('main().catch(sendErrorResponse)')
290
+ // Should preserve the http import
291
+ expect(result).toContain("import { http } from '@chainlink/cre-sdk/capabilities'")
292
+ })
293
+
294
+ test('does not modify workflow that already has proper error handling', () => {
295
+ const input = `import { cre, Runner, sendErrorResponse, type Config } from '@chainlink/cre-sdk'
296
+
297
+ interface WorkflowConfig extends Config {
298
+ message: string
299
+ }
300
+
301
+ async function initWorkflow(runtime: cre.Runtime<WorkflowConfig>) {
302
+ const config = runtime.getConfig()
303
+ console.log(config.message)
304
+ }
305
+
306
+ export async function main() {
307
+ const runner = await Runner.newRunner<WorkflowConfig>()
308
+ await runner.run(initWorkflow)
309
+ }
310
+
311
+ main().catch(sendErrorResponse)`
312
+ const result = wrapWorkflowCode(input, 'test.ts')
313
+
314
+ // Should only have one main().catch() call
315
+ expect(result.match(/main\(\)\.catch\(sendErrorResponse\)/g)?.length).toBe(1)
316
+ // Should only have one sendErrorResponse in imports
317
+ const importLine = result.split('\n').find((l) => l.includes('@chainlink/cre-sdk'))
318
+ expect(importLine?.match(/sendErrorResponse/g)?.length).toBe(1)
319
+ })
320
+ })
321
+ })
@@ -0,0 +1,163 @@
1
+ import * as ts from 'typescript'
2
+
3
+ /**
4
+ * Wraps workflow code with automatic error boundary.
5
+ * This function:
6
+ * 1. Detects if `sendErrorResponse` import from `@chainlink/cre-sdk` exists in the workflow code.
7
+ * 2. Detects if `main()` function is exported.
8
+ * 3. Detects if there's already a top-level `main()` call with `.catch()` handler.
9
+ * 4. Adds `sendErrorResponse` to imports if missing.
10
+ * 5. Appends `main().catch(sendErrorResponse)` only if no error handling exists.
11
+ *
12
+ * @param sourceCode - The TypeScript source code to wrap
13
+ * @param filePath - The file path (used for source file creation)
14
+ * @returns The wrapped source code
15
+ */
16
+ export function wrapWorkflowCode(sourceCode: string, filePath: string): string {
17
+ const sourceFile = ts.createSourceFile(filePath, sourceCode, ts.ScriptTarget.Latest, true)
18
+
19
+ // Analysis state
20
+ let hasSendErrorResponseImport = false
21
+ let creSdkImportDeclaration: ts.ImportDeclaration | null = null
22
+ let hasMainExport = false
23
+ let hasExistingErrorHandling = false
24
+
25
+ // Helper to check if a node is a main() call expression
26
+ const isMainCall = (node: ts.Node): boolean => {
27
+ return (
28
+ ts.isCallExpression(node) &&
29
+ ts.isIdentifier(node.expression) &&
30
+ node.expression.text === 'main'
31
+ )
32
+ }
33
+
34
+ // Helper to check if a call expression is wrapped with .catch()
35
+ const isWrappedWithCatch = (node: ts.Node): boolean => {
36
+ if (ts.isCallExpression(node)) {
37
+ const expr = node.expression
38
+ if (ts.isPropertyAccessExpression(expr) && expr.name.text === 'catch') {
39
+ // Check if the object being called with .catch is main()
40
+ return isMainCall(expr.expression)
41
+ }
42
+ }
43
+ return false
44
+ }
45
+
46
+ // First pass: analyze AST
47
+ for (const statement of sourceFile.statements) {
48
+ // Check for @chainlink/cre-sdk import
49
+ if (ts.isImportDeclaration(statement)) {
50
+ const moduleSpecifier = statement.moduleSpecifier
51
+ if (ts.isStringLiteral(moduleSpecifier) && moduleSpecifier.text === '@chainlink/cre-sdk') {
52
+ creSdkImportDeclaration = statement
53
+ if (
54
+ statement.importClause?.namedBindings &&
55
+ ts.isNamedImports(statement.importClause.namedBindings)
56
+ ) {
57
+ hasSendErrorResponseImport = statement.importClause.namedBindings.elements.some(
58
+ (element) => element.name.text === 'sendErrorResponse',
59
+ )
60
+ }
61
+ }
62
+ }
63
+
64
+ // Check for main() export
65
+ if (ts.isFunctionDeclaration(statement) && statement.name?.text === 'main') {
66
+ if (statement.modifiers?.some((mod) => mod.kind === ts.SyntaxKind.ExportKeyword)) {
67
+ hasMainExport = true
68
+ }
69
+ }
70
+
71
+ // Check for top-level main() call with error handling
72
+ if (ts.isExpressionStatement(statement)) {
73
+ const expr = statement.expression
74
+
75
+ // Check for main().catch(...)
76
+ if (isWrappedWithCatch(expr)) {
77
+ hasExistingErrorHandling = true
78
+ }
79
+
80
+ // Also check for: main().then(...).catch(...) or other chained patterns
81
+ if (ts.isCallExpression(expr)) {
82
+ // Walk the chain to find if there's a .catch anywhere
83
+ let current: ts.Expression = expr
84
+ while (ts.isCallExpression(current)) {
85
+ const propAccess = current.expression
86
+ if (ts.isPropertyAccessExpression(propAccess)) {
87
+ if (propAccess.name.text === 'catch') {
88
+ // Find if main() is somewhere in this chain
89
+ let innerCurrent: ts.Expression = propAccess.expression
90
+ while (ts.isCallExpression(innerCurrent)) {
91
+ if (isMainCall(innerCurrent)) {
92
+ hasExistingErrorHandling = true
93
+ break
94
+ }
95
+ if (ts.isPropertyAccessExpression(innerCurrent.expression)) {
96
+ innerCurrent = innerCurrent.expression.expression
97
+ } else {
98
+ break
99
+ }
100
+ }
101
+ }
102
+ current = propAccess.expression
103
+ } else {
104
+ break
105
+ }
106
+ }
107
+ }
108
+ }
109
+ }
110
+
111
+ // Build the transformed code
112
+ let result = sourceCode
113
+
114
+ // If we need to add sendErrorResponse import
115
+ if (!hasSendErrorResponseImport) {
116
+ if (creSdkImportDeclaration) {
117
+ // Add to existing import
118
+ const importClause = creSdkImportDeclaration.importClause
119
+ if (importClause?.namedBindings && ts.isNamedImports(importClause.namedBindings)) {
120
+ const elements = importClause.namedBindings.elements
121
+ const lastElement = elements[elements.length - 1]
122
+ const lastElementEnd = lastElement.end
123
+
124
+ // Insert sendErrorResponse after the last import element
125
+ result =
126
+ result.slice(0, lastElementEnd) + ', sendErrorResponse' + result.slice(lastElementEnd)
127
+ }
128
+ } else {
129
+ // Add new import at the beginning or after existing imports
130
+ const importLine = "import { sendErrorResponse } from '@chainlink/cre-sdk'\n"
131
+
132
+ // Find the last import declaration to insert after it
133
+ let lastImportEnd = 0
134
+ for (const statement of sourceFile.statements) {
135
+ if (ts.isImportDeclaration(statement)) {
136
+ lastImportEnd = statement.end
137
+ }
138
+ }
139
+
140
+ if (lastImportEnd > 0) {
141
+ // Insert after the last import, handling newlines
142
+ const afterImport = result.slice(lastImportEnd)
143
+ const leadingNewlines = afterImport.match(/^[\r\n]*/)?.[0] || ''
144
+ result =
145
+ result.slice(0, lastImportEnd) +
146
+ leadingNewlines +
147
+ importLine +
148
+ afterImport.slice(leadingNewlines.length)
149
+ } else {
150
+ // No imports exist, add at the beginning
151
+ result = importLine + result
152
+ }
153
+ }
154
+ }
155
+
156
+ // Append main().catch(sendErrorResponse) if no error handling exists
157
+ if (!hasExistingErrorHandling) {
158
+ const trimmedResult = result.trimEnd()
159
+ result = trimmedResult + '\n\nmain().catch(sendErrorResponse)\n'
160
+ }
161
+
162
+ return result
163
+ }