Skip to content
Shiny.NET
Commercial SupportGuaranteed response times on Shiny bug fixes, straight from the maintainers. Plans from US$1,500 a year.See the plans

Typed Clients

Every bridge ships a .Client package: its request and response contracts, and an interface such as ICalendarBridge whose implementation is generated at build time. The native bridge serializes the same contracts, so the page and the device agree on every shape by construction.

Program.cs
builder.Services
.AddWebAppHostClient() // transport + host, settings, files, links
.AddCalendarBridgeClient()
.AddWifiBridgeClient();
// a page
@inject ICalendarBridge Calendar
var created = await Calendar.CreateEventAsync(new NewCalendarEvent
{
Title = "Standup",
Start = DateTimeOffset.Now.AddHours(1),
End = DateTimeOffset.Now.AddHours(1.5)
});
  • Errors throw BridgeException with StatusCode, the bridge’s Code and IsNotSupported for 501.
  • Events are methods too: await using var sub = await Wifi.OnChangedAsync(e => …). The subscription completes once the host is delivering the event, and disposing the last one for an event stops its native source — see Events.
  • Files and binaries: a method returning Task<Stream> or Task<byte[]> reads the body raw; a [BridgeBody("text/plain")] parameter sends one.
  • TypeScript: clients/typescript holds the same clients, generated from the same assemblies by tools/Shiny.AppDeviceBridge.TypeScript — new CalendarBridge().createEvent({ title, start, end }). Required members are required, optional parameters go in an options object with an AbortSignal, and events return a promise of an unsubscribe function: const stop = await new WifiBridge().onChanged(e => …). A test fails when the committed TypeScript falls behind the C# declarations. It isn’t on npm yet: npm run build in clients/typescript compiles it to ES modules with type declarations, which load with or without a bundler.
  • Your own endpoints can still be called untyped through WebAppBridge.GetAsync<T>(path, typeInfo) and SendAsync<TBody, TResult>(…), or given a [BridgeClient] interface of their own.
public sealed class ClipboardBridge(IClipboard clipboard) : IWebAppBridge
{
public string Name => "clipboard";
public bool IsSupported => true;
public void Map(WebAppBridgeRoutes routes) => routes
.MapGet("", async ctx => await WebAppBridgeResults.Json(ctx, new Clip(await clipboard.GetTextAsync()), MyJson.Default.Clip));
}
// IClipboard is MAUI Essentials, so this one extends the MAUI builder and registers what it needs through bridge.Maui.
public static MauiAppDeviceBridgeBuilder AddClipboardBridge(this MauiAppDeviceBridgeBuilder bridge)
{
bridge.Maui.Services.TryAddSingleton(Clipboard.Default);
bridge.AddBridge<ClipboardBridge>();
return bridge;
}

Or skip the extension and register it where the others are: bridge.AddBridge<ClipboardBridge>().

  • No MAUI, no MAUI builder. A bridge that needs no MAUI takes any builder and returns the one it was given, so it chains in a MAUI app and runs headless too: public static TBuilder AddOrdersBridge<TBuilder>(this TBuilder bridge) where TBuilder : AppDeviceBridgeBuilder.
  • UI goes through IWebAppMainThread. For a permission prompt or a picker, resolve IWebAppMainThread and run the call in InvokeAsync. A MAUI host routes it to the app’s dispatcher; headless, it runs where it is. Don’t reach for Application.Current.Dispatcher.
  • Startup work. An IAppDeviceBridgeServerExtension gets Initialize(IServiceProvider) once, when the container builds the server, before any request.

Use source-generated JSON contexts (JsonTypeInfo). The packages are trim and AOT clean, and bridges should stay that way.

An event is an IAsyncEnumerable<T> mapped with MapEvent. The host runs it once for each page stream that asks for the event and cancels it when that stream lets go, so a native event hooked in it is unhooked in its finally, whether the page stopped listening, disconnected, or the source threw. WebAppEventStream.FromEvent does the hooking:

public void Map(WebAppBridgeRoutes routes) => routes
.MapEvent("clipboard.changed", ct => WebAppEventStream.FromEvent<Clip>(emit =>
{
EventHandler<EventArgs> handler = async (_, _) => emit(new Clip(await clipboard.GetTextAsync()));
clipboard.ClipboardContentChanged += handler;
return () => clipboard.ClipboardContentChanged -= handler;
}, ct), MyJson.Default.Clip)
.MapGet("", async ctx => await WebAppBridgeResults.Json(ctx, new Clip(await clipboard.GetTextAsync()), MyJson.Default.Clip));

Values your own code raises, such as those from a Shiny delegate the OS calls or a session the bridge runs, go through a WebAppEventSource<T>: Publish does nothing while no page listens, and ListenAsync(stopped, ct) tells you how many listeners remain when one leaves, which is where a bridge stops a session nobody is watching. routes.Events.Source("name", typeInfo) gets or creates one mapped to an event, for a delegate to publish into.

To give it a typed client, declare the API once in a plain net10.0 project that references Shiny.AppDeviceBridge.Client. The generator implements the interface, and the same contracts serialize on both sides:

[BridgeClient("clipboard", typeof(ClipboardJson))]
public interface IClipboardBridge
{
[BridgeGet] Task<Clip> GetAsync(CancellationToken cancellationToken = default);
[BridgePut] Task SetAsync(Clip clip, CancellationToken cancellationToken = default);
[BridgeEvent("clipboard.changed")] Task<IAsyncDisposable> OnChangedAsync(Func<Clip, Task> handler);
}
public sealed record Clip(string? Text);
[JsonSerializable(typeof(Clip))]
public partial class ClipboardJson : JsonSerializerContext;
// the page
builder.Services.AddWebAppHostClient().AddClipboardBridgeClient();

Route tokens ([BridgeGet("items/{id}")]) bind parameters by name, a complex parameter on a POST or PUT is the JSON body, and everything else is the query string. A declaration the generator can’t turn into a request — a complex type on a GET, a route token with no parameter — is build error ADB001.