diff --git a/.github/workflows/fsharp.yml b/.github/workflows/fsharp.yml
index 7a6c8d6b4..2d855b941 100644
--- a/.github/workflows/fsharp.yml
+++ b/.github/workflows/fsharp.yml
@@ -85,6 +85,9 @@ jobs:
run: dotnet test src/Testing/Wolverine.Cosmos.FSharpTests/Wolverine.Cosmos.FSharpTests.csproj -c "$config" --nologo
# Run-verified: boots a host in TypeLoadMode.Static against the pre-generated F# adapter and
- # asserts the handler actually executes (not just compiles).
- - name: Behavioural run-step (TypeLoadMode.Static)
+ # asserts the handler actually executes (not just compiles). Also exercises the
+ # `dotnet run -- codegen write --language fsharp` CLI end to end against the F# app, asserting it
+ # exits 0 (every chain — including Wolverine's internal HandlerRegistry — emits F#) and that the
+ # generated adapter matches the committed, run-verified Generated.fs.
+ - name: Behavioural run-step + codegen write --language fsharp (TypeLoadMode.Static)
run: dotnet test src/Testing/Wolverine.Behavioural.FSharpTests/Wolverine.Behavioural.FSharpTests.csproj -c "$config" --nologo
diff --git a/Directory.Build.props b/Directory.Build.props
index 136e0c64c..09972ef26 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -14,7 +14,7 @@
true
true
enable
- 6.3.0
+ 6.3.1
$(PackageProjectUrl)
true
true
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 687c59681..fccc662ba 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -32,13 +32,13 @@
-
-
-
+
+
+
-
+
diff --git a/docs/tutorials/fsharp.md b/docs/tutorials/fsharp.md
index 6a49d26a3..75b09a3aa 100644
--- a/docs/tutorials/fsharp.md
+++ b/docs/tutorials/fsharp.md
@@ -126,12 +126,41 @@ type BehaviouralPingHandler1244766258() =
System.Threading.Tasks.Task.CompletedTask
```
+## Generating the F# code from the command line
+
+Wolverine apps answer the JasperFx command line (the final `RunJasperFxCommands(args)` in your
+`Program`). As of JasperFx 2.4.1 the `codegen` command takes a `--language` flag, so you can write the
+pre-generated code out as **F#** instead of C#:
+
+```bash
+dotnet run -- codegen write --language fsharp
+```
+
+This emits one `.fs` file per generated type into your code-generation output directory
+(`Internal/Generated/…` by default) — the handler adapters plus Wolverine's static
+`GeneratedHandlerRegistry`. Because F# requires explicit, ordered compilation, add the generated files
+to your `.fsproj` `` list (the registry and adapters depend on your handler/message types, so
+list them after those):
+
+```xml
+
+
+
+
+
+
+```
+
+Re-run the command (and commit the regenerated files) whenever your handler graph changes; the generated
+type names are deterministic for a given handler graph.
+
## Running on pre-generated F# code
To ship an F# app that runs on pre-generated code rather than compiling at startup:
-1. Generate the F# adapters and commit them into your application (for example as a `Generated.fs`
- compiled into the app assembly). The generated type names are deterministic for a given handler graph.
+1. Generate the F# adapters with `codegen write --language fsharp` (above) and commit them into your
+ application, compiled into the app assembly. The generated type names are deterministic for a given
+ handler graph.
2. Boot the host in `TypeLoadMode.Static` and point Wolverine's `ApplicationAssembly` at the assembly that
contains the pre-generated F# — Wolverine then loads each handler adapter **by name** out of that
assembly's exported types, with no Roslyn at runtime:
diff --git a/src/Testing/Wolverine.Behavioural.FSharpApp/Program.fs b/src/Testing/Wolverine.Behavioural.FSharpApp/Program.fs
new file mode 100644
index 000000000..44bb1a179
--- /dev/null
+++ b/src/Testing/Wolverine.Behavioural.FSharpApp/Program.fs
@@ -0,0 +1,22 @@
+module WolverineBehaviouralFSharpApp.Program
+
+open Microsoft.Extensions.Hosting
+open JasperFx
+open Wolverine
+open WolverineBehaviouralFSharpApp
+
+// Entry point so the app answers the JasperFx CLI verbs — notably
+// `dotnet run -- codegen write --language fsharp`, which regenerates the F# handler adapter.
+// The Wolverine configuration here MUST match BehaviouralCodegen.Configure in the test project
+// (DisableConventionalDiscovery + IncludeType) so the generated handler
+// type-name hash is identical to the one the behavioural run-step computes under TypeLoadMode.Static.
+[]
+let main args =
+ Host
+ .CreateDefaultBuilder(args)
+ .UseWolverine(fun opts ->
+ opts.Discovery.DisableConventionalDiscovery() |> ignore
+ opts.Discovery.IncludeType() |> ignore)
+ .RunJasperFxCommands(args)
+ .GetAwaiter()
+ .GetResult()
diff --git a/src/Testing/Wolverine.Behavioural.FSharpApp/Wolverine.Behavioural.FSharpApp.fsproj b/src/Testing/Wolverine.Behavioural.FSharpApp/Wolverine.Behavioural.FSharpApp.fsproj
index 62f5ccc0d..53a119acc 100644
--- a/src/Testing/Wolverine.Behavioural.FSharpApp/Wolverine.Behavioural.FSharpApp.fsproj
+++ b/src/Testing/Wolverine.Behavioural.FSharpApp/Wolverine.Behavioural.FSharpApp.fsproj
@@ -13,6 +13,11 @@
net9.0
false
+
+ Exe
+
false
disable
@@ -25,6 +30,7 @@
+
diff --git a/src/Testing/Wolverine.Behavioural.FSharpTests/BehaviouralRunStep.cs b/src/Testing/Wolverine.Behavioural.FSharpTests/BehaviouralRunStep.cs
index 6f00247c7..50ae91242 100644
--- a/src/Testing/Wolverine.Behavioural.FSharpTests/BehaviouralRunStep.cs
+++ b/src/Testing/Wolverine.Behavioural.FSharpTests/BehaviouralRunStep.cs
@@ -16,6 +16,7 @@ namespace Wolverine.Behavioural.FSharpTests;
/// carries the committed, pre-generated F# handler adapter), sends a message, and asserts the F#
/// handler actually executed — run-verification, not just compilation.
///
+[Collection("BehaviouralFSharp")]
public class BehaviouralRunStep
{
private readonly ITestOutputHelper _output;
diff --git a/src/Testing/Wolverine.Behavioural.FSharpTests/CodegenWriteFSharpCli.cs b/src/Testing/Wolverine.Behavioural.FSharpTests/CodegenWriteFSharpCli.cs
new file mode 100644
index 000000000..1e0045613
--- /dev/null
+++ b/src/Testing/Wolverine.Behavioural.FSharpTests/CodegenWriteFSharpCli.cs
@@ -0,0 +1,108 @@
+using System.Diagnostics;
+using Shouldly;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace Wolverine.Behavioural.FSharpTests;
+
+// The behavioural F# tests each spawn nested `dotnet build`/`dotnet run` of the F# app and load its
+// assembly; running them concurrently races on the app's build outputs. Serialize them.
+[CollectionDefinition("BehaviouralFSharp", DisableParallelization = true)]
+public class BehaviouralFSharpCollection;
+
+///
+/// Proves the JasperFx codegen write --language fsharp CLI flag works end to end against a
+/// real Wolverine F# application: it runs the verb against Wolverine.Behavioural.FSharpApp,
+/// asserts every handler chain (including Wolverine's internal static HandlerRegistry) emits F#
+/// (exit 0), and confirms the generated handler adapter is identical to the committed
+/// Generated.fs — which the behavioural run-step already compiles and executes under
+/// . So: the CLI produces the same F#
+/// that is proven to run.
+///
+[Collection("BehaviouralFSharp")]
+public class CodegenWriteFSharpCli
+{
+ private readonly ITestOutputHelper _output;
+
+ public CodegenWriteFSharpCli(ITestOutputHelper output)
+ {
+ _output = output;
+ }
+
+ [Fact]
+ public async Task codegen_write_fsharp_generates_runnable_fsharp_for_a_wolverine_app()
+ {
+ var appProject = BehaviouralCodegen.AppProjectPath();
+ var appDir = Path.GetDirectoryName(appProject)!;
+ var generatedDir = Path.Combine(appDir, "Internal");
+
+ // Start clean so we only see what THIS run produced.
+ if (Directory.Exists(generatedDir)) Directory.Delete(generatedDir, recursive: true);
+
+ try
+ {
+ var (exitCode, output) = await RunDotnetAsync(appDir, "run --framework net9.0 -- codegen write --language fsharp");
+
+ // A transient F# build crash / file lock can fail the embedded `dotnet run` build; retry once.
+ if (exitCode != 0 && (output.Contains("FS0193") || output.Contains("internal error")
+ || output.Contains("being used by another process")
+ || output.Contains("MSB3883")))
+ {
+ if (Directory.Exists(generatedDir)) Directory.Delete(generatedDir, recursive: true);
+ (exitCode, output) = await RunDotnetAsync(appDir, "run --framework net9.0 -- codegen write --language fsharp");
+ }
+
+ _output.WriteLine(output);
+
+ // Exit 0 means every generated handler chain — including Wolverine's internal static
+ // HandlerRegistry (WriteTypeArrayFrame) — successfully emitted F#.
+ exitCode.ShouldBe(0);
+
+ var generatedFiles = Directory.GetFiles(generatedDir, "*.fs", SearchOption.AllDirectories);
+ generatedFiles.ShouldNotBeEmpty();
+
+ // The handler adapter the CLI produced must match the committed Generated.fs that the
+ // behavioural run-step compiles + executes under TypeLoadMode.Static.
+ var adapterFile = generatedFiles.Single(f =>
+ Path.GetFileName(f).StartsWith("BehaviouralPingHandler", StringComparison.Ordinal));
+ var generatedAdapter = Normalize(await File.ReadAllTextAsync(adapterFile));
+ var committedAdapter = Normalize(await File.ReadAllTextAsync(BehaviouralCodegen.GeneratedFilePath()));
+ generatedAdapter.ShouldBe(committedAdapter);
+
+ // The static HandlerRegistry was also emitted as valid F# (the Type[] accessors as F#
+ // array literals) — this is what previously threw NotSupportedException.
+ var registryFile = generatedFiles.Single(f =>
+ Path.GetFileName(f) == "GeneratedHandlerRegistry.fs");
+ var registry = await File.ReadAllTextAsync(registryFile);
+ registry.ShouldContain("inherit Wolverine.Runtime.Handlers.HandlerRegistry()");
+ registry.ShouldContain("typeof");
+ }
+ finally
+ {
+ if (Directory.Exists(generatedDir)) Directory.Delete(generatedDir, recursive: true);
+ }
+ }
+
+ private static string Normalize(string code)
+ => code.Replace("\r\n", "\n").Trim();
+
+ private static async Task<(int ExitCode, string Output)> RunDotnetAsync(string workingDirectory, string arguments)
+ {
+ var info = new ProcessStartInfo("dotnet", arguments)
+ {
+ WorkingDirectory = workingDirectory,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false
+ };
+ info.Environment["DOTNET_CLI_USE_MSBUILD_SERVER"] = "0";
+ info.Environment["MSBUILDDISABLENODEREUSE"] = "1";
+
+ using var process = Process.Start(info)!;
+ var stdout = await process.StandardOutput.ReadToEndAsync();
+ var stderr = await process.StandardError.ReadToEndAsync();
+ await process.WaitForExitAsync();
+
+ return (process.ExitCode, stdout + stderr);
+ }
+}
diff --git a/src/Wolverine/Runtime/Handlers/HandlerRegistry.cs b/src/Wolverine/Runtime/Handlers/HandlerRegistry.cs
index 37098b0fe..bcc718671 100644
--- a/src/Wolverine/Runtime/Handlers/HandlerRegistry.cs
+++ b/src/Wolverine/Runtime/Handlers/HandlerRegistry.cs
@@ -131,4 +131,22 @@ public override void GenerateCode(GeneratedMethod method, ISourceWriter writer)
Next?.GenerateCode(method, writer);
}
+
+ // F# counterpart so `codegen write --language fsharp` can emit the static HandlerRegistry. F#
+ // method bodies are expressions (no `return`/`;`): an empty array, or an F# array literal of
+ // typeof<...> values.
+ public override void GenerateFSharpCode(GeneratedMethod method, ISourceWriter writer)
+ {
+ if (_types.Length == 0)
+ {
+ writer.Write("System.Array.Empty()");
+ }
+ else
+ {
+ var literals = string.Join("; ", _types.Select(t => $"typeof<{t.FSharpName()}>"));
+ writer.Write($"[| {literals} |]");
+ }
+
+ Next?.GenerateFSharpCode(method, writer);
+ }
}