Typed Clients
Typed clients
Section titled “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.
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
BridgeExceptionwithStatusCode, the bridge’sCodeandIsNotSupportedfor501. - 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>orTask<byte[]>reads the body raw; a[BridgeBody("text/plain")]parameter sends one. - TypeScript:
clients/typescriptholds the same clients, generated from the same assemblies bytools/Shiny.AppDeviceBridge.TypeScript—new CalendarBridge().createEvent({ title, start, end }). Required members are required, optional parameters go in an options object with anAbortSignal, 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 buildinclients/typescriptcompiles 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)andSendAsync<TBody, TResult>(…), or given a[BridgeClient]interface of their own.
Writing a bridge
Section titled “Writing a bridge”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, resolveIWebAppMainThreadand run the call inInvokeAsync. A MAUI host routes it to the app’s dispatcher; headless, it runs where it is. Don’t reach forApplication.Current.Dispatcher. - Startup work. An
IAppDeviceBridgeServerExtensiongetsInitialize(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.
Events
Section titled “Events”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 pagebuilder.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.


