AyCode.Core/docs/SIGNALR.md

12 KiB

SignalR Communication

The transport layer for AyCode real-time communication. Source: AyCode.Services/SignalRs/ (client), AyCode.Services.Server/SignalRs/ (server).

For the change-tracked collection built on top of this transport, see SIGNALR_DATASOURCE.md.

Design Overview

All communication flows through a single hub method with tag-based dispatch:

Client ──OnReceiveMessage(tag, bytes, requestId)──► Server
Client ◄──OnReceiveMessage(tag, bytes, requestId)── Server

OnReceiveMessage is the only transport channel — all calls go through it. The tag (int) determines which server method to invoke. The DynamicMethodRegistry maps the tag to a concrete [SignalR(tag)]-annotated method at runtime. Projects call any endpoint by specifying the tag; the server dispatches accordingly.

Client side:                          Server side:
─────────────────────                 ─────────────────────
AcSignalRClientBase                   AcWebSignalRHubBase<TTags, TLogger>
  ├─ HubConnection (WebSocket)          ├─ Hub<IAcSignalRHubItemServer>
  ├─ AcBinaryHubProtocol                ├─ DynamicMethodRegistry
  ├─ Pending request tracking           ├─ Parameter deserialization
  └─ Response callbacks                 └─ Broadcast to other clients

Tag System

Tag Definition

AyCode.Core defines only the base class and built-in infrastructure tags:

public class AcSignalRTags
{
    public const int None = 0;
    public const int PingTag = 90001;
    public const int EchoTag = 90002;
}

Consuming projects define their own tags by inheriting AcSignalRTags:

public abstract class MyProjectTags : AcSignalRTags
{
    public const int GetOrders = 100;
    public const int GetOrderById = 101;
    public const int SaveOrder = 102;
    // ... project-specific tags
}

Tags are plain int constants. The project decides the numbering. Tags must be unique across all registered services within a hub.

Tag Attributes

Three attribute levels:

// Base: maps an integer tag to a method
[Tag(42)]

// Server method: tag + optional broadcast behavior
[SignalR(messageTag: 100, sendToOtherClientTag: 100, sendToOtherClientType: SendToClientType.Others)]
// messageTag:              incoming tag this method handles
// sendToOtherClientTag:    tag used when broadcasting result to other clients (can differ from messageTag)
// sendToOtherClientType:   who receives the broadcast

// Client receive: marks method for server→client dispatch
[SignalRSendToClient(100)]

SendToClientType: None (no broadcast), Others (all except caller), Caller (response only), All (everyone).

How Projects Use Tags

Projects call the SignalR transport directly — not only through DataSource:

// Direct call: project code calls any endpoint by tag
var orders = await signalRClient.PostAsync<List<Order>>(MyTags.GetOrders, companyId);
var order = await signalRClient.GetByIdAsync<Order>(MyTags.GetOrderById, orderId);
await signalRClient.PostDataAsync(MyTags.SaveOrder, order);

// Async callback (non-blocking)
await signalRClient.PostDataAsync(MyTags.SaveOrder, order, async response => { ... });

// Fire-and-forget
await signalRClient.PostDataAsync(MyTags.SaveOrder, order, response => { ... });

The AcSignalRClientBase CRUD helpers (PostAsync, GetByIdAsync, GetAllAsync, PostDataAsync) are all generic transport methods that work with any tag — they are not tied to DataSource.

Dynamic Method Dispatch

See also: AyCode.Models.Server/DynamicMethods/README.md

Server-Side Lookup

1. OnReceiveMessage(tag=100, bytes, requestId)

2. DynamicMethodRegistry.GetMethodByMessageTag(100)
   ├─ Check static ConcurrentDictionary<int, (Type, AcMethodInfoModel)?> cache
   ├─ Hit? → find instance of cached Type from registered instances
   ├─ Miss? → scan all registered instances' methods for [SignalR(100)]
   │          cache the result (including negative = null)
   └─ Return (instance, methodInfoModel) or null

3. AcMethodInfoModel contains:
   ├─ MethodInfo (the method to invoke)
   ├─ SignalRAttribute (tag, sendToOtherClientTag, sendToOtherClientType)
   └─ ParamInfos[] (ParameterInfo for deserialization)

The DynamicMethodRegistry uses a static ConcurrentDictionary for the global tag→method cache. Note: AcDynamicMethodCallModel uses a FrozenDictionary per-type cache internally, but the registry's own lookup path does direct method scanning with ConcurrentDictionary caching.

Registration

The hub registers service instances during initialization:

DynamicMethodRegistry.Register(myService);    // scans [SignalR(tag)] methods lazily
DynamicMethodRegistry.Register(anotherService);

Reflection runs lazily per tag on first request, then results are cached statically.

Wire Protocol

AcBinaryHubProtocol

Custom IHubProtocol (name: "acbinary"), replaces default JSON. Frame format:

[4 bytes: payload length, little-endian] [1 byte: message type] [payload...]

Message types: Invocation(1), StreamItem(2), Completion(3), Ping(6), Close(7), Ack(8), Sequence(9).

Arguments serialized individually with VarUInt length prefix. Direct write to IBufferWriter via BufferWriterBinaryOutput.

Response Message

SignalResponseDataMessage carries:

Field Type Purpose
MessageTag int Tag identifying the operation
Status SignalResponseStatus Success/Error
ResponseData byte[] Binary-serialized payload (or GZip+JSON fallback)
DataSerializerType AcSerializerType Binary or Json

Binary mode (default): AcBinarySerializer.ToBinary(data) → raw bytes. JSON fallback: ToJson(data)GzipHelper.Compress(json) → bytes.

Request/Response Flow

Client → Server

1. PostAsync<T>(tag, postData) or PostDataAsync(tag, data, callback)
2. CreatePostMessage(postData):
   ├─ Primitives/strings/enums/value types → IdMessage (JSON array of serialized values)
   └─ Complex objects → SignalPostJsonDataMessage<T>     ⚠️ tech debt: JSON-in-Binary
3. SerializeToBinary(message)                            wraps in Binary envelope
4. HubConnection.SendAsync("OnReceiveMessage", tag, bytes, requestId)
5. AcBinaryHubProtocol frames it on the wire

Server Processing

6. OnReceiveMessage(tag, bytes, requestId)
7. DynamicMethodRegistry.GetMethodByMessageTag(tag)      ← ConcurrentDictionary lookup
8. DeserializeParameters(bytes):
   ├─ DeserializeFromBinary<SignalPostJsonMessage>()     ← unwrap Binary envelope
   ├─ IdMessage format? → parse each Ids[i] as JSON per parameter type
   └─ Complex object? → json.JsonTo(paramType)           ⚠️ tech debt: JSON parse
9. MethodInfo.InvokeMethod(instance, params)              ← unwraps Task/ValueTask
10. CreateResponseMessage(tag, Success, result)           ← pure Binary serialization
11. ResponseToCaller(tag, message, requestId)
12. If SendToOtherClientType != None:
    └─ SendMessageToOthers(sendToOtherClientTag, result) ← uses sendToOtherClientTag, not messageTag

Server → Client

13. Client.OnReceiveMessage(tag, bytes, requestId)
14. Has matching requestId in pending ConcurrentDictionary?
    ├─ YES (response to own request):
    │   15. DeserializeFromBinary<SignalResponseDataMessage>(bytes)
    │   16. Route based on pending request type:
    │       ├─ null (sync wait) → set ResponseByRequestId, WaitToAsync completes
    │       ├─ Action<SignalResponseDataMessage> → invoke directly
    │       └─ Func<SignalResponseDataMessage, Task> → invoke and await
    │   17. GetResponseData<T>():
    │       ├─ Binary: ResponseData.BinaryTo<T>()
    │       └─ JSON: GzipDecompress → AcJsonDeserializer.Deserialize<T>()
    │
    └─ NO (broadcast from another client's action):
        18. abstract MessageReceived(tag, bytes).Forget()
            └─ Consuming project overrides to handle server push

Broadcast receive: When the server broadcasts to Others/All, receiving clients have no matching requestId. The message falls through to MessageReceived(int messageTag, byte[] messageBytes) — an abstract method the consuming project overrides to handle unsolicited server pushes (e.g., refresh UI, update local state).

Request model pooling: SignalRRequestModel instances are managed via SignalRRequestModelPool (ObjectPool + IResettable) to avoid allocations per request.

Response Patterns

Pattern Method Blocking
Sync wait PostAsync<T>(tag, data) Yes, polls up to TransportSendTimeout (60s)
Async callback PostDataAsync(tag, data, async msg => {...}) No, Func<SignalResponseDataMessage, Task>
Fire-and-forget PostDataAsync(tag, data, msg => {...}) No, Action<SignalResponseDataMessage>

Session Management

AcSessionService<TSessionItem, TSessionItemId> tracks connected clients:

ConcurrentDictionary<TSessionItemId, TSessionItem> Sessions

IAcSessionItem<TSessionItemId> requires SessionId property. Used for targeting messages to specific users/connections.

Broadcast Service

AcSignalRSendToClientService<THub, TTags, TLogger> provides server-push methods:

Method Target
SendMessageToAllClients All connected
SendMessageToConnection(connectionId) Single connection
SendMessageToUser(userId) User (all connections)
SendMessageToUsers(userIds) Multiple users

All messages wrapped in SignalResponseDataMessage → binary serialized → OnReceiveMessage.

Connection Lifecycle

Client configuration:

  • Transport: WebSockets only (SkipNegotiation = true)
  • Buffer: 30MB transport + 30MB application
  • Reconnection: WithAutomaticReconnect() + WithStatefulReconnect()
  • Keepalive: 60s interval, 180s server timeout

Hub events:

  • OnConnectedAsync() — log connection
  • OnDisconnectedAsync(exception) — log disconnection, cleanup session

Diagnostics

Enable with AcSignalRClientBase.EnableBinaryDiagnostics = true or AcWebSignalRHubBase.EnableBinaryDiagnostics = true.

Logs: hex dump (500 byte sample), header parsing (version, marker), property count + names via VarUInt reading.

SignalResponseDataMessage.DiagnosticLogger — per-response logging: target type info, property list, inheritance chain, hex dump.

Known Technical Debt

JSON-in-Binary request parameters: Client→server requests currently wrap parameters in JSON inside the binary envelope (SignalPostJsonDataMessage). This adds an unnecessary serialization round-trip. Responses are already pure binary. Do NOT attempt to fix as a side effect — requires coordinated changes across all consuming projects.

Key Source Files

Component Path
Hub base AyCode.Services.Server/SignalRs/AcWebSignalRHubBase.cs
Client base AyCode.Services/SignalRs/AcSignalRClientBase.cs
Binary protocol AyCode.Services/SignalRs/AcBinaryHubProtocol.cs
Message types AyCode.Services/SignalRs/IAcSignalRHubClient.cs
Serialization AyCode.Services/SignalRs/SignalRSerializationHelper.cs
Tag attributes AyCode.Services/SignalRs/SignalMessageTagAttribute.cs
Base tags AyCode.Services/SignalRs/AcSignalRTags.cs
Session service AyCode.Services.Server/SignalRs/AcSessionService.cs
Broadcast service AyCode.Services.Server/SignalRs/AcSignalRSendToClientService.cs
Dynamic dispatch AyCode.Models.Server/DynamicMethods/AcDynamicMethodRegistry.cs