Files
punktfunk/web/src/api/fetcher.ts
T
enricobuehlerandClaude Opus 5 9e505aba41 fix(web): the console stops swallowing the host's answer when it says no
The host writes genuinely useful refusals — "entry is owned by provider `x`,
update it through its reconcile" — and a dozen call sites threw them away. The
pattern was always one of two: a mutation whose `error` nothing rendered, or an
`await mutateAsync(...)` with no catch, which additionally produced an unhandled
rejection. Either way the operator clicked, nothing visible happened, and the
thing they asked for silently hadn't.

Fixed at each site, with the host's own message shown where there is one:

- Adding or editing a library entry kept the form open and said why, instead of
  closing it as if it had saved and taking the typing with it. Deleting one
  reports the refusal rather than leaving the card sitting there.
- The GPU preference, capture start/stop, recording delete and download, and the
  dashboard's stop-session / request-keyframe / end-game all report failure. The
  failed capture STOP is the one that mattered most: it is "stop & save", so a
  swallowed error meant minutes of recording vanished with nothing on screen.
- The recordings Download had a comment claiming the detail view surfaces its
  errors. It only does that for the selected row, and Download is on every row.

Two related fixes in the same area:

- `apiFetch` no longer navigates to /login synchronously from inside whichever
  call noticed a 401 — very often a background poll the user never started.
  Tearing the page down mid-render took unsaved editing state with it, which the
  Displays page explicitly models. It defers a beat and coalesces, so a burst of
  parallel 401s schedules one navigation.
- The plugin liveness probe treated the auth gate's 302 → /login → 200 HTML as a
  healthy plugin, and rendered the console's own login page inside the plugin's
  iframe. It also gave up permanently on the first failed probe, so the runner
  restart at the end of every install threw away whatever was open in another
  plugin. It rejects the redirect and keeps probing on a slower beat while down.

`apiErrorMessage` moves out of the display card into src/lib/errors.ts, since
half the console needs it now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 00:20:10 +02:00

78 lines
2.7 KiB
TypeScript

// The fetch mutator orval-generated hooks call: `apiFetch<T>(url, RequestInit)`. orval is
// configured (includeHttpResponseReturnType: false) so `T` is the response BODY; on an HTTP
// error we THROW an `ApiError` so React Query's `isError` works (the query client skips
// retries on 4xx — see src/router.tsx).
//
// Auth: requests are same-origin to `/api/...`; the browser sends only the session cookie
// (the server-side proxy injects the management bearer token — the token never lives in the
// browser). A 401 means the session is gone → bounce to /login.
/** A failed API call. `status` is the HTTP code; `data` is the parsed `ApiError` body if any. */
export class ApiError extends Error {
status: number;
data: unknown;
constructor(status: number, data: unknown, message?: string) {
super(message ?? `API error ${status}`);
this.name = "ApiError";
this.status = status;
this.data = data;
}
}
export async function apiFetch<T>(
url: string,
options?: RequestInit,
): Promise<T> {
const headers = new Headers(options?.headers);
headers.set("Accept", "application/json");
const res = await fetch(url, {
...options,
headers,
credentials: "same-origin",
});
const text = await res.text();
const body = text ? safeJson(text) : undefined;
if (res.status === 401) redirectToLogin();
if (!res.ok) throw new ApiError(res.status, body, res.statusText);
return body as T;
}
/**
* On lost session, send the user to the login screen, remembering where they were.
*
* Deferred by a beat rather than navigating inline. This runs inside whichever call noticed the
* 401 — very often a background poll the user never asked for — and a synchronous
* `location.href =` there tears the page down mid-render, taking any unsaved editing state with it
* (the Displays page models exactly such a draft). Letting the current task finish first means the
* caller's own error handling still runs, and a `beforeunload` guard can still speak up.
*
* Guarded so a burst of parallel 401s (every card on a page polling at once) schedules one
* navigation, not one per request.
*/
let redirecting = false;
function redirectToLogin(): void {
if (typeof window === "undefined") return;
if (window.location.pathname === "/login") return;
if (redirecting) return;
redirecting = true;
// Keep the full path (query + hash too), so re-login returns to the exact view.
const next = encodeURIComponent(
window.location.pathname + window.location.search + window.location.hash,
);
setTimeout(() => {
window.location.href = `/login?next=${next}`;
}, 0);
}
function safeJson(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return text;
}
}
export default apiFetch;