diff --git a/DevProxy.Abstractions/Proxy/Http/IProxySession.cs b/DevProxy.Abstractions/Proxy/Http/IProxySession.cs index 550da2fd..9bec624d 100644 --- a/DevProxy.Abstractions/Proxy/Http/IProxySession.cs +++ b/DevProxy.Abstractions/Proxy/Http/IProxySession.cs @@ -62,6 +62,16 @@ public interface IProxySession /// void Respond(ReadOnlyMemory body, HttpStatusCode statusCode, IEnumerable headers); + /// + /// WebSocket messages captured for a WebSocket connection. Populated by the proxy + /// engine after the upgrade handshake, for both relayed (pass-through) traffic and + /// per-message interception — including the mock-only fallback when the origin is + /// unreachable. Empty for non-WebSocket exchanges, for connections mocked via + /// , and for connections the engine chose not to + /// capture (e.g.\ requests that aren't being watched). + /// + IReadOnlyList WebSocketMessages { get; } + /// /// Mocks a WebSocket exchange: when this request is a WebSocket upgrade /// (), the engine completes the @@ -71,4 +81,24 @@ public interface IProxySession /// the plugin declares intent here during BeforeRequest and the engine executes it. /// void HandleWebSocket(Func handler); + + /// + /// Registers a per-message WebSocket interceptor. Unlike , + /// the engine still connects to the origin and relays traffic, but each client→origin + /// message is passed through first. When the interceptor + /// returns true, the message is considered handled (not forwarded to the origin); + /// when it returns false, the message is forwarded normally. This mirrors how HTTP + /// mock plugins selectively mock matched requests while passing through the rest. + /// + /// + /// Called for each client→origin message. Receives the message, a connection to send + /// responses back to the client, and a cancellation token. Returns true if handled. + /// + /// + /// Optional callback invoked once after the WebSocket handshake completes. Can be used + /// to send initial messages (e.g.\ OnConnect mock messages) to the client. + /// + void InterceptWebSocketMessages( + Func> interceptor, + Func? onConnected); } diff --git a/DevProxy.Abstractions/Proxy/Http/WebSocketMessageRecord.cs b/DevProxy.Abstractions/Proxy/Http/WebSocketMessageRecord.cs new file mode 100644 index 00000000..f844ed9f --- /dev/null +++ b/DevProxy.Abstractions/Proxy/Http/WebSocketMessageRecord.cs @@ -0,0 +1,42 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Net.WebSockets; +using System.Text; + +namespace DevProxy.Abstractions.Proxy.Http; + +/// +/// Direction of a captured WebSocket message relative to the client. +/// +public enum WebSocketMessageDirection +{ + /// Client → origin (the client sent this message). + Send, + + /// Origin → client (the server sent this message). + Receive +} + +/// +/// A timestamped record of a WebSocket message that flowed through the proxy, +/// captured for reporting (e.g.\ HAR generation). Follows the Chrome/mitmproxy +/// _webSocketMessages convention. +/// +/// Whether the message was sent by the client or received from the server. +/// +/// The WebSocket message type (, +/// , or ). +/// +/// The reassembled message payload (empty for close frames). +/// When the message was observed by the proxy. +public sealed record WebSocketMessageRecord( + WebSocketMessageDirection Direction, + WebSocketMessageType Type, + ReadOnlyMemory Data, + DateTimeOffset Timestamp) +{ + /// The payload decoded as UTF-8 text (useful for text messages). + public string Text => Encoding.UTF8.GetString(Data.Span); +} diff --git a/DevProxy.Integration.Tests/GenerationPluginsIntegrationTests.cs b/DevProxy.Integration.Tests/GenerationPluginsIntegrationTests.cs index b323ea8f..f9b0bfe0 100644 --- a/DevProxy.Integration.Tests/GenerationPluginsIntegrationTests.cs +++ b/DevProxy.Integration.Tests/GenerationPluginsIntegrationTests.cs @@ -3,8 +3,12 @@ // See the LICENSE file in the project root for more information. using System.Net; +using System.Net.WebSockets; +using System.Text; +using System.Text.Json; using DevProxy.Abstractions.Models; using DevProxy.Abstractions.Proxy; +using DevProxy.Abstractions.Proxy.Http; using DevProxy.Abstractions.Utils; using DevProxy.Plugins.Generation; using Microsoft.Extensions.Logging.Abstractions; @@ -84,6 +88,59 @@ public async Task HarGenerator_WritesHarFile() } } + [Fact] + public async Task HarGenerator_WritesWebSocketMessages() + { + var plugin = new HarGeneratorPlugin( + TestDefaults.HttpClient, + NullLogger.Instance, + Watch, + new TestProxyConfiguration(), + PluginConfig.Empty()); + + var t0 = DateTimeOffset.FromUnixTimeMilliseconds(1_700_000_000_500); + var wsExchange = TestExchange.WebSocket( + "https://api.contoso.com/socket", + new WebSocketMessageRecord(WebSocketMessageDirection.Send, WebSocketMessageType.Text, Encoding.UTF8.GetBytes("ping"), t0), + new WebSocketMessageRecord(WebSocketMessageDirection.Receive, WebSocketMessageType.Text, Encoding.UTF8.GetBytes("pong"), t0.AddMilliseconds(10)), + new WebSocketMessageRecord(WebSocketMessageDirection.Send, WebSocketMessageType.Binary, new byte[] { 0x01, 0x02, 0x03 }, t0.AddMilliseconds(20))) + .AsRequestLog(MessageType.InterceptedResponse); + + var args = Recording([wsExchange]); + var dir = await InTempCwdAsync(() => plugin.AfterRecordingStopAsync(args, CancellationToken.None)); + try + { + var har = dir.GetFiles("devproxy-*.har").Single(); + using var doc = JsonDocument.Parse(await File.ReadAllTextAsync(har.FullName)); + var entry = doc.RootElement.GetProperty("log").GetProperty("entries").EnumerateArray().Single(); + + Assert.Equal("websocket", entry.GetProperty("_resourceType").GetString()); + + var messages = entry.GetProperty("_webSocketMessages").EnumerateArray().ToArray(); + Assert.Equal(3, messages.Length); + + // send text "ping": type=send, opcode=1 (RFC 6455 text), data is raw UTF-8. + Assert.Equal("send", messages[0].GetProperty("type").GetString()); + Assert.Equal(1, messages[0].GetProperty("opcode").GetInt32()); + Assert.Equal("ping", messages[0].GetProperty("data").GetString()); + Assert.Equal(1_700_000_000.5, messages[0].GetProperty("time").GetDouble(), 3); + + // receive text "pong". + Assert.Equal("receive", messages[1].GetProperty("type").GetString()); + Assert.Equal(1, messages[1].GetProperty("opcode").GetInt32()); + Assert.Equal("pong", messages[1].GetProperty("data").GetString()); + + // send binary: opcode=2 (RFC 6455 binary), data is base64. + Assert.Equal("send", messages[2].GetProperty("type").GetString()); + Assert.Equal(2, messages[2].GetProperty("opcode").GetInt32()); + Assert.Equal(Convert.ToBase64String(new byte[] { 0x01, 0x02, 0x03 }), messages[2].GetProperty("data").GetString()); + } + finally + { + dir.Delete(recursive: true); + } + } + [Fact] public async Task MockGenerator_WritesMockFile() { diff --git a/DevProxy.Integration.Tests/TestExchange.cs b/DevProxy.Integration.Tests/TestExchange.cs index 34090c8b..73ea42f4 100644 --- a/DevProxy.Integration.Tests/TestExchange.cs +++ b/DevProxy.Integration.Tests/TestExchange.cs @@ -97,4 +97,22 @@ public TestExchange WithResponse( /// public RequestLog AsRequestLog(MessageType messageType = MessageType.InterceptedRequest) => new($"{Session.Request.Method} {Session.Request.Url}", messageType, new LoggingContext(Session)); + + /// + /// Builds a WebSocket upgrade exchange (GET with Upgrade: websocket + a + /// 101 Switching Protocols response) and records the supplied relayed + /// messages on the session, exactly as the engine does after the handshake. Used to + /// exercise the WebSocket HAR extension (_resourceType / _webSocketMessages). + /// + public static TestExchange WebSocket(string url, params WebSocketMessageRecord[] messages) + { + var exchange = Request("GET", url, headers: [("Upgrade", "websocket"), ("Connection", "Upgrade")]) + .WithResponse(HttpStatusCode.SwitchingProtocols); + foreach (var message in messages) + { + exchange.Session.RecordWebSocketMessage(message); + } + + return exchange; + } } diff --git a/DevProxy.Plugins/Generation/HarGeneratorPlugin.cs b/DevProxy.Plugins/Generation/HarGeneratorPlugin.cs index b0e3ed8c..cf4728bb 100644 --- a/DevProxy.Plugins/Generation/HarGeneratorPlugin.cs +++ b/DevProxy.Plugins/Generation/HarGeneratorPlugin.cs @@ -4,6 +4,7 @@ using DevProxy.Abstractions.Plugins; using DevProxy.Abstractions.Proxy; +using DevProxy.Abstractions.Proxy.Http; using DevProxy.Abstractions.Utils; using DevProxy.Plugins.Models; using DevProxy.Plugins.Utils; @@ -11,6 +12,7 @@ using Microsoft.Extensions.Logging; using System.Diagnostics; using System.Globalization; +using System.Net.WebSockets; using System.Text.Json; using System.Text.RegularExpressions; using System.Web; @@ -162,9 +164,38 @@ private HarEntry CreateHarEntry(RequestLog log) } : null }; + // Attach WebSocket messages (if any) following the Chrome/mitmproxy convention. + var wsMessages = log.Context.Session.WebSocketMessages; + if (request.IsWebSocketRequest && wsMessages.Count > 0) + { + entry.ResourceType = "websocket"; + entry.WebSocketMessages = [.. wsMessages.Select(m => + { + var isText = m.Type == WebSocketMessageType.Text; + return new HarWebSocketMessage + { + Type = m.Direction == WebSocketMessageDirection.Send ? "send" : "receive", + Time = m.Timestamp.ToUnixTimeMilliseconds() / 1000.0, + Opcode = ToRfc6455Opcode(m.Type), + Data = isText ? m.Text : Convert.ToBase64String(m.Data.Span) + }; + })]; + } + return entry; } + // Maps the framework WebSocketMessageType to the RFC 6455 opcode used by the + // Chrome DevTools / mitmproxy _webSocketMessages convention (1=text, 2=binary, + // 8=close). WebSocketMessageType values (0/1/2) are NOT the wire opcodes. + private static int ToRfc6455Opcode(WebSocketMessageType type) => type switch + { + WebSocketMessageType.Text => 1, + WebSocketMessageType.Binary => 2, + WebSocketMessageType.Close => 8, + _ => 1 + }; + private static string UnescapeSurrogatePairs(string json) { return surrogatePairRegex.Replace(json, match => diff --git a/DevProxy.Plugins/Mocking/WebSocketMockResponsePlugin.cs b/DevProxy.Plugins/Mocking/WebSocketMockResponsePlugin.cs index 897cfed7..085ec130 100644 --- a/DevProxy.Plugins/Mocking/WebSocketMockResponsePlugin.cs +++ b/DevProxy.Plugins/Mocking/WebSocketMockResponsePlugin.cs @@ -32,19 +32,22 @@ public sealed class WebSocketMockResponseConfiguration } /// -/// Mocks WebSocket conversations: matched ws:///wss:// upgrades are answered -/// by the proxy itself (the origin is never contacted) and a scripted exchange runs over -/// the connection. This is the WebSocket analogue of . +/// Mocks WebSocket messages: matched ws:///wss:// upgrades are connected +/// to the origin normally, but individual client messages that match a mock rule are +/// intercepted — the mock response is sent to the client and the message is not forwarded +/// to the origin. Unmatched messages pass through to the origin, just like HTTP mocking. /// /// /// BeforeRequest: is this a watched WebSocket upgrade with a matching mock? /// │ yes /// ▼ -/// session.HandleWebSocket(handler) ── engine completes the handshake, then runs: +/// session.InterceptWebSocketMessages(interceptor, onConnected) /// │ -/// ├─ send each OnConnect message -/// └─ loop: receive client message → first matching Rule → send Responses -/// (no match → CloseOnUnmatched ? close : ignore) +/// ├─ onConnected: send each OnConnect message to the client +/// └─ interceptor: for each client message: +/// ├─ matches a Rule → send mock Responses, don't forward to origin +/// ├─ no match → forward to origin (passthrough) +/// └─ CloseOnUnmatched? close the connection /// /// public sealed class WebSocketMockResponsePlugin( @@ -164,62 +167,68 @@ public override Task BeforeRequestAsync(ProxyRequestArgs e, CancellationToken ca // Clone so concurrent connections don't share/mutate the same instance. var scripted = (WebSocketMock)mock.Clone(); - // NOTE: do NOT set ResponseState.HasBeenSet here. The mock is served over the - // WebSocket transport, not as an HTTP response — leaving the session in the - // Watched phase (no HTTP response) lets the engine's IsWebSocketRequest branch - // dispatch to the WebSocketMockResponder. Setting HasBeenSet would short-circuit - // the pipeline into the Mocked/ResponseWriter path and corrupt the handshake. - e.ProxySession.HandleWebSocket((connection, ct) => RunMockAsync(scripted, connection, ct)); + // Register a per-message interceptor: the engine connects to the origin and + // relays traffic, but each client→origin message is offered to our interceptor + // first. Matched messages get mock responses; unmatched ones pass through. + e.ProxySession.InterceptWebSocketMessages( + interceptor: (message, client, ct) => InterceptMessageAsync(scripted, message, client, ct), + onConnected: scripted.OnConnect.Any() + ? (client, ct) => SendOnConnectAsync(scripted, client, ct) + : null); - Logger.LogRequest($"Mocking WebSocket {request.Url}", MessageType.Mocked, new LoggingContext(e.ProxySession)); + Logger.LogRequest($"Intercepting WebSocket {request.Url}", MessageType.Mocked, new LoggingContext(e.ProxySession)); Logger.LogTrace("Left {Name}", nameof(BeforeRequestAsync)); return Task.CompletedTask; } - // ── mock conversation pump ────────────────────────────────────────────── + // ── per-message interceptor ──────────────────────────────────────────── // - // send OnConnect messages - // while open: - // receive client message - // └─ first Rule whose Match matches → send Responses (+ optional close) - // no match → CloseOnUnmatched ? close : keep listening - private static async Task RunMockAsync(WebSocketMock mock, IWebSocketConnection connection, CancellationToken ct) + // onConnected: send OnConnect messages + // interceptor: for each client message: + // └─ first Rule whose Match matches → send Responses (+ optional close) → return true + // no match → CloseOnUnmatched ? close + return true : return false (passthrough) + + private static async Task SendOnConnectAsync( + WebSocketMock mock, IWebSocketConnection client, CancellationToken ct) { foreach (var message in mock.OnConnect) { - await SendAsync(connection, message, ct).ConfigureAwait(false); + await SendAsync(client, message, ct).ConfigureAwait(false); } + } - while (!ct.IsCancellationRequested) + private static async Task InterceptMessageAsync( + WebSocketMock mock, WebSocketMessage message, IWebSocketConnection client, CancellationToken ct) + { + if (message.Type == FrameworkWsMessageType.Close) { - var received = await connection.ReceiveAsync(ct).ConfigureAwait(false); - if (received is null || received.Type == FrameworkWsMessageType.Close) - { - break; - } + return false; // let the relay handle close propagation + } - var text = received.Text; - var rule = mock.Rules.FirstOrDefault(r => WebSocketMessageMatcher.Matches(r.Match, text)); - if (rule is null) + var text = message.Text; + var rule = mock.Rules.FirstOrDefault(r => WebSocketMessageMatcher.Matches(r.Match, text)); + if (rule is null) + { + if (mock.CloseOnUnmatched) { - if (mock.CloseOnUnmatched) - { - break; - } - continue; + await client.CloseAsync(ct).ConfigureAwait(false); + return true; } + return false; // no match — forward to origin + } - foreach (var response in rule.Responses) - { - await SendAsync(connection, response, ct).ConfigureAwait(false); - } + foreach (var response in rule.Responses) + { + await SendAsync(client, response, ct).ConfigureAwait(false); + } - if (rule.CloseAfter) - { - break; - } + if (rule.CloseAfter) + { + await client.CloseAsync(ct).ConfigureAwait(false); } + + return true; // handled — don't forward to origin } private static Task SendAsync(IWebSocketConnection connection, WebSocketMessageMock message, CancellationToken ct) diff --git a/DevProxy.Plugins/Models/Har.cs b/DevProxy.Plugins/Models/Har.cs index e0fb7635..c20069ea 100644 --- a/DevProxy.Plugins/Models/Har.cs +++ b/DevProxy.Plugins/Models/Har.cs @@ -2,6 +2,8 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Text.Json.Serialization; + namespace DevProxy.Plugins.Models; internal sealed class HarFile @@ -30,6 +32,20 @@ internal sealed class HarEntry public HarResponse? Response { get; set; } public HarCache Cache { get; set; } = new(); public HarTimings Timings { get; set; } = new(); + + /// + /// Custom HAR extension field. Set to "websocket" for WebSocket upgrade + /// entries (Chrome/mitmproxy convention). + /// + [JsonPropertyName("_resourceType")] + public string? ResourceType { get; set; } + + /// + /// Custom HAR extension field containing captured WebSocket messages for + /// entries where is "websocket". + /// + [JsonPropertyName("_webSocketMessages")] + public List? WebSocketMessages { get; set; } } internal sealed class HarRequest @@ -114,4 +130,23 @@ internal sealed class HarTimings public double Send { get; set; } public double Wait { get; set; } public double Receive { get; set; } +} + +/// +/// A single WebSocket message in the _webSocketMessages HAR extension array. +/// Follows the Chrome DevTools / mitmproxy convention. +/// +internal sealed class HarWebSocketMessage +{ + /// "send" (client → server) or "receive" (server → client). + public string? Type { get; set; } + + /// Epoch timestamp (seconds since Unix epoch, with fractional ms). + public double Time { get; set; } + + /// WebSocket opcode (1 = text, 2 = binary, 8 = close). + public int Opcode { get; set; } + + /// Message payload: UTF-8 text for text messages, base64 for binary. + public string? Data { get; set; } } \ No newline at end of file diff --git a/DevProxy.Proxy.Kestrel.Tests/WebSocketRelayTests.cs b/DevProxy.Proxy.Kestrel.Tests/WebSocketRelayTests.cs index a2e61d02..c8e7f04b 100644 --- a/DevProxy.Proxy.Kestrel.Tests/WebSocketRelayTests.cs +++ b/DevProxy.Proxy.Kestrel.Tests/WebSocketRelayTests.cs @@ -2,8 +2,10 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Collections.Concurrent; using System.Net; using System.Net.Sockets; +using System.Net.WebSockets; using System.Text; using DevProxy.Abstractions.Proxy.Http; using DevProxy.Proxy.Kestrel.Http; @@ -55,7 +57,7 @@ public async Task RelayAsync_ReplaysHandshake_RelaysFrames_AndReportsResponse() { using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); - // Fake origin: capture the replayed handshake, answer 101 + a "frame", echo back. + // Fake origin: capture the replayed handshake, answer 101, exchange WebSocket messages. using var originListener = new TcpListener(IPAddress.Loopback, 0); originListener.Start(); var originPort = ((IPEndPoint)originListener.LocalEndpoint).Port; @@ -76,20 +78,33 @@ public async Task RelayAsync_ReplaysHandshake_RelaysFrames_AndReportsResponse() "GET", new Uri($"http://127.0.0.1:{originPort}/chat?room=1"), HttpVersion.Version11, headers, ReadOnlyMemory.Empty); MutableHttpResponse? observed = null; + var capturedMessages = new ConcurrentQueue(); var relay = new WebSocketRelay(NullLogger.Instance); var relayTask = relay.RelayAsync( proxySide, request, request.RequestUri, - r => { observed = r; return Task.CompletedTask; }, cts.Token); + r => { observed = r; return Task.CompletedTask; }, + msg => capturedMessages.Enqueue(msg), + messageInterceptor: null, onConnected: null, cts.Token); - // Client reads the 101 handshake the proxy wrote back, then the origin's frame. + // Client reads the 101 handshake the proxy wrote back. var handshakeBack = await ReadUntilDoubleCrlfAsync(clientSide, cts.Token); Assert.StartsWith("HTTP/1.1 101 Switching Protocols", handshakeBack, StringComparison.Ordinal); Assert.Contains("Sec-WebSocket-Accept: abc123", handshakeBack, StringComparison.Ordinal); - Assert.Equal("origin-frame", await ReadTextAsync(clientSide, "origin-frame".Length, cts.Token)); - // Client → origin frame is spliced through. - await clientSide.WriteAsync(Encoding.ASCII.GetBytes("client-frame"), cts.Token); - await clientSide.FlushAsync(cts.Token); + // Wrap client side as a WebSocket to exchange proper frames. + using var clientWs = WebSocket.CreateFromStream( + clientSide, new WebSocketCreationOptions { IsServer = false, KeepAliveInterval = Timeout.InfiniteTimeSpan }); + + // Receive "origin-frame" sent by the fake origin. + var receiveBuffer = new byte[1024]; + var receiveResult = await clientWs.ReceiveAsync(receiveBuffer, cts.Token); + Assert.Equal(WebSocketMessageType.Text, receiveResult.MessageType); + Assert.Equal("origin-frame", Encoding.UTF8.GetString(receiveBuffer, 0, receiveResult.Count)); + + // Client → origin WebSocket message. + await clientWs.SendAsync( + Encoding.UTF8.GetBytes("client-frame"), WebSocketMessageType.Text, endOfMessage: true, cts.Token); + await clientWs.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, null, cts.Token); var replayed = await originHandshakeText.Task; // Replayed in origin-form, preserving the WebSocket headers, dropping proxy ones. @@ -102,16 +117,22 @@ public async Task RelayAsync_ReplaysHandshake_RelaysFrames_AndReportsResponse() Assert.NotNull(observed); Assert.Equal(HttpStatusCode.SwitchingProtocols, observed!.StatusCode); - var echoed = await originTask; // origin returns what it received after the handshake + var echoed = await originTask; // origin returns what it received Assert.Equal("client-frame", echoed); - clientSide.Dispose(); proxySide.Dispose(); await relayTask; + + // Verify captured messages (origin→client "origin-frame", client→origin "client-frame", close). + var messages = capturedMessages.ToArray(); + Assert.True(messages.Length >= 2, $"Expected at least 2 messages, got {messages.Length}"); + Assert.Equal(WebSocketMessageDirection.Receive, messages[0].Direction); + Assert.Equal("origin-frame", messages[0].Text); + Assert.Equal(WebSocketMessageDirection.Send, messages[1].Direction); + Assert.Equal("client-frame", messages[1].Text); } - // Fake origin: read the request head, reply 101 + "origin-frame", then read one - // post-handshake chunk and return it (so the test can assert client→origin splicing). + // Fake origin: read the request head, reply 101, send a text message, receive one back. private static async Task RunFakeOriginAsync( TcpListener listener, TaskCompletionSource handshakeText, CancellationToken ct) { @@ -121,16 +142,32 @@ private static async Task RunFakeOriginAsync( var head = await ReadUntilDoubleCrlfAsync(stream, ct); handshakeText.SetResult(head); - var response = Encoding.ASCII.GetBytes( + var responseHead = Encoding.ASCII.GetBytes( "HTTP/1.1 101 Switching Protocols\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + - "Sec-WebSocket-Accept: abc123\r\n\r\n" + - "origin-frame"); - await stream.WriteAsync(response, ct); + "Sec-WebSocket-Accept: abc123\r\n\r\n"); + await stream.WriteAsync(responseHead, ct); await stream.FlushAsync(ct); - return await ReadTextAsync(stream, "client-frame".Length, ct); + // Use a proper WebSocket to exchange frames. + using var ws = WebSocket.CreateFromStream( + stream, new WebSocketCreationOptions { IsServer = true, KeepAliveInterval = Timeout.InfiniteTimeSpan }); + await ws.SendAsync( + Encoding.UTF8.GetBytes("origin-frame"), WebSocketMessageType.Text, endOfMessage: true, ct); + + var buffer = new byte[1024]; + var result = await ws.ReceiveAsync(buffer, ct); + var received = Encoding.UTF8.GetString(buffer, 0, result.Count); + + // Wait for close from client, then close our side. + var closeResult = await ws.ReceiveAsync(buffer, ct); + if (closeResult.MessageType == WebSocketMessageType.Close) + { + await ws.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, null, ct); + } + + return received; } private static async Task ReadUntilDoubleCrlfAsync(Stream stream, CancellationToken ct) @@ -154,20 +191,4 @@ private static async Task ReadUntilDoubleCrlfAsync(Stream stream, Cancel } return Encoding.ASCII.GetString(bytes.ToArray()); } - - private static async Task ReadTextAsync(Stream stream, int byteCount, CancellationToken ct) - { - var buffer = new byte[byteCount]; - var offset = 0; - while (offset < byteCount) - { - var read = await stream.ReadAsync(buffer.AsMemory(offset), ct); - if (read == 0) - { - break; - } - offset += read; - } - return Encoding.ASCII.GetString(buffer, 0, offset); - } } diff --git a/DevProxy.Proxy.Kestrel/DevProxy.Proxy.Kestrel.csproj b/DevProxy.Proxy.Kestrel/DevProxy.Proxy.Kestrel.csproj index bb6142cf..a323735b 100644 --- a/DevProxy.Proxy.Kestrel/DevProxy.Proxy.Kestrel.csproj +++ b/DevProxy.Proxy.Kestrel/DevProxy.Proxy.Kestrel.csproj @@ -19,6 +19,7 @@ + diff --git a/DevProxy.Proxy.Kestrel/Http/CanonicalProxySession.cs b/DevProxy.Proxy.Kestrel/Http/CanonicalProxySession.cs index acbf9d4b..4aa89ac5 100644 --- a/DevProxy.Proxy.Kestrel/Http/CanonicalProxySession.cs +++ b/DevProxy.Proxy.Kestrel/Http/CanonicalProxySession.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Collections.Concurrent; using System.Net; using DevProxy.Abstractions.Proxy.Http; @@ -19,6 +20,9 @@ public sealed class CanonicalProxySession : IProxySession private MutableHttpResponse? _response; private Func? _webSocketHandler; + private Func>? _wsInterceptor; + private Func? _wsOnConnected; + private readonly ConcurrentQueue _webSocketMessages = new(); public CanonicalProxySession(string sessionId, MutableHttpRequest request, int? processId) : this(sessionId, request, processId, requestId: 0) @@ -78,6 +82,24 @@ public CanonicalProxySession(string sessionId, MutableHttpRequest request, int? /// The plugin-supplied WebSocket mock handler, or null. public Func? WebSocketHandler => _webSocketHandler; + /// True when a plugin registered a per-message WebSocket interceptor. + public bool HasWebSocketInterceptor => _wsInterceptor is not null; + + /// The per-message interceptor, or null. + public Func>? WebSocketMessageInterceptor => _wsInterceptor; + + /// Callback to run after the WebSocket handshake completes, or null. + public Func? WebSocketOnConnected => _wsOnConnected; + + /// + public IReadOnlyList WebSocketMessages => [.. _webSocketMessages]; + + /// + /// Records a WebSocket message observed during the relay. Thread-safe — called + /// concurrently from the client→origin and origin→client relay tasks. + /// + internal void RecordWebSocketMessage(WebSocketMessageRecord message) => _webSocketMessages.Enqueue(message); + /// /// Sets the response received from the origin. Does not flag the exchange as /// plugin-mocked. @@ -117,4 +139,14 @@ public void HandleWebSocket(Func ArgumentNullException.ThrowIfNull(handler); _webSocketHandler = handler; } + + /// + public void InterceptWebSocketMessages( + Func> interceptor, + Func? onConnected) + { + ArgumentNullException.ThrowIfNull(interceptor); + _wsInterceptor = interceptor; + _wsOnConnected = onConnected; + } } diff --git a/DevProxy.Proxy.Kestrel/Internal/ProxyConnectionHandler.cs b/DevProxy.Proxy.Kestrel/Internal/ProxyConnectionHandler.cs index c4a99f88..79408e46 100644 --- a/DevProxy.Proxy.Kestrel/Internal/ProxyConnectionHandler.cs +++ b/DevProxy.Proxy.Kestrel/Internal/ProxyConnectionHandler.cs @@ -8,6 +8,7 @@ using System.Net; using System.Net.Security; using System.Net.Sockets; +using System.Net.WebSockets; using System.Text; using DevProxy.Abstractions.Proxy.Http; using DevProxy.Proxy.Kestrel.Http; @@ -379,6 +380,13 @@ async Task OnHandshakeAsync(MutableHttpResponse handshakeResponse) try { + // Only capture messages when the request is watched — nothing consumes + // IProxySession.WebSocketMessages otherwise, and capturing every frame on + // long-lived/high-volume sockets would grow memory unbounded. + Action? onMessage = phase == RequestPhase.Watched + ? session.RecordWebSocketMessage + : null; + if (session.WebSocketHandledByPlugin) { await _webSocketMockResponder.RespondAsync( @@ -386,7 +394,29 @@ await _webSocketMockResponder.RespondAsync( } else { - await _webSocketRelay.RelayAsync(clientStream, request, requestUri, OnHandshakeAsync, ct).ConfigureAwait(false); + var relayed = await _webSocketRelay.RelayAsync(clientStream, request, requestUri, OnHandshakeAsync, + onMessage, + session.WebSocketMessageInterceptor, + session.WebSocketOnConnected, + ct).ConfigureAwait(false); + + // Origin unreachable but a per-message interceptor is registered → + // fall back to mock-only mode: the proxy becomes the WebSocket server + // and runs the interceptor loop without an origin, exactly like + // HandleWebSocket but driven by the interceptor callbacks. + if (!relayed && session.HasWebSocketInterceptor) + { + logger.LogDebug("Origin unreachable for {Url}; falling back to interceptor-only WebSocket mode", absoluteUrl); + await _webSocketMockResponder.RespondAsync( + clientStream, request, + (connection, innerCt) => RunInterceptorOnlyAsync( + session.WebSocketMessageInterceptor!, + session.WebSocketOnConnected, + connection, + onMessage, + innerCt), + OnHandshakeAsync, ct).ConfigureAwait(false); + } } } catch (Exception ex) when (ConnectionTeardown.IsExpected(ex)) @@ -597,4 +627,50 @@ private static int GetClientPort(ConnectionContext connection) => private static Task WriteAsciiAsync(Stream stream, string text, CancellationToken ct) => stream.WriteAsync(Encoding.ASCII.GetBytes(text), ct).AsTask(); + + /// + /// Runs a per-message interceptor loop without an origin connection. Used as a + /// fallback when the origin is unreachable but a plugin registered an interceptor. + /// The proxy becomes the WebSocket server (via ) + /// and dispatches each client message through the interceptor; unmatched messages + /// are silently dropped since there is no origin to forward them to. + /// When is set, both incoming client messages and + /// interceptor/onConnected responses are captured so HAR output is complete. + /// + private static async Task RunInterceptorOnlyAsync( + Func> interceptor, + Func? onConnected, + IWebSocketConnection connection, + Action? onMessage, + CancellationToken ct) + { + // Wrap so interceptor/onConnected sends to the client are captured as "receive". + var scriptedConnection = onMessage is not null + ? new CapturingWebSocketConnection(connection, onMessage) + : connection; + + if (onConnected is not null) + { + await onConnected(scriptedConnection, ct).ConfigureAwait(false); + } + + while (!ct.IsCancellationRequested) + { + var msg = await connection.ReceiveAsync(ct).ConfigureAwait(false); + if (msg is null || msg.Type == WebSocketMessageType.Close) + { + break; + } + + // Record the incoming client message. + onMessage?.Invoke(new WebSocketMessageRecord( + WebSocketMessageDirection.Send, + msg.Type, + msg.Data, + DateTimeOffset.UtcNow)); + + // Offer to the interceptor. If not handled, drop — no origin to forward to. + _ = await interceptor(msg, scriptedConnection, ct).ConfigureAwait(false); + } + } } diff --git a/DevProxy.Proxy.Kestrel/Internal/WebSocketRelay.cs b/DevProxy.Proxy.Kestrel/Internal/WebSocketRelay.cs index 9e1b514f..e4ca47cf 100644 --- a/DevProxy.Proxy.Kestrel/Internal/WebSocketRelay.cs +++ b/DevProxy.Proxy.Kestrel/Internal/WebSocketRelay.cs @@ -6,6 +6,7 @@ using System.Net; using System.Net.Security; using System.Net.Sockets; +using System.Net.WebSockets; using System.Text; using DevProxy.Abstractions.Proxy.Http; using DevProxy.Proxy.Kestrel.Http; @@ -50,14 +51,25 @@ internal sealed class WebSocketRelay(ILogger logger) /// Connects to the origin, replays the upgrade handshake, invokes /// with the origin's parsed response (so the /// caller can run the response pipeline / log it), writes that response back to the - /// client verbatim, and — on 101 — splices frames in both directions until - /// either peer closes. + /// client verbatim, and — on 101 — relays WebSocket messages in both directions + /// until either peer closes. Each relayed message is reported via + /// (when non-null) for HAR / reporting capture. + /// When is provided, each client→origin message + /// is offered to the interceptor first; if it returns true, the message is not + /// forwarded to the origin (per-message mocking). /// - public async Task RelayAsync( + /// + /// true if the origin was reachable and the relay ran (or completed); + /// false if the origin could not be reached (caller can fall back). + /// + public async Task RelayAsync( Stream clientStream, IHttpRequest request, Uri origin, Func onHandshakeResponse, + Action? onMessage, + Func>? messageInterceptor, + Func? onConnected, CancellationToken ct) { ArgumentNullException.ThrowIfNull(clientStream); @@ -76,7 +88,7 @@ public async Task RelayAsync( catch (SocketException ex) { logger.LogDebug(ex, "WebSocket connect to {Host}:{Port} failed", origin.Host, origin.Port); - return; + return false; } // leaveInnerStreamOpen: false on the SslStream disposes the NetworkStream; the @@ -90,7 +102,7 @@ public async Task RelayAsync( if (head is null) { logger.LogDebug("WebSocket origin {Host} closed before sending a handshake response", origin.Host); - return; + return true; // origin was reachable, just closed early } var (statusCode, reason, headers, rawHead, leftover) = head.Value; @@ -101,10 +113,6 @@ public async Task RelayAsync( // Write the origin's handshake response to the client verbatim. await clientStream.WriteAsync(rawHead, ct).ConfigureAwait(false); - if (leftover.Length > 0) - { - await clientStream.WriteAsync(leftover, ct).ConfigureAwait(false); - } await clientStream.FlushAsync(ct).ConfigureAwait(false); if (statusCode != (int)HttpStatusCode.SwitchingProtocols) @@ -112,13 +120,51 @@ public async Task RelayAsync( // Origin declined the upgrade. We've relayed its response; there's no // tunnel to splice. Close (a non-101 may carry a body we don't frame yet). logger.LogDebug("WebSocket origin {Host} declined upgrade with {Status}", origin.Host, statusCode); - return; + return true; } + // Extract sub-protocol from handshake response for WebSocket creation. + var subProtocol = headers.GetFirst("Sec-WebSocket-Protocol")?.Value; + logger.LogDebug("WebSocket {Scheme}://{Host}:{Port}{Path} established", useTls ? "wss" : "ws", origin.Host, origin.Port, origin.PathAndQuery); - await StreamRelay.RelayBidirectionalAsync(clientStream, originStream, ct).ConfigureAwait(false); + // Wrap both sides as WebSocket instances for frame-level relay and message + // capture. Any leftover bytes (read past the response head) are prepended to + // the origin stream so the first origin frame isn't lost. +#pragma warning disable CA2000 // PrefixedStream is disposed via await using below; ownership is clear. + await using var prefixed = leftover.Length > 0 ? new PrefixedStream(leftover, originStream) : null; +#pragma warning restore CA2000 + Stream effectiveOriginStream = prefixed ?? originStream; + + using var clientWs = WebSocket.CreateFromStream( + clientStream, new WebSocketCreationOptions { IsServer = true, SubProtocol = subProtocol, KeepAliveInterval = KeepAliveInterval }); + using var originWs = WebSocket.CreateFromStream( + effectiveOriginStream, new WebSocketCreationOptions { IsServer = false, SubProtocol = subProtocol, KeepAliveInterval = KeepAliveInterval }); + + // When an interceptor is present, we need a send-serialized client connection + // wrapper and a semaphore — the interceptor and the origin→client relay task both + // send to the client WebSocket and must not overlap. + SemaphoreSlim? clientSendLock = messageInterceptor is not null ? new SemaphoreSlim(1, 1) : null; + InterceptorClientConnection? clientConnection = messageInterceptor is not null + ? new InterceptorClientConnection(clientWs, clientSendLock!, onMessage) + : null; + + try + { + if (onConnected is not null && clientConnection is not null) + { + await onConnected(clientConnection, ct).ConfigureAwait(false); + } + + await RelayWebSocketAsync(clientWs, originWs, onMessage, messageInterceptor, clientConnection, clientSendLock, ct).ConfigureAwait(false); + } + finally + { + clientSendLock?.Dispose(); + } + + return true; } /// @@ -266,4 +312,382 @@ internal static (int StatusCode, string Reason, HeaderCollection Headers) ParseR return (statusCode, reason, headers); } + + private static readonly TimeSpan KeepAliveInterval = TimeSpan.FromSeconds(30); + private const int ReceiveChunkSize = 8 * 1024; + + /// + /// Relays complete WebSocket messages between and + /// in both directions, capturing each message via + /// . When is set, + /// client→origin messages are offered to it first; handled messages are not + /// forwarded. Runs until either peer closes or the token fires. + /// + private static async Task RelayWebSocketAsync( + WebSocket client, + WebSocket origin, + Action? onMessage, + Func>? interceptor, + InterceptorClientConnection? clientConnection, + SemaphoreSlim? clientSendLock, + CancellationToken ct) + { + using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct); + + var clientToOrigin = RelayClientToOriginAsync( + client, origin, onMessage, interceptor, clientConnection, linked.Token); + var originToClient = RelayOriginToClientAsync( + origin, client, onMessage, clientSendLock, linked.Token); + + try + { + _ = await Task.WhenAny(clientToOrigin, originToClient).ConfigureAwait(false); + await linked.CancelAsync().ConfigureAwait(false); + } + finally + { + try + { + await Task.WhenAll(clientToOrigin, originToClient).ConfigureAwait(false); + } + catch (Exception ex) when (ConnectionTeardown.IsExpected(ex)) + { + // Either peer closed — normal teardown. + } + } + } + + /// + /// Relays client→origin messages, optionally intercepting them. When the + /// interceptor handles a message, it is not forwarded to the origin. + /// + private static async Task RelayClientToOriginAsync( + WebSocket client, + WebSocket origin, + Action? onMessage, + Func>? interceptor, + InterceptorClientConnection? clientConnection, + CancellationToken ct) + { + var buffer = new byte[ReceiveChunkSize]; + + while (client.State is WebSocketState.Open or WebSocketState.CloseSent) + { + var (message, result) = await ReceiveFullMessageAsync(client, buffer, ct).ConfigureAwait(false); + if (message is null || result is null) + { + return; + } + + if (result.MessageType == WebSocketMessageType.Close) + { + if (origin.State is WebSocketState.Open or WebSocketState.CloseReceived) + { + await origin.CloseOutputAsync( + result.CloseStatus ?? WebSocketCloseStatus.NormalClosure, + result.CloseStatusDescription, ct).ConfigureAwait(false); + } + onMessage?.Invoke(new WebSocketMessageRecord( + WebSocketMessageDirection.Send, WebSocketMessageType.Close, ReadOnlyMemory.Empty, DateTimeOffset.UtcNow)); + return; + } + + var data = message.Value; + + // Offer to interceptor before forwarding. + if (interceptor is not null && clientConnection is not null) + { + var wsMessage = new WebSocketMessage(result.MessageType, data); + var handled = await interceptor(wsMessage, clientConnection, ct).ConfigureAwait(false); + onMessage?.Invoke(new WebSocketMessageRecord( + WebSocketMessageDirection.Send, result.MessageType, data, DateTimeOffset.UtcNow)); + if (handled) + { + continue; // don't forward to origin + } + } + else + { + onMessage?.Invoke(new WebSocketMessageRecord( + WebSocketMessageDirection.Send, result.MessageType, data, DateTimeOffset.UtcNow)); + } + + await origin.SendAsync(data, result.MessageType, endOfMessage: true, ct).ConfigureAwait(false); + } + } + + /// + /// Relays origin→client messages. When a is + /// provided (because an interceptor is active), sends are serialized to avoid + /// overlapping with interceptor-initiated sends. + /// + private static async Task RelayOriginToClientAsync( + WebSocket origin, + WebSocket client, + Action? onMessage, + SemaphoreSlim? clientSendLock, + CancellationToken ct) + { + var buffer = new byte[ReceiveChunkSize]; + + while (origin.State is WebSocketState.Open or WebSocketState.CloseSent) + { + var (message, result) = await ReceiveFullMessageAsync(origin, buffer, ct).ConfigureAwait(false); + if (message is null || result is null) + { + return; + } + + if (result.MessageType == WebSocketMessageType.Close) + { + if (clientSendLock is not null) + { + await clientSendLock.WaitAsync(ct).ConfigureAwait(false); + try + { + if (client.State is WebSocketState.Open or WebSocketState.CloseReceived) + { + await client.CloseOutputAsync( + result.CloseStatus ?? WebSocketCloseStatus.NormalClosure, + result.CloseStatusDescription, ct).ConfigureAwait(false); + } + } + finally + { + clientSendLock.Release(); + } + } + else if (client.State is WebSocketState.Open or WebSocketState.CloseReceived) + { + await client.CloseOutputAsync( + result.CloseStatus ?? WebSocketCloseStatus.NormalClosure, + result.CloseStatusDescription, ct).ConfigureAwait(false); + } + + onMessage?.Invoke(new WebSocketMessageRecord( + WebSocketMessageDirection.Receive, WebSocketMessageType.Close, ReadOnlyMemory.Empty, DateTimeOffset.UtcNow)); + return; + } + + var data = message.Value; + + if (clientSendLock is not null) + { + await clientSendLock.WaitAsync(ct).ConfigureAwait(false); + try + { + await client.SendAsync(data, result.MessageType, endOfMessage: true, ct).ConfigureAwait(false); + } + finally + { + clientSendLock.Release(); + } + } + else + { + await client.SendAsync(data, result.MessageType, endOfMessage: true, ct).ConfigureAwait(false); + } + + onMessage?.Invoke(new WebSocketMessageRecord( + WebSocketMessageDirection.Receive, result.MessageType, data, DateTimeOffset.UtcNow)); + } + } + + /// + /// Receives a complete WebSocket message (reassembling fragments). Returns + /// (null, null) on abrupt connection end and a close-typed result on + /// clean close. + /// + private static async Task<(ReadOnlyMemory? Data, WebSocketReceiveResult? Result)> ReceiveFullMessageAsync( + WebSocket ws, byte[] buffer, CancellationToken ct) + { + var assembled = new List(ReceiveChunkSize); + WebSocketReceiveResult result; + do + { + try + { + result = await ws.ReceiveAsync(buffer, ct).ConfigureAwait(false); + } + catch (WebSocketException) + { + return (null, null); + } + + if (result.MessageType == WebSocketMessageType.Close) + { + return (ReadOnlyMemory.Empty, result); + } + + assembled.AddRange(buffer.AsSpan(0, result.Count)); + } + while (!result.EndOfMessage); + + return (assembled.ToArray(), result); + } +} + +/// +/// A send-serialized wrapper for the client-side +/// WebSocket, used by per-message interceptors. Sends are guarded by a +/// to avoid overlapping with the origin→client relay task. +/// Interceptor-sent responses are also captured via for +/// HAR / reporting. +/// +internal sealed class InterceptorClientConnection( + WebSocket clientWs, + SemaphoreSlim sendLock, + Action? onMessage) : IWebSocketConnection +{ + public async Task SendTextAsync(string message, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(message); + var data = Encoding.UTF8.GetBytes(message); + await sendLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + await clientWs.SendAsync(data, WebSocketMessageType.Text, endOfMessage: true, cancellationToken).ConfigureAwait(false); + } + finally + { + sendLock.Release(); + } + // Interceptor sends to the client appear as "receive" from the client's perspective. + onMessage?.Invoke(new WebSocketMessageRecord( + WebSocketMessageDirection.Receive, WebSocketMessageType.Text, data, DateTimeOffset.UtcNow)); + } + + public async Task SendBinaryAsync(ReadOnlyMemory message, CancellationToken cancellationToken) + { + await sendLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + await clientWs.SendAsync(message, WebSocketMessageType.Binary, endOfMessage: true, cancellationToken).ConfigureAwait(false); + } + finally + { + sendLock.Release(); + } + onMessage?.Invoke(new WebSocketMessageRecord( + WebSocketMessageDirection.Receive, WebSocketMessageType.Binary, message, DateTimeOffset.UtcNow)); + } + + public Task ReceiveAsync(CancellationToken cancellationToken) => + throw new InvalidOperationException("Receiving is handled by the relay loop; interceptors should not call ReceiveAsync."); + + public async Task CloseAsync(CancellationToken cancellationToken) + { + await sendLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (clientWs.State is WebSocketState.Open or WebSocketState.CloseReceived) + { + await clientWs.CloseOutputAsync( + WebSocketCloseStatus.NormalClosure, statusDescription: null, cancellationToken).ConfigureAwait(false); + } + } + catch (WebSocketException) { } + catch (OperationCanceledException) { } + finally + { + sendLock.Release(); + } + } +} + +/// +/// Decorates an so that messages sent to the client +/// (by an interceptor or onConnected callback) are captured as Receive records +/// for HAR / reporting. Used by the interceptor-only fallback where there is no origin +/// relay to observe the scripted responses. Receives are delegated unchanged. +/// +internal sealed class CapturingWebSocketConnection( + IWebSocketConnection inner, + Action onMessage) : IWebSocketConnection +{ + public async Task SendTextAsync(string message, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(message); + await inner.SendTextAsync(message, cancellationToken).ConfigureAwait(false); + onMessage(new WebSocketMessageRecord( + WebSocketMessageDirection.Receive, WebSocketMessageType.Text, + Encoding.UTF8.GetBytes(message), DateTimeOffset.UtcNow)); + } + + public async Task SendBinaryAsync(ReadOnlyMemory message, CancellationToken cancellationToken) + { + await inner.SendBinaryAsync(message, cancellationToken).ConfigureAwait(false); + onMessage(new WebSocketMessageRecord( + WebSocketMessageDirection.Receive, WebSocketMessageType.Binary, message, DateTimeOffset.UtcNow)); + } + + public Task ReceiveAsync(CancellationToken cancellationToken) => + inner.ReceiveAsync(cancellationToken); + + public Task CloseAsync(CancellationToken cancellationToken) => + inner.CloseAsync(cancellationToken); +} + +/// +/// A read-only stream wrapper that prepends a byte prefix to an inner stream. +/// Used to replay leftover bytes read past the HTTP response head before the +/// inner stream is wrapped as a WebSocket. +/// +internal sealed class PrefixedStream(byte[] prefix, Stream inner) : Stream +{ + private int _prefixOffset; + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => inner.CanWrite; + public override long Length => throw new NotSupportedException(); + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override int Read(byte[] buffer, int offset, int count) + { + if (_prefixOffset < prefix.Length) + { + var available = Math.Min(count, prefix.Length - _prefixOffset); + Array.Copy(prefix, _prefixOffset, buffer, offset, available); + _prefixOffset += available; + return available; + } + return inner.Read(buffer, offset, count); + } + + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + if (_prefixOffset < prefix.Length) + { + var available = Math.Min(buffer.Length, prefix.Length - _prefixOffset); + prefix.AsMemory(_prefixOffset, available).CopyTo(buffer); + _prefixOffset += available; + return available; + } + return await inner.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); + } + + public override async Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + => await ReadAsync(buffer.AsMemory(offset, count), cancellationToken).ConfigureAwait(false); + + public override void Write(byte[] buffer, int offset, int count) => inner.Write(buffer, offset, count); + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + => inner.WriteAsync(buffer, offset, count, cancellationToken); + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + => inner.WriteAsync(buffer, cancellationToken); + + public override void Flush() => inner.Flush(); + public override Task FlushAsync(CancellationToken cancellationToken) => inner.FlushAsync(cancellationToken); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + // Don't dispose inner — it's owned by the caller (TcpClient/SslStream). + base.Dispose(disposing); + } }