Fetch
Issue browser fetch() requests from C# with a typed Request / Response / Headers object model, download progress reporting, streamed uploads and two flavors of cancellation - for the cases where HttpClient can't reach fetch-only semantics.
@inject Bit.Butil.Fetch fetchMDN reference
Send issues the request and resolves with a FetchResponse carrying the status, final URL (after redirects), response headers and body bytes. Ok is true for any 2xx status; network or CORS failures surface in the Error property instead of throwing.
FetchResponse response = await fetch.Send(new FetchRequest
{
Url = "https://jsonplaceholder.typicode.com/todos/1",
});
if (response.Ok)
{
var json = Encoding.UTF8.GetString(response.Body);
}FetchRequest exposes the fetch() init options as plain properties: Method, Headers, Body bytes, plus the fetch-specific Credentials, Mode, Cache and Redirect strings. This demo POSTs a JSON document with an explicit Content-Type and cache bypass.
var request = new FetchRequest
{
Url = "https://jsonplaceholder.typicode.com/posts",
Method = "POST",
Headers = new() { ["Content-Type"] = "application/json; charset=utf-8" },
Body = Encoding.UTF8.GetBytes("{\"title\":\"Bit.Butil\",\"userId\":1}"),
Credentials = "omit", // "omit" | "same-origin" | "include"
Mode = "cors", // "cors" | "no-cors" | "same-origin" | "navigate"
Cache = "no-store", // "default" | "no-store" | "reload" | "no-cache" | ...
Redirect = "follow", // "follow" | "error" | "manual"
};
FetchResponse response = await fetch.Send(request);FetchHeaders is the Headers half of the object model: an ordered list where a name may repeat, matched case-insensitively. Append keeps what is already there, Set replaces it, GetAll returns every value and Get joins them with a comma the way the Headers specification does. A Dictionary<string, string> still converts both ways, so code written against the old shape keeps working.
var headers = new FetchHeaders()
.Append("Accept", "application/json")
.Append("X-Trace", "one")
.Append("X-Trace", "two");
headers.GetAll("x-trace"); // ["one", "two"] - case-insensitive
headers.Get("X-Trace"); // "one, two"
headers.Set("Accept", "text/plain"); // replaces every Accept
// still assignable from - and to - a dictionary
FetchRequest request = new() { Headers = new() { ["Content-Type"] = "application/json" } };
Dictionary<string, string> flat = request.Headers;Beyond the status and body, FetchResponse carries what only the browser knows: whether the request was redirected to get here, and the response Type - basic, cors, or opaque for a no-cors request whose status reads 0 and whose body is unreadable by design. A network or CORS failure is not an exception; it is a response with Ok false and Error set.
FetchResponse response = await fetch.Send(new FetchRequest { Url = url });
// response.Redirected - true when at least one redirect got us here
// response.Type - "basic" | "cors" | "opaque" | "opaqueredirect" | "error"
// response.Error - set instead of throwing when the network or CORS said no
var opaque = await fetch.Send(new FetchRequest { Url = url, Mode = "no-cors" });
// opaque.Type == "opaque", opaque.Status == 0, opaque.Body is emptySendStream hands the browser a .NET Stream as the request body and lets it pull as the connection drains, so neither side holds the payload whole - the way to POST a file larger than memory. Chromium-only, over HTTP/2 or HTTP/3 in a secure context, which is what SupportsStreamingUpload answers; where it is unsupported the request comes back as a failed response rather than throwing.
if (await fetch.SupportsStreamingUpload() is false) return; // fall back to Send
await using var file = File.OpenRead(path);
FetchResponse response = await fetch.SendStream(
new FetchRequest
{
Url = "https://example.com/upload",
Method = "POST",
Headers = new() { ["Content-Type"] = "application/octet-stream" },
},
file,
onProgress: p => { /* p.Loaded of p.Total handed to the browser */ });Pass an onProgress callback and it fires as body chunks arrive, with Loaded bytes so far and Total from Content-Length (null for chunked responses). Ideal for progress bars on large downloads - something HttpClient on WebAssembly can't observe.
var response = await fetch.Send(
new FetchRequest { Url = "https://example.com/large-file" },
onProgress: p =>
{
var percent = p.Total.HasValue ? $"{100.0 * p.Loaded / p.Total.Value:F0}%" : "?";
// update UI: p.Loaded of p.Total bytes
});Send accepts a CancellationToken; triggering it aborts the underlying fetch through an AbortController. Cancellation does not throw - it resolves with a FetchResponse whose Aborted flag is true, matching the abort-handle path.
using var cts = new CancellationTokenSource();
var responseTask = fetch.Send(
new FetchRequest { Url = "https://example.com/large-file" },
cancellationToken: cts.Token);
cts.Cancel(); // abort mid-flight
var response = await responseTask;
// response.Aborted == trueStart launches the request and immediately returns an AbortableFetch handle without waiting for (or returning) the response payload. Call Abort to stop it; disposing the handle also aborts unless the request already completed. Prefer Send whenever you need the response.
Bit.Butil.Fetch fetch
{
private AbortableFetch? handle;
private async Task Start() =>
handle = await fetch.Start(new FetchRequest
{
Url = "https://example.com/large-file",
});
// Nothing waits for the response, so this is the only way to stop the transfer.
private async Task Cancel() => await handle!.Abort();
}API reference
Task<FetchResponse> Send(FetchRequest request, Action<FetchProgress>? onProgress = null, CancellationToken cancellationToken = default)Task<FetchResponse> SendStream(FetchRequest request, Stream body, Action<FetchProgress>? onProgress = null, CancellationToken cancellationToken = default)ValueTask<bool> SupportsStreamingUpload()Task<AbortableFetch> Start(FetchRequest request)void InvokeFetchProgress(Guid id, FetchProgress progress)ValueTask DisposeAsync()string Urlstring Method = "GET"FetchHeaders Headersbyte[]? Bodystring Credentials = "same-origin"string Mode = "cors"string Cache = "default"string Redirect = "follow"string? Referrerstring? ReferrerPolicystring? Integritybool KeepAlivestring? Prioritybool Okint Statusstring StatusTextstring UrlFetchHeaders Headersbyte[] Bodybool Redirectedstring Typebool Abortedstring? ErrorFetchHeaders Append(string name, string value)FetchHeaders Set(string name, string? value)string? Get(string name)string[] GetAll(string name)bool Has(string name)bool Remove(string name)IEnumerable<string> NamesDictionary<string, string> ToDictionary()implicit operator FetchHeaders?(Dictionary<string, string>?)implicit operator Dictionary<string, string>?(FetchHeaders?)long Loadedlong? TotalGuid IdValueTask Abort()