Cancel

Run a long operation, stream progress via SignalR, and demonstrate cooperative cancellation guarded by antiforgery.

Full source

File: pwsh/tutorial/examples/Assets/Pages/Cancel.cshtml

@page
@model Kestrun.Razor.PwshKestrunModel
@{
    dynamic d = Model.Data ?? new { HasAntiforgery = false, Seconds = 30 };
    bool enabled = d.HasAntiforgery == true;
}

<!doctype html>
<html>
<head>
    <meta charset="utf-8" />
    <title>Cancel (SignalR + Antiforgery)</title>
    <meta name="viewport" content="width=device-width, initial-scale=1" />

    <style>
        body { font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; margin: 2rem; line-height: 1.4; }
        .row { display:flex; gap:1rem; flex-wrap:wrap; align-items:center; }
        .card { border: 1px solid #ddd; border-radius: 12px; padding: 1rem; max-width: 980px; margin-bottom: 1rem; }
        .muted { color:#666; }
        .log { white-space: pre-wrap; background:#111; color:#ddd; padding:1rem; border-radius: 12px; min-height: 240px; max-height: 60vh; overflow:auto; }
        button { padding: .55rem .9rem; border-radius: 10px; border: 1px solid #ccc; background: #fafafa; cursor:pointer; }
        button:disabled { opacity:.55; cursor:not-allowed; }
        input { padding: .45rem .6rem; border-radius: 10px; border: 1px solid #ccc; width: 8rem; }
        code { background:#f6f6f6; padding:.15rem .35rem; border-radius: 6px; }
        .pill { display:inline-block; padding:.2rem .55rem; border-radius: 999px; border:1px solid #ddd; background:#f6f6f6; }
    </style>

    @if (enabled)
    {
        <script src="https://cdnjs.cloudflare.com/ajax/libs/microsoft-signalr/8.0.7/signalr.min.js"></script>
    }
</head>

<body>

@if (!enabled)
{
    <div class="card">
        <h1>Cancel demo unavailable</h1>
        <p class="muted">
            This page is shown only when Antiforgery is enabled on the server
            (<code>$KrServer.Antiforgery -ne $null</code>).
        </p>
    </div>
}
else
{
    <h1>Cancellation + Live Progress</h1>
    <p class="muted">
        SignalR hub: <span class="pill">/hubs/kestrun</span> • CSRF token: <span class="pill">/csrf-token</span>
    </p>

    <div class="card">
        <div class="row">
            <label>Seconds:
                <input id="seconds" type="number" min="1" max="600" value="@(d.Seconds)" />
            </label>

            <button id="btnStart">Start</button>
            <button id="btnCancel" disabled>Cancel</button>

            <span class="muted">TaskId: <code id="taskId">(none)</code></span>
            <span class="muted">State: <span id="state" class="pill">idle</span></span>
        </div>

        <p class="muted" style="margin-top:.75rem">
            Start: <code>POST /api/operation/start?seconds=...</code><br />
            Cancel: <code>POST /tasks/cancel?id=...</code><br />
            Header: <code>X-CSRF-TOKEN</code> (token from <code>/csrf-token</code>)
        </p>
    </div>

    <div class="card">
        <h2>Live log</h2>
        <div id="log" class="log"></div>
    </div>

    <script>
        (function () {
            // Paths match your server script
            const hubUrl = "/hubs/kestrun";
            const csrfUrl = "/csrf-token";
            const csrfHeaderName = "X-CSRF-TOKEN";

            const startUrl  = (s)  => "/api/operation/start?seconds=" + encodeURIComponent(s);
            const cancelUrl = (id) => "/tasks/cancel?id=" + encodeURIComponent(id);

            const elSeconds = document.getElementById("seconds");
            const elStart   = document.getElementById("btnStart");
            const elCancel  = document.getElementById("btnCancel");
            const elTaskId  = document.getElementById("taskId");
            const elState   = document.getElementById("state");
            const elLog     = document.getElementById("log");

            let currentTaskId = null;
            let csrfToken = null;

            function log(line) {
                const ts = new Date().toISOString();
                elLog.textContent += "[" + ts + "] " + line + "\n";
                elLog.scrollTop = elLog.scrollHeight;
            }

            function setState(s) { elState.textContent = s; }

            async function ensureCsrf() {
                if (csrfToken) return csrfToken;

                const resp = await fetch(csrfUrl, { method: "GET", credentials: "same-origin" });
                if (!resp.ok) {
                    const t = await resp.text();
                    throw new Error("CSRF token fetch failed: HTTP " + resp.status + " " + t);
                }

                const json = await resp.json();
                csrfToken =
                    json.Token ||
                    json.token ||
                    json.RequestToken ||
                    json.requestToken ||
                    json.CsrfToken ||
                    json.csrfToken ||
                    null;

                if (!csrfToken) {
                    throw new Error("CSRF token missing in /csrf-token response: " + JSON.stringify(json));
                }

                log("CSRF token obtained");
                return csrfToken;
            }

            async function postJson(url, bodyObj) {
                const token = await ensureCsrf();

                const resp = await fetch(url, {
                    method: "POST",
                    credentials: "same-origin",
                    headers: {
                        "Content-Type": "application/json",
                        [csrfHeaderName]: token
                    },
                    body: JSON.stringify(bodyObj || {})
                });

                if (!resp.ok) {
                    const t = await resp.text();
                    throw new Error("POST failed: HTTP " + resp.status + " " + t);
                }

                return await resp.json();
            }

            function handleProgress(data) {
                const d = data || {};
                const taskId = d.TaskId || d.taskId || null;

                // Only filter if message includes a task id
                if (currentTaskId && taskId && taskId !== currentTaskId) return;

                setState("running");
                log((d.Progress != null ? d.Progress : "?") + "%: " + (d.Message || ""));
            }

            function handleComplete(data) {
                const d = data || {};
                const taskId = d.TaskId || d.taskId || null;

                if (currentTaskId && taskId && taskId !== currentTaskId) return;

                setState("completed");
                log((d.Progress != null ? d.Progress : "?") + "%: " + (d.Message || ""));

                elStart.disabled = false;
                elCancel.disabled = true;
            }

            function normalizeEvent(payload) {
                const name = payload && (payload.EventName || payload.eventName || payload.Name || payload.name);
                const data = payload && (payload.Data || payload.data || payload);
                return { name: name, data: data };
            }

            const connection = new signalR.HubConnectionBuilder()
                .withUrl(hubUrl)
                .withAutomaticReconnect()
                .build();

            // ✅ Direct delivery style
            connection.on("OperationProgress", handleProgress);
            connection.on("OperationComplete", handleComplete);

            // ✅ Wrapper delivery style
            connection.on("ReceiveEvent", function (payload) {
                const ev = normalizeEvent(payload);
                if (!ev.name) return;

                if (ev.name === "OperationProgress") handleProgress(ev.data);
                else if (ev.name === "OperationComplete") handleComplete(ev.data);
                else log("event " + ev.name + ": " + JSON.stringify(ev.data || payload));
            });

            async function ensureConnected() {
                if (connection.state === signalR.HubConnectionState.Connected) return;
                await connection.start();
                log("SignalR connected");
            }

            elStart.addEventListener("click", async function () {
                elLog.textContent = "";
                setState("starting");
                elStart.disabled = true;

                try {
                    await ensureConnected();

                    const seconds = parseInt(elSeconds.value, 10) || 30;

                    const json = await postJson(startUrl(seconds), {});
                    currentTaskId = json.TaskId || json.taskId || json.id || null;

                    elTaskId.textContent = currentTaskId || "(unknown)";
                    elCancel.disabled = !currentTaskId;

                    setState("running");
                    log(json.Message || ("Task started (TaskId=" + currentTaskId + ")"));
                } catch (e) {
                    setState("error");
                    log("ERROR starting: " + (e && e.message ? e.message : e));
                    elStart.disabled = false;
                    elCancel.disabled = true;
                }
            });

            elCancel.addEventListener("click", async function () {
                if (!currentTaskId) return;

                setState("cancelling");
                elCancel.disabled = true;

                try {
                    const json = await postJson(cancelUrl(currentTaskId), {});
                    log(json.Message || "Cancel requested");
                    setState("cancel requested");
                } catch (e) {
                    setState("cancel error");
                    log("ERROR cancelling: " + (e && e.message ? e.message : e));
                    elCancel.disabled = false;
                }finally {
                    // Allow restart
                    elStart.disabled = false;
                }
            });

            // Warm up: connect + token early
            ensureConnected().catch(err => log("SignalR connect failed: " + err));
            ensureCsrf().catch(err => log("CSRF init failed: " + err));
        })();
    </script>
}
</body>
</html>

File: pwsh/tutorial/examples/Assets/Pages/Cancel.cshtml.ps1

[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')]
param()

$Model = [pscustomobject]@{
    HasAntiforgery = ($null -ne $KrServer.AntiforgeryOptions)
    Seconds        = 30
}

Step-by-step

  1. Feature gate: The page script sets $Model.HasAntiforgery based on $KrServer.AntiforgeryOptions.
  2. Conditional UI: The view shows an “unavailable” message when antiforgery is not enabled.
  3. Token bootstrap: The client fetches /csrf-token and stores the request token.
  4. Start request: The client calls POST /api/operation/start?seconds=... with the X-CSRF-TOKEN header.
  5. Live updates: The client connects to /hubs/kestrun and listens for progress/complete events.
  6. Cancel: The client calls POST /tasks/cancel?id=... with the same CSRF header.

Try it

This page is designed for browsers (SignalR + JS), but you can still sanity-check the endpoints:

# GET the page (requires HTTPS in the antiforgery sample)
curl -k -i https://127.0.0.1:5000/Cancel

# Get token (stores cookie)
curl -k -c cookies.txt https://127.0.0.1:5000/csrf-token

# Start operation (paste token from previous JSON)
curl -k -b cookies.txt -X POST \
  -H "X-CSRF-TOKEN: <paste-token-here>" \
  "https://127.0.0.1:5000/api/operation/start?seconds=3"

Troubleshooting

Symptom Cause Fix
Page shows “Cancel demo unavailable” Antiforgery not enabled Use the antiforgery server sample (11.2-RazorPages-Antiforgery.ps1)
SignalR fails to connect Hub middleware missing Ensure the server adds the SignalR hub at /hubs/kestrun
POST returns 400/403 Missing token header or cookie Call /csrf-token first, then send cookie + X-CSRF-TOKEN

References


Previous / Next

Previous: AppInfo Next: None