Files
app/src/admin/utils/api.ts
BOHA 4bb7de7247 feat(app): detect new deploy and prompt refresh (+ lazy-chunk recovery)
When a new version is deployed, open tabs no longer silently run stale code
until a manual F5.

- Server stamps every response with X-App-Version (src/middleware/version.ts,
  onSend hook in server.ts).
- Client bakes its build version via Vite define (__APP_VERSION__); apiFetch
  compares the header to it (no polling — piggybacks existing traffic) and
  latches a mismatch in a small store (appVersion.ts).
- UpdateBanner: a persistent bottom Snackbar "Je k dispozici nová verze
  aplikace. — Obnovit" → location.reload(). User-initiated, so an in-progress
  form is never interrupted.
- lazyWithReload: every React.lazy page reloads once if its hashed chunk 404s
  after a deploy (sessionStorage-guarded against loops), fixing the "old chunk
  gone" error screen.

Approach chosen after researching current SPA practice (version header + banner
beats polling / a service worker for a non-PWA admin app).

Tests: +4 (server header, client store latch/notify). Full suite 669 pass,
tsc -b clean, lint 0. Verified live via Playwright (header emitted; banner
appears on a simulated mismatch). Detection applies to deploys from the NEXT
release onward (current clients lack the detection code).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 21:19:46 +02:00

178 lines
4.7 KiB
TypeScript

import { reportServerVersion } from "./appVersion";
class ApiState {
showSessionExpiredAlert = false;
showLogoutAlert = false;
getTokenFn: (() => string | null) | null = null;
refreshFn: (() => Promise<boolean>) | null = null;
refreshPromise: Promise<boolean> | null = null;
reset() {
this.showSessionExpiredAlert = false;
this.showLogoutAlert = false;
this.getTokenFn = null;
this.refreshFn = null;
this.refreshPromise = null;
}
}
const state = new ApiState();
export const resetApiState = (): void => {
state.reset();
};
export const shouldShowSessionExpiredAlert = (): boolean => {
if (state.showSessionExpiredAlert) {
state.showSessionExpiredAlert = false;
return true;
}
return false;
};
export const setSessionExpired = (): void => {
state.showSessionExpiredAlert = true;
};
export const shouldShowLogoutAlert = (): boolean => {
if (state.showLogoutAlert) {
state.showLogoutAlert = false;
return true;
}
return false;
};
export const setLogoutAlert = (): void => {
state.showLogoutAlert = true;
};
export const setTokenGetter = (fn: () => string | null): void => {
state.getTokenFn = fn;
};
export const setRefreshFn = (fn: () => Promise<boolean>): void => {
state.refreshFn = fn;
};
export const apiFetch = async (
url: string,
options: RequestInit = {},
): Promise<Response> => {
// Honour abort signals up-front — TanStack Query may cancel a stale
// request before we've done any work.
if (options.signal?.aborted) {
return new Response(null, { status: 499 });
}
let token: string | null = null;
try {
token = state.getTokenFn ? state.getTokenFn() : null;
} catch {
// token retrieval failed
}
// No usable token yet (e.g. fresh page load where the silent refresh
// kicked off in useEffect hasn't populated the in-memory access token).
// Refresh up front, BEFORE firing the request, so the caller never sees
// a 401 in the network log on the first attempt. Concurrent callers join
// the same in-flight refresh via state.refreshPromise.
if (!token && state.refreshFn) {
try {
if (!state.refreshPromise) {
state.refreshPromise = state.refreshFn().finally(() => {
state.refreshPromise = null;
});
}
const refreshed = await state.refreshPromise;
if (options.signal?.aborted) {
return new Response(null, { status: 499 });
}
if (refreshed) {
try {
token = state.getTokenFn ? state.getTokenFn() : null;
} catch {
token = null;
}
}
} catch {
// Refresh itself failed — fall through and let the request fire
// without a token. The 401 handler below will set a session-expired
// flag so the UI can react.
}
}
const headers: Record<string, string> = {
...(options.headers as Record<string, string>),
};
if (
!headers["Content-Type"] &&
options.body &&
!(options.body instanceof FormData)
) {
headers["Content-Type"] = "application/json";
}
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
let response = await fetch(url, {
...options,
headers,
credentials: "include",
});
// Detect a new deploy: compare the server's build version to ours.
reportServerVersion(response.headers.get("X-App-Version"));
if (response.status === 401 && state.refreshFn) {
// If the caller already unmounted (TanStack Query aborted the request),
// do not refresh + retry. Doing so would fire a global "session expired"
// toast on a page the user has already left and leak a fetch+refresh pair.
if (options.signal?.aborted) {
return response;
}
try {
if (!state.refreshPromise) {
state.refreshPromise = state.refreshFn().finally(() => {
state.refreshPromise = null;
});
}
const refreshed = await state.refreshPromise;
// Re-check after the await: the caller may have unmounted while the
// refresh was in flight (refresh + abort race).
if (options.signal?.aborted) {
return response;
}
if (refreshed) {
token = state.getTokenFn ? state.getTokenFn() : null;
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const { signal: _signal, ...retryOptions } = options;
response = await fetch(url, {
...retryOptions,
headers,
credentials: "include",
});
} else {
setSessionExpired();
}
} catch {
setSessionExpired();
}
}
return response;
};
export const getAccessToken = (): string | null => {
try {
return state.getTokenFn ? state.getTokenFn() : null;
} catch {
return null;
}
};
export default apiFetch;