Architecture Overview
BrowserApi is a code-generation project that produces typed C# wrappers for browser APIs. Its central design principle is separation of types from transport: the core package contains all the type definitions and serialization logic with zero external dependencies, while separate packages provide the actual communication channel to JavaScript.
The Five Packages
| Package | Dependencies | Purpose |
|---|---|---|
| BrowserApi | None | All generated and hand-written types: DOM, CSS, Canvas, Fetch, Events, etc. |
| BrowserApi.JSInterop | Microsoft.JSInterop | JSInteropBackend -- bridges BrowserApi to Blazor's IJSRuntime |
| BrowserApi.Blazor | ASP.NET Components | BrowserApiComponentBase, DI registration via AddBrowserApi() |
| BrowserApi.Runtime | Jint | BrowserEngine -- in-process JS engine + virtual DOM for testing |
| BrowserApi.Generator | (standalone CLI) | WebIDL parser and C# code emitter |
This split is by dependency, not by API surface. All DOM, CSS, Canvas, Fetch, and other browser types live in the core BrowserApi package. You never install BrowserApi.Canvas as a separate package -- it does not exist.
IBrowserBackend -- the Transport Abstraction
The IBrowserBackend interface (src/BrowserApi/Common/IBrowserBackend.cs) defines every operation the type system needs from a JavaScript runtime:
public interface IBrowserBackend : IAsyncDisposable {
// Property access
T GetProperty<T>(JsHandle target, string propertyName);
void SetProperty(JsHandle target, string propertyName, object? value);
// Method invocation (sync + async)
void InvokeVoid(JsHandle target, string methodName, object?[] args);
T Invoke<T>(JsHandle target, string methodName, object?[] args);
Task InvokeVoidAsync(JsHandle target, string methodName, object?[] args);
Task<T> InvokeAsync<T>(JsHandle target, string methodName, object?[] args);
// Object lifecycle
JsHandle Construct(string jsClassName, object?[] args);
JsHandle GetGlobal(string name);
ValueTask DisposeHandle(JsHandle handle);
// Events
JsHandle AddEventListener(JsHandle target, string eventName, Action<JsHandle> callback);
void RemoveEventListener(JsHandle target, string eventName, JsHandle listenerId);
}
Every method operates on JsHandle -- an opaque struct that wraps a backend-specific reference to a JavaScript object. In the Blazor backend, the handle wraps an IJSObjectReference. In the test backend, it wraps a VirtualElement or VirtualDocument directly. Consumer code never inspects the handle's inner value.
The core BrowserApi package defines this interface but provides no implementation. The implementations live in their respective packages:
JSInteropBackendinBrowserApi.JSInteropJintBackendinBrowserApi.Runtime
How JsObject Works
JsObject (src/BrowserApi/Common/JsObject.cs) is the abstract base class for every generated browser API type. It has three key aspects:
1. Static Backend
A single static property provides the backend for all instances:
public abstract class JsObject : IDisposable, IAsyncDisposable {
public static IBrowserBackend Backend { get; set; }
public JsHandle Handle { get; internal set; }
}
You set JsObject.Backend once at startup. Every JsObject instance then delegates through it:
// Inside JsObject:
protected T GetProperty<T>(string jsName) {
var raw = Backend.GetProperty<object?>(Handle, jsName);
return ConvertFromJs<T>(raw);
}
protected void SetProperty(string jsName, object? value) {
Backend.SetProperty(Handle, jsName, ConvertToJs(value));
}
protected void InvokeVoid(string jsName, params object?[] args) {
Backend.InvokeVoid(Handle, jsName, ConvertArgs(args));
}
protected T Invoke<T>(string jsName, params object?[] args) {
var raw = Backend.Invoke<object?>(Handle, jsName, ConvertArgs(args));
return ConvertFromJs<T>(raw);
}
2. Generated Types Delegate Through JsObject
Every generated class calls GetProperty, SetProperty, Invoke, or InvokeVoid. For example, the generated Document class:
// Generated (Document.g.cs):
public partial class Document : Node {
[JsName("URL")]
public string Url => GetProperty<string>("URL");
[JsName("characterSet")]
public string CharacterSet => GetProperty<string>("characterSet");
}
The [JsName] attribute records the original JavaScript name for tooling and reflection. The string passed to GetProperty/SetProperty/Invoke is the camelCase JavaScript name.
3. The Conversion Pipeline
Before values cross the interop boundary, ConvertToJs and ConvertFromJs<T> handle type translation automatically.
C# to JS (ConvertToJs):
internal static object? ConvertToJs(object? value) {
return value switch {
null => null,
JsObject obj => obj.Handle, // unwrap to handle
ICssValue css => css.ToCss(), // serialize: Length.Rem(1.5) -> "1.5rem"
IWebIdlSerializable s => s.ToJs(), // serialize complex WebIDL types
Enum e => GetStringValue(e) ?? value, // ScrollBehavior.Smooth -> "smooth"
_ => value // primitives pass through
};
}
JS to C# (ConvertFromJs<T>):
- If
Tis aJsObjectsubclass, a new instance is created and assigned the returned handle. - If
Tis an enum, the string value is matched against[StringValue]attributes. - If
TisIConvertible,Convert.ChangeTypehandles numeric conversions. - Otherwise, a direct cast is attempted.
Data Flow Diagram
C# Application Code
|
v
┌──────────────────────────────────────┐
│ Generated Types │
│ Document, Element, HtmlInputElement │
│ CssStyleDeclaration, Event, ... │
│ │
│ GetProperty<T>("textContent") │
│ SetProperty("className", value) │
│ Invoke<T>("querySelector", sel) │
└──────────────┬───────────────────────┘
│
v
┌──────────────────────────────────────┐
│ JsObject │
│ │
│ ConvertToJs(value) │
│ JsObject -> Handle │
│ ICssValue -> .ToCss() string │
│ Enum -> [StringValue] string │
│ │
│ ConvertFromJs<T>(raw) │
│ Handle -> new T { Handle = h } │
│ string -> enum via [StringValue] │
└──────────────┬───────────────────────┘
│
v
┌──────────────────────────────────────┐
│ IBrowserBackend │
│ │
│ GetProperty, SetProperty │
│ Invoke, InvokeVoid │
│ InvokeAsync, InvokeVoidAsync │
│ Construct, GetGlobal │
│ AddEventListener │
└──────────┬───────────┬───────────────┘
│ │
v v
┌─────────────┐ ┌──────────────┐
│ JSInterop │ │ JintBackend │
│ Backend │ │ │
│ │ │ VirtualDOM │
│ IJSRuntime │ │ Jint engine │
│ -> browser │ │ -> in-memory │
└─────────────┘ └──────────────┘
Generated Code + Hand-Written Partial Classes
BrowserApi uses partial class and partial struct to compose generated code with hand-written ergonomic extensions. The generator produces the structural skeleton; hand-written code adds developer-friendly APIs on top.
Generated (src/BrowserApi/Generated/Css/Length.g.cs):
// <auto-generated/>
public readonly partial struct Length : ICssValue {
private readonly string _value;
public Length(string value) {
_value = value;
}
public string ToCss() => _value;
public override string ToString() => _value;
}
Hand-written (src/BrowserApi/Css/Length.cs):
public readonly partial struct Length : IEquatable<Length> {
public static Length Zero { get; } = new("0");
public static Length Auto { get; } = new("auto");
public static Length Px(double value) => new($"{FormatNumber(value)}px");
public static Length Rem(double value) => new($"{FormatNumber(value)}rem");
public static Length Percent(double value) => new($"{FormatNumber(value)}%");
public static Length Calc(string expression) => new($"calc({expression})");
// Implicit conversion: Length margin = 16; -> "16px"
public static implicit operator Length(int value) => Px(value);
// Arithmetic operators produce calc() expressions
public static Length operator +(Length a, Length b) =>
new($"calc({a.ToCss()} + {b.ToCss()})");
public static Length operator -(Length a, Length b) =>
new($"calc({a.ToCss()} - {b.ToCss()})");
}
The same pattern applies to CssColor (generated skeleton + hand-written Rgb(), Hsl(), Hex() factories), Transform (hand-written Translate(), Rotate(), Scale() with fluent Then() chaining), and all other CSS value types.
Why Swappable Backends Matter
Because IBrowserBackend is the only dependency the type system has on any runtime, you can swap backends freely:
| Backend | Package | Use Case |
|---|---|---|
JSInteropBackend |
BrowserApi.JSInterop | Production Blazor apps (WASM and Server) |
JintBackend |
BrowserApi.Runtime | Unit/integration tests without a browser |
| Custom mock | Your test project | Targeted test doubles for specific scenarios |
| (Future) WASM Component Model | TBD | Direct WASM host interop without Blazor |
The test backend (JintBackend) is particularly powerful: it combines a Jint JavaScript engine with a virtual DOM, so you can run both C# BrowserApi code and JavaScript against the same in-memory DOM tree. See the Testing with BrowserEngine article for details.
Backend Setup in Practice
Blazor (production)
// Program.cs
builder.Services.AddBrowserApi();
// Your component:
@inherits BrowserApiComponentBase
@code {
protected override async Task OnBrowserApiReadyAsync() {
// Window and Document are ready here
var title = Document.Title;
}
}
BrowserApiComponentBase creates a JSInteropBackend from the injected IJSRuntime on first render, assigns it to JsObject.Backend, and provides Window and Document properties.
Testing
using var engine = new BrowserEngine();
// JsObject.Backend is set automatically
// engine.Document is a live BrowserApi Document backed by VirtualDocument
engine.Execute("document.body.innerHTML = '<div id=\"app\">Hello</div>'");
var el = engine.VirtualDocument.GetElementById("app");
Assert.Equal("Hello", el?.TextContent);
Manual setup
var backend = new JSInteropBackend(jsRuntime);
JsObject.Backend = backend;
var doc = new Document { Handle = backend.GetGlobal("document") };
doc.Title = "Hello, BrowserApi!";
Key Takeaways
- Types have zero dependencies. The core
BrowserApipackage is pure C# -- no Blazor, no JSInterop, no Jint. - One interface separates types from transport.
IBrowserBackendis the single seam between your typed C# code and the JavaScript world. - Conversion is automatic.
ConvertToJs/ConvertFromJs<T>handleJsObjectunwrapping, CSS serialization, enum mapping, and primitive coercion transparently. - Partial classes compose generated + hand-written code. The generator provides the structural mapping; hand-written code adds ergonomic factory methods, operators, and builders.
- Backends are swappable. The same
Document.QuerySelector("#app")call works identically against a real browser or a virtual DOM in a unit test.