Files
edgeever/apps/web/src/lib/api.ts
T

364 lines
12 KiB
TypeScript

import {
ApiRequestError,
createEdgeEverClient,
type EdgeEverClientRequestContext,
} from "@edgeever/client";
import type { AuthSession } from "@edgeever/shared";
import { normalizeInstanceUrl } from "@edgeever/shared/public-demo";
import { createClientUuid } from "./client-id";
export { ApiRequestError };
export type {
AiProviderCreatePayload,
AiProviderUpdatePayload,
InstanceHealth,
InstanceRelease,
JsonBackupPage,
MarkdownExportPage,
MemoShareResponse,
SyncBootstrapResponse,
SyncChangesResponse,
} from "@edgeever/client";
const WEB_DEVICE_ID_STORAGE_KEY = "edgeever.web.device-id";
export const DESKTOP_API_BASE_URL_STORAGE_KEY = "edgeever.desktop.api-base-url";
const DESKTOP_SESSION_STORAGE_KEY = "edgeever.desktop.session";
let desktopSessionToken: string | null | undefined;
export const getCachedDesktopSession = (): AuthSession | null => {
if (typeof window === "undefined" || !window.edgeeverDesktop?.isAvailable) return null;
try {
const value = window.localStorage.getItem(DESKTOP_SESSION_STORAGE_KEY);
if (!value) return null;
const parsed = JSON.parse(value) as AuthSession;
return parsed && typeof parsed === "object" && "authenticated" in parsed ? parsed : null;
} catch {
return null;
}
};
const getDesktopSessionToken = () => {
if (typeof window === "undefined" || !window.edgeeverDesktop?.isAvailable) return undefined;
if (desktopSessionToken) return desktopSessionToken;
const storedToken = window.edgeeverDesktop.getSessionToken().trim();
const legacyToken = getCachedDesktopSession()?.sessionToken?.trim() ?? "";
// A legacy token remains only when secure persistence has not completed,
// so it must win over a possibly stale encrypted file from an earlier login.
desktopSessionToken = legacyToken || storedToken || null;
return desktopSessionToken ?? undefined;
};
const setDesktopSessionToken = async (value: string) => {
desktopSessionToken = value;
try {
await window.edgeeverDesktop?.setSessionToken(value);
return true;
} catch {
return false;
}
};
const clearDesktopSessionToken = () => {
desktopSessionToken = null;
void window.edgeeverDesktop?.clearSessionToken().catch(() => {});
};
export const cacheDesktopSession = async (session: AuthSession) => {
if (typeof window === "undefined" || !window.edgeeverDesktop?.isAvailable) return;
try {
const cached = getCachedDesktopSession();
const candidateToken = session.authenticated
? session.sessionToken ?? cached?.sessionToken
: undefined;
let tokenStoredSecurely = true;
if (candidateToken) {
tokenStoredSecurely = await setDesktopSessionToken(candidateToken);
} else if (
session.authenticated &&
cached?.authenticated &&
cached.user?.id !== session.user?.id
) {
clearDesktopSessionToken();
}
const { sessionToken: _sessionToken, ...cachedSession } = session;
window.localStorage.setItem(
DESKTOP_SESSION_STORAGE_KEY,
JSON.stringify(candidateToken && !tokenStoredSecurely ? { ...cachedSession, sessionToken: candidateToken } : cachedSession),
);
} catch {
// A session cache is an offline convenience and must never block login.
}
};
export const clearCachedDesktopSession = () => {
if (typeof window === "undefined" || !window.edgeeverDesktop?.isAvailable) return;
try {
window.localStorage.removeItem(DESKTOP_SESSION_STORAGE_KEY);
} catch {
// Ignore restricted storage contexts.
}
clearDesktopSessionToken();
};
export const getConfiguredDesktopApiBaseUrl = () => {
if (typeof window === "undefined" || !window.edgeeverDesktop?.isAvailable) return "";
try {
const savedUrl = (window.localStorage.getItem(DESKTOP_API_BASE_URL_STORAGE_KEY) ?? "").trim();
if (savedUrl) return savedUrl.replace(/\/$/, "");
} catch {}
const bridgeUrl = (window.edgeeverDesktop.apiBaseUrl ?? "").trim();
return bridgeUrl.replace(/\/$/, "");
};
export class DesktopInstanceUrlError extends Error {
constructor() {
super("Desktop instance URL must use http or https");
this.name = "DesktopInstanceUrlError";
}
}
export const saveDesktopApiBaseUrl = async (value: string) => {
const normalized = normalizeInstanceUrl(value);
let parsed: URL;
try {
parsed = new URL(normalized);
} catch {
throw new DesktopInstanceUrlError();
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new DesktopInstanceUrlError();
}
if (getConfiguredDesktopApiBaseUrl() !== normalized) {
clearCachedDesktopSession();
}
await window.edgeeverDesktop?.setApiBaseUrl(normalized);
window.localStorage.setItem(DESKTOP_API_BASE_URL_STORAGE_KEY, normalized);
return normalized;
};
const createWebDeviceId = () => `web-${createClientUuid()}`;
export const getOrCreateClientDeviceId = () => {
try {
const existing = window.localStorage.getItem(WEB_DEVICE_ID_STORAGE_KEY);
if (existing) return existing;
const deviceId = createWebDeviceId();
window.localStorage.setItem(WEB_DEVICE_ID_STORAGE_KEY, deviceId);
return deviceId;
} catch {
return createWebDeviceId();
}
};
let desktopSessionRejected = false;
let unauthorizedConfirmPromise: Promise<boolean> | null = null;
const isDesktopPublicRequest = (path: string) =>
path === "/api/release" || path === "/api/v1/auth/login" || path === "/api/v1/auth/session";
/** Confirm the current browser or desktop session is lost before signing out. */
const confirmSessionLost = async (): Promise<boolean> => {
if (typeof window === "undefined") return true;
if (unauthorizedConfirmPromise) return unauthorizedConfirmPromise;
unauthorizedConfirmPromise = (async () => {
try {
const headers = new Headers();
const isDesktop = Boolean(window.edgeeverDesktop?.isAvailable);
const sessionToken = isDesktop ? getDesktopSessionToken() : undefined;
if (sessionToken) headers.set("Authorization", `Bearer ${sessionToken}`);
const baseUrl = getConfiguredDesktopApiBaseUrl();
const response = await fetch(`${baseUrl}/api/v1/auth/session`, {
credentials: "include",
headers,
});
if (!response.ok) return true;
const session = await response.json().catch(() => null) as AuthSession | null;
return !session?.authenticated;
} catch {
return true;
} finally {
queueMicrotask(() => {
unauthorizedConfirmPromise = null;
});
}
})();
return unauthorizedConfirmPromise;
};
const notifyUnauthorized = async (isDesktop: boolean, rejectedDesktopSessionToken?: string) => {
if (isDesktop && desktopSessionRejected) return;
if (isDesktop && rejectedDesktopSessionToken) {
if (getDesktopSessionToken() !== rejectedDesktopSessionToken) return;
const sessionLost = await confirmSessionLost();
if (!sessionLost || getDesktopSessionToken() !== rejectedDesktopSessionToken) return;
clearCachedDesktopSession();
desktopSessionRejected = true;
window.dispatchEvent(new CustomEvent("edgeever:unauthorized"));
return;
}
const sessionLost = await confirmSessionLost();
if (!sessionLost) return;
if (isDesktop) {
clearCachedDesktopSession();
desktopSessionRejected = true;
}
window.dispatchEvent(new CustomEvent("edgeever:unauthorized"));
};
const beforeRequest = ({ path }: EdgeEverClientRequestContext) => {
const isDesktop = Boolean(typeof window !== "undefined" && window.edgeeverDesktop?.isAvailable);
if (isDesktop && desktopSessionRejected && !isDesktopPublicRequest(path)) {
throw new ApiRequestError("Authentication required", 401, "unauthorized");
}
};
const handleUnauthorized = ({ path, token }: EdgeEverClientRequestContext) => {
if (path === "/api/v1/auth/login" || path.startsWith("/api/public/") || typeof window === "undefined") return;
const isDesktop = Boolean(window.edgeeverDesktop?.isAvailable);
void notifyUnauthorized(isDesktop, token);
};
const toUint8Array = (bytes: ArrayBuffer | Uint8Array | undefined) => {
if (!bytes) return new Uint8Array();
return bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
};
const desktopProviderFetch: typeof fetch = async (input, init) => {
init?.signal?.throwIfAborted();
const bridge = window.edgeeverDesktop;
if (!bridge?.openAiProviderStream || !bridge.onAiProviderStreamChunk) {
throw new TypeError("Desktop AI transport is unavailable.");
}
const url = typeof input === "string"
? input
: input instanceof URL
? input.href
: input.url;
const headers: Record<string, string> = {};
new Headers(init?.headers).forEach((value, key) => {
headers[key] = value;
});
let bodyBytes: Uint8Array | undefined;
if (init?.body instanceof FormData) {
const multipart = new Request(url, { method: init.method ?? "POST", headers, body: init.body });
multipart.headers.forEach((value, key) => { headers[key] = value; });
bodyBytes = new Uint8Array(await multipart.arrayBuffer());
}
init?.signal?.throwIfAborted();
const requestId = crypto.randomUUID();
const pending: Array<{ type: "data" | "end" | "error"; bytes?: ArrayBuffer | Uint8Array; message?: string }> = [];
let streamController: ReadableStreamDefaultController<Uint8Array> | null = null;
let cleanup = () => {};
const stop = bridge.onAiProviderStreamChunk((id, chunk) => {
if (id !== requestId) return;
if (!streamController) {
pending.push(chunk);
return;
}
if (chunk.type === "data") streamController.enqueue(toUint8Array(chunk.bytes));
else {
if (chunk.type === "end") streamController.close();
else streamController.error(new Error(chunk.message || "AI provider request failed."));
cleanup();
}
});
const abort = () => {
bridge.cancelAiProviderStream(requestId);
const reason = init?.signal?.reason ?? new DOMException("The operation was aborted.", "AbortError");
if (streamController) streamController.error(reason);
else pending.push({ type: "error", message: String(reason) });
cleanup();
};
cleanup = () => {
stop();
init?.signal?.removeEventListener("abort", abort);
};
init?.signal?.addEventListener("abort", abort, { once: true });
try {
const opened = await bridge.openAiProviderStream(requestId, {
url,
method: init?.method,
headers,
body: typeof init?.body === "string" ? init.body : "",
...(bodyBytes ? { bodyBytes } : {}),
});
init?.signal?.throwIfAborted();
const body = new ReadableStream<Uint8Array>({
start(controller) {
streamController = controller;
for (const chunk of pending) {
if (chunk.type === "data") controller.enqueue(toUint8Array(chunk.bytes));
else {
if (chunk.type === "end") controller.close();
else controller.error(new Error(chunk.message || "AI provider request failed."));
cleanup();
break;
}
}
pending.length = 0;
},
cancel() {
abort();
},
});
return new Response(body, { status: opened.status, headers: opened.headers });
} catch (error) {
cleanup();
throw error;
}
};
const desktopAiDirectEnabled = Boolean(
(typeof __EDGEEVER_DESKTOP_BUILD__ !== "undefined" && __EDGEEVER_DESKTOP_BUILD__)
|| (typeof window !== "undefined" && window.edgeeverDesktop?.openAiProviderStream),
);
/** Direct client-to-provider transport. Browser CORS failures are surfaced to the caller. */
export const directProviderFetch: typeof fetch = (input, init) =>
desktopAiDirectEnabled ? desktopProviderFetch(input, init) : fetch(input, init);
const client = createEdgeEverClient({
baseUrl: getConfiguredDesktopApiBaseUrl,
token: getDesktopSessionToken,
beforeRequest,
shouldAttachToken: (path) => path !== "/api/v1/auth/login",
onUnauthorized: handleUnauthorized,
directAiGeneration: desktopAiDirectEnabled,
providerFetch: desktopAiDirectEnabled ? desktopProviderFetch : undefined,
});
export const api = {
...client,
getSession: async () => {
const session = await client.getSession();
if (
typeof window !== "undefined"
&& window.edgeeverDesktop?.isAvailable
&& !session.authenticated
&& getDesktopSessionToken()
) {
clearCachedDesktopSession();
desktopSessionRejected = true;
window.dispatchEvent(new CustomEvent("edgeever:unauthorized"));
}
return session;
},
login: async (payload: { username: string; password: string; locale?: string }) => {
const session = await client.login({ ...payload, deviceId: getOrCreateClientDeviceId() });
desktopSessionRejected = false;
return session;
},
};