@chainlink/cre-sdk 1.0.1 → 1.0.3

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.3",
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,335 @@
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 replace the original main() call
132
+ expect(result.match(/main\(\)\.catch\(sendErrorResponse\)/g)?.length).toBe(1)
133
+ expect(result.match(/(^|\n)\s*main\(\)\s*;?\s*$/g)?.length ?? 0).toBe(0)
134
+ })
135
+
136
+ test('replaces await main() with await main().catch()', () => {
137
+ const input = `import { Runner } from '@chainlink/cre-sdk'
138
+
139
+ export async function main() {
140
+ const runner = await Runner.newRunner()
141
+ }
142
+
143
+ await main()`
144
+ const result = wrapWorkflowCode(input, 'test.ts')
145
+
146
+ expect(result).toContain('await main().catch(sendErrorResponse)')
147
+ expect(result.match(/main\(\)\.catch\(sendErrorResponse\)/g)?.length).toBe(1)
148
+ })
149
+ })
150
+
151
+ describe('main function detection', () => {
152
+ test('handles exported async main function', () => {
153
+ const input = `export async function main() {
154
+ console.log('hello')
155
+ }`
156
+ const result = wrapWorkflowCode(input, 'test.ts')
157
+
158
+ expect(result).toContain('main().catch(sendErrorResponse)')
159
+ })
160
+
161
+ test('handles exported sync main function', () => {
162
+ const input = `export function main() {
163
+ console.log('hello')
164
+ }`
165
+ const result = wrapWorkflowCode(input, 'test.ts')
166
+
167
+ expect(result).toContain('main().catch(sendErrorResponse)')
168
+ })
169
+ })
170
+
171
+ describe('edge cases', () => {
172
+ test('handles empty file', () => {
173
+ const input = ''
174
+ const result = wrapWorkflowCode(input, 'test.ts')
175
+
176
+ expect(result).toContain("import { sendErrorResponse } from '@chainlink/cre-sdk'")
177
+ expect(result).toContain('main().catch(sendErrorResponse)')
178
+ })
179
+
180
+ test('handles file with only imports', () => {
181
+ const input = `import { Runner } from '@chainlink/cre-sdk'`
182
+ const result = wrapWorkflowCode(input, 'test.ts')
183
+
184
+ expect(result).toContain('Runner, sendErrorResponse')
185
+ expect(result).toContain('main().catch(sendErrorResponse)')
186
+ })
187
+
188
+ test('ignores main() in comments', () => {
189
+ const input = `import { Runner } from '@chainlink/cre-sdk'
190
+
191
+ // main() is called automatically
192
+ /* main().catch(sendErrorResponse) */
193
+
194
+ export async function main() {
195
+ console.log('hello')
196
+ }`
197
+ const result = wrapWorkflowCode(input, 'test.ts')
198
+
199
+ // Should add wrapper because the main() calls in comments don't count
200
+ expect(result).toContain('main().catch(sendErrorResponse)')
201
+ })
202
+
203
+ test('ignores main() in string literals', () => {
204
+ const input = `import { Runner } from '@chainlink/cre-sdk'
205
+
206
+ export async function main() {
207
+ const code = "main().catch(handler)"
208
+ console.log(code)
209
+ }`
210
+ const result = wrapWorkflowCode(input, 'test.ts')
211
+
212
+ // Should add wrapper because the main() in string doesn't count
213
+ expect(result).toContain('main().catch(sendErrorResponse)')
214
+ })
215
+
216
+ test('preserves original code structure', () => {
217
+ const input = `import { Runner } from '@chainlink/cre-sdk'
218
+
219
+ const config = {
220
+ name: 'test'
221
+ }
222
+
223
+ export async function main() {
224
+ const runner = await Runner.newRunner()
225
+ await runner.run()
226
+ }`
227
+ const result = wrapWorkflowCode(input, 'test.ts')
228
+
229
+ expect(result).toContain('const config = {')
230
+ expect(result).toContain("name: 'test'")
231
+ expect(result).toContain('export async function main()')
232
+ })
233
+
234
+ test('handles Windows-style line endings', () => {
235
+ const input =
236
+ "import { Runner } from '@chainlink/cre-sdk'\r\n\r\nexport async function main() {\r\n console.log('hello')\r\n}"
237
+ const result = wrapWorkflowCode(input, 'test.ts')
238
+
239
+ expect(result).toContain('sendErrorResponse')
240
+ expect(result).toContain('main().catch(sendErrorResponse)')
241
+ })
242
+
243
+ test('handles file ending without newline', () => {
244
+ const input = `import { Runner } from '@chainlink/cre-sdk'
245
+
246
+ export async function main() {
247
+ console.log('hello')
248
+ }`
249
+ const result = wrapWorkflowCode(input, 'test.ts')
250
+
251
+ expect(result).toContain('main().catch(sendErrorResponse)')
252
+ expect(result.endsWith('\n')).toBe(true)
253
+ })
254
+ })
255
+
256
+ describe('real-world workflow examples', () => {
257
+ test('wraps typical hello-world workflow', () => {
258
+ const input = `import { cre, Runner, type Config } from '@chainlink/cre-sdk'
259
+
260
+ interface WorkflowConfig extends Config {
261
+ message: string
262
+ }
263
+
264
+ async function initWorkflow(runtime: cre.Runtime<WorkflowConfig>) {
265
+ const config = runtime.getConfig()
266
+ console.log(config.message)
267
+ }
268
+
269
+ export async function main() {
270
+ const runner = await Runner.newRunner<WorkflowConfig>()
271
+ await runner.run(initWorkflow)
272
+ }`
273
+ const result = wrapWorkflowCode(input, 'test.ts')
274
+
275
+ expect(result).toContain('cre, Runner, type Config, sendErrorResponse')
276
+ expect(result).toContain('main().catch(sendErrorResponse)')
277
+ // Original code should be preserved
278
+ expect(result).toContain('interface WorkflowConfig extends Config')
279
+ expect(result).toContain('async function initWorkflow')
280
+ })
281
+
282
+ test('wraps workflow with http capability', () => {
283
+ const input = `import { cre, Runner, type Config } from '@chainlink/cre-sdk'
284
+ import { http } from '@chainlink/cre-sdk/capabilities'
285
+
286
+ interface WorkflowConfig extends Config {
287
+ apiUrl: string
288
+ }
289
+
290
+ async function initWorkflow(runtime: cre.Runtime<WorkflowConfig>) {
291
+ const config = runtime.getConfig()
292
+ const response = await http.fetch(runtime, { url: config.apiUrl })
293
+ console.log(response)
294
+ }
295
+
296
+ export async function main() {
297
+ const runner = await Runner.newRunner<WorkflowConfig>()
298
+ await runner.run(initWorkflow)
299
+ }`
300
+ const result = wrapWorkflowCode(input, 'test.ts')
301
+
302
+ expect(result).toContain('sendErrorResponse')
303
+ expect(result).toContain('main().catch(sendErrorResponse)')
304
+ // Should preserve the http import
305
+ expect(result).toContain("import { http } from '@chainlink/cre-sdk/capabilities'")
306
+ })
307
+
308
+ test('does not modify workflow that already has proper error handling', () => {
309
+ const input = `import { cre, Runner, sendErrorResponse, type Config } from '@chainlink/cre-sdk'
310
+
311
+ interface WorkflowConfig extends Config {
312
+ message: string
313
+ }
314
+
315
+ async function initWorkflow(runtime: cre.Runtime<WorkflowConfig>) {
316
+ const config = runtime.getConfig()
317
+ console.log(config.message)
318
+ }
319
+
320
+ export async function main() {
321
+ const runner = await Runner.newRunner<WorkflowConfig>()
322
+ await runner.run(initWorkflow)
323
+ }
324
+
325
+ main().catch(sendErrorResponse)`
326
+ const result = wrapWorkflowCode(input, 'test.ts')
327
+
328
+ // Should only have one main().catch() call
329
+ expect(result.match(/main\(\)\.catch\(sendErrorResponse\)/g)?.length).toBe(1)
330
+ // Should only have one sendErrorResponse in imports
331
+ const importLine = result.split('\n').find((l) => l.includes('@chainlink/cre-sdk'))
332
+ expect(importLine?.match(/sendErrorResponse/g)?.length).toBe(1)
333
+ })
334
+ })
335
+ })
@@ -0,0 +1,198 @@
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. Replaces top-level `main()` or `await main()` with `main().catch(sendErrorResponse)`.
11
+ * 6. Appends `main().catch(sendErrorResponse)` only if no error handling exists and no call exists.
12
+ *
13
+ * @param sourceCode - The TypeScript source code to wrap
14
+ * @param filePath - The file path (used for source file creation)
15
+ * @returns The wrapped source code
16
+ */
17
+ export function wrapWorkflowCode(sourceCode: string, filePath: string): string {
18
+ const sourceFile = ts.createSourceFile(filePath, sourceCode, ts.ScriptTarget.Latest, true)
19
+
20
+ // Analysis state
21
+ let hasMainExport = false
22
+ let hasExistingErrorHandling = false
23
+ const mainCallStatements: { start: number; end: number; useAwait: boolean }[] = []
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
+ const getMainCallFromExpression = (expr: ts.Expression): { useAwait: boolean } | null => {
47
+ if (isMainCall(expr)) {
48
+ return { useAwait: false }
49
+ }
50
+ if (ts.isAwaitExpression(expr) && isMainCall(expr.expression)) {
51
+ return { useAwait: true }
52
+ }
53
+ return null
54
+ }
55
+
56
+ // First pass: analyze AST
57
+ for (const statement of sourceFile.statements) {
58
+ // Check for main() export
59
+ if (ts.isFunctionDeclaration(statement) && statement.name?.text === 'main') {
60
+ if (statement.modifiers?.some((mod) => mod.kind === ts.SyntaxKind.ExportKeyword)) {
61
+ hasMainExport = true
62
+ }
63
+ }
64
+
65
+ // Check for top-level main() call with error handling
66
+ if (ts.isExpressionStatement(statement)) {
67
+ const expr = statement.expression
68
+ const exprToCheck = ts.isAwaitExpression(expr) ? expr.expression : expr
69
+
70
+ // Check for main().catch(...)
71
+ if (isWrappedWithCatch(exprToCheck)) {
72
+ hasExistingErrorHandling = true
73
+ }
74
+
75
+ // Also check for: main().then(...).catch(...) or other chained patterns
76
+ if (ts.isCallExpression(exprToCheck)) {
77
+ // Walk the chain to find if there's a .catch anywhere
78
+ let current: ts.Expression = exprToCheck
79
+ while (ts.isCallExpression(current)) {
80
+ const propAccess = current.expression
81
+ if (ts.isPropertyAccessExpression(propAccess)) {
82
+ if (propAccess.name.text === 'catch') {
83
+ // Find if main() is somewhere in this chain
84
+ let innerCurrent: ts.Expression = propAccess.expression
85
+ while (ts.isCallExpression(innerCurrent)) {
86
+ if (isMainCall(innerCurrent)) {
87
+ hasExistingErrorHandling = true
88
+ break
89
+ }
90
+ if (ts.isPropertyAccessExpression(innerCurrent.expression)) {
91
+ innerCurrent = innerCurrent.expression.expression
92
+ } else {
93
+ break
94
+ }
95
+ }
96
+ }
97
+ current = propAccess.expression
98
+ } else {
99
+ break
100
+ }
101
+ }
102
+ }
103
+
104
+ if (!hasExistingErrorHandling) {
105
+ const mainCall = getMainCallFromExpression(expr)
106
+ if (mainCall) {
107
+ mainCallStatements.push({
108
+ start: statement.getStart(sourceFile),
109
+ end: statement.end,
110
+ useAwait: mainCall.useAwait,
111
+ })
112
+ }
113
+ }
114
+ }
115
+ }
116
+
117
+ // Build the transformed code
118
+ let result = sourceCode
119
+
120
+ if (!hasExistingErrorHandling && mainCallStatements.length > 0) {
121
+ for (const statement of [...mainCallStatements].sort((a, b) => b.start - a.start)) {
122
+ const replacement = `${statement.useAwait ? 'await ' : ''}main().catch(sendErrorResponse)`
123
+ result = result.slice(0, statement.start) + replacement + result.slice(statement.end)
124
+ }
125
+ }
126
+
127
+ // If we need to add sendErrorResponse import
128
+ const nextSourceFile = ts.createSourceFile(filePath, result, ts.ScriptTarget.Latest, true)
129
+ let hasSendErrorResponseImport = false
130
+ let creSdkImportDeclaration: ts.ImportDeclaration | null = null
131
+
132
+ for (const statement of nextSourceFile.statements) {
133
+ // Check for @chainlink/cre-sdk import
134
+ if (ts.isImportDeclaration(statement)) {
135
+ const moduleSpecifier = statement.moduleSpecifier
136
+ if (ts.isStringLiteral(moduleSpecifier) && moduleSpecifier.text === '@chainlink/cre-sdk') {
137
+ creSdkImportDeclaration = statement
138
+ if (
139
+ statement.importClause?.namedBindings &&
140
+ ts.isNamedImports(statement.importClause.namedBindings)
141
+ ) {
142
+ hasSendErrorResponseImport = statement.importClause.namedBindings.elements.some(
143
+ (element) => element.name.text === 'sendErrorResponse',
144
+ )
145
+ }
146
+ }
147
+ }
148
+ }
149
+
150
+ if (!hasSendErrorResponseImport) {
151
+ if (creSdkImportDeclaration) {
152
+ // Add to existing import
153
+ const importClause = creSdkImportDeclaration.importClause
154
+ if (importClause?.namedBindings && ts.isNamedImports(importClause.namedBindings)) {
155
+ const elements = importClause.namedBindings.elements
156
+ const lastElement = elements[elements.length - 1]
157
+ const lastElementEnd = lastElement.end
158
+
159
+ // Insert sendErrorResponse after the last import element
160
+ result =
161
+ result.slice(0, lastElementEnd) + ', sendErrorResponse' + result.slice(lastElementEnd)
162
+ }
163
+ } else {
164
+ // Add new import at the beginning or after existing imports
165
+ const importLine = "import { sendErrorResponse } from '@chainlink/cre-sdk'\n"
166
+
167
+ // Find the last import declaration to insert after it
168
+ let lastImportEnd = 0
169
+ for (const statement of nextSourceFile.statements) {
170
+ if (ts.isImportDeclaration(statement)) {
171
+ lastImportEnd = statement.end
172
+ }
173
+ }
174
+
175
+ if (lastImportEnd > 0) {
176
+ // Insert after the last import, handling newlines
177
+ const afterImport = result.slice(lastImportEnd)
178
+ const leadingNewlines = afterImport.match(/^[\r\n]*/)?.[0] || ''
179
+ result =
180
+ result.slice(0, lastImportEnd) +
181
+ leadingNewlines +
182
+ importLine +
183
+ afterImport.slice(leadingNewlines.length)
184
+ } else {
185
+ // No imports exist, add at the beginning
186
+ result = importLine + result
187
+ }
188
+ }
189
+ }
190
+
191
+ // Append main().catch(sendErrorResponse) if no error handling exists
192
+ if (!hasExistingErrorHandling && mainCallStatements.length === 0) {
193
+ const trimmedResult = result.trimEnd()
194
+ result = trimmedResult + '\n\nmain().catch(sendErrorResponse)\n'
195
+ }
196
+
197
+ return result
198
+ }