* chore(javascript): update Jint to 4.15.3 and stop blocking on promises `Engine.Evaluate(...).UnwrapIfPromise()` blocks the calling thread while the engine's event loop drains, which is exactly the wrong thing to do inside an `async` method — an expression that awaits a .NET `Task`, such as one calling `getSecret()`, held a thread pool thread for the duration of the I/O. `Engine.EvaluateAsync` awaits the returned promise instead, and takes the cancellation token while it is at it. The Jint version is moved from 4.4.2 to 4.15.3. `EvaluateAsync` arrived in 4.14.0, but the pin lands past 4.15.2 deliberately: once expressions genuinely suspend and resume instead of draining the event loop on the calling thread, they exercise the async suspension machinery 4.15.2 corrected — an `await` on a right-hand side no longer stores the suspension sentinel, async generators and `for await...of` preserve loop iteration state across a suspension, and a suspension node is unwrapped correctly. Shipping the non-blocking change on an earlier 4.14/4.15 would enable exactly the code paths those releases fixed. One default changed along the way: since 4.14 `Interop.ArrayConversion` defaults to `LiveView`, so a CLR array reaches script as a live view over the original array rather than as a copy. That is observable — a script that sorts an array would now reorder the workflow's own array, and the value round-trips back as its original element type rather than as `object[]`. The evaluator therefore pins the previous `Copy` behaviour so the upgrade is not a behavioural change; hosts that prefer the live view can opt in through `JintOptions.ConfigureEngineOptions`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0179sA2T7HuRfRfSc2JirFik * test(javascript): pin the array copy lane and adopt JsString.Create The array audit. The parent commit fixes `ArrayConversion` to `Copy`, because 4.14 changed the default to `LiveView` and the two differ in behaviour a script can observe. The existing tests assert what a copy *produces*, which a live view also satisfies for a value nothing mutates; asking the engine how many conversions of each kind it performed (4.15.1's interop conversion counters) pins the lane itself. The second assertion is the more interesting one: an ordinary evaluation converts no CLR array at all, because Elsa converts collection-valued variables itself in `ObjectConverterHelper` long before Jint's array lane could see them. That makes the `ArrayConversion` setting a narrow compatibility pin rather than something every evaluation depends on. `JsString.Create` (public since Jint 4.15.3) is adopted in `JsonElementConverter`, where the string case was the only one still routed through `JsValue.FromObject` — re-entering the whole conversion pipeline, the registered object converters and this one included, to arrive at the same call the number and boolean cases beside it already make directly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016uV6H9cTntzsoKiaJRBn4f * perf(javascript): register .NET type globals lazily A fresh Jint engine is built for every expression evaluation, and roughly twenty .NET types are registered on it before the expression runs: the common types (`DateTime`, `Guid`, `TimeSpan`, …) plus every non-primitive workflow variable descriptor type. Each registration builds a `TypeReference`, which describes the type through reflection. The overwhelming majority of expressions reference none of them. `Options.AddLazyGlobal` installs a global whose value is produced by a factory the first time a script reads the name. Both type registration handlers now run on `CreatingJavaScriptEngine` — which already carries the `Jint.Options` being built — and register through it, so a type is only described if an expression actually mentions it. A script that uses `Guid.NewGuid()` still sees `Guid`; a script that uses none of them pays for none of them. Types whose name cannot be written as a JavaScript identifier are skipped while we are here, since no script can reach them. That covers constructed generic types (``IDictionary`2``, which two different variable descriptors both claimed) and array types (`Byte[]`). Moving the registrations to engine construction has a consequence worth pinning beyond the ordering: a global installed by the host through `configureEngine` is no longer overwritten by the built-in registration of the same name. The lazy global for `Guid` is already installed when that callback runs, and `Engine.SetValue` goes through `[[Set]]` on the global object, which reads the current value — running the factory once and discarding the result — before replacing the descriptor. The end state is the host value, but only the test says so. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016uV6H9cTntzsoKiaJRBn4f * perf(javascript): register the common functions lazily The same argument as the type globals, applied to the other bulk registration a fresh engine pays for. `ConfigureEngineWithCommonFunctions` installs twenty-seven functions on every engine — `getVariable`, `toJson`, `newGuid`, the base64 helpers, the deprecated GUID pair — and each `engine.SetValue(name, Delegate)` builds an interop function wrapper around the delegate: a JavaScript function object, plus the signature metadata and invoker lookups Jint resolves per delegate type. An expression such as `variables.Foo` or a comparison calls none of them. This was recorded in the PR as deferred, because a lazy version had to keep the `NonEnumerable` flag `SetValue(string, Delegate)` applies — otherwise the functions would start appearing in `Object.keys(globalThis)`, which is observable. Jint 4.15.3's `Engine.Advanced.AddLazyGlobal` takes a `PropertyFlag` and is the post-construction counterpart of the options-time API used for the type globals, so the flag is passed explicitly and the port is a line per function. The CLR delegate is now created inside the factory as well, so a function nothing reads costs one closure rather than a closure, a delegate and a wrapper. The laziness is invisible, and the tests say so rather than leaving it implied. `AddLazyGlobal` installs the property itself eagerly and defers only its value, so `in`, `hasOwnProperty` and `Object.getOwnPropertyNames` answer immediately without materialising anything; `Object.keys(globalThis)` still omits them; the descriptor still reports writable, non-enumerable and configurable; two reads of a function are the same value, which is what says the factory ran once and the result was stored rather than recomputed per read; and a script can still overwrite one. Not separately measured. The measured table in the PR predates this commit and was not re-run for it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016uV6H9cTntzsoKiaJRBn4f * refactor(javascript): let Jint expose enums as names and type its converters `EnumToStringConverter` turned every CLR enum crossing into JavaScript into `Enum.ToString()`. Jint 4.15 does that with `Interop.EnumConversion`, so the converter goes away. It is not only fewer moving parts. The converter could only see values that crossed the interop boundary, and a constant read off a registered enum type does not: `LogPersistenceMode.Include` came back as the underlying number while the same value held in a workflow variable came back as `"Include"`, so `mode === LogPersistenceMode.Include` was always false. The built-in switch covers both directions and they now agree. Values written back to the CLR keep accepting the member name and the number, as they did before. That fix changes what an expression using a constant *numerically* produces, and the hazard worth calling out is persisted state: a workflow variable holding a number written from a constant before the upgrade no longer compares equal to that constant after it, which reaches in-flight and resumed instances rather than only new ones. Typed conversions hold in both directions, so activity inputs and typed variable reads are unaffected. Recorded in the 3.8.0 changelog. The two remaining converters register through the overload that declares the CLR types they handle. A converter that does not declare them has to be offered every value crossing the boundary, which costs the engine its compiled member-read and method-invoker lanes for every wrapped .NET object; declaring `byte[]` and `JsonElement` keeps those lanes for everything that cannot produce one. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016uV6H9cTntzsoKiaJRBn4f * perf(javascript): register only the accessors an expression names Every evaluation registers a getter and a setter for each variable in scope, a getter for each workflow input, and a getter for each (activity, output) pair in the enclosing container. The last of those is the expensive one: naming those accessors walks every node of the workflow and resolves each against the activity registry, so the cost of setting up an engine grows with the size of the workflow rather than the size of the expression. Almost no expression uses any of them. Jint reports the free identifiers of a prepared program through `Prepared<T>.ReferencedGlobals`, which is exactly the question being asked here: an identifier the expression never mentions cannot be read from it. The evaluator now prepares the script before configuring the engine — the parse was already cached, so this only reorders work — and passes the set on `EvaluatingJavaScript`. The accessor handler registers a name only if it is in the set, and skips the workflow walk entirely when no identifier of the `get{Output}From{Activity}` shape appears. The filter is sound for an expression that names an accessor the way one is meant to be named, and not for one that builds or reaches a name at run time. Four such forms are detectable and each turns the filter off. A direct `eval` call is reported as `HasDirectEvalCall`. An *indirect* `eval` call and the `Function` constructor are deliberately not flagged as direct calls — Jint reports the identifiers `eval` and `Function` in the set instead, and says so, because that is the signal a host is meant to act on. Missing that signal is not theoretical: `new Function('return getMyVariable()')()` would regress from working to a `ReferenceError`, since Function-constructed code resolves only against the global scope, and `var e = eval; e('getMyVariable()')` would fail the same way. The fourth is a reference to `globalThis`, which reaches a global without naming it. All four are pinned. What stays undetectable is reaching the global object without naming it at all — a sloppy-mode top-level `this`, or `[].constructor.constructor(…)`. An expression written that way loses the generated accessor but not the data: `getVariable(name)`, `getInput(name)` and `getOutputFrom(activityId, outputName)` are always registered and reach the same values. Note this does not replace the regular expression in `ConfigureEngineWithVariables`, which extracts the member names in `variables.Foo`. Those are not free identifiers and Jint deliberately does not report them; the set only says whether `variables` itself is referenced. The filtering is invisible from inside a script, which also makes it unprovable from there, so two of the tests hold on to the engine and assert on the globals directly. While here, the cancellation token is registered as an engine constraint. Passing it to `EvaluateAsync` only covers the awaiting part; Jint's own remarks say the parameter cannot preempt the synchronous evaluation loop and point at this constraint, so the token read as more coverage than it delivered. A cancellation constraint is amortizable, so the interpreter keeps its tight-loop fast path, and a default token registers nothing. #7891 supersedes this with the full constraint set. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016uV6H9cTntzsoKiaJRBn4f * perf(javascript): build marshalled variables in Jint's shaped representation `ConvertToJsObject` built the JavaScript object a workflow variable is copied into one `DefineOwnProperty` call at a time, with an explicit descriptor. That lands the object in Jint's per-object property dictionary, so every marshalled variable carries its own descriptors even though sibling variables and the repeated nested payloads of one document present the same key set. `JsObject.CreateFromEntries` defines the same writable, enumerable and configurable properties but builds through the shared-layout path. Both wins land inside a single evaluation — half the descriptor allocations, since the explicit descriptor caused a second one inside `ValidateAndApplyPropertyDescriptor`, and one shared layout across objects presenting the same keys. Nothing carries across evaluations: layouts are interned per engine and per-node inline caches live in per-engine handler trees that only engage on a second evaluation on the same engine, which a fresh-engine-per-evaluation host never reaches. Reaching the shared layout is silent: `CreateFromEntries` falls back to the ordinary property dictionary whenever a key or a growth guard says the layout cannot continue, and the object behaves identically either way. A test now asks the engine whether the object actually got one. `Engine.Advanced.HasSharedShape`, added in 4.15.3, is the part of that answer Jint documents as a contract — the finer-grained `GetObjectRepresentation` names an internal representation that may be renamed or subdivided in any release — and `CreateFromEntries` is one of its three documented success cases. The assertion is not vacuous: against the previous property-by-property build it is false, including for the `CreateDataProperty` variant in #7892, because that object is created through `Intrinsics.Object.Construct` rather than built as entries. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016uV6H9cTntzsoKiaJRBn4f * test(javascript): run the scripting suites with Jint's host-contract verifiers on Jint has a set of verifiers for the extension points it *trusts* — the ones it cannot afford to re-check on a hot path, so a violation is otherwise silent. They used to be compiled out of Release, which made "run your suite against a Debug Jint" the only way to reach them, and Jint ships Release-only. 4.15.3 moves them behind an AppContext switch read once at type initialization, so a host that never sets it pays nothing (the JIT folds the guards away) and a test host can turn them on against the shipped package. The one Elsa is subject to today is the object-converter type declaration this PR introduced. Registering a converter with `AddObjectConverter(converter, handledTypes)` promises the engine the converter produces values only for those types, and in exchange the compiled interop lanes are kept for every member that cannot produce one. Nothing links that promise to the converter's own `TryConvert` switch: a case added there and not added to the registration is silently skipped on exactly the members the declaration excluded, and honoured everywhere else. `ByteArrayConverter` and `JsonElementConverter` are consistent today; this is what would report it if they drifted. Elsa defines no `ObjectInstance` subclass, so the rest of the verifiers have nothing to check here yet. They are a standing guard for the day a host handler or a satellite module adds one. Wired as a module initializer, because the switch has to be set before the first use of any Jint type. Duplicated across the three suites that reference `Elsa.Expressions.JavaScript` rather than shared: `Elsa.Testing.Shared.Integration` would be the obvious home, but it is a published package, and a module initializer there would flip a process-wide switch for every external consumer of it as well. A one-line test pins that the initializer ran, since Elsa satisfies the contracts it is subject to and nothing else would notice the checks going away. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016uV6H9cTntzsoKiaJRBn4f --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com> |
||
|---|---|---|
| .agents/skills | ||
| .claude/skills | ||
| .config | ||
| .github | ||
| .nuke | ||
| .specify | ||
| .vscode | ||
| announcements | ||
| build | ||
| design | ||
| doc | ||
| docker | ||
| docs | ||
| gen | ||
| scripts | ||
| specs | ||
| src | ||
| test | ||
| .editorconfig | ||
| .gitignore | ||
| AGENTS.md | ||
| build.cmd | ||
| build.ps1 | ||
| build.sh | ||
| CLAUDE.md | ||
| CONTEXT.md | ||
| CONTRIBUTING.md | ||
| Directory.Build.props | ||
| Directory.Build.targets | ||
| Directory.Packages.props | ||
| dotnet-install.sh | ||
| Elsa.sln | ||
| Elsa.sln.DotSettings | ||
| icon.png | ||
| jordi-delay-bookmark-reply.md | ||
| LICENSE | ||
| NuGet.Config | ||
| pau-delay-dispatch-response.md | ||
| README.md | ||
| ROADMAP.md | ||
| run-dsl-tests.sh | ||
| test-flowchart.txt | ||
| test-simple-flowchart.elsa | ||
Elsa Workflows
For Elsa 2, Click Here
Introduction
Elsa is a powerful workflow library that enables workflow execution within any .NET application. Elsa allows you to define workflows in various ways, including:
- Writing C# code
- Using a visual designer
- Specifying workflows in JSON
Try with Docker
To give the Elsa Studio + Elsa Server a quick spin, you can run the following command to start the Elsa Docker container:
docker pull elsaworkflows/elsa-server-and-studio-v3:latest
docker run -t -i -e ASPNETCORE_ENVIRONMENT='Development' -e HTTP_PORTS=8080 -e HTTP__BASEURL=http://localhost:13000 -p 13000:8080 elsaworkflows/elsa-server-and-studio-v3:latest
This Docker image is based on a reference ASP.NET application that hosts both the workflow server and designer and is not intended for production use.
For any non-development deployment, inject a secure random JWT signing key through environment variables or a secrets manager instead of using committed appsettings values. For code-first hosts such as Elsa.Server.Web, set Identity__Tokens__SigningKey. For shell-based hosts, set the shell feature key, for example CShells__Shells__Default__Features__Identity__SigningKey.
By default, you can access http://localhost:13000 and log in with:
Username: admin
Password: password
TLS and custom certificate authorities
All Elsa Docker images now ship with the operating system's certificate authority bundle baked in at build time. This means you can call public HTTPS endpoints such as https://example.com without any additional configuration.
If you need to trust a private or corporate CA, mount the certificate bundle into the container and reference it via EXTRA_CA_CERT:
docker run \
-v /path/to/company-ca.crt:/certs/company-ca.crt:ro \
-e EXTRA_CA_CERT=/certs/company-ca.crt \
elsaworkflows/elsa-server-and-studio-v3:latest
On startup, the container copies the certificate into /usr/local/share/ca-certificates and runs update-ca-certificates, making the trust available to .NET, OpenSSL, curl, and other system components. Multiple certificates can be provided by pointing EXTRA_CA_CERT at a directory containing .crt or .pem files.
In highly restricted environments where you cannot modify the system trust store, you can instead rely on the standard SSL_CERT_FILE or SSL_CERT_DIR environment variables:
docker run \
-v /path/to/company-ca-bundle.pem:/certs/custom.pem:ro \
-e SSL_CERT_FILE=/certs/custom.pem \
elsaworkflows/elsa-server-and-studio-v3:latest
ℹ️ Installing the CA bundle adds roughly 300KB to the Debian-based images. No package managers run at container startup; all trust updates happen immutably at build time or via the mounted certificates shown above.
Table of Contents
- Documentation
- Known Issues and Limitations
- Features
- Roadmap
- Use Cases
- Coding Workflows
- Designed Workflows
- Contributing
- Support
Documentation
Known Issues and Limitations
Elsa is continually evolving, and while it offers powerful capabilities, there are some known limitations and ongoing work:
- Documentation is still a work in progress.
- Input/Output is not yet implemented in the Workflow Instance Viewer.
- Starting workflows from the designer is currently supported only for workflows that do not require input and do not start with a trigger; this is planned for a future release.
- The designer currently only supports Flowchart activities. Support for Sequence and StateMachine activities is planned for a future release.
- UI input validation is not yet implemented.
Features
Elsa offers a wide range of features for building and executing workflows, including:
- Execution of workflows in any .NET application with support for .NET 6 and beyond.
- Support for both short-running and long-running workflows.
- A programming model loosely inspired by Windows Workflow Foundation.
- A web-based drag & drop designer with support for custom activities.
- Native support for activity composition, including activities like
Sequence,Flowchart, andForEach. - Parallel execution of activities.
- Built-in activities for common scenarios, such as sending emails, making HTTP calls, scheduling tasks, sending and receiving messages, and more.
- Workflow versioning and migration via API.
- Easy integration with external applications via HTTP, message queues, and more.
- Actor model for increased workflow throughput.
- Dynamic expressions with support for C#, JavaScript, Python, and Liquid.
- Persistence agnostic, with support for Entity Framework Core, MongoDB, and Dapper out of the box.
- Elsa Studio: a modular Blazor dashboard app for managing and designing workflows.
Roadmap
See ROADMAP.md for the current roadmap and #3232 for historical roadmap discussion.
Use Cases
Elsa can be used in a variety of scenarios, including:
- Long-running workflows such as order fulfillment and product approval.
- Short-running workflows such as sending emails and generating PDFs.
- Scheduled workflows such as sending daily reports.
- Event-driven workflows such as sending welcome emails when a user signs up.
Coding Workflows
Elsa allows you to define workflows in code using C#. The following example shows how to receive HTTP requests and send an email in response:
public class SendEmailWorkflow : WorkflowBase
{
protected override void Build(IWorkflowBuilder builder)
{
builder.Root = new Sequence
{
Activities =
{
new HttpEndpoint
{
Path = new("/send-email"),
SupportedMethods = new(new[] { HttpMethods.Post }),
CanStartWorkflow = true
},
new SendEmail
{
From = new("alic@acme.com"),
To = new(new[]{ "bob@acme.com" }),
Subject = new("Your workflow has been triggered!"),
Body = new("Hello!")
}
}
};
}
}
Designing Workflows
Elsa allows you to define workflows using a visual designer. The following example shows how to receive HTTP requests and send an email in response:
Contributing
We welcome contributions from the community and are pleased that you are interested in helping to improve the Elsa Workflow project! Here are the steps to contribute to our project:
1. Fork and Clone the Repo
To get started, you'll need to fork the repository to your own GitHub account. You can do this by navigating to the Elsa Workflow GitHub repository and clicking the "Fork" button in the top-right corner of the page. Once you have forked the repo, you can clone it to your local machine using the following command:
git clone https://github.com/YOUR_USERNAME/elsa-core.git
Replace YOUR_USERNAME with your GitHub username. For more information on forking a repo, check out the GitHub documentation here.
Incorporating the details about the "apps" folder and its projects into the second point about opening the Elsa.sln using your favorite IDE, we can expand the instructions to guide developers on where to start and what projects they might want to explore first. Here's an updated version of that section with the additional information:
2. Open Elsa.sln Using Your Favorite IDE
After cloning the repository, navigate to the cloned directory and open the Elsa.sln solution file with your preferred IDE that supports .NET development, such as Visual Studio, JetBrains Rider, or Visual Studio Code with the appropriate extensions.
Within the solution, you will find an "apps" folder containing three projects designed to help you get started and explore the capabilities of Elsa Workflow:
-
Elsa.Server.Web: This project is a reference ASP.NET Core application that acts as a workflow server. It's a great starting point if you want to understand how Elsa functions as a server-side workflow engine.
-
Elsa.ServerAndStudio.Web: This project serves a dual purpose. Like
Elsa.Server.Web, it acts as a workflow server. Additionally, it hosts the Elsa Studio Blazor WebAssembly app. This is the perfect project to run if you want to see the full capabilities of Elsa, including both the server aspects and the client-side studio experience in one application. -
Elsa.Studio.Web: This project is a reference Blazor WebAssembly application that solely hosts the Elsa Studio Blazor WebAssembly app. It requires a running Elsa server application to connect to. Use this project if you're interested in focusing on the Elsa Studio UI and its interactions with an Elsa workflow server.
3. Submit a PR with Your Changes
Once you have made your changes, commit them and push them back to your fork. Then, navigate to the original Elsa Workflow repository and create a new Pull Request. Ensure your PR description clearly describes the changes and any relevant information that will help the reviewers understand your contributions. For a detailed guide on creating a pull request, visit Creating a pull request from a fork.
4. Open an Issue First
Before you start working on your changes or submit a pull request, please open an issue to discuss what you would like to do. This step is crucial as it ensures you don't spend time working on something that might not align with the project's goals or might already be under development by someone else. You can open an issue here.
This approach helps us streamline contributions and ensures that your efforts are aligned with the project's needs and priorities. We look forward to your contributions and are here to support you throughout the process. Thank you for contributing to the Elsa Workflow project!
Support
There are various ways to get support for Elsa Workflows, ranging from community-driven channels to enterprise-level services.
Community Support
Elsa has an active and helpful community where you can find support through multiple channels:
- GitHub Issues for bug reports and feature requests.
- GitHub Discussions for open-ended conversations, questions, and community-driven support.
- Discord for real-time support and interaction with the Elsa community.
- StackOverflow for searching or asking technical questions.
Professional Support
For organizations requiring professional support and long-term commitment, check out Elsa+, a growing ecosystem of premium services, tooling, and extensions around Elsa Workflows.


