@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.
- package/dist/sdk/impl/runtime-impl.js +36 -4
- package/dist/sdk/utils/capabilities/http/http-helpers.d.ts +2 -2
- package/dist/sdk/utils/config/index.js +2 -1
- package/dist/sdk/wasm/index.d.ts +1 -0
- package/dist/sdk/wasm/index.js +1 -0
- package/dist/sdk/wasm/send-error-response.d.ts +16 -0
- package/dist/sdk/wasm/send-error-response.js +43 -0
- package/dist/workflows/standard_tests/host_wasm_write_errors_are_respected/test.ts +32 -0
- package/package.json +8 -3
- package/scripts/src/compile-to-js.ts +38 -19
- package/scripts/src/workflow-wrapper.test.ts +335 -0
- package/scripts/src/workflow-wrapper.ts +198 -0
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
}
|
package/dist/sdk/wasm/index.d.ts
CHANGED
package/dist/sdk/wasm/index.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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
|
-
//
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
//
|
|
45
|
-
const
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
53
|
-
|
|
60
|
+
if (!existsSync(builtFile)) {
|
|
61
|
+
console.error(`❌ Expected file not found: ${builtFile}`)
|
|
62
|
+
process.exit(1)
|
|
63
|
+
}
|
|
54
64
|
|
|
55
|
-
|
|
56
|
-
|
|
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
|
+
}
|