fix(web):centralize session api handling

This commit is contained in:
Anish Sarkar 2026-06-23 12:55:50 +05:30
parent fcf2485863
commit 411bb0019e
2 changed files with 98 additions and 6 deletions

View file

@ -19,6 +19,25 @@ enum ResponseType {
// Add more response types as needed
}
const REFRESH_RETRY_BLOCK_MS = 30_000;
const refreshRetryBlockedUntil = new Map<string, number>();
function getRefreshRetryKey(method: RequestOptions["method"], url: string): string {
return `${method}:${url}`;
}
function isRefreshRetryBlocked(key: string): boolean {
const blockedUntil = refreshRetryBlockedUntil.get(key);
if (!blockedUntil) return false;
if (Date.now() < blockedUntil) return true;
refreshRetryBlockedUntil.delete(key);
return false;
}
function blockRefreshRetry(key: string): void {
refreshRetryBlockedUntil.set(key, Date.now() + REFRESH_RETRY_BLOCK_MS);
}
export type RequestOptions = {
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
headers?: Record<string, string>;
@ -31,7 +50,12 @@ export type RequestOptions = {
};
class BaseApiService {
noAuthEndpoints: string[] = ["/auth/jwt/login", "/auth/register", "/auth/refresh"];
noAuthEndpoints: string[] = [
"/auth/jwt/login",
"/auth/register",
"/auth/refresh",
"/auth/jwt/refresh",
];
// Prefixes that don't require auth (checked with startsWith)
noAuthPrefixes: string[] = ["/api/v1/public/"];
@ -42,6 +66,10 @@ class BaseApiService {
return typeof window !== "undefined" ? getBearerToken() || "" : "";
}
get isDesktopClient(): boolean {
return typeof window !== "undefined" && !!window.electronAPI;
}
// Keep for backward compatibility, but token is now always read from localStorage
setBearerToken(_bearerToken: string) {
void _bearerToken;
@ -71,7 +99,9 @@ class BaseApiService {
*/
const defaultOptions: RequestOptions = {
headers: {
Authorization: `Bearer ${this.bearerToken || ""}`,
...(this.isDesktopClient && this.bearerToken
? { Authorization: `Bearer ${this.bearerToken}` }
: {}),
"X-SurfSense-Client-Platform":
typeof window === "undefined" ? "web" : getClientPlatform(),
},
@ -93,7 +123,8 @@ class BaseApiService {
this.noAuthEndpoints.includes(url) ||
this.noAuthPrefixes.some((prefix) => url.startsWith(prefix)) ||
/^\/api\/v1\/invites\/[^/]+\/info$/.test(url);
if (!this.bearerToken && !isNoAuthEndpoint) {
const refreshRetryKey = getRefreshRetryKey(mergedOptions.method, url);
if (this.isDesktopClient && !this.bearerToken && !isNoAuthEndpoint) {
throw new AuthenticationError("You are not authenticated. Please login again.");
}
@ -104,6 +135,7 @@ class BaseApiService {
method: mergedOptions.method,
headers: mergedOptions.headers,
signal: mergedOptions.signal,
credentials: "include",
};
// Automatically stringify body if Content-Type is application/json and body is an object
@ -150,18 +182,21 @@ class BaseApiService {
// Handle 401 - try to refresh token first (only once)
if (response.status === 401) {
if (!options?._isRetry) {
if (options?._isRetry) {
blockRefreshRetry(refreshRetryKey);
} else if (!isNoAuthEndpoint && !isRefreshRetryBlocked(refreshRetryKey)) {
const newToken = await refreshAccessToken();
if (newToken) {
return this.request(url, responseSchema, {
...mergedOptions,
headers: {
...mergedOptions.headers,
Authorization: `Bearer ${newToken}`,
...(this.isDesktopClient ? { Authorization: `Bearer ${newToken}` } : {}),
},
_isRetry: true,
} as RequestOptions & { responseType?: R });
}
blockRefreshRetry(refreshRetryKey);
}
handleUnauthorized();
throw new AuthenticationError(
@ -196,6 +231,7 @@ class BaseApiService {
);
}
}
refreshRetryBlockedUntil.delete(getRefreshRetryKey(mergedOptions.method, url));
// biome-ignore lint/suspicious: Unknown
let data;
@ -381,7 +417,9 @@ class BaseApiService {
...options,
headers: {
// Don't set Content-Type - let browser set it with multipart boundary
Authorization: `Bearer ${this.bearerToken}`,
...(this.isDesktopClient && this.bearerToken
? { Authorization: `Bearer ${this.bearerToken}` }
: {}),
...headersWithoutContentType,
},
responseType: ResponseType.JSON,