WebSocket endpoint
The ASP.NET Core connection adapter — subclass one handler for the authenticator and codec; accept, framing, lifecycle, and teardown are framework.
Elarion.Connections.AspNetCore ships the first adapter, shaped for device gateways: the endpoint
owns accept, message reassembly (size-capped), registry lifecycle, and teardown; the app supplies an
authenticator and a codec by subclassing WebSocketConnectionHandler. For device links, the
provisioning chain behind the authenticator — pairing codes, per-device keys, the HMAC
challenge/response — is device identity (Elarion.Devices).
// HmacChallengeVerifier + DevicePrincipal come from Elarion.Devices — pairing codes, key storage,
// and the constant-time verification are covered on the device identity page. The handler is a
// factory: it creates one session per upgrade request; per-connection state lives on the session.
public sealed class GatewaySocketHandler(IActorSystem actors, HmacChallengeVerifier verifier)
: WebSocketConnectionHandler {
public override ValueTask<WebSocketConnectionSession?> CreateSessionAsync(
HttpContext context, CancellationToken ct) {
return ValueTask.FromResult<WebSocketConnectionSession?>(new GatewaySession(actors, verifier));
}
}
public sealed class GatewaySession(IActorSystem actors, HmacChallengeVerifier verifier)
: WebSocketConnectionSession {
// In-socket challenge/response (HTTP-level token auth works too — the HttpContext is available).
public override async ValueTask<ClientConnectionTicket?> AuthenticateAsync(
WebSocketHandshakeContext handshake, CancellationToken ct) {
var nonce = HmacChallengeVerifier.CreateNonce();
await handshake.SendTextAsync($"challenge:{Convert.ToBase64String(nonce)}", ct);
var answer = await handshake.ReceiveTextAsync(ct); // "deviceId:base64(mac)"
if (answer?.Split(':', 2) is not [var deviceId, var mac]) {
return null; // → PolicyViolation close, nothing registered
}
var principal = await verifier.VerifyAsync(deviceId, nonce, Convert.FromBase64String(mac), ct);
if (principal is null) {
return null;
}
return new ClientConnectionTicket {
Principal = principal,
PrincipalId = deviceId, // the registry indexes by this
Metadata = new Dictionary<string, string> { ["channel"] = "telemetry" },
};
}
public override IClientConnectionProtocol CreateProtocol(WebSocketClientConnection connection) =>
new GatewayCodec(connection, actors);
}
// The codec: parse the device's frames, route into the digital-twin actor, answer over raw sends.
internal sealed class GatewayCodec(WebSocketClientConnection connection, IActorSystem actors)
: IClientConnectionProtocol {
public async ValueTask OnTextAsync(string message, CancellationToken ct) {
var frame = GatewayFrame.Parse(message); // your wire format
var twin = actors.Get<IDeviceTwin>(connection.Connection.PrincipalId!);
await twin.ApplyAsync(frame); // the actor serializes all channels + UI actions
}
}builder.Services.AddSingleton<GatewaySocketHandler>();
var app = builder.Build();
app.UseWebSockets();
app.MapElarionConnectionSocket<GatewaySocketHandler>("/gateway/ws",
o => o.MaxMessageBytes = 64 * 1024);Everything after the handshake is framework: the connection is minted (v7 id, Transport = "websocket"), registered, observed, pumped, and unregistered on every exit path — client close,
abrupt death, oversized message (MessageTooBig), codec exception (logged, closed), host shutdown.
Required opening work
IClientConnectionObserver.OnConnectedAsync remains best-effort: observers are deliberately
failure-isolated and suit presence/client-event projections. A codec that cannot process frames until
it has completed required work (for example, attaching the authenticated link to a keyed device actor)
implements IClientConnectionProtocol.OnOpenedAsync. It runs exactly once after registry
registration and observer visibility, before the first inbound frame. It is awaited; a failure or
cancellation closes the link, calls OnClosedAsync, and unregisters it without delivering a frame.
For tests, choose the lowest faithful tier: SimulatedClientConnection for kernel/observer work,
InMemoryTcpLink (or loopback TCP) for a framed TCP lifecycle, and the separate
Elarion.Connections.AspNetCore.Simulation package's WebSocketTestHost for a real Kestrel upgrade,
handshake, codec, and close path — see Simulation & testing.
Per-connection settings and bindings
Settings can vary per connection on one route: CreateSessionAsync(HttpContext, …) resolves the
binding configuration from the upgrade request (route values, query, headers) before the socket is
accepted, and the session's Settings returns WebSocketConnectionSettings (size cap, idle window,
keep-alive interval, transport tag — nulls inherit the endpoint options). And where the
TCP adapter needs a runtime endpoint manager (ports are OS
resources), WebSocket bindings-as-data are just routing: map one wildcard route
(/gateway/{binding}/ws) and let CreateSessionAsync consult the binding row per connection — an
unknown or disabled binding returns null and the request is rejected with 403 before the socket is
even accepted; a changed binding takes effect the next time the device connects (close its current
connections via the registry to force it).
Client connections
Long-lived bidirectional links — device gateways and interactive clients — over a transport-neutral kernel; the app writes only the handshake and the codec.
TCP endpoints
Raw-socket listeners and dialers over the same handler/codec seams — framing, runtime endpoint management, TLS, bounded outbound backpressure, deterministic close.