Skip to content

HTTP Stream References ​

SignalARRR can transparently transfer large files through what looks like a normal RPC call. When a server-to-client method has a Stream parameter, SignalARRR automatically routes the data through HTTP instead of the WebSocket — no code changes needed.

How it works ​

When the server calls a client method with a Stream argument:

  1. Server stores the stream and generates a download URI
  2. Only a StreamReference (a small JSON object with the URI) is sent over WebSocket
  3. Client detects the Stream parameter, fetches the actual data via HTTP GET
  4. Client receives the stream and passes it to the handler

The developer sees a regular method call with a Stream parameter on both sides. The HTTP transfer is invisible.

Server side ​

Define an interface with a Stream parameter:

csharp
[SignalARRRContract]
public interface IFileClient
{
    long ProcessFile(string filename, Stream fileStream);
}

Call it from a ServerMethods class or controller — the stream is automatically routed through HTTP:

csharp
public class FileMethods : ServerMethods<AppHub>
{
    public async Task<long> SendFileToClient(string filename)
    {
        var fileStream = File.OpenRead($"/data/{filename}");
        var client = ClientContext.GetTypedMethods<IFileClient>();
        return await client.ProcessFile(filename, fileStream);
    }
}

You can also pass an HTTP request body directly as a stream:

csharp
[ApiController]
[Route("api/files")]
public class FileController : ControllerBase
{
    private readonly ClientManager _clients;

    public FileController(ClientManager clients) => _clients = clients;

    [HttpPost("push/{connectionId}")]
    public async Task<IActionResult> PushFile(string connectionId)
    {
        var client = _clients.GetTypedMethods<IFileClient>(connectionId);
        var size = await client.ProcessFile("upload.bin", HttpContext.Request.Body);
        return Ok(new { size });
    }
}

.NET client side ​

The client handler receives a regular Stream — no awareness of the HTTP transfer needed:

csharp
public class FileClientHandler : IFileClient
{
    public long ProcessFile(string filename, Stream fileStream)
    {
        using (fileStream)
        {
            // Process the stream — even multi-GB files work
            return fileStream.Length;
        }
    }
}

// Before StartAsync()
connection.RegisterInterface<IFileClient, FileClientHandler>();

Streaming vs Buffered

By default, the resolved stream is buffered in memory. For large files, use the streaming API directly:

On .NET there is nothing to call: the client resolves the reference before it invokes your handler, and the Stream you receive is already unbuffered (ReadAsStreamAsync with ResponseHeadersRead). The other SDKs hand you the reference and let you choose:

ts
// TypeScript
import { resolveStreamReference, resolveStreamReferenceAsStream } from '@cocoar/signalarrr';
const buffer = await resolveStreamReference(ref);          // ArrayBuffer (buffered)
const stream = await resolveStreamReferenceAsStream(ref);  // ReadableStream (not buffered)
swift
// Swift
let data = try await StreamReferenceResolver.resolve(ref)           // Data (buffered)
let bytes = try await StreamReferenceResolver.resolveAsStream(ref)  // AsyncBytes (not buffered)

Client → Server (Stream as argument) ​

Clients can also send Stream data TO the server. When a Stream (.NET), Blob/ArrayBuffer (TypeScript), or Data (Swift) is passed as an argument to a server method, the client automatically uploads it via HTTP before the call:

csharp
var fileStream = File.OpenRead("/data/report.pdf");
await connection.InvokeCoreAsync<string>(
    new ClientRequestMessage("FileMethods.ProcessUpload", new object[] { fileStream }), ct);
ts
const data = new Blob([fileContent], { type: 'application/octet-stream' });
await connection.invoke('FileMethods.ProcessUpload', data);
swift
let data = try Data(contentsOf: URL(fileURLWithPath: "/data/report.pdf"))
try await connection.invoke("FileMethods.ProcessUpload", arguments: data)

The server method receives a regular Stream:

csharp
public class FileMethods : ServerMethods<AppHub>
{
    public string ProcessUpload(Stream data)
    {
        using var reader = new StreamReader(data);
        return reader.ReadToEnd();
    }
}

One Stream per Client

A Stream can only be read once. Do NOT pass the same Stream instance to multiple clients — only the first client would receive data, the others would get empty responses. SignalARRR throws an InvalidOperationException if you try.

To send the same file to multiple clients, open a separate Stream for each:

csharp
foreach (var client in clients.WithHub<AppHub>())
{
    // Each client gets its own FileStream — do NOT reuse the same stream
    using var fileStream = File.OpenRead("/data/movie.mp4");
    var methods = client.GetTypedMethods<IFileClient>();
    methods.ProcessFile("movie.mp4", fileStream);
}

Why not just use WebSocket? ​

WebSocket connections have practical limits for large binary transfers:

ConcernWebSocketHTTP Stream Reference
Multi-GB filesMessage buffer limits, memory pressureStreamed in 128KB chunks
BackpressureBlocks the hub connection for all clientsIndependent HTTP connection
TimeoutHub timeout appliesSeparate HTTP timeout
Concurrent transfersShares the single WebSocketParallel HTTP connections

Under the hood ​

StreamReference ​

The only thing sent over WebSocket is a small reference object:

json
{
    "Uri": "http://server/apphub/download/550e8400-e29b-41d4-a716-446655440000"
}

ServerPushStreamManager ​

Manages both download streams (server → client) and upload slots (client → server). Registered as a singleton. Streams are automatically disposed after transfer or after a 10-minute cleanup timeout.

Endpoints ​

MapSignalARRRHub<T>() registers the hub and two HTTP endpoints:

/apphub                ← SignalR hub
/apphub/download/{id}  ← HTTP stream downloads (server → client)
/apphub/upload/{id}    ← HTTP stream uploads (client → server)

Both endpoints inherit the hub's protection, whichever way you applied it — an [Authorize] attribute on the hub class, or anything chained onto MapSignalARRRHub<T>():

csharp
// Both of these protect the transfer endpoints too.
app.MapSignalARRRHub<AppHub>("/apphub").RequireAuthorization("files");

[Authorize(Policy = "files")]
public class AppHub : HARRR { ... }

The clients authenticate these requests with their message credential — the one that travels with every hub call: WithMessageCredential in .NET and .NET Framework, messageCredential in TypeScript, Swift and Kotlin, or the credential shortcut that sets it (see the two credentials). The connection credential is not used here. A credential without a space is sent as Bearer <token>; one that carries its own scheme (Basic …) is sent verbatim — the same rule the server applies to the credential in a message.

If you protect the endpoints with something the connection's credential cannot satisfy — a separate policy requiring a claim the hub token does not carry, say — the transfer will 401 while the hub calls keep working. Either widen the policy or hand the clients a credential that satisfies both.

Limitations ​

  • Stream / Blob / Data types only — other large types (e.g., byte[]) are not automatically intercepted
  • In-memory storage — download streams are held in memory on the server until the client fetches them (10 minute timeout)
  • One Stream per client — the same Stream instance cannot be sent to multiple clients (see warning above)
  • The transfer URL is a capability — the id is an unguessable GUID and the endpoint carries the hub's authorization, but the HTTP request itself cannot be tied to the connection that requested the transfer: an HTTP request carries no connection identity. Consuming an upload slot is restricted to the connection that asked for it, so a leaked URL cannot be used to read another client's upload back. Still: do not log these URLs — they routinely end up in reverse-proxy and ingress access logs otherwise.
  • Upload slots are capped per connection — 32 unused slots at a time by default (WithMaxUploadSlotsPerConnection, 0 to disable). Beyond that a client gets the upload_slot_limit_reached error code until it completes or abandons one.

Next steps ​

Released under the Apache-2.0 License.