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 Navigator download bridge. 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 inOnMountAsync - Uploading files — a typed file picker and
RaskFilemetadata - Downloading files — stage bytes with
Navigator.Download
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() =>
Div.Class($"{Tw.Card} border-0 bg-ui-well")[
Div.Class(Tw.CardBody)[
Div.Class("text-sm text-ui-muted 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 OnMountAsync — 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:
OnMountAsync 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 is registered as a service in Program.cs and injected through the primary
// constructor. OnMountAsync 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) : Component
{
private const int MaxTransientRetries = 3;
private static readonly TimeSpan RetryDelay = TimeSpan.FromMilliseconds(150);
// 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 = TimeSpan.FromSeconds(5);
private string? _error;
private Post? _post;
protected override async Task OnMountAsync()
{
for (var attempt = 0; ; attempt++)
{
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken);
deadline.CancelAfter(AttemptTimeout);
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, 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, 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 Div.Class($"{Tw.AlertDanger} mb-0")[
Strong["Error: "], _error
];
}
if (_post is null)
{
return Div.Class("text-ui-muted flex items-center")[
Span.Class($"{Tw.Spinner} size-4 me-2"),
"Loading…"
];
}
return Article.Class($"{Tw.Card} border-0 bg-ui-well")[
Div.Class(Tw.CardBody)[
Div.Class("text-sm text-ui-muted uppercase mb-1")[$"Post #{_post.Id}"],
H3.Class("text-base font-semibold")[_post.Title],
P.Class("mb-0 text-sm")[_post.Body]
]
];
}
public sealed record Post(
[property: JsonPropertyName("id")] int Id,
[property: JsonPropertyName("title")] string Title,
[property: JsonPropertyName("body")] string Body);
}
[JsonSerializable(typeof(HttpFetchDemo.Post))]
internal sealed partial class HttpJsonContext : JsonSerializerContext;
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<string>().Type(InputType.File).Files(…) wires a file picker to a typed handler. Each change event
hands the handler an IReadOnlyList<RaskFile>; RaskFile 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. RaskFile 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. Input(Type: InputType.File, OnFiles: …) wires the picker to a
// typed handler; RaskFile 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<RaskFile> 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[
Input.Value<string>(null)
.Id("upload-input")
.Type(InputType.File)
.Class($"{Tw.Input} mb-3")
.OnFiles(OnFiles),
_name is null
? (Component)Div.Class("text-ui-muted text-sm")["No file selected yet."]
: Dl.Class("grid grid-cols-12 gap-4 text-sm mb-0")[
Dt.Class("col-span-4 text-ui-muted")["Name"],
Dd.Class("col-span-8 text-break").Data(Meta("name"))[_name],
Dt.Class("col-span-4 text-ui-muted")["Size"],
Dd.Class("col-span-8").Data(Meta("size"))[_size.ToString("N0", CultureInfo.InvariantCulture),
" bytes"],
Dt.Class("col-span-4 text-ui-muted")["Type"],
Dd.Class("col-span-8").Data(Meta("type"))[_contentType ?? string.Empty],
Dt.Class("col-span-4 text-ui-muted")["Modified"],
Dd.Class("col-span-8 mb-0").Data(Meta("modified"))[
_modified.ToString("u", CultureInfo.InvariantCulture)]
]
];
private static new IReadOnlyDictionary<string, string?> Meta(string field) =>
new Dictionary<string, string?> { ["rask-meta"] = field };
}
A
RaskFileis 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.
Downloading files
Navigator.Download stages bytes (or a stream) on the active session: on the Server they're served from
/_rask/download/{sid}/{token}; on WASM they're handed to JS as a base64 payload. 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:
Navigator.Download 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;
using Rask.Core.Routing;
namespace Rask.Site.Features;
// Navigator.Download stages bytes on the active session — served from /_rask/download/{sid}/{token}
// on the server, handed to JS as a base64 payload 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(Navigator nav) : Component
{
private int _reportCount;
private void DownloadReport()
{
_reportCount++;
var report =
$"Rask download demo\nGenerated at {DateTimeOffset.UtcNow.ToString("u", CultureInfo.InvariantCulture)}\nCount: {_reportCount}\n";
nav.Download("report.txt", Encoding.UTF8.GetBytes(report), "text/plain");
}
protected override Component? Render() =>
Div[
Button.Type("button").Class(Tw.BtnPrimary).Id("download-report").OnClick(DownloadReport)[
UiIcon.Name(UiIconName.Document).Class("me-2"),
"Download report"
],
Div
.Class("text-sm text-ui-muted mt-2")
.Data(new Dictionary<string, string?> { ["rask-report-count"] = "true" })[
$"Generated {_reportCount} time(s)."
]
];
}
See also: Data access for EF Core persistence, Forms & validation for the
Form<T> pipeline and typed inputs, and Lifecycle for OnMountAsync and cancellation.