You're reading the Rask v1.0.0 guides.View source
All guides

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.

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.

HttpRegisterDemo.cs

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."
                ]
            ]
        ];
}
Live result
Configured HttpClient

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.

HttpFetchDemo.cs

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;
Live result
Error: The 'file' scheme is not supported.

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 BaseAddress differs 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.

UploadDemo.cs

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 };
}
Live result
No file selected yet.

A RaskFile is 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.

DownloadDemo.cs

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)."
            ]
        ];
}
Live result
Generated 0 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.