NE.Standard

Web hosting

Web is the platform implemented today: ASP.NET Core for the shell and the transport, SignalR for the live channel, and a TypeScript client embedded in the assembly.

Startup

WebStartupBuilder.Configure<TWebStartup, TStartup>(services) drives a WebStartupBase<TStartup> subclass:

internal sealed class AppWebStartup : WebStartupBase<AppStartup>
{
    protected override void ConfigureServices(IServiceCollection services)
    {
        _ = services.AddStandardRenderers();
        _ = services.AddMaterialWebIcons(MaterialIconStyle.Fill | MaterialIconStyle.Outlined, AppIcons.All());
    }
}

WebStartupBase<TStartup>.Configure registers the render cache, endpoint and compression option types, calls your ConfigureServices override, then registers the framework's own web defaults: response compression (configured, not yet installed), the SignalR hub with the framework's JSON wire settings, IWebAssetRegistry, IWebRendererRegistry, IWebViewRenderer, the file-system render cache (IWebViewRenderCache), the update sink and the dialog/download/upload services — and finally UIStartupBuilder.Configure<TStartup>, which wires the platform-agnostic engine (platforms says where that seam runs).

Hosting itself is three lines:

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
WebStartupBuilder.Configure<AppWebStartup, AppStartup>(builder.Services);
WebApplication app = builder.Build();
await app.MapStandardUIWebAsync();
await app.RunAsync();

MapStandardUIWebAsync

Maps, in order: every registered asset's own GET route, the file transfer endpoints (/_ne/files/*), the protected-content endpoint (/_ne/content/{key}), the response compression middleware in front of everything just mapped, the SignalR hub at /_ui/hub, and last the catch-all shell route GET /{**route} — which matches every path there is.

That last part is why an application's own static files must be served before routing. app.UseStaticFiles() left where ASP.NET puts it by default runs after routing has already matched the catch-all, stands down, and every file comes back as a rendered page with a 200 on it. Call it, and UseRouting(), by hand before the map:

app.UseStaticFiles();
app.UseRouting();

await app.MapStandardUIWebAsync();

MapStaticAssets() looks like the modern replacement and is not one here: its precompressed variants and the framework's own response compression together return an empty body for every compressible asset.

WebEndpointOptions gates the hub and the shell route behind ASP.NET authorization: RequireAuthorization (off by default) and AuthorizationPolicy (the default policy when unset). Assets and file endpoints are left open regardless — they carry their own session check.

Every response the framework maps carries X-Content-Type-Options: nosniff. The upload POST additionally refuses a cross-site request: a mismatched Origin/Sec-Fetch-Site is rejected before the session check even runs, so a page on another origin cannot fill a session's file store by making the visitor's browser post to it.

The error page a view-resolution failure routes to (UIErrorHandlingOptions.ErrorRoute) carries the real exception text in its message navigation parameter only when IncludeExceptionDetail is on — the same switch that gates a failed command's reported message (see interactions and effects). Off, it carries UIErrorHandlingOptions.ErrorPageMessage instead, translated like any other framework string.

Response compression

WebResponseCompressionOptions, on by default:

Option Meaning
Enabled whether the framework installs the middleware at all (default true)
Level the level both Brotli and Gzip run at — Optimal, not the providers' own default of Fastest; at Fastest, Brotli is worse than Gzip while being the one a browser is actually served
EnableForHttps whether compression also applies over HTTPS (default true) — turn it off if a page renders a secret beside attacker-controlled text (BREACH); a page here carries no anti-forgery token to begin with, since the session is an HttpOnly cookie and commands travel the hub

Turn Enabled off where a reverse proxy already compresses. A host that maps endpoints inside UseEndpoints hands the framework something that is not an IApplicationBuilder; it logs that it could not install the middleware rather than skipping silently.

The render cache

A page with a controller renders twice per request: once for the view's shared shape — cached — and once with this session's own values. WebViewRenderCacheOptions:

services.Configure<WebViewRenderCacheOptions>(options =>
{
    options.DirectoryPath = "/var/ne-render-cache";
    options.ClearOnStartup = true;
});

DirectoryPath is where FileSystemWebViewRenderCache keeps the cached shape, keyed by {ViewKey}:{Language}. ClearOnStartup (default true) wipes it on boot, so a deployed change to a view is never served from a stale entry left by the previous build.

The files are what survives a restart and what a second process shares; each entry is also held in memory once it has been read, so a page load costs a dictionary lookup rather than the files again. A page with a controller reads the entry for its init bindings alone and renders the rest itself, and without the memory layer it read the whole shape off disk on every request only to drop it.

Assets and icon packs

IWebAssetRegistry collects every WebAssetDescriptor the host serves — the client bundle, stylesheets, icon packs — each mapped to its own GET route by MapStandardUIWebAsync, versioned (?v=) and cached immutable for a year when the version matches, no-cache with an ETag otherwise. AddStandardRenderers() registers an IWebComponentRenderer for every built-in component; call it once from ConfigureServices.

Icons ship as separate packages. Lucide:

services.AddLucideWebIcons("chevron-down", "trash-2", "settings");
services.AddLucideWebIcons(LucideIconScope.All); // the whole set, ~700 KB

Material Symbols:

services.AddMaterialWebIcons(MaterialIconStyle.Fill | MaterialIconStyle.Outlined, "home", "search");
services.AddMaterialWebIcons("home", "search"); // MaterialIconStyle.Fill
services.AddMaterialWebIcons(MaterialIconScope.All, MaterialIconStyle.Fill); // the whole set, ~3 MB per style

Call either as many times as suits the application — a feature can register its own icons where it lives, and the pack builds one stylesheet from everything registered by the time the host starts.

Sessions on the web

The session id travels in an HttpOnly cookie named by UISessionOptions.ClientKey ("ne.ui.session" by default) — written only by the shell render, and read by the hub off its negotiate request, which is what makes both halves of one page load share a session.

UISecurityOptions.IdentitySource says where identity comes from: Session (the default) means the application signs users in itself through Context.SignInAsync; Claims makes the host's ClaimsPrincipal — filled from HttpContext.User — authoritative in both directions, mapped by an IUserClaimsMapper. Either way, the framework consumes a principal and never authenticates: the host owns AddAuthentication() and whatever scheme populates HttpContext.User.

The page lifecycle

A request renders the page server-side — markup, theme CSS and a metadata blob describing every binding, property, event and interaction — and that markup is the finished page; nothing the client does afterwards changes what the reader is looking at. The browser then attaches over SignalR, which sends the initial change set (scalar values plus any collection the server could not render statically), and from then on two channels apply changes: the invoke return value of a command, and the pushed "ui.changes"/"ui.commandResult" messages the runtime's flush loop drives. A reload re-renders from scratch; state survives it if and only if it lives in the controller — nothing client-side is replayed onto a fresh page.

Extending the platform

A custom component's HTML comes from an IWebComponentRenderer (ComponentTypeKey, Render(WebRenderContext)), registered with services.TryAddEnumerable(ServiceDescriptor.Singleton<IWebComponentRenderer, MyRenderer>()) — the same pattern AddStandardRenderers() uses for every built-in.

On the client, window.NEStandardUI exposes six register* extension points, all keyed by a plain string: ConverterRegistry (a named convert function), EventCatalog (a native DOM event name and/or a custom attach), DomOperationRegistry (a WebDomOperationKind → DOM mutation, and it can override a built-in one), ValueReaderRegistry (data-ui-value-kind → how a written value is read off an element), the effect registry for ClientEffect kinds, and client-strings.ts for words the client writes itself.

A custom client effect names its own ClientEffect.Kind (prefix it — the client keys handlers by this string) and registers a handler with NEStandardUI.addEffect; nothing needs registering on the server side, since an effect travels by value.GetType() and nothing in the framework reads one back.

This is a pointer, not a tutorial: read the client's sources under src/Platforms/Web/NE.Standard.UI.Web/Client before adding to any of these.