Rask
Rask
v0.23.1-alpha.0.496
You're reading the Rask v0.23.1-alpha.0.496 guides. Updated .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 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.

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

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);
}
Live result

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

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

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 IRaskFile 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.


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.

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

Confirm