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
- Feature gate: The page script sets
$Model.HasAntiforgerybased on$KrServer.AntiforgeryOptions. - Conditional UI: The view shows an “unavailable” message when antiforgery is not enabled.
- Token bootstrap: The client fetches
/csrf-tokenand stores the request token. - Start request: The client calls
POST /api/operation/start?seconds=...with theX-CSRF-TOKENheader. - Live updates: The client connects to
/hubs/kestrunand listens for progress/complete events. - 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 |