Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions DevProxy.Abstractions/Proxy/Http/IProxySession.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,16 @@ public interface IProxySession
/// </summary>
void Respond(ReadOnlyMemory<byte> body, HttpStatusCode statusCode, IEnumerable<IHttpHeader> headers);

/// <summary>
/// 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
/// <see cref="HandleWebSocket"/>, and for connections the engine chose not to
/// capture (e.g.\ requests that aren't being watched).
/// </summary>
IReadOnlyList<WebSocketMessageRecord> WebSocketMessages { get; }

/// <summary>
/// Mocks a WebSocket exchange: when this request is a WebSocket upgrade
/// (<see cref="IHttpRequest.IsWebSocketRequest"/>), the engine completes the
Expand All @@ -71,4 +81,24 @@ public interface IProxySession
/// the plugin declares intent here during <c>BeforeRequest</c> and the engine executes it.
/// </summary>
void HandleWebSocket(Func<IWebSocketConnection, CancellationToken, Task> handler);

/// <summary>
/// Registers a per-message WebSocket interceptor. Unlike <see cref="HandleWebSocket"/>,
/// the engine still connects to the origin and relays traffic, but each client→origin
/// message is passed through <paramref name="interceptor"/> first. When the interceptor
/// returns <c>true</c>, the message is considered handled (not forwarded to the origin);
/// when it returns <c>false</c>, the message is forwarded normally. This mirrors how HTTP
/// mock plugins selectively mock matched requests while passing through the rest.
/// </summary>
/// <param name="interceptor">
/// Called for each client→origin message. Receives the message, a connection to send
/// responses back to the client, and a cancellation token. Returns <c>true</c> if handled.
/// </param>
/// <param name="onConnected">
/// Optional callback invoked once after the WebSocket handshake completes. Can be used
/// to send initial messages (e.g.\ <c>OnConnect</c> mock messages) to the client.
/// </param>
void InterceptWebSocketMessages(
Func<WebSocketMessage, IWebSocketConnection, CancellationToken, Task<bool>> interceptor,
Func<IWebSocketConnection, CancellationToken, Task>? onConnected);
}
42 changes: 42 additions & 0 deletions DevProxy.Abstractions/Proxy/Http/WebSocketMessageRecord.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Direction of a captured WebSocket message relative to the client.
/// </summary>
public enum WebSocketMessageDirection
{
/// <summary>Client → origin (the client sent this message).</summary>
Send,

/// <summary>Origin → client (the server sent this message).</summary>
Receive
}

/// <summary>
/// A timestamped record of a WebSocket message that flowed through the proxy,
/// captured for reporting (e.g.\ HAR generation). Follows the Chrome/mitmproxy
/// <c>_webSocketMessages</c> convention.
/// </summary>
/// <param name="Direction">Whether the message was sent by the client or received from the server.</param>
/// <param name="Type">
/// The WebSocket message type (<see cref="WebSocketMessageType.Text"/>,
/// <see cref="WebSocketMessageType.Binary"/>, or <see cref="WebSocketMessageType.Close"/>).
/// </param>
/// <param name="Data">The reassembled message payload (empty for close frames).</param>
/// <param name="Timestamp">When the message was observed by the proxy.</param>
public sealed record WebSocketMessageRecord(
WebSocketMessageDirection Direction,
WebSocketMessageType Type,
ReadOnlyMemory<byte> Data,
DateTimeOffset Timestamp)
{
/// <summary>The payload decoded as UTF-8 text (useful for text messages).</summary>
public string Text => Encoding.UTF8.GetString(Data.Span);
}
57 changes: 57 additions & 0 deletions DevProxy.Integration.Tests/GenerationPluginsIntegrationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -84,6 +88,59 @@ public async Task HarGenerator_WritesHarFile()
}
}

[Fact]
public async Task HarGenerator_WritesWebSocketMessages()
{
var plugin = new HarGeneratorPlugin(
TestDefaults.HttpClient,
NullLogger<HarGeneratorPlugin>.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()
{
Expand Down
18 changes: 18 additions & 0 deletions DevProxy.Integration.Tests/TestExchange.cs
Original file line number Diff line number Diff line change
Expand Up @@ -97,4 +97,22 @@ public TestExchange WithResponse(
/// </summary>
public RequestLog AsRequestLog(MessageType messageType = MessageType.InterceptedRequest) =>
new($"{Session.Request.Method} {Session.Request.Url}", messageType, new LoggingContext(Session));

/// <summary>
/// Builds a WebSocket upgrade exchange (GET with <c>Upgrade: websocket</c> + a
/// <c>101 Switching Protocols</c> 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 (<c>_resourceType</c> / <c>_webSocketMessages</c>).
/// </summary>
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;
}
}
31 changes: 31 additions & 0 deletions DevProxy.Plugins/Generation/HarGeneratorPlugin.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@

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;
using Microsoft.Extensions.Configuration;
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;
Expand Down Expand Up @@ -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 =>
Comment thread
waldekmastykarz marked this conversation as resolved.
{
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 =>
Expand Down
97 changes: 53 additions & 44 deletions DevProxy.Plugins/Mocking/WebSocketMockResponsePlugin.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,19 +32,22 @@ public sealed class WebSocketMockResponseConfiguration
}

/// <summary>
/// Mocks WebSocket conversations: matched <c>ws://</c>/<c>wss://</c> 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 <see cref="MockResponsePlugin"/>.
/// Mocks WebSocket messages: matched <c>ws://</c>/<c>wss://</c> 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.
///
/// <code>
/// 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
/// </code>
/// </summary>
public sealed class WebSocketMockResponsePlugin(
Expand Down Expand Up @@ -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<bool> 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)
Expand Down
Loading
Loading