AOT (Ahead-of-Time) Compatibility
This guide explains how to use WebDriverBiDi.NET in Native AOT compilation scenarios, including how to ensure custom modules work correctly without reflection-based serialization.
Overview
.NET Native AOT (Ahead-of-Time) compilation produces standalone executables that don't rely on a just-in-time (JIT) compiler at runtime. This improves startup time and reduces memory usage, but it also means that reflection-based features — including System.Text.Json's default serialization — may not work correctly.
WebDriverBiDi.NET supports AOT out of the box for all built-in modules. If you're using custom modules, you'll need to take one additional step to register serialization metadata for your custom types.
How AOT Serialization Works
System.Text.Json supports AOT through source-generated serializer contexts. Instead of using reflection at runtime to inspect types, a source generator produces serialization code at compile time.
WebDriverBiDi.NET ships with a pre-built context, WebDriverBiDiJsonSerializerContext, that covers every built-in command, result, and event type. When reflection is unavailable (i.e., in AOT mode), the library's Transport automatically uses this context.
Using Built-in Modules in AOT
If you're only using the built-in modules, no additional configuration is needed. The library handles everything automatically:
BiDiDriver driver = new BiDiDriver(TimeSpan.FromSeconds(30));
await driver.StartAsync(webSocketUrl);
// All built-in modules work in AOT with no extra setup
await driver.BrowsingContext.NavigateAsync(
new NavigateCommandParameters(contextId, "https://example.com"));
Custom Modules in AOT
When you create a custom module with your own command parameters, results, or event args, the library's source-generated context doesn't know about those types. In a JIT environment this isn't a problem — System.Text.Json falls back to reflection. In AOT, however, serialization of your custom types will fail unless you provide the metadata yourself.
The solution has two parts:
- Create a source-generated
JsonSerializerContextfor your custom types - Register it with the driver before connecting
Step 1: Define Your Custom Types
Suppose you have a custom module with its own command and event types:
public class MyCommandParameters : CommandParameters<MyCommandResult>
{
[JsonIgnore]
public override string MethodName => "myModule.myCommand";
[JsonPropertyName("value")]
public string Value { get; set; } = string.Empty;
}
public record MyCommandResult : CommandResult
{
[JsonIgnore]
public override bool IsError => false;
[JsonPropertyName("data")]
public string Data { get; set; } = string.Empty;
}
public record MyEventArgs : WebDriverBiDiEventArgs
{
[JsonPropertyName("detail")]
public string Detail { get; set; } = string.Empty;
}
Step 2: Create a Source-Generated Serializer Context
Add a [JsonSerializable] attribute for each of your own types: every CommandParameters subclass, every CommandResult subclass, and every event args type:
[JsonSerializable(typeof(MyCommandParameters))]
[JsonSerializable(typeof(MyCommandResult))]
[JsonSerializable(typeof(MyEventArgs))]
public partial class MyModuleJsonSerializerContext : JsonSerializerContext
{
}
Note: Do not register the library's envelope types (
CommandResponseMessage<T>,EventMessage<T>). Their members are internal to the library, so a context in your assembly cannot generate working metadata for them; the transport reads the envelopes itself and asks the serializer only for your result and event args types. The BIDI034 analyzer reports an envelope type named in a[JsonSerializable]attribute.
Step 3: Register the Context with the Driver
Call RegisterTypeInfoResolverAsync before starting the driver:
BiDiDriver driver = new BiDiDriver(TimeSpan.FromSeconds(30));
// Register your serialization metadata for AOT support
await driver.RegisterTypeInfoResolverAsync(MyModuleJsonSerializerContext.Default);
// Register your custom module
MyCustomModule myModule = new MyCustomModule(driver);
driver.RegisterModule(myModule);
// Now connect — the transport will use both the built-in and your custom metadata
await driver.StartAsync(webSocketUrl);
The library combines your resolver with its own using JsonTypeInfoResolver.Combine(), so both built-in and custom types are handled seamlessly.
Multiple Custom Modules
If you have several custom modules, you can either include all types in a single JsonSerializerContext, or register multiple contexts separately:
// Option A: One context for everything
[JsonSerializable(typeof(ModuleACommandParameters))]
[JsonSerializable(typeof(ModuleACommandResult))]
[JsonSerializable(typeof(ModuleBCommandParameters))]
[JsonSerializable(typeof(ModuleBCommandResult))]
[JsonSerializable(typeof(ModuleBEventArgs))]
public partial class AllCustomModulesJsonContext : JsonSerializerContext { }
// Register once
await driver.RegisterTypeInfoResolverAsync(AllCustomModulesJsonContext.Default);
// Option B: Separate contexts per module
await driver.RegisterTypeInfoResolverAsync(ModuleAJsonContext.Default);
await driver.RegisterTypeInfoResolverAsync(ModuleBJsonContext.Default);
Both approaches work. Option A produces a single source-generated context, which is slightly more efficient. Option B is better for independently packaged modules.
Packaging AOT-Compatible Modules
When distributing a custom module as a NuGet package, include the source-generated context so consumers don't have to create their own. See Core Concepts - Custom JSON Type Resolvers for the context definition pattern.
Document that consumers should register it:
await driver.RegisterTypeInfoResolverAsync(MyExtensionJsonSerializerContext.Default);
driver.RegisterModule(new MyExtensionModule(driver));
Troubleshooting
Serialization fails at runtime in AOT
If types are not being serialized correctly:
- Ensure
RegisterTypeInfoResolverAsyncis called beforeStartAsync. Registering after the transport has connected throwsInvalidOperationExceptionwith the message "Cannot register a type info resolver after the transport is connected". The transport rebuilds its serializer options around each new resolver, so this is a lifecycle restriction rather than a frozen-options one. - Verify that every custom
CommandParameters,CommandResultand event args type is listed in your context; the library's envelope types (CommandResponseMessage<T>,EventMessage<T>) must not be.
Types work in development but fail in AOT
This typically means reflection-based serialization was handling your types in development (JIT mode), masking the fact that they aren't in any source-generated context. Add [JsonSerializable] attributes for all custom types and register the context.
Enums with a custom converter
Enums that use EnumValueJsonConverter<T> need nothing extra under AOT. The converter reads the
enum's members through Enum.GetValues<T>(), whose specialization roots the T[] array type, so the
compiler generates it without being asked. Earlier versions of this guide recommended rooting that
array type from a static constructor on your context; that is no longer necessary, and the library no
longer does it either.
Diagnostic events and logging produce nothing under AOT
The ILCompiler sets EventSourceSupport to false by default, so in a Native AOT application
EventSource.IsEnabled() is permanently false. The library's WebDriverBiDiEventSource emits nothing, no
EventListener is ever called, and the WebDriverBiDi.Logging bridge forwards no entries. Nothing throws
and nothing is written to say why, so it looks simply like an absence of events. The Web SDK (Microsoft.NET.Sdk.Web) sets it to true when the property is empty, so an ASP.NET Core application already has it; every other SDK needs the opt-in. Opt
back in:
<PropertyGroup>
<PublishAot>true</PublishAot>
<EventSourceSupport>true</EventSourceSupport>
</PropertyGroup>
See Observability for the events this restores.
Values in AdditionalData are not serialized through your context
AdditionalData, CapabilityRequest.AdditionalCapabilities and Command.AdditionalCommandProperties hold
object? values, so the serializer must look their runtime types up when the command is sent, rather than knowing
them from a generated contract. Under Native AOT, a type nothing has registered fails the send with
WebDriverBiDiSerializationException wrapping a NotSupportedException naming the type. The
BIDI022 analyzer flags every write to these dictionaries as a reminder.
The library's own context already registers the types these dictionaries usually hold: string, bool, int,
long, uint, ulong, double, decimal, DateTime, object, List<object?> and Dictionary<string, object?>.
Values of those types need nothing from you. A value of any other type — your own record, an enum, an array of a
custom type — needs a context that includes it, registered exactly as a custom module's types are:
// A value of a type the library's own context does not already register needs a context of its own.
public record ExtensionPayload
{
[JsonPropertyName("detail")]
public string Detail { get; set; } = string.Empty;
}
[JsonSerializable(typeof(ExtensionPayload))]
public partial class ExtensionPayloadJsonContext : JsonSerializerContext { }
Register it before StartAsync, exactly as a custom module's context is registered (see
Step 3). Registering the value's own type is enough; the transport
serializes the command envelope itself.
Best Practices
- Always create a
JsonSerializerContextfor custom modules — even if you don't target AOT today, this future-proofs your code and avoids reflection overhead. - Register resolvers before starting —
RegisterTypeInfoResolverAsyncmust be called beforeStartAsync. Attempting to register after connecting throws an exception. - Register your payload types only — your parameters, results and event args. The transport handles the response and event envelopes itself.
- Test in AOT mode — publish your application with
dotnet publish -p:PublishAot=trueand verify end-to-end behavior. - Ship contexts with packages — if distributing modules as NuGet packages, include the serializer context so consumers can register it.
Next Steps
- Custom Modules: Learn how to create custom modules
- Architecture: Understand the module and transport system
- Error Handling: Handle failures in custom modules
- Performance Considerations: Optimize command execution