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.