Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions .github/workflows/stepfunctions.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ name: Stepfunctions
on:
push:
branches: [master]
# Run on every pull request, including ones stacked on feature branches.
pull_request:
branches: [master]

jobs:
build:
Expand All @@ -15,13 +15,17 @@ jobs:
node-version: [20.x, 24.x, 26.x]
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- run: npm install
- run: npm run lint
- run: npm test
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm test
- run: pnpm test:smoke
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ node_modules
.env
*.log
*.lock
*.tgz
.serverless
coverage
2 changes: 1 addition & 1 deletion .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -1 +1 @@
npx lint-staged
pnpm exec lint-staged
3 changes: 3 additions & 0 deletions .npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# The only peer dependency (serverless) is optional, so don't pull it (and its
# large tree) into the dev install — matches the lean npm behavior.
auto-install-peers=false
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
node_modules
coverage
package-lock.json
pnpm-lock.yaml
45 changes: 31 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,18 @@ AWS Step Functions implementation in Node, so you can run your Node.js lambda ha
## Installation

```
npm i -D stepfunctions
pnpm add -D stepfunctions
```

or if you're using yarn like me:
or with npm / yarn:

```
npm i -D stepfunctions
yarn add -D stepfunctions
```

> This repository itself is developed with [pnpm](https://pnpm.io) (`pnpm install`, `pnpm test`, `pnpm lint`).

## Motivation

I was working on getting step functions orchestrated using Serverless, Lambda, and Step functions and there was no way to run through the statemachine in Jest. So I made the spec, or parts of it, work in JS so that I can spy and mock the statemachine.
Expand All @@ -34,20 +37,29 @@ If you are:

## Usage

Include it in your test files, tested with Jest so far.
The package ships both ESM and CommonJS entry points with TypeScript types, so use whichever you prefer:

```js
// ESM
import Sfn from 'stepfunctions';
// or: import { StepFunction } from 'stepfunctions';

// CommonJS
const Sfn = require('stepfunctions');
```

Include it in your test files (tested with Jest so far). You can pass a bare Amazon States Language definition straight to the constructor, and `startExecution` resolves to the final output:

```js
const Sfn = require('stepfunctions');

const sm = new Sfn({
StateMachine: {
StartAt: 'Test',
States: {
Test: {
Type: 'Task',
Resource: 'arn:aws:lambda:ap-southeast-1:123456789012:function:test',
End: true,
},
StartAt: 'Test',
States: {
Test: {
Type: 'Task',
Resource: 'arn:aws:lambda:ap-southeast-1:123456789012:function:test',
End: true,
},
},
});
Expand All @@ -56,20 +68,25 @@ describe('StateMachine Test', () => {
it('Check if a task was run', async () => {
const mockfn = jest.fn((input) => input.test === 1);
sm.bindTaskResource('Test', mockfn);
await sm.startExecution({ test: 1 });
const result = await sm.startExecution({ test: 1 });
expect(mockfn).toHaveBeenCalled();
expect(result).toBe(true);
});
});
```

The `new Sfn({ StateMachine })` form is still supported for backwards compatibility, as is calling `getExecutionResult()` after `startExecution` (it is now idempotent — it no longer consumes the result).

You can see more examples in `/test/stepfunctions.test.js`.

## API

### startExecution(Input, Options);

Resolves to the final output of the execution.

```js
sm.startExecution(input, {
const output = await sm.startExecution(input, {
respectTime: false,
maxWaitTime: 30,
maxConcurrency: 10,
Expand Down Expand Up @@ -105,7 +122,7 @@ Must be called before `startExecution`, binds to `Tasks` and replaces their hand

### getExecutionResult()

Must be called after `startExecution`. This function returns the absolute result from the statemachine if it has finished.
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.)

### getReport()

Expand Down
6 changes: 6 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,10 @@ module.exports = [
'no-unused-vars': ['error', { args: 'none', varsIgnorePattern: '^_' }],
},
},
{
files: ['**/*.mjs'],
languageOptions: {
sourceType: 'module',
},
},
];
50 changes: 50 additions & 0 deletions index.d.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { EventEmitter } from 'events';

/** Options accepted by `startExecution`. */
export interface ExecutionOptions {
/** Respect the real durations of `Wait` states instead of capping them. */
respectTime?: boolean;
/** Maximum number of seconds a `Wait` state may block for. Defaults to 30. */
maxWaitTime?: number;
/** Maximum number of branches/iterations run concurrently. Defaults to 10. */
maxConcurrency?: number;
}

/** A bound Task handler, e.g. a Serverless/Lambda handler. */
export type TaskResource = (input: any) => any;

/** An Amazon States Language state machine definition. */
export type StateMachine = Record<string, any>;

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<string, TaskResource>;
}

/**
* An in-memory AWS Step Functions / Amazon States Language interpreter, made to
* run Node.js Lambda handlers inside a test environment.
*/
export declare class StepFunction extends EventEmitter {
constructor(sm: Options | StateMachine);

/**
* Start an execution, passing `input` to the first state. Resolves to the
* final output (the same value as `getExecutionResult()`).
*/
startExecution(input?: any, opts?: ExecutionOptions): Promise<any>;

/** 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;
}

export default StepFunction;
9 changes: 6 additions & 3 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,16 @@ declare namespace StepFunction {
* run Node.js Lambda handlers inside a test environment.
*/
declare class StepFunction extends EventEmitter {
constructor(sm: StepFunction.Options);
constructor(sm: StepFunction.Options | StepFunction.StateMachine);

/** Start an execution, passing `input` to the first state. */
/**
* Start an execution, passing `input` to the first state. Resolves to the
* final output (the same value as `getExecutionResult()`).
*/
startExecution(
input?: any,
opts?: StepFunction.ExecutionOptions,
): Promise<void>;
): Promise<any>;

/** Return the final output of the most recent execution. */
getExecutionResult(): any;
Expand Down
4 changes: 4 additions & 0 deletions index.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import StepFunction from './lib/stepfunctions.js';

export default StepFunction;
export { StepFunction };
20 changes: 14 additions & 6 deletions lib/stepfunctions.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,16 +43,19 @@ class StepFunction extends EventEmitter {
*/
constructor(sm) {
super();
const { isValid, errorsText } = aslValidator(sm.StateMachine);
// Accept either `{ StateMachine, Name?, Resources? }` or a bare Amazon
// States Language definition (`{ StartAt, States, ... }`) directly.
const opts = sm && sm.StateMachine ? sm : { StateMachine: sm };
const { isValid, errorsText } = aslValidator(opts.StateMachine);
if (!isValid) {
throw new Error(errorsText());
}

this.Name = sm.Name || sm.StateMachine.StartAt;
this.StateMachine = sm.StateMachine;
this.Resources = typeof sm.Resources === 'object' ? sm.Resources : {};
this.Name = opts.Name || opts.StateMachine.StartAt;
this.StateMachine = opts.StateMachine;
this.Resources = typeof opts.Resources === 'object' ? opts.Resources : {};
// Default query language for the whole machine; a state may override it.
this.queryLanguage = sm.StateMachine.QueryLanguage || 'JSONPath';
this.queryLanguage = opts.StateMachine.QueryLanguage || 'JSONPath';
// Each Map iteration / Parallel branch runs in its own variable scope; an
// AsyncLocalStorage keeps those scopes isolated even under concurrency.
this.variableStore = new AsyncLocalStorage();
Expand Down Expand Up @@ -116,6 +119,9 @@ class StepFunction extends EventEmitter {
throw err;
}
}
// Return the result directly so callers can `const out = await
// sm.startExecution(...)` without a separate getExecutionResult() call.
return this.getExecutionResult();
}

/**
Expand All @@ -127,7 +133,9 @@ class StepFunction extends EventEmitter {
* @returns {any}
*/
getExecutionResult() {
return this.steps.pop().output;
// Non-mutating: returns the final output and can be called repeatedly.
const last = this.steps[this.steps.length - 1];
return last ? last.output : undefined;
}

/**
Expand Down
Loading
Loading