feat(battle-node): SocketIoFrame parse/encode for SIO2 incl. binary attachments
This commit is contained in:
171
SVSim.BattleNode/Wire/SocketIoFrame.cs
Normal file
171
SVSim.BattleNode/Wire/SocketIoFrame.cs
Normal file
@@ -0,0 +1,171 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace SVSim.BattleNode.Wire;
|
||||
|
||||
/// <summary>
|
||||
/// Socket.IO v2 packet. Wire form: <c><type><N>-<ackId?>[json-args]</c> where
|
||||
/// <c><N>-</c> appears only on binary types (5/6). For binary events/acks, the JSON contains
|
||||
/// placeholders <c>{"_placeholder":true,"num":N}</c> that index into <see cref="BinaryAttachments"/>.
|
||||
/// </summary>
|
||||
public sealed class SocketIoFrame
|
||||
{
|
||||
public SocketIoPacketType Type { get; }
|
||||
public int? AckId { get; }
|
||||
public int AttachmentCount { get; }
|
||||
public string? EventName { get; }
|
||||
public JsonElement[] RawArgs { get; }
|
||||
public IReadOnlyList<byte[]> BinaryAttachments { get; }
|
||||
|
||||
public SocketIoFrame(
|
||||
SocketIoPacketType type,
|
||||
int? ackId,
|
||||
int attachmentCount,
|
||||
string? eventName,
|
||||
JsonElement[] rawArgs,
|
||||
IReadOnlyList<byte[]> binaryAttachments)
|
||||
{
|
||||
Type = type;
|
||||
AckId = ackId;
|
||||
AttachmentCount = attachmentCount;
|
||||
EventName = eventName;
|
||||
RawArgs = rawArgs;
|
||||
BinaryAttachments = binaryAttachments;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse the text portion of a SIO frame. For binary events the attachments arrive as separate
|
||||
/// WS frames after the text — the caller wires them up via <see cref="WithAttachments"/>.
|
||||
/// </summary>
|
||||
public static SocketIoFrame Parse(string raw)
|
||||
{
|
||||
if (string.IsNullOrEmpty(raw))
|
||||
throw new ArgumentException("Empty SIO payload", nameof(raw));
|
||||
|
||||
var type = (SocketIoPacketType)(raw[0] - '0');
|
||||
var cursor = 1;
|
||||
|
||||
var attachmentCount = 0;
|
||||
if (type is SocketIoPacketType.BinaryEvent or SocketIoPacketType.BinaryAck)
|
||||
{
|
||||
var dashIdx = raw.IndexOf('-', cursor);
|
||||
if (dashIdx < 0)
|
||||
throw new ArgumentException("Binary frame missing '-' separator", nameof(raw));
|
||||
if (!int.TryParse(raw.AsSpan(cursor, dashIdx - cursor), out attachmentCount))
|
||||
throw new ArgumentException("Binary frame attachment count not parseable", nameof(raw));
|
||||
cursor = dashIdx + 1;
|
||||
}
|
||||
|
||||
// Skip namespace (only present if a '/' starts here, terminated by ',').
|
||||
if (cursor < raw.Length && raw[cursor] == '/')
|
||||
{
|
||||
var commaIdx = raw.IndexOf(',', cursor);
|
||||
cursor = commaIdx >= 0 ? commaIdx + 1 : raw.Length;
|
||||
}
|
||||
|
||||
int? ackId = null;
|
||||
if (cursor < raw.Length && char.IsDigit(raw[cursor]))
|
||||
{
|
||||
var start = cursor;
|
||||
while (cursor < raw.Length && char.IsDigit(raw[cursor])) cursor++;
|
||||
ackId = int.Parse(raw.AsSpan(start, cursor - start));
|
||||
}
|
||||
|
||||
var argsJson = cursor < raw.Length ? raw.Substring(cursor) : string.Empty;
|
||||
var allElements = string.IsNullOrEmpty(argsJson)
|
||||
? Array.Empty<JsonElement>()
|
||||
: JsonDocument.Parse(argsJson).RootElement.EnumerateArray().ToArray();
|
||||
|
||||
string? eventName = null;
|
||||
JsonElement[] rawArgs;
|
||||
if (type is SocketIoPacketType.Event or SocketIoPacketType.BinaryEvent && allElements.Length > 0)
|
||||
{
|
||||
eventName = allElements[0].GetString();
|
||||
// RawArgs excludes the leading event-name element so callers index args from 0.
|
||||
rawArgs = allElements.Length > 1 ? allElements[1..] : Array.Empty<JsonElement>();
|
||||
}
|
||||
else
|
||||
{
|
||||
rawArgs = allElements;
|
||||
}
|
||||
|
||||
return new SocketIoFrame(type, ackId, attachmentCount, eventName, rawArgs, Array.Empty<byte[]>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Return a new frame with the given binary attachments attached. Throws if the count doesn't
|
||||
/// match the header's declared attachment count.
|
||||
/// </summary>
|
||||
public SocketIoFrame WithAttachments(IReadOnlyList<byte[]> attachments)
|
||||
{
|
||||
if (attachments.Count != AttachmentCount)
|
||||
throw new ArgumentException(
|
||||
$"Attachment count mismatch: header says {AttachmentCount}, got {attachments.Count}");
|
||||
return new SocketIoFrame(Type, AckId, AttachmentCount, EventName, RawArgs, attachments);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build a binary event frame for the given event name + binary attachments.
|
||||
/// The JSON args become <c>[eventName, {_placeholder:true,num:0}, {_placeholder:true,num:1}, ...]</c>.
|
||||
/// </summary>
|
||||
public static SocketIoFrame BinaryEventWithAttachments(string eventName, IReadOnlyList<byte[]> attachments)
|
||||
{
|
||||
// Build the placeholder-only portion (without the event name) — event name is stored separately.
|
||||
var sb = new StringBuilder();
|
||||
sb.Append('[');
|
||||
for (var i = 0; i < attachments.Count; i++)
|
||||
{
|
||||
if (i > 0) sb.Append(',');
|
||||
sb.Append("{\"_placeholder\":true,\"num\":").Append(i).Append('}');
|
||||
}
|
||||
sb.Append(']');
|
||||
var args = JsonDocument.Parse(sb.ToString()).RootElement.EnumerateArray().ToArray();
|
||||
|
||||
return new SocketIoFrame(
|
||||
SocketIoPacketType.BinaryEvent,
|
||||
ackId: null,
|
||||
attachmentCount: attachments.Count,
|
||||
eventName: eventName,
|
||||
rawArgs: args,
|
||||
binaryAttachments: attachments);
|
||||
}
|
||||
|
||||
/// <summary>Build an ack response with a single int argument (the spec's pubSeq echo).</summary>
|
||||
public static SocketIoFrame AckResponse(int ackId, int arg)
|
||||
{
|
||||
var args = JsonDocument.Parse($"[{arg}]").RootElement.EnumerateArray().ToArray();
|
||||
return new SocketIoFrame(
|
||||
SocketIoPacketType.Ack, ackId, 0, null, args, Array.Empty<byte[]>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encode to the wire form: (text payload, ordered list of binary attachments).
|
||||
/// The caller is responsible for sending the text frame first then each binary attachment frame.
|
||||
/// </summary>
|
||||
public (string Text, IReadOnlyList<byte[]> Binaries) Encode()
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append((int)Type);
|
||||
if (Type is SocketIoPacketType.BinaryEvent or SocketIoPacketType.BinaryAck)
|
||||
{
|
||||
sb.Append(AttachmentCount).Append('-');
|
||||
}
|
||||
if (AckId.HasValue) sb.Append(AckId.Value);
|
||||
// Re-serialize args — for event/binary-event types, re-prepend the event name.
|
||||
sb.Append('[');
|
||||
var prependEventName = Type is SocketIoPacketType.Event or SocketIoPacketType.BinaryEvent
|
||||
&& EventName is not null;
|
||||
if (prependEventName)
|
||||
{
|
||||
sb.Append('"').Append(EventName).Append('"');
|
||||
if (RawArgs.Length > 0) sb.Append(',');
|
||||
}
|
||||
for (var i = 0; i < RawArgs.Length; i++)
|
||||
{
|
||||
if (i > 0) sb.Append(',');
|
||||
sb.Append(RawArgs[i].GetRawText());
|
||||
}
|
||||
sb.Append(']');
|
||||
return (sb.ToString(), BinaryAttachments);
|
||||
}
|
||||
}
|
||||
12
SVSim.BattleNode/Wire/SocketIoPacketType.cs
Normal file
12
SVSim.BattleNode/Wire/SocketIoPacketType.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace SVSim.BattleNode.Wire;
|
||||
|
||||
public enum SocketIoPacketType
|
||||
{
|
||||
Connect = 0,
|
||||
Disconnect = 1,
|
||||
Event = 2,
|
||||
Ack = 3,
|
||||
Error = 4,
|
||||
BinaryEvent = 5,
|
||||
BinaryAck = 6,
|
||||
}
|
||||
88
SVSim.UnitTests/BattleNode/Wire/SocketIoFrameTests.cs
Normal file
88
SVSim.UnitTests/BattleNode/Wire/SocketIoFrameTests.cs
Normal file
@@ -0,0 +1,88 @@
|
||||
using System.Text.Json;
|
||||
using NUnit.Framework;
|
||||
using SVSim.BattleNode.Wire;
|
||||
|
||||
namespace SVSim.UnitTests.BattleNode.Wire;
|
||||
|
||||
[TestFixture]
|
||||
public class SocketIoFrameTests
|
||||
{
|
||||
[Test]
|
||||
public void Parse_ConnectPacket_HasNoPayload()
|
||||
{
|
||||
var frame = SocketIoFrame.Parse("0");
|
||||
Assert.That(frame.Type, Is.EqualTo(SocketIoPacketType.Connect));
|
||||
Assert.That(frame.AckId, Is.Null);
|
||||
Assert.That(frame.AttachmentCount, Is.EqualTo(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Parse_EventWithAck_ExtractsAckIdAndArgs()
|
||||
{
|
||||
var frame = SocketIoFrame.Parse("27[\"msg\",42]");
|
||||
Assert.That(frame.Type, Is.EqualTo(SocketIoPacketType.Event));
|
||||
Assert.That(frame.AckId, Is.EqualTo(7));
|
||||
Assert.That(frame.EventName, Is.EqualTo("msg"));
|
||||
Assert.That(frame.RawArgs[0].GetInt32(), Is.EqualTo(42));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Parse_BinaryEvent_RecordsAttachmentCount_WithAttachments_Assembles()
|
||||
{
|
||||
var attachment = new byte[] { 0x01, 0x02, 0x03 };
|
||||
var header = SocketIoFrame.Parse("51-[\"msg\",{\"_placeholder\":true,\"num\":0}]");
|
||||
var assembled = header.WithAttachments(new[] { attachment });
|
||||
|
||||
Assert.That(assembled.Type, Is.EqualTo(SocketIoPacketType.BinaryEvent));
|
||||
Assert.That(assembled.AckId, Is.Null);
|
||||
Assert.That(assembled.AttachmentCount, Is.EqualTo(1));
|
||||
Assert.That(assembled.BinaryAttachments[0], Is.EqualTo(attachment));
|
||||
Assert.That(assembled.EventName, Is.EqualTo("msg"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Parse_BinaryEventWithAckId_ExtractsBoth()
|
||||
{
|
||||
var frame = SocketIoFrame.Parse("51-3[\"msg\",{\"_placeholder\":true,\"num\":0}]");
|
||||
Assert.That(frame.Type, Is.EqualTo(SocketIoPacketType.BinaryEvent));
|
||||
Assert.That(frame.AckId, Is.EqualTo(3));
|
||||
Assert.That(frame.AttachmentCount, Is.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Parse_AckResponse_ExtractsIdAndIntArg()
|
||||
{
|
||||
var frame = SocketIoFrame.Parse("37[123]");
|
||||
Assert.That(frame.Type, Is.EqualTo(SocketIoPacketType.Ack));
|
||||
Assert.That(frame.AckId, Is.EqualTo(7));
|
||||
Assert.That(frame.RawArgs[0].GetInt32(), Is.EqualTo(123));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Encode_BinaryEventWithAttachment_EmitsCountDashAndPlaceholder()
|
||||
{
|
||||
var attachment = new byte[] { 0xff };
|
||||
var frame = SocketIoFrame.BinaryEventWithAttachments("synchronize", new[] { attachment });
|
||||
|
||||
var (text, bins) = frame.Encode();
|
||||
Assert.That(text, Is.EqualTo("51-[\"synchronize\",{\"_placeholder\":true,\"num\":0}]"));
|
||||
Assert.That(bins.Single(), Is.EqualTo(attachment));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Encode_AckResponse_IsTypeIdAndArrayOfArgs()
|
||||
{
|
||||
var frame = SocketIoFrame.AckResponse(ackId: 7, arg: 123);
|
||||
var (text, bins) = frame.Encode();
|
||||
|
||||
Assert.That(text, Is.EqualTo("37[123]"));
|
||||
Assert.That(bins, Is.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WithAttachments_CountMismatch_Throws()
|
||||
{
|
||||
var header = SocketIoFrame.Parse("51-[\"msg\",{\"_placeholder\":true,\"num\":0}]");
|
||||
Assert.Throws<ArgumentException>(() => header.WithAttachments(Array.Empty<byte[]>()));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user