HTTP & files
Fetching JSON over HTTP and moving files in and out of the browser are plain .NET in Rask: a
dependency-injected HttpClient, the typed file-picker input, and the Download facade. The
same component code runs server-rendered over a WebSocket or client-side on WebAssembly — only the host
wiring differs. This guide walks the three, each with a live demo.
- Fetching data with
HttpClient— register a DI'd client, fetch inOnMount - Uploading files — a typed file picker and
IRaskFilemetadata - Keeping an upload — where the bytes go once the handler returns
- Downloading files — hand the browser a file with
Download.File
For server-side persistence (EF Core + SQLite, IDbContextFactory, vertical slices), see the
Data access guide — this one is about data and file transfer.
Fetching data with HttpClient
HttpClient is registered once in Program.cs and injected into components through the constructor —
no [Inject], no service locator. Point its BaseAddress at the app's own origin so relative URLs
(data/posts-1.json) resolve against the static files the app serves itself; the demos below fetch a small
static JSON file, so the showcase stays self-contained and offline-safe.
The base address differs per host: on the Server it's the server's own origin; on WASM it's
WasmHostBuilder.BaseAddress — the app root, carrying any sub-path (a GitHub Pages deploy under
/rask/, say). Read it lazily inside the factory so it fires after the JS module imports:
Relative URLs require BaseAddress. WasmHostBuilder.BaseAddress is the app root (and carries any sub-path) — read it lazily inside the factory so it fires after the JS module imports.
namespace Rask.Site.Features;
// Illustrates the HttpClient registration pattern shown beside the live result. In each host's
// Program.cs the HttpClient is registered as a singleton pointed at the app's OWN origin, so
// relative fetches (e.g. "data/posts-1.json") resolve to the static files the app serves itself
// and the showcase stays self-contained and offline-safe. The base address differs per host —
// on WASM it is WasmHostBuilder.BaseAddress (the page origin, carrying any sub-path), read
// lazily inside the factory so it fires after the JS module imports; on the Server host it is
// the server's own origin. This component builds the same configured client and shows it.
public sealed partial class HttpRegisterDemo : Component
{
// The factory the host registers: configure HttpClient to resolve relative URLs against the
// app's own origin. Pass the origin in lazily (on WASM: () => WasmHostBuilder.BaseAddress).
private static HttpClient CreateClient(Func<string> baseAddress) =>
new() { BaseAddress = new Uri(baseAddress()) };
protected override Component? Render() =>
Ui.Card.Muted[
Div.Class("text-sm text-zinc-500 dark:text-white/70 uppercase mb-1")["Configured HttpClient"],
P.Class("mb-0 text-sm")[
"BaseAddress: ", Code[CreateClient(() => "https://localhost/").BaseAddress!.ToString()],
" — relative fetches resolve against the app's own origin."
]
];
}BaseAddress: https://localhost/ — relative fetches resolve against the app's own origin.
Inject the configured client and load in OnMount — it runs once on first render, and the framework's
async lifecycle handler re-renders when the awaited task completes. Component.CancellationToken cancels on
unmount, so navigating away mid-fetch aborts the in-flight request instead of writing to a dead component:
OnMount runs once on first render. The framework's async lifecycle handler triggers a re-render when the awaited task completes. Component.CancellationToken cancels on unmount — navigate away mid-fetch and the in-flight request aborts.
using System.Net.Http.Json;
using System.Text.Json.Serialization;
namespace Rask.Site.Features;
// HttpClient and TimeProvider are registered as services (AddExampleServices) and injected through the
// primary constructor. Mount runs once on first render; the framework's async lifecycle handler
// triggers a re-render when the awaited task completes. Component.CancellationToken cancels on
// unmount — navigate away mid-fetch and the in-flight request aborts.
public sealed partial class HttpFetchDemo(HttpClient http, TimeProvider time) : Component
{
private const int MaxTransientRetries = 3;
private static readonly TimeSpan RetryDelay = 150.Milliseconds;
// A fetch that never settles is the one failure the retry loop below could not see. Without a
// per-attempt deadline the await simply never returns: no exception, no retry, and the spinner
// stays up for ever — which is precisely the outcome the retries were written to prevent.
private static readonly TimeSpan AttemptTimeout = 5.Seconds;
private string? _error;
private Post? _post;
protected override async Task OnMount()
{
for (var attempt = 0; attempt <= MaxTransientRetries; attempt++)
{
// Every wait in this loop reads the injected clock rather than the wall clock: the system clock
// in the app, and in a test a clock the test advances itself instead of waiting the delays out.
using var timeout = new CancellationTokenSource(AttemptTimeout, time);
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken, timeout.Token);
try
{
_post = await http.GetFromJsonAsync("data/posts-1.json", HttpJsonContext.Default.Post,
deadline.Token);
return;
}
// Navigating away unmounts the page and cancels the token — the page is gone, nothing to show.
// Distinguished from the deadline below by asking the COMPONENT's token, since a linked
// source reports both the same way.
catch (OperationCanceledException) when (CancellationToken.IsCancellationRequested) { return; }
// The attempt itself ran out of time: a fetch that never settled. Retried like any other
// transient transport failure.
catch (OperationCanceledException) when (attempt < MaxTransientRetries)
{
try { await Task.Delay(RetryDelay, time, CancellationToken); }
catch (OperationCanceledException) { return; }
}
// Still not settling after every retry. Says so, rather than reporting the framework's
// "A task was canceled." — which tells a reader nothing about what was being waited on.
catch (OperationCanceledException)
{
_error = $"The request did not complete within {AttemptTimeout.TotalSeconds:0}s.";
return;
}
// On WASM a hard browser refresh kills the in-flight fetch outside the AbortController, so it
// surfaces as an HttpRequestException with no StatusCode ("TypeError: Load failed") rather than
// an OperationCanceledException. The same null-status failure also fires transiently on the
// freshly-booted page when its first fetch races the discarded page's network teardown — so retry
// a few times and the page self-heals instead of hanging on the spinner forever.
catch (HttpRequestException ex) when (ex.StatusCode is null && attempt < MaxTransientRetries)
{
try { await Task.Delay(RetryDelay, time, CancellationToken); }
catch (OperationCanceledException) { return; }
}
// A real HTTP-status failure, or a transport failure that never recovers, surfaces the error banner.
catch (Exception ex)
{
_error = ex.Message;
return;
}
}
}
protected override Component? Render()
{
if (_error is not null)
{
return Ui.Callout.Danger.Icon(Ui.IconName.XCircle).Role("alert").Heading("Error: ").Text(_error);
}
if (_post is null)
{
// Flux's loading icon spins; the words beside it are what a screen reader hears.
return Ui.Text.Role("status").Class("flex items-center gap-2")[
Ui.Icon.Name(Ui.IconName.Loading).Variant(Ui.IconVariant.Mini),
"Loading…"
];
}
// An <article> around Flux's card: this is a self-contained syndicated item, and the element is the
// semantics.
return Article[
Ui.Card[
Ui.Text.Class("mb-1 uppercase")[$"Post #{_post.Id}"],
Ui.Heading.Level(3)[_post.Title],
Ui.Text.Class("mt-2")[_post.Body]
]
];
}
public sealed record Post(
[property: JsonPropertyName("id")] int Id,
[property: JsonPropertyName("title")] string Title,
[property: JsonPropertyName("body")] string Body);
}Same demo, two hosts. On a Server host the request is a loopback call to the server's own static file; on the browser-WASM host (which is what https://rask.sh serves) the browser fetches the same file from the AppBundle. The page code is identical — only the
BaseAddressdiffers per host.
Uploading files
Input.Of<string>().Type(InputType.File).OnFiles(…) wires a file picker to a typed handler. Each change event
hands the handler an IReadOnlyList<IRaskFile>; IRaskFile carries the metadata (name, size, content type,
last-modified) and OpenReadStream gives you a Stream for the bytes — over a multipart POST on the
Server, via JS chunked reads on WASM. The same component code runs unchanged on both hosts:
The handler runs once per change event. IRaskFile is only valid while the handler is on the stack — read whatever you need (bytes, metadata) before returning. The same component code runs unchanged on both hosts.
using System.Globalization;
using Rask.Core.Forms;
namespace Rask.Site.Features;
// A self-contained file-picker demo. A file Ui.Input's OnFiles wires the picker to a typed handler; IRaskFile carries the metadata while the handler is on the stack. The mutating
// handler lives in this component so its field updates re-render the right tree.
public sealed partial class UploadDemo : Component
{
private string? _contentType;
private DateTimeOffset _modified;
private string? _name;
private long _size;
private void OnFiles(IReadOnlyList<IRaskFile> files)
{
if (files.Count == 0)
{
_name = null;
return;
}
var file = files[0];
_name = file.Name;
_size = file.Size;
_contentType = file.ContentType;
_modified = file.LastModified;
}
protected override Component? Render() =>
Div[
Ui.Input.Of<string>()
.Type(InputType.File)
.Label("File")
.Id("upload-input")
.Class("mb-3")
.OnFiles(OnFiles),
_name is null
? Div.Class("text-zinc-500 dark:text-white/70 text-sm")["No file selected yet."]
: Dl.Class("grid grid-cols-12 gap-4 text-sm mb-0")[
Dt.Class("col-span-4 text-zinc-500 dark:text-white/70")["Name"],
Dd.Class("col-span-8 text-break").Data(Meta("name"))[_name],
Dt.Class("col-span-4 text-zinc-500 dark:text-white/70")["Size"],
Dd.Class("col-span-8").Data(Meta("size"))[_size.ToString("N0", CultureInfo.InvariantCulture),
" bytes"],
Dt.Class("col-span-4 text-zinc-500 dark:text-white/70")["Type"],
Dd.Class("col-span-8").Data(Meta("type"))[_contentType ?? string.Empty],
Dt.Class("col-span-4 text-zinc-500 dark:text-white/70")["Modified"],
Dd.Class("col-span-8 mb-0").Data(Meta("modified"))[
_modified.ToString("u", CultureInfo.InvariantCulture)]
]
];
private static new AttrBag Meta(string field) => new("rask-meta", field);
}The kit draws the same input two ways, both reporting through OnFiles. A plain file field is
Ui.Input.Of<string>().Type(InputType.File).Label("Logo"). An area to drop files on is
Ui.FileUpload — Flux UI's file upload — with a Ui.FileItem for each file the page kept:
Ui.FileUpload.Label("Upload files").Multiple().OnFiles(Keep)[
Ui.FileUploadDropzone.Heading("Drop files here or click to browse").Text("JPG, PNG, GIF up to 10MB")
],
_photos.Select((photo, index) =>
Ui.FileItem.Key(photo.Name).Heading(photo.Name).Size(photo.Size)
.Actions(Ui.FileItemRemove.OnClick(() => _photos.RemoveAt(index))))
A dropped file takes the same road as a chosen one: the drop lands in the input, and the input uploads. While
it does, the upload carries data-loading and the dropzone shows a spinner, or with WithProgress() a bar: the
request's own progress on the Server host, and in a WebAssembly app — where nothing is sent — how much of the
files the handler has read through OpenReadStream
(the data-rask-loading hook).
A
IRaskFileis only valid while the handler is on the stack — read whatever you need (bytes, metadata) before returning. The mutating handler lives inside the component so its field updates re-render the right subtree.
Keeping an upload
Because a IRaskFile stops being readable when its handler returns, a file you mean to keep has to be written
somewhere before then. That is what file storage is for, and in a RaskApp it is already
on — save from the handler with nothing injected, then keep the id on your own entity:
var file = picked[0];
var saved = await Files.Save(file.OpenReadStream, file.Name, file.Size);
order.AttachReceipt(saved.Id);
Files.Save writes the bytes to disk, an S3-compatible bucket or Azure Blob, records a row whose content type
is sniffed from the bytes rather than taken from the browser, and refuses a file over the size limit or of a
type you haven't allowed. The file comes back later as a public URL, a temporary URL, or a download behind
your own authorization check. It runs on the server: a WebAssembly page uploads there first.
Downloading files
Download.File stages bytes (or a stream) on the active session: on the Server they're served from
/_rask/download/{sid}/{token}; on WASM the bytes stay on the .NET side and the render carries only a short
token, which the page trades for the bytes through a JS-to-.NET call when it starts the download. The
component code is the same. It must be called from an event handler — outside that scope it throws, because there's no live
render round-trip to attach the download to. The handler can make other state changes too (here it bumps a
counter); both ship in the same render:
Download.File must be called from an event handler — outside that scope it throws, because there's no live render round-trip to attach the download to. The handler can do other state changes too (here, bump a counter); both ship in the same render.
using System.Globalization;
using System.Text;
namespace Rask.Site.Features;
// Download.File stages bytes on the active session — served from /_rask/download/{sid}/{token}
// on the server, pulled by JS from the .NET side by token on WASM. It must be called from an event handler,
// so the state and the handler live together in this self-contained component.
public sealed partial class DownloadDemo : Component
{
private int _reportCount;
private void DownloadReport()
{
_reportCount++;
var report =
$"Rask download demo\nGenerated at {DateTimeOffset.UtcNow.ToString("u", CultureInfo.InvariantCulture)}\nCount: {_reportCount}\n";
Download.File("report.txt", Encoding.UTF8.GetBytes(report), "text/plain");
}
protected override Component? Render() =>
Div[
Ui.Button.Primary.Icon(Ui.IconName.DocumentText).Id("download-report").OnClick(DownloadReport)["Download report"],
Div
.Class("text-sm text-zinc-500 dark:text-white/70 mt-2")
.Data("rask-report-count", "true")[
$"Generated {_reportCount} time(s)."
]
];
}See also: Data access for EF Core persistence, Forms & validation for the
HTMLFormElement<T> pipeline and typed inputs, and Lifecycle for OnMount and cancellation.