Skip to content
Closed
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
28 changes: 28 additions & 0 deletions DevProxy.Abstractions/Proxy/Http/IProxySession.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,14 @@ public interface IProxySession
/// </summary>
void Respond(ReadOnlyMemory<byte> body, HttpStatusCode statusCode, IEnumerable<IHttpHeader> headers);

/// <summary>
/// WebSocket messages captured during the relay (pass-through) phase of a
/// WebSocket connection. Empty for non-WebSocket exchanges or when the
/// connection was mocked (no origin relay). Populated by the proxy engine
/// after the upgrade handshake completes.
/// </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 +79,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);
}
20 changes: 20 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,6 +164,24 @@ 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 = (int)m.Type,
Data = isText ? m.Text : Convert.ToBase64String(m.Data.Span)
};
})];
}

return entry;
}

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
35 changes: 35 additions & 0 deletions DevProxy.Plugins/Models/Har.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();

/// <summary>
/// Custom HAR extension field. Set to <c>"websocket"</c> for WebSocket upgrade
/// entries (Chrome/mitmproxy convention).
/// </summary>
[JsonPropertyName("_resourceType")]
public string? ResourceType { get; set; }

/// <summary>
/// Custom HAR extension field containing captured WebSocket messages for
/// entries where <see cref="ResourceType"/> is <c>"websocket"</c>.
/// </summary>
[JsonPropertyName("_webSocketMessages")]
public List<HarWebSocketMessage>? WebSocketMessages { get; set; }
}

internal sealed class HarRequest
Expand Down Expand Up @@ -114,4 +130,23 @@ internal sealed class HarTimings
public double Send { get; set; }
public double Wait { get; set; }
public double Receive { get; set; }
}

/// <summary>
/// A single WebSocket message in the <c>_webSocketMessages</c> HAR extension array.
/// Follows the Chrome DevTools / mitmproxy convention.
/// </summary>
internal sealed class HarWebSocketMessage
{
/// <summary><c>"send"</c> (client → server) or <c>"receive"</c> (server → client).</summary>
public string? Type { get; set; }

/// <summary>Epoch timestamp (seconds since Unix epoch, with fractional ms).</summary>
public double Time { get; set; }

/// <summary>WebSocket opcode (1 = text, 2 = binary, 8 = close).</summary>
public int Opcode { get; set; }

/// <summary>Message payload: UTF-8 text for text messages, base64 for binary.</summary>
public string? Data { get; set; }
}
Loading
Loading