loading
Note:
A stream has one consumer Reading from a stream locks it, and a locked stream can no longer be teed or piped - the specification's rule, not this wrapper's, and what stops two consumers silently stealing each other's chunks. Tee, PipeThrough and PipeTo spend the handle they were called on: the stream belongs to the result afterwards.

Support check

IsSupported / IsTransformSupported

ReadableStream is everywhere. CompressionStream - which the transforms need - is newer, so it has its own check. During prerender/SSR both return false rather than throwing, so defer them to OnAfterRenderAsync.

C#
@inject Bit.Butil.Streams streams

var supported = await streams.IsSupported();
var transforms = await streams.IsTransformSupported();
Live sample
support check output
Results will appear here when you interact with the samples.

Read a response as it arrives

FromResponse / Read

The difference between 'download it, then look at it' and 'look at it as it downloads': a gigabyte through Fetch.Send is a gigabyte of managed memory, while a gigabyte through here is one chunk at a time. TotalBytes is the Content-Length header, which describes the transfer rather than the stream - a compressed response reports the encoded size while the chunks you read are the decoded ones, and a chunked response sends no header at all. It is a hint for a progress bar, never the number of bytes you will read. The final read carries no data, so the loop ends on Done rather than on an empty array.

@implements IAsyncDisposable
@inject Bit.Butil.Streams streams

@code {
    private async Task Read()
    {
        var response = await streams.FromResponse(new FetchRequest { Url = "/api/stream" });
        if (response.Stream is null) { /* response.Error says why */ return; }

        // The stream holds a live connection open, so it is disposed however the loop ends.
        await using var stream = response.Stream;

        var received = 0L;
        while (true)
        {
            var chunk = await stream.Read();
            if (chunk.Done) break;
            received += chunk.Data!.Length;   // ... and do something with it now
        }
    }
}
Live sample
Progress Idle.
read output
Results will appear here when you interact with the samples.

Cancelling actually cancels

Cancel

Cancelling a stream tells the source to give up - for a fetch body that aborts the download rather than letting it finish into nothing. Start this section's read and press Cancel; the server stops sending, and the read loop ends where it was.

C#
await response.Stream!.Cancel("the user navigated away");

// the next Read answers Done, and the request is gone from the network panel
Live sample
Progress Idle.
cancel output
Results will appear here when you interact with the samples.

One stream, two consumers

Tee

Tee splits a stream in two, each getting every chunk - for hashing a download while also writing it somewhere, say. The original handle is spent: the stream has been split, not copied. Both branches are fed from the one source, so a branch nobody reads makes the browser buffer for it indefinitely - read both, or cancel the one you do not want.

C#
var split = await response.Stream!.Tee();
var (first, second) = split!.Value;

// both see every chunk; the original handle is spent
var counting = CountBytes(first);
var hashing = HashBytes(second);
await Task.WhenAll(counting, hashing);
Live sample
tee output
Results will appear here when you interact with the samples.

A pipeline of your own

CreateCompression / PipeThrough / CreateWritable / PipeTo

The transforms are the browser's own codecs, and PipeThrough is what accepts them. PipeTo runs the whole pipe in one call - back-pressure, closing the destination, and propagating an error from either end all happen inside it. The sink's callback is where the bytes reach C#, and the producer waits for it to return before sending the next chunk, so a slow handler slows the download rather than queueing behind it.

C#
var gzip = await streams.CreateCompression(CompressionFormat.Gzip);
var sink = await streams.CreateWritable(
    onChunk: bytes => { compressed += bytes.Length; },
    onFinished: reason => { /* null on a clean close */ });

var compressedStream = await response.Stream!.PipeThrough(gzip!);
var error = await compressedStream!.PipeTo(sink!);   // null on success
Live sample
Result Idle.
pipeline output
Results will appear here when you interact with the samples.

Writing into a sink by hand

Write / Close

A writable does not have to be fed by a pipe. Write waits until the sink is ready for the chunk rather than queueing without limit, which is what makes a producer in a loop run at the speed of its consumer. A stream being piped into cannot also be written to by hand - the pipe holds it.

C#
var sink = await streams.CreateWritable(bytes => received.AddRange(bytes));

foreach (var chunk in chunks)
{
    await sink!.Write(chunk);   // returns when the sink is ready for the next one
}
await sink!.Close();
Live sample
write output
Results will appear here when you interact with the samples.
Warning:
Cancel what you abandon A stream you stop reading does not stop arriving. Dispose the handle - which cancels it, and for a fetch body cancels the download with it. As a safety net the Streams service cancels anything left behind when its scope is torn down. A handle spent by Tee, PipeThrough or a successful PipeTo owns nothing any more, so disposing it cancels nothing.

API reference

Member
Signature
Description
IsSupported
ValueTask<bool> IsSupported()
True when the runtime exposes ReadableStream. Returns default (false) during prerender/SSR instead of throwing.
IsTransformSupported
ValueTask<bool> IsTransformSupported()
True when the runtime exposes CompressionStream, which the transforms need.
FromResponse
ValueTask<StreamedResponse> FromResponse(FetchRequest request)
Starts a request and hands back its body as a stream before the body has arrived. Honours FetchRequest.Signal. A failure or a bodyless response sets Error rather than throwing.
CreateWritable
ValueTask<WritableStreamHandle?> CreateWritable(Action<byte[]> onChunk, Action<string?>? onFinished = null, int highWaterMark = 1)
A stream that writes into C#. The producer waits for onChunk to return before sending the next chunk.
CreateCompression
ValueTask<TransformStreamHandle?> CreateCompression(CompressionFormat format = CompressionFormat.Gzip)
A transform that compresses what passes through it, using the browser's own codec. Null where CompressionStream is missing.
CreateDecompression
ValueTask<TransformStreamHandle?> CreateDecompression(CompressionFormat format = CompressionFormat.Gzip)
The inverse. A format that does not match the data errors the pipe rather than this call.
ReadableStreamHandle.Read
ValueTask<StreamChunk> Read()
Reads the next chunk. The first read locks the stream. The final read carries Done and no data.
ReadableStreamHandle.Tee
ValueTask<(ReadableStreamHandle First, ReadableStreamHandle Second)?> Tee()
Splits the stream in two, each getting every chunk. Null when it is already locked. This handle is spent afterwards.
ReadableStreamHandle.PipeThrough
ValueTask<ReadableStreamHandle?> PipeThrough(TransformStreamHandle transform)
Runs the stream through a transform and returns its output. This handle is spent afterwards.
ReadableStreamHandle.PipeTo
ValueTask<string?> PipeTo(WritableStreamHandle destination, bool preventClose = false)
Pumps everything into a destination and completes when done. Null on success, or the reason it failed.
ReadableStreamHandle.Cancel
ValueTask Cancel(string? reason = null)
Stops the stream and tells the source to give up - for a fetch body, cancels the download.
ReadableStreamHandle.GetLocked
ValueTask<bool> GetLocked()
Whether a reader or a pipe holds the stream. A locked stream cannot be teed or piped.
WritableStreamHandle.Write
ValueTask<bool> Write(byte[] data)
Writes a chunk, waiting until the sink is ready for it. False when the stream is closed, aborted or released.
WritableStreamHandle.Close / Abort
ValueTask<bool> Close(), Abort(string? reason = null)
Ends the stream cleanly once queued chunks are handled, or now discarding them. onFinished runs with null or the reason.
TransformStreamHandle.Readable / Writable
ReadableStreamHandle Readable, WritableStreamHandle Writable
The two ends, for wiring a transform up by hand rather than through PipeThrough.
StreamedResponse
record (ReadableStreamHandle? Stream, int Status, string StatusText, string Url, long? TotalBytes, string? Error)
The response's status and its unread body. TotalBytes is null for a chunked or compressed response.
StreamChunk
record (bool Done, byte[]? Data, string? Error)
One read. Chunk sizes are the browser's choice and vary within one stream.
An unhandled error has occurred. Reload 🗙