Skip to content

Repository files navigation

RemoteService.Net

NuGet NuGet NuGet License: MIT

Call your server services from Blazor WebAssembly as if they were local — no controllers, no HttpClient boilerplate, no duplicated DTO plumbing.

Define a C# service interface once. RemoteService.Net's Roslyn source generator does the rest:

  • On the server (prerendering, InteractiveServer, or InteractiveAuto running server-side) the interface resolves to your real implementation and is called in-process.
  • In the browser (InteractiveWebAssembly, or InteractiveAuto after WASM takes over) the interface resolves to a generated HTTP proxy that calls generated Minimal API endpoints on the server.

Your components don't know the difference — they just inject the interface.

public interface IProductService : IRemoteService
{
    Task<Product[]> SearchAsync(string query, CancellationToken cancellationToken = default);
}
@inject IProductService Products

@code {
    private Product[]? _results;

    private async Task Search(string query)
        => _results = await Products.SearchAsync(query);
}

That's it. No API controller, no route constants, no JsonSerializer calls, no manual DI wiring per service. Everything is generated at compile time — fully typed, refactor-safe, and AOT/trimming friendly.

Prefer request/handler style with pipeline behaviors? There's also a mediator flavor.

Why?

Blazor Web Apps with WebAssembly interactivity have a well-known friction point: components run in two places. During prerendering they execute on the server where they could call services directly; after hydration they execute in the browser where every data access must go over HTTP. The standard answer is to hand-write an API controller and a typed HttpClient wrapper and keep both in sync with the service interface — for every service.

RemoteService.Net removes that entire layer:

Hand-rolled approach RemoteService.Net
Write API controller per service Generated Minimal API endpoints
Write HttpClient wrapper per service Generated proxy per interface
Register each client + endpoint manually One AddRemoteServiceClients() / MapRemoteServices()
Routes/DTOs drift out of sync Compile-time generated from a single interface
Errors arrive as raw HttpRequestException Typed exceptions round-trip (validation, 404, …)

Installation

RemoteService.Net ships as three NuGet packages — one per layer of a typical Blazor Web App solution. Install each package in the project it belongs to.

Package Install in Contains
NuGet Shared contracts class library IRemoteService marker, attributes, exception types
NuGet Blazor WebAssembly project (*.Client) Client runtime + source generator (emits HTTP proxies)
NuGet ASP.NET Core host project Server runtime + source generator (emits endpoints)

The contracts library is referenced by both the client and the server project, so a single interface definition is shared across the whole solution.

Option A — .NET CLI

Run each command from the directory of the project it applies to:

# Shared contracts project (class library)
dotnet add package RemoteService.Net.Abstractions

# Blazor WebAssembly client project
dotnet add package RemoteService.Net.Client

# ASP.NET Core server project
dotnet add package RemoteService.Net.Server

Option B — PackageReference in the .csproj

If you prefer editing project files directly (or use Visual Studio's NuGet UI), add the reference to the matching project:

<!-- MyApp.Contracts.csproj — shared class library -->
<ItemGroup>
  <PackageReference Include="RemoteService.Net.Abstractions" Version="1.2.0" />
</ItemGroup>
<!-- MyApp.Client.csproj — Blazor WebAssembly project -->
<ItemGroup>
  <PackageReference Include="RemoteService.Net.Client" Version="1.2.0" />
  <ProjectReference Include="..\MyApp.Contracts\MyApp.Contracts.csproj" />
</ItemGroup>
<!-- MyApp.csproj — ASP.NET Core server project -->
<ItemGroup>
  <PackageReference Include="RemoteService.Net.Server" Version="1.2.0" />
  <ProjectReference Include="..\MyApp.Contracts\MyApp.Contracts.csproj" />
</ItemGroup>

Using Central Package Management? Put the <PackageVersion Include="RemoteService.Net.Client" Version="1.2.0" /> entries in Directory.Packages.props and drop the Version attribute from the PackageReference elements above.

Visual Studio

Right-click the project → Manage NuGet Packages…Browse → search for RemoteService.Net → install the package listed for that project in the table above.

Notes

  • Requires .NET 10.
  • The source generator ships inside the Client and Server packages and detects its role automatically from which package a project references — no configuration needed. Override with the RemoteServiceNetRole MSBuild property if you ever need to (Client, Server, or Client,Server).
  • RemoteService.Net.Abstractions is pulled in transitively by both the Client and Server packages, so you only need to reference it explicitly in the shared contracts project.

Quickstart — Blazor Web App

Works with the standard Blazor Web App template using WebAssembly or Auto interactivity:

dotnet new blazor -int WebAssembly --all-interactive   # or -int Auto

1. Define the contract (shared project)

using RemoteService.Net;

public sealed record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary);

public interface IWeatherService : IRemoteService
{
    Task<WeatherForecast[]> GetForecastAsync(int days, CancellationToken cancellationToken = default);
}

The only requirement: inherit the IRemoteService marker interface and return Task/Task<T>/ValueTask/ValueTask<T>.

2. Implement it (server project)

public sealed class ServerWeatherService : IWeatherService
{
    public async Task<WeatherForecast[]> GetForecastAsync(int days, CancellationToken ct = default)
    {
        // Talk to your database, other services, etc.
    }
}
// Program.cs (server)
builder.Services.AddScoped<IWeatherService, ServerWeatherService>();

var app = builder.Build();
// ...
app.MapRemoteServices(); // generated: maps POST /_rpc/{Interface}/{Method}

MapRemoteServices() returns the route group holding every generated endpoint, so you can chain the usual Minimal API conventions to apply them to all RPC endpoints at once:

app.MapRemoteServices()
   .RequireAuthorization()          // protect every RemoteService endpoint
   .RequireRateLimiting("rpc")
   .WithMetadata(new SomeAttribute());

Per-method [RemoteAllowAnonymous] still opts individual endpoints out of a group-level .RequireAuthorization().

3. Register the proxies (client project)

// Program.cs (client)
builder.Services.AddScoped(_ => new HttpClient
{
    BaseAddress = new Uri(builder.HostEnvironment.BaseAddress),
});
builder.Services.AddRemoteServiceClients(); // generated: registers a proxy per interface

4. Use it from any component

@page "/weather"
@inject IWeatherService WeatherService

@code {
    [PersistentState] // .NET 10: reuse prerendered data instead of fetching twice
    public WeatherForecast[]? Forecasts { get; set; }

    protected override async Task OnInitializedAsync()
        => Forecasts ??= await WeatherService.GetForecastAsync(days: 5);
}

During prerendering this calls ServerWeatherService directly. In the browser it becomes POST /_rpc/IWeatherService/GetForecastAsync. Combined with [PersistentState], the service is called exactly once per navigation.

Render mode compatibility

Render mode What happens
Static SSR / prerendering In-process call to the implementation
InteractiveServer In-process call to the implementation
InteractiveWebAssembly HTTP call via generated proxy
InteractiveAuto In-process while server-side, HTTP once WASM takes over — automatically

A complete runnable demo (weather + todos with validation) lives in samples/.

Errors that behave like exceptions

Throw a typed exception on the server; catch the same typed exception in the browser. On the wire it's a standard RFC 7807 ProblemDetails.

// Server implementation
public Task<TodoItem> AddAsync(string title, CancellationToken ct = default)
{
    if (string.IsNullOrWhiteSpace(title))
        throw new RemoteValidationException("Validation failed.",
            new Dictionary<string, string[]> { ["title"] = ["Title must not be empty."] });
    // ...
}
// Component — identical whether the call was in-process or HTTP
try
{
    await TodoService.AddAsync(title);
}
catch (RemoteValidationException ex)
{
    _errors = ex.Errors;
}
Throw on server HTTP status Caught in client
RemoteValidationException 400 RemoteValidationException (with Errors)
RemoteUnauthorizedException 401 RemoteUnauthorizedException
RemoteNotFoundException 404 RemoteNotFoundException
any other exception 500 RemoteServerException (detail only in Development)

Endpoint conventions

MapRemoteServices() returns the RouteGroupBuilder that owns every generated RPC endpoint. Because it implements IEndpointConventionBuilder, all the familiar Minimal API extension methods can be chained onto it and apply to every endpoint at once — exactly like MapGet/MapPost:

app.MapRemoteServices()
   .RequireAuthorization()              // every RPC endpoint requires an authenticated user
   .RequireRateLimiting("rpc")
   .RequireCors("client")
   .WithTags("rpc")
   .AddEndpointFilter<AuditFilter>()
   .CacheOutput();                      // …and any other IEndpointConventionBuilder extension

The return value is also an IEndpointRouteBuilder, so existing code that ignores it (app.MapRemoteServices();) keeps working unchanged.

Securing everything by default

app.MapRemoteServices().RequireAuthorization();

That single line protects all current and future remote services — no attribute to forget when a new interface is added. Individual methods can still opt out:

public interface ISessionService : IRemoteService
{
    Task<UserInfo> GetCurrentUserAsync(CancellationToken ct = default); // requires auth

    [RemoteAllowAnonymous]
    Task<bool> IsAliveAsync(CancellationToken ct = default);            // reachable anonymously
}

Group conventions are applied before the generated per-endpoint metadata, so [RemoteAllowAnonymous] wins over a group-level .RequireAuthorization(), and [RemoteAuthorize] adds its policy on top of it.

Authorization

Interfaces can't carry [Authorize], so RemoteService.Net provides mirrored attributes that the generator translates to endpoint authorization:

[RemoteAuthorize(Roles = "admin")]
public interface IAdminService : IRemoteService
{
    Task<AuditEntry[]> GetAuditLogAsync(CancellationToken ct = default);

    [RemoteAllowAnonymous]
    Task<bool> PingAsync(CancellationToken ct = default);
}

Policy, Roles, and AuthenticationSchemes are supported, on the interface or per method. To require authorization for everything without annotating each interface, see Endpoint conventions above.

The protocol, briefly

  • Every method maps to POST /_rpc/{InterfaceName}/{MethodName} — override with [RemoteRoute] / [RemoteMethod].
  • Request body is a JSON object with one property per parameter. Generated types never appear on the wire, so AOT users only need a JsonSerializerContext for their own DTOs.
  • Task<T>200 (including JSON null); Task204. CancellationToken flows to HttpContext.RequestAborted.
  • A required custom header (X-RemoteService-Call) blocks classic CSRF against cookie-authenticated endpoints.
  • A protocol version header detects stale cached WASM clients after a deployment and surfaces a clear RemoteProtocolException instead of confusing serialization errors.
  • Overloads and generic interfaces/methods are rejected at compile time with descriptive diagnostics (RSN001RSN008).

Mediator flavor

Prefer request/handler style over service interfaces? RemoteService.Net also ships a mediator flavor (MediatR-like, but with a remote-aware design and no external dependency). A request record is the whole contract:

// Shared contracts project
public sealed record SearchProducts(string Term) : IRemoteRequest<IReadOnlyList<Product>>;

public sealed record RecordProductView(Guid ProductId) : IRemoteRequest; // fire-and-forget
// Server project — the handler
public sealed class SearchProductsHandler : IRemoteRequestHandler<SearchProducts, IReadOnlyList<Product>>
{
    public Task<IReadOnlyList<Product>> HandleAsync(SearchProducts request, CancellationToken ct) => ...;
}
@inject IRemoteMediator Mediator

@code {
    private async Task Search(string term)
        => _results = await Mediator.SendAsync(new SearchProducts(term));
}

Same dual dispatch as the interface flavor: in-process on the server (prerendering, InteractiveServer, InteractiveAuto server-side), POST /_rpc/mediator/{RequestName} from the browser. Same error round-tripping, CSRF protection, and protocol versioning. [RemoteAuthorize] / [RemoteAllowAnonymous] / [RemoteRoute] go on the request type.

Packages

Package Install in
NuGet Shared contracts class library
NuGet Blazor WebAssembly project (*.Client)
NuGet ASP.NET Core host project
# Shared contracts project (class library)
dotnet add package RemoteService.Net.Mediator.Abstractions

# Blazor WebAssembly client project
dotnet add package RemoteService.Net.Mediator.Client

# ASP.NET Core server project
dotnet add package RemoteService.Net.Mediator.Server

The mediator packages coexist with the interface-flavor packages — use either or both in the same app.

Registration

// Client Program.cs
builder.Services.AddRemoteMediator();

// Server Program.cs
builder.Services.AddRemoteMediatorHandlers(); // all handlers + in-process mediator
app.MapRemoteMediator();                      // returns RouteGroupBuilder — chain
                                              // .RequireAuthorization() etc. as usual

Everything is source generated: dispatch is a compile-time switch over your request types (no reflection, AOT-safe), and missing/duplicate handlers or route collisions are compiler diagnostics (RSN010RSN014).

Pipeline behaviors

Cross-cutting concerns wrap dispatch via IRemotePipelineBehavior<TRequest, TResponse>:

public sealed class LoggingBehavior<TRequest, TResponse>(ILogger<TRequest> logger)
    : IRemotePipelineBehavior<TRequest, TResponse>
    where TRequest : IRemoteRequest<TResponse>
{
    public async Task<TResponse> HandleAsync(
        TRequest request, RemoteHandlerDelegate<TResponse> next, CancellationToken ct)
    {
        logger.LogInformation("Handling {Request}", typeof(TRequest).Name);
        return await next();
    }
}
services.AddRemotePipelineBehavior(typeof(LoggingBehavior<,>));          // open generic: every request
services.AddRemotePipelineBehavior<SearchProducts, IReadOnlyList<Product>, SearchCacheBehavior>(); // one request

Behaviors belong to the container they're registered in. This is the key rule:

  • Behaviors registered in the client Program.cs exist only in the browser and wrap the HTTP call — e.g. an in-browser memory cache or optimistic retry.
  • Behaviors registered in the server Program.cs wrap the handler — for HTTP-served requests and in-process calls during prerendering — e.g. validation, HybridCache, transactions.
  • Want the same behavior on both sides? Register it in both containers.

Registration order = execution order (first registered = outermost).

The demo's Products page shows a client-side cache behavior and a server-side timing behavior working together.

Interface flavor or mediator flavor?

Interface (IRemoteService) Mediator (IRemoteRequest<T>)
Contract Service interface with methods One record per request
Feels like Injected typed service MediatR-style Send
Cross-cutting Endpoint conventions / decorators Pipeline behaviors (client + server)
Best when Cohesive service APIs, easy mocking CQRS-style apps, per-request pipelines

Building from source

dotnet build
dotnet test
dotnet pack -c Release -o artifacts/packages

License

MIT

About

No description, website, or topics provided.

Resources

Stars

3 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages