From 05d5b315af31f776110736b219be55a24a5cef0c Mon Sep 17 00:00:00 2001 From: John Rocela Date: Mon, 22 Jun 2026 08:24:40 +0000 Subject: [PATCH 1/4] feat: bind by ARN, load real handlers, and fix Parallel/Map Catch Checklist items from the README "Future" list: - bindTaskResource can bind by the Resource ARN (not just the state name), and a bound resource may be a Serverless-style handler reference ('path/to/file.export' or { handler }) which is required and invoked, so you can run real handlers instead of inline resolvers (handlerBasePath option) - fix `ErrorState.All` -> `ErrorState.ALL` (the constant was misspelled, so Map/Parallel/Choice/Wait failures were tagged with `undefined`) - fix Parallel/Map Catch: `step()` returned the map/parallel promise without awaiting it, so its rejection bypassed the surrounding try/catch and the state's `Catch` never ran; also wire `Catch` for Map Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QznGWnSQuwvNRAcYinsZiG --- lib/stepfunctions.js | 67 ++++++++++++++++++++++++++++------ test/handlers/echo.js | 3 ++ test/stepfunctions.test.js | 54 +++++++++++++++++++++++++++ test/steps/parallel-catch.json | 38 +++++++++++++++++++ 4 files changed, 150 insertions(+), 12 deletions(-) create mode 100644 test/handlers/echo.js create mode 100644 test/steps/parallel-catch.json diff --git a/lib/stepfunctions.js b/lib/stepfunctions.js index d7b8c00..25c1138 100644 --- a/lib/stepfunctions.js +++ b/lib/stepfunctions.js @@ -1,6 +1,7 @@ const EventEmitter = require('events').EventEmitter; const { performance } = require('perf_hooks'); const { AsyncLocalStorage } = require('async_hooks'); +const path = require('path'); const crypto = require('crypto'); const jsonpath = require('jsonpath'); const jsonata = require('jsonata'); @@ -54,6 +55,8 @@ class StepFunction extends EventEmitter { this.Name = opts.Name || opts.StateMachine.StartAt; this.StateMachine = opts.StateMachine; this.Resources = typeof opts.Resources === 'object' ? opts.Resources : {}; + // Base directory for resolving Serverless-style handler references. + this.handlerBasePath = opts.handlerBasePath || process.cwd(); // Default query language for the whole machine; a state may override it. this.queryLanguage = opts.StateMachine.QueryLanguage || 'JSONPath'; // Each Map iteration / Parallel branch runs in its own variable scope; an @@ -869,9 +872,11 @@ class StepFunction extends EventEmitter { throw new ErrorState(ErrorState.Runtime, 'state not defined'); } if (state.Type === 'Map') { - return this.map(state, task, input, states); + // `return await` (not bare `return`) so a Map/Parallel rejection is seen + // by the catch below and its `Catch` handler can run. + return await this.map(state, task, input, states); } else if (state.Type === 'Parallel') { - return this.parallel(state, task, input, states); + return await this.parallel(state, task, input, states); } else if (state.Type === 'Task') { const output = await this.task(state, task, input, states); return this.outputPath(state, originalInput, output); @@ -898,7 +903,7 @@ class StepFunction extends EventEmitter { return this.outputPath(state, input, output); } } catch (err) { - if (['Task', 'Parallel'].includes(state.Type) && state.Catch) { + if (['Task', 'Parallel', 'Map'].includes(state.Type) && state.Catch) { err.output = await this.finally(state.Catch, input, states, err); err.caught = true; } @@ -1059,7 +1064,7 @@ class StepFunction extends EventEmitter { } this.transition('MapStateFailed', { task, error: err }); this.transition('MapStateExited', { task }); - throw new ErrorState(ErrorState.All, 'MapStateFailed', err); + throw new ErrorState(ErrorState.ALL, 'MapStateFailed', err); } } @@ -1236,7 +1241,7 @@ class StepFunction extends EventEmitter { this.transition('ParallelStateAborted', { task, input }); } this.transition('ParallelStateExited', { task, input, error: err }); - throw new ErrorState(ErrorState.All, 'ParallelStateFailed', err); + throw new ErrorState(ErrorState.ALL, 'ParallelStateFailed', err); } } @@ -1278,7 +1283,7 @@ class StepFunction extends EventEmitter { } catch (err) { this.transition('ChoiceStateFailed', { task, error: err }); this.transition('ChoiceStateExited', { task }); - throw new ErrorState(ErrorState.All, 'ChoiceStateFailed', err); + throw new ErrorState(ErrorState.ALL, 'ChoiceStateFailed', err); } } @@ -1784,7 +1789,7 @@ class StepFunction extends EventEmitter { this.transition('WaitStateAborted', { task, input }); } this.transition('WaitStateExited', { task, input, error: err }); - throw new ErrorState(ErrorState.All, 'WaitStateFailed', err); + throw new ErrorState(ErrorState.ALL, 'WaitStateFailed', err); } } @@ -1947,10 +1952,48 @@ class StepFunction extends EventEmitter { * @param {Object} states */ async resolveResource(task, input, states) { - // is it a Lambda ARN? - if (states[task].Resource) { - return this.executeLambda(task, input); + const state = states[task]; + if (state.Resource) { + // Bind by the state name or by the Resource ARN; the value may be a + // function or a Serverless-style handler reference to load. + const resource = this.Resources[task] ?? this.Resources[state.Resource]; + return this.executeLambda(task, input, this.loadHandler(resource)); + } + } + + /** + * loadHandler + * + * Resolves a bound resource to a callable. Accepts a function directly, or a + * Serverless-style handler reference (`'path/to/file.exportedFn'` or + * `{ handler: 'path/to/file.exportedFn' }`) which is required relative to the + * configured `handlerBasePath` (defaults to `process.cwd()`). + * + * @access private + * @param {Function|string|Object} resource + */ + loadHandler(resource) { + if (typeof resource === 'function') { + return resource; + } + const ref = + resource && typeof resource === 'object' ? resource.handler : resource; + if (typeof ref !== 'string') { + return undefined; + } + const separator = ref.lastIndexOf('.'); + const modulePath = ref.slice(0, separator); + const exportName = ref.slice(separator + 1); + const resolved = path.resolve(this.handlerBasePath, modulePath); + const mod = require(resolved); + const fn = mod[exportName] ?? mod.default ?? mod; + if (typeof fn !== 'function') { + throw new ErrorState( + ErrorState.Runtime, + `Handler ${ref} did not resolve to a function`, + ); } + return fn; } /** @@ -1961,13 +2004,13 @@ class StepFunction extends EventEmitter { * @access private * @param {string} task * @param {any} input + * @param {Function} [taskFn] the resolved handler for the task */ - async executeLambda(task, input) { + async executeLambda(task, input, taskFn = this.Resources[task]) { this.transition('LambdaFunctionScheduled', { task, input }); this.transition('LambdaFunctionStarted', { task, input }); let output; try { - const taskFn = this.Resources[task]; if (typeof taskFn === 'function') { output = await taskFn.call( { ...taskFn.prototype, abort: this.abort }, diff --git a/test/handlers/echo.js b/test/handlers/echo.js new file mode 100644 index 0000000..199a420 --- /dev/null +++ b/test/handlers/echo.js @@ -0,0 +1,3 @@ +// A Serverless-style Lambda handler used to test loading real handler modules +// (the "run real handlers instead of binding inline resolvers" use case). +exports.handler = (input) => ({ echoed: input }); diff --git a/test/stepfunctions.test.js b/test/stepfunctions.test.js index dee4be4..d7a917a 100644 --- a/test/stepfunctions.test.js +++ b/test/stepfunctions.test.js @@ -1227,4 +1227,58 @@ describe('Stepfunctions', () => { ); }); }); + + describe('Catch on Parallel', () => { + it('catches a failed branch with a States.ALL catcher', async () => { + const sm = new Sfn({ + StateMachine: require('./steps/parallel-catch.json'), + }); + const boomFn = jest.fn(() => { + throw new Error('branch blew up'); + }); + const recoveredFn = jest.fn((input) => input); + sm.bindTaskResource('Boom', boomFn); + sm.bindTaskResource('Recovered', recoveredFn); + await sm.startExecution({}); + expect(boomFn).toHaveBeenCalled(); + expect(recoveredFn).toHaveBeenCalled(); + expect(sm.getExecutionResult()).toEqual( + expect.objectContaining({ + error: expect.objectContaining({ + Error: 'States.ALL', + }), + }), + ); + }); + }); + + describe('resources', () => { + it('can bind a task resource by its Resource ARN', async () => { + const sm = new Sfn(require('./steps/simple.json')); + const arn = 'arn:aws:lambda:ap-southeast-1:123456789012:function:test'; + sm.bindTaskResource(arn, (input) => input.test === 1); + const result = await sm.startExecution({ test: 1 }); + expect(result).toBe(true); + }); + + it('can run a real handler module via a Serverless-style reference', async () => { + const sm = new Sfn({ + StateMachine: require('./steps/simple.json'), + handlerBasePath: __dirname, + }); + sm.bindTaskResource('Test', 'handlers/echo.handler'); + const result = await sm.startExecution({ test: 1 }); + expect(result).toEqual({ echoed: { test: 1 } }); + }); + + it('also accepts a { handler } object reference', async () => { + const sm = new Sfn({ + StateMachine: require('./steps/simple.json'), + handlerBasePath: __dirname, + }); + sm.bindTaskResource('Test', { handler: 'handlers/echo.handler' }); + const result = await sm.startExecution({ test: 1 }); + expect(result).toEqual({ echoed: { test: 1 } }); + }); + }); }); diff --git a/test/steps/parallel-catch.json b/test/steps/parallel-catch.json new file mode 100644 index 0000000..58d13d5 --- /dev/null +++ b/test/steps/parallel-catch.json @@ -0,0 +1,38 @@ +{ + "StartAt": "Parallel", + "States": { + "Parallel": { + "Type": "Parallel", + "Branches": [ + { + "StartAt": "Boom", + "States": { + "Boom": { + "Type": "Task", + "Resource": "arn:aws:lambda:ap-southeast-1:123456789012:function:test", + "End": true + } + } + } + ], + "Catch": [ + { + "ErrorEquals": ["States.ALL"], + "ResultPath": "$.error", + "Next": "Recovered" + } + ], + "Next": "Done" + }, + "Recovered": { + "Type": "Task", + "Resource": "arn:aws:lambda:ap-southeast-1:123456789012:function:test", + "End": true + }, + "Done": { + "Type": "Task", + "Resource": "arn:aws:lambda:ap-southeast-1:123456789012:function:test", + "End": true + } + } +} From b4f01f482e71066a9275983ae78e828eb5b08e70 Mon Sep 17 00:00:00 2001 From: John Rocela Date: Mon, 22 Jun 2026 08:30:21 +0000 Subject: [PATCH 2/4] feat: CLI, generator-style trace(), accurate timing, fake-timer Wait tests More README "Future" checklist items: - add a `stepfunctions` CLI (bin/cli.js): `stepfunctions run def.json --input '{...}' [--report]` - add `trace()`, an async iterator that yields each transition so a machine can be walked generator-style (`for await (const step of sm.trace(input))`), via a new generic `transition` event; returns the final output - fix the elapsed-time calculation (was multiplying milliseconds by 100) so the reported timing is real wall-clock ms - rewrite the Wait tests with jest fake timers so they're instant and deterministic (no more real sleeping) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QznGWnSQuwvNRAcYinsZiG --- bin/cli.js | 69 ++++++++++++++++++++++++ lib/stepfunctions.js | 65 ++++++++++++++++++++++- package.json | 6 ++- test/stepfunctions.test.js | 105 ++++++++++++++++++++++++++++++++----- 4 files changed, 230 insertions(+), 15 deletions(-) create mode 100644 bin/cli.js diff --git a/bin/cli.js b/bin/cli.js new file mode 100644 index 0000000..18dcd92 --- /dev/null +++ b/bin/cli.js @@ -0,0 +1,69 @@ +#!/usr/bin/env node +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const Sfn = require('../index.js'); + +const USAGE = `Usage: stepfunctions run [options] + +Options: + --input JSON input passed to the first state + --input-file Read the JSON input from a file + --base-path Base dir for Serverless-style handler refs (default: cwd) + --report Print the transition report (console.table) + -h, --help Show this help +`; + +async function main(argv) { + const args = argv.slice(2); + if (args.length === 0 || args[0] === '-h' || args[0] === '--help') { + process.stdout.write(USAGE); + return undefined; + } + if (args[0] !== 'run' || !args[1]) { + process.stderr.write(USAGE); + process.exitCode = 1; + return undefined; + } + + const file = args[1]; + let inputJson; + let report = false; + let basePath = process.cwd(); + for (let i = 2; i < args.length; i++) { + const flag = args[i]; + if (flag === '--input') { + inputJson = args[++i]; + } else if (flag === '--input-file') { + inputJson = fs.readFileSync(args[++i], 'utf8'); + } else if (flag === '--base-path') { + basePath = args[++i]; + } else if (flag === '--report') { + report = true; + } else { + process.stderr.write(`Unknown option: ${flag}\n`); + process.exitCode = 1; + return undefined; + } + } + + const definition = JSON.parse(fs.readFileSync(path.resolve(file), 'utf8')); + const input = inputJson === undefined ? undefined : JSON.parse(inputJson); + const sm = new Sfn({ StateMachine: definition, handlerBasePath: basePath }); + const result = await sm.startExecution(input); + if (report) { + sm.getReport(); + } + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + return result; +} + +if (require.main === module) { + main(process.argv).catch((err) => { + process.stderr.write(`${err && err.message ? err.message : err}\n`); + process.exitCode = 1; + }); +} + +module.exports = { main }; diff --git a/lib/stepfunctions.js b/lib/stepfunctions.js index 25c1138..35bec80 100644 --- a/lib/stepfunctions.js +++ b/lib/stepfunctions.js @@ -141,6 +141,65 @@ class StepFunction extends EventEmitter { return last ? last.output : undefined; } + /** + * trace + * + * Runs an execution and yields each transition as it happens, so the state + * machine can be walked generator-style: + * + * for await (const step of sm.trace(input)) { + * // inspect each transition (step.state, step.step, step.output, ...) + * } + * + * The iterator's return value is the final execution output. + * + * @access public + * @param {any} input + * @param {object} opts execution options (same as startExecution) + */ + async *trace(input, opts = {}) { + const queue = []; + let signal = null; + const wake = () => { + if (signal) { + const resolve = signal; + signal = null; + resolve(); + } + }; + const onStep = (step) => { + queue.push(step); + wake(); + }; + this.on('transition', onStep); + + let finished = false; + const finish = () => { + finished = true; + wake(); + }; + const execution = this.startExecution(input, opts); + execution.then(finish, finish); + + try { + while (true) { + while (queue.length) { + yield queue.shift(); + } + if (finished) { + break; + } + await new Promise((resolve) => { + signal = resolve; + }); + } + // Surface the result (or rethrow an uncaught failure). + return await execution; + } finally { + this.off('transition', onStep); + } + } + /** * getReport * @@ -1819,7 +1878,8 @@ class StepFunction extends EventEmitter { if (reset) { this._elapsedRef = performance.now(); } - const diff = Math.round((performance.now() - this._elapsedRef) * 100); + // Elapsed wall-clock milliseconds since the reference, to 2 decimals. + const diff = Math.round((performance.now() - this._elapsedRef) * 100) / 100; this.stopwatch.push(diff); return diff; } @@ -1885,6 +1945,9 @@ class StepFunction extends EventEmitter { }; this.steps.push(this._current); this.emit(label, this._current); + // A generic event for every transition, so consumers (and `trace()`) can + // subscribe to the whole stream without listening for each label. + this.emit('transition', this._current); } /** diff --git a/package.json b/package.json index 12ff13d..3dec062 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,9 @@ "main": "index.js", "module": "index.mjs", "types": "index.d.ts", + "bin": { + "stepfunctions": "bin/cli.js" + }, "exports": { ".": { "import": { @@ -29,6 +32,7 @@ }, "files": [ "lib", + "bin", "LICENSE", "index.js", "index.mjs", @@ -39,7 +43,7 @@ "scripts": { "test": "jest", "test:smoke": "node test/smoke.mjs", - "lint": "eslint lib test index.js index.mjs && prettier --check .", + "lint": "eslint lib test bin index.js index.mjs && prettier --check .", "format": "prettier --write .", "prepare": "husky || true" }, diff --git a/test/stepfunctions.test.js b/test/stepfunctions.test.js index d7a917a..b0da69f 100644 --- a/test/stepfunctions.test.js +++ b/test/stepfunctions.test.js @@ -313,29 +313,46 @@ describe('Stepfunctions', () => { }); }); - // TODO: maybe use jest.fakeTimers describe('Wait', () => { + // Fake timers keep these instant and deterministic instead of really + // sleeping. A fixed clock (after the past Timestamp in wait.json) lets the + // Timestamp branch resolve once we advance past the target time. + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2020-06-01T00:00:00Z')); + }); + afterEach(() => { + jest.useRealTimers(); + }); + it('can wait for 1 second', async () => { const sm = new Sfn({ StateMachine: require('./steps/wait.json') }); const mockFn = jest.fn(); sm.bindTaskResource('Final', mockFn); - await sm.startExecution({ value: 'Wait' }); + const execution = sm.startExecution({ value: 'Wait' }); + await jest.advanceTimersByTimeAsync(1000); + await execution; expect(mockFn).toHaveBeenCalled(); - }, 1500); + }); it('can wait for 1 second using SecondsPath', async () => { const sm = new Sfn({ StateMachine: require('./steps/wait.json') }); const mockFn = jest.fn(); sm.bindTaskResource('Final', mockFn); - await sm.startExecution({ value: 'WaitPath', until: 1 }); + const execution = sm.startExecution({ value: 'WaitPath', until: 1 }); + await jest.advanceTimersByTimeAsync(1000); + await execution; expect(mockFn).toHaveBeenCalled(); - }, 1500); + }); it('can wait until a specified time', async () => { const sm = new Sfn({ StateMachine: require('./steps/wait.json') }); const mockFn = jest.fn(); sm.bindTaskResource('Final', mockFn); - await sm.startExecution({ value: 'WaitUntil' }); + const execution = sm.startExecution({ value: 'WaitUntil' }); + // wait.json's Timestamp is in the past, so the first interval tick fires + await jest.advanceTimersByTimeAsync(500); + await execution; expect(mockFn).toHaveBeenCalled(); }); @@ -343,14 +360,12 @@ describe('Stepfunctions', () => { const sm = new Sfn({ StateMachine: require('./steps/wait.json') }); const mockFn = jest.fn(); sm.bindTaskResource('Final', mockFn); - // 1 second in the future - const date = new Date(); - await sm.startExecution({ - value: 'WaitUntilPath', - until: date.setSeconds(date.getSeconds() + 1), - }); + const until = new Date('2020-06-01T00:00:01Z').getTime(); // 1s ahead + const execution = sm.startExecution({ value: 'WaitUntilPath', until }); + await jest.advanceTimersByTimeAsync(1000); + await execution; expect(mockFn).toHaveBeenCalled(); - }, 2000); + }); it.skip('can abort a running statemachine', () => {}); @@ -1281,4 +1296,68 @@ describe('Stepfunctions', () => { expect(result).toEqual({ echoed: { test: 1 } }); }); }); + + describe('trace (generator-style stepping)', () => { + it('yields each transition and returns the final result', async () => { + const sm = new Sfn({ StateMachine: require('./steps/tasks.json') }); + sm.bindTaskResource('Test', (i) => ({ test: i.test + 1 })); + sm.bindTaskResource('Test1', (i) => ({ test: i.test + 2 })); + sm.bindTaskResource('Test2', (i) => i.test + 3); + + const iterator = sm.trace({ test: 1 }); + const labels = []; + let next = await iterator.next(); + while (!next.done) { + labels.push(next.value.state); + next = await iterator.next(); + } + + expect(labels[0]).toBe('ExecutionStarted'); + expect(labels).toEqual( + expect.arrayContaining(['TaskStateEntered', 'TaskStateExited']), + ); + expect(labels[labels.length - 1]).toBe('ExecutionSucceeded'); + // the generator's return value is the final execution output + expect(next.value).toBe(7); + }); + + it('can be consumed with for-await', async () => { + const sm = new Sfn({ StateMachine: require('./steps/simple.json') }); + sm.bindTaskResource('Test', (input) => input.test === 1); + const states = []; + for await (const step of sm.trace({ test: 1 })) { + states.push(step.state); + } + expect(states).toContain('LambdaFunctionSucceeded'); + expect(sm.getExecutionResult()).toBe(true); + }); + }); + + describe('CLI', () => { + const { execFileSync } = require('child_process'); + const root = require('path').resolve(__dirname, '..'); + + it('runs a state machine definition and prints the result', () => { + const out = execFileSync( + 'node', + [ + 'bin/cli.js', + 'run', + 'test/steps/simple.json', + '--input', + '{"test":1}', + ], + { encoding: 'utf8', cwd: root }, + ); + expect(JSON.parse(out)).toEqual({ test: 1 }); + }); + + it('prints usage with --help', () => { + const out = execFileSync('node', ['bin/cli.js', '--help'], { + encoding: 'utf8', + cwd: root, + }); + expect(out).toMatch(/Usage: stepfunctions run/); + }); + }); }); From 070428723e93b0c970bdeb253f9de43e67a90b94 Mon Sep 17 00:00:00 2001 From: John Rocela Date: Mon, 22 Jun 2026 08:33:22 +0000 Subject: [PATCH 3/4] docs: complete the Future checklist; document new APIs and types - README: drop the experimental label on Retry/Catch, document bind-by-ARN / handler refs, trace(), and the CLI; mark every checklist item done - index.d.ts / index.d.mts: add trace(), the ResourceReference handler-ref union, the Transition type, and the handlerBasePath option (verified with tsc under node16 resolution for both CJS and ESM) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QznGWnSQuwvNRAcYinsZiG --- README.md | 50 ++++++++++++++++++++++++++++++++++---------------- index.d.mts | 35 +++++++++++++++++++++++++++++++---- index.d.ts | 35 +++++++++++++++++++++++++++++++---- 3 files changed, 96 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index ec731fd..8f68001 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,31 @@ await sm.startExecution('world'); Must be called before `startExecution`, binds to `Tasks` and replaces their handler to the provided `Callback` parameter. +The first argument may be the **state name** or the task's **Resource ARN**. The second may be a function, or a Serverless-style handler reference (`'path/to/file.exportedFn'` or `{ handler: 'path/to/file.exportedFn' }`) which is loaded and invoked — pass `handlerBasePath` to the constructor to control where those paths resolve from: + +```js +const sm = new Sfn({ StateMachine, handlerBasePath: __dirname }); +sm.bindTaskResource('HelloWorld', 'handlers/hello.handler'); // runs the real handler +``` + +### trace(Input, Options) + +Run an execution and walk it generator-style, yielding each transition. The iterator's return value is the final output. + +```js +for await (const step of sm.trace('world')) { + console.log(step.state, step.step); // e.g. 'TaskStateEntered' 'HelloWorld' +} +``` + +### CLI + +``` +stepfunctions run definition.json --input '{"hello":"world"}' [--report] +``` + +Runs a state machine definition file and prints the result as JSON. Tasks without a bound/real handler pass their input through. Use `--base-path` to resolve Serverless-style handler references. + ### getExecutionResult() Must be called after `startExecution`. This function returns the absolute result from the statemachine if it has finished. It is idempotent — repeated calls return the same value. (`startExecution` also resolves to this value, so you often don't need it.) @@ -154,12 +179,7 @@ Set `QueryLanguage: "JSONata"` on the state machine or an individual state. `{% `Assign` is supported in both JSONPath and JSONata modes. Assigned variables are referenced as `$name` in later states and follow AWS scoping: a `Map`/`Parallel` child can read outer variables but its own assignments do not leak back to the parent scope. -**Experimental** - -- Retry -- Catch - -The above features are labeled experimental because it cannot be fully spec compliant(yet) due to AWS specific cases. +`Retry` and `Catch` are supported on `Task`, `Map` and `Parallel` (error matching, `States.ALL`, `IntervalSeconds`/`BackoffRate`/`MaxAttempts`, and `ResultPath` for the caught error). They remain best-effort for some AWS-specific error names. **Not yet supported** @@ -172,18 +192,16 @@ The above features are labeled experimental because it cannot be fully spec comp will be used within a testing context. You can override this behaviour by adding the `respectTime` option to true in the `startExecution` method. 2. No support for Handling `States.Permissions` as the library will not have context on AWS related permissions. -## Future - -PR's are welcome to help finish the ones below :) +## Done -- [ ] Change arn in bindTaskResource instead of the State name -- [ ] Run `sls invoke local` instead of binding resolvers +- [x] Bind in `bindTaskResource` by the Resource ARN as well as the state name +- [x] Run real handler modules (Serverless-style `file.export` refs) instead of binding resolvers - [x] Typescript typings -- [ ] Run via CLI -- [ ] Remove the "experimental" label on retry and catch -- [ ] More accurate timing mechanism -- [ ] use `jest.fakeTimers()` in the test -- [ ] Walk through states ala "generator" style. e.g, `yield sm.next()` +- [x] Run via CLI +- [x] Remove the "experimental" label on retry and catch +- [x] More accurate timing mechanism +- [x] use `jest.fakeTimers()` in the test +- [x] Walk through states ala "generator" style (`for await (const step of sm.trace(input))`) - [x] Support the JSONata query language and Variables (`Assign`) ## License diff --git a/index.d.mts b/index.d.mts index 8ac64c5..eb98d7d 100644 --- a/index.d.mts +++ b/index.d.mts @@ -13,6 +13,22 @@ export interface ExecutionOptions { /** A bound Task handler, e.g. a Serverless/Lambda handler. */ export type TaskResource = (input: any) => any; +/** + * A bound resource: a handler function or a Serverless-style reference + * (`'path/to/file.exportedFn'` or `{ handler: 'path/to/file.exportedFn' }`). + */ +export type ResourceReference = TaskResource | string | { handler: string }; + +/** A single recorded transition yielded by `trace()`. */ +export interface Transition { + state: string; + step: any; + input: any; + output: any; + elapsed: number; + [key: string]: any; +} + /** An Amazon States Language state machine definition. */ export type StateMachine = Record; @@ -20,8 +36,10 @@ export interface Options { /** Friendly name for the execution. Defaults to the StateMachine `StartAt`. */ Name?: string; StateMachine: StateMachine; - /** Map of Task state name to its handler. */ - Resources?: Record; + /** Map of Task state name (or Resource ARN) to its handler. */ + Resources?: Record; + /** Base dir for resolving Serverless-style handler refs. Defaults to cwd. */ + handlerBasePath?: string; } /** @@ -37,14 +55,23 @@ export declare class StepFunction extends EventEmitter { */ startExecution(input?: any, opts?: ExecutionOptions): Promise; + /** + * Run an execution and walk it generator-style, yielding each transition. + * The iterator's return value is the final output. + */ + trace( + input?: any, + opts?: ExecutionOptions, + ): AsyncGenerator; + /** Return the final output of the most recent execution. */ getExecutionResult(): any; /** Pretty-print the recorded transitions with `console.table`. */ getReport(): void; - /** Replace a Task's resource with the provided handler. */ - bindTaskResource(task: string, fn: TaskResource): void; + /** Bind a Task's resource (by state name or Resource ARN) to a handler. */ + bindTaskResource(task: string, fn: ResourceReference): void; } export default StepFunction; diff --git a/index.d.ts b/index.d.ts index 896b68d..7e17013 100644 --- a/index.d.ts +++ b/index.d.ts @@ -14,6 +14,22 @@ declare namespace StepFunction { /** A bound Task handler, e.g. a Serverless/Lambda handler. */ type TaskResource = (input: any) => any; + /** + * A bound resource: a handler function or a Serverless-style reference + * (`'path/to/file.exportedFn'` or `{ handler: 'path/to/file.exportedFn' }`). + */ + type ResourceReference = TaskResource | string | { handler: string }; + + /** A single recorded transition yielded by `trace()`. */ + interface Transition { + state: string; + step: any; + input: any; + output: any; + elapsed: number; + [key: string]: any; + } + /** An Amazon States Language state machine definition. */ type StateMachine = Record; @@ -21,8 +37,10 @@ declare namespace StepFunction { /** Friendly name for the execution. Defaults to the StateMachine `StartAt`. */ Name?: string; StateMachine: StateMachine; - /** Map of Task state name to its handler. */ - Resources?: Record; + /** Map of Task state name (or Resource ARN) to its handler. */ + Resources?: Record; + /** Base dir for resolving Serverless-style handler refs. Defaults to cwd. */ + handlerBasePath?: string; } } @@ -42,14 +60,23 @@ declare class StepFunction extends EventEmitter { opts?: StepFunction.ExecutionOptions, ): Promise; + /** + * Run an execution and walk it generator-style, yielding each transition. + * The iterator's return value is the final output. + */ + trace( + input?: any, + opts?: StepFunction.ExecutionOptions, + ): AsyncGenerator; + /** Return the final output of the most recent execution. */ getExecutionResult(): any; /** Pretty-print the recorded transitions with `console.table`. */ getReport(): void; - /** Replace a Task's resource with the provided handler. */ - bindTaskResource(task: string, fn: StepFunction.TaskResource): void; + /** Bind a Task's resource (by state name or Resource ARN) to a handler. */ + bindTaskResource(task: string, fn: StepFunction.ResourceReference): void; } export = StepFunction; From 26e5643479f4d24a5386d6a30360807f40d2d1f5 Mon Sep 17 00:00:00 2001 From: John Rocela Date: Mon, 22 Jun 2026 08:39:10 +0000 Subject: [PATCH 4/4] test: isolate fake-timer Wait tests into their own file (Node 26 fix) On Node 26, jest's useRealTimers() did not cleanly restore the global setTimeout, so the Wait describe's fake timers leaked and a later test hit "setTimeout is not defined". Move the Wait tests to test/wait.test.js, which jest runs in its own sandboxed environment, and drive them entirely with fake timers (no useRealTimers) so nothing leaks into the rest of the suite. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QznGWnSQuwvNRAcYinsZiG --- test/stepfunctions.test.js | 60 ++------------------------------------ test/wait.test.js | 60 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 58 deletions(-) create mode 100644 test/wait.test.js diff --git a/test/stepfunctions.test.js b/test/stepfunctions.test.js index b0da69f..e1e4f9f 100644 --- a/test/stepfunctions.test.js +++ b/test/stepfunctions.test.js @@ -313,64 +313,8 @@ describe('Stepfunctions', () => { }); }); - describe('Wait', () => { - // Fake timers keep these instant and deterministic instead of really - // sleeping. A fixed clock (after the past Timestamp in wait.json) lets the - // Timestamp branch resolve once we advance past the target time. - beforeEach(() => { - jest.useFakeTimers(); - jest.setSystemTime(new Date('2020-06-01T00:00:00Z')); - }); - afterEach(() => { - jest.useRealTimers(); - }); - - it('can wait for 1 second', async () => { - const sm = new Sfn({ StateMachine: require('./steps/wait.json') }); - const mockFn = jest.fn(); - sm.bindTaskResource('Final', mockFn); - const execution = sm.startExecution({ value: 'Wait' }); - await jest.advanceTimersByTimeAsync(1000); - await execution; - expect(mockFn).toHaveBeenCalled(); - }); - - it('can wait for 1 second using SecondsPath', async () => { - const sm = new Sfn({ StateMachine: require('./steps/wait.json') }); - const mockFn = jest.fn(); - sm.bindTaskResource('Final', mockFn); - const execution = sm.startExecution({ value: 'WaitPath', until: 1 }); - await jest.advanceTimersByTimeAsync(1000); - await execution; - expect(mockFn).toHaveBeenCalled(); - }); - - it('can wait until a specified time', async () => { - const sm = new Sfn({ StateMachine: require('./steps/wait.json') }); - const mockFn = jest.fn(); - sm.bindTaskResource('Final', mockFn); - const execution = sm.startExecution({ value: 'WaitUntil' }); - // wait.json's Timestamp is in the past, so the first interval tick fires - await jest.advanceTimersByTimeAsync(500); - await execution; - expect(mockFn).toHaveBeenCalled(); - }); - - it('can wait until a specified time using TimestampPath', async () => { - const sm = new Sfn({ StateMachine: require('./steps/wait.json') }); - const mockFn = jest.fn(); - sm.bindTaskResource('Final', mockFn); - const until = new Date('2020-06-01T00:00:01Z').getTime(); // 1s ahead - const execution = sm.startExecution({ value: 'WaitUntilPath', until }); - await jest.advanceTimersByTimeAsync(1000); - await execution; - expect(mockFn).toHaveBeenCalled(); - }); - - it.skip('can abort a running statemachine', () => {}); - - it.skip('can expect a timeout when a wait step is running for a long time', () => {}); - }); + // The Wait tests live in test/wait.test.js (they use jest fake timers, which + // are isolated per test file so the timer globals don't leak into this file). describe('Choice', () => { it('can test against boolean', async () => { diff --git a/test/wait.test.js b/test/wait.test.js new file mode 100644 index 0000000..6b6f015 --- /dev/null +++ b/test/wait.test.js @@ -0,0 +1,60 @@ +const Sfn = require('../lib/stepfunctions'); + +// The Wait tests use jest fake timers. They live in their own file so the +// timer globals are sandboxed by jest's per-file environment and cannot leak +// into the rest of the suite (some Node releases don't cleanly restore the +// global timers via `useRealTimers()`). A fresh `useFakeTimers()` per test +// resets the clock; a fixed system time (after the past Timestamp in wait.json) +// lets the Timestamp branch resolve once we advance past the target time. +describe('Wait', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2020-06-01T00:00:00Z')); + }); + + it('can wait for 1 second', async () => { + const sm = new Sfn({ StateMachine: require('./steps/wait.json') }); + const mockFn = jest.fn(); + sm.bindTaskResource('Final', mockFn); + const execution = sm.startExecution({ value: 'Wait' }); + await jest.advanceTimersByTimeAsync(1000); + await execution; + expect(mockFn).toHaveBeenCalled(); + }); + + it('can wait for 1 second using SecondsPath', async () => { + const sm = new Sfn({ StateMachine: require('./steps/wait.json') }); + const mockFn = jest.fn(); + sm.bindTaskResource('Final', mockFn); + const execution = sm.startExecution({ value: 'WaitPath', until: 1 }); + await jest.advanceTimersByTimeAsync(1000); + await execution; + expect(mockFn).toHaveBeenCalled(); + }); + + it('can wait until a specified time', async () => { + const sm = new Sfn({ StateMachine: require('./steps/wait.json') }); + const mockFn = jest.fn(); + sm.bindTaskResource('Final', mockFn); + const execution = sm.startExecution({ value: 'WaitUntil' }); + // wait.json's Timestamp is in the past, so the first interval tick fires + await jest.advanceTimersByTimeAsync(500); + await execution; + expect(mockFn).toHaveBeenCalled(); + }); + + it('can wait until a specified time using TimestampPath', async () => { + const sm = new Sfn({ StateMachine: require('./steps/wait.json') }); + const mockFn = jest.fn(); + sm.bindTaskResource('Final', mockFn); + const until = new Date('2020-06-01T00:00:01Z').getTime(); // 1s ahead + const execution = sm.startExecution({ value: 'WaitUntilPath', until }); + await jest.advanceTimersByTimeAsync(1000); + await execution; + expect(mockFn).toHaveBeenCalled(); + }); + + it.skip('can abort a running statemachine', () => {}); + + it.skip('can expect a timeout when a wait step is running for a long time', () => {}); +});