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:
- Server stores the stream and generates a download URI
- Only a
StreamReference(a small JSON object with the URI) is sent over WebSocket - Client detects the
Streamparameter, fetches the actual data via HTTP GET - 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:
[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:
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:
[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:
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:
// TypeScript
import { resolveStreamReference, resolveStreamReferenceAsStream } from '@cocoar/signalarrr';
const buffer = await resolveStreamReference(ref); // ArrayBuffer (buffered)
const stream = await resolveStreamReferenceAsStream(ref); // ReadableStream (not buffered)// 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:
var fileStream = File.OpenRead("/data/report.pdf");
await connection.InvokeCoreAsync<string>(
new ClientRequestMessage("FileMethods.ProcessUpload", new object[] { fileStream }), ct);const data = new Blob([fileContent], { type: 'application/octet-stream' });
await connection.invoke('FileMethods.ProcessUpload', data);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:
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:
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:
| Concern | WebSocket | HTTP Stream Reference |
|---|---|---|
| Multi-GB files | Message buffer limits, memory pressure | Streamed in 128KB chunks |
| Backpressure | Blocks the hub connection for all clients | Independent HTTP connection |
| Timeout | Hub timeout applies | Separate HTTP timeout |
| Concurrent transfers | Shares the single WebSocket | Parallel HTTP connections |
Under the hood
StreamReference
The only thing sent over WebSocket is a small reference object:
{
"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>():
// 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/Datatypes 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
Streaminstance 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 theupload_slot_limit_reachederror code until it completes or abandons one.
Next steps
- Item Streaming: Server-to-Client — IAsyncEnumerable patterns (different from file transfer)
- Client Manager — push files from controllers
- Wire Protocol — protocol details