Files
geo/apps/desktop-client/src/main/transport/api-client.ts
T

331 lines
9.9 KiB
TypeScript
Raw Normal View History

import { ApiClientError, createApiClient, type ApiClient } from "@geo/http-client";
import type {
ApiEnvelope,
ApiErrorEnvelope,
CompleteDesktopTaskRequest,
CreatePublishJobResponse,
DesktopArticleContent,
DesktopAccountInfo,
DesktopClientHeartbeatRequest,
DesktopClientHeartbeatResponse,
DesktopPublishTaskListResponse,
DesktopTaskInfo,
DesktopRuntimeSessionSyncRequest,
ExtendDesktopTaskRequest,
LeaseDesktopTaskRequest,
LeaseDesktopTaskResponse,
ListDesktopPublishTasksParams,
UpsertDesktopAccountRequest,
} from "@geo/shared-types";
import { canUseRendererDevtoolsProxy, rendererDevtoolsFetch } from "../renderer-devtools-proxy";
type TransportAuthState = "pending" | "registered" | "expired";
type TransportSseState = "idle" | "connecting" | "streaming";
interface DesktopTransportSession {
baseURL: string;
clientToken: string | null;
}
let desktopApiClient: ApiClient | null = null;
let transportSession: DesktopTransportSession = {
baseURL: process.env.DESKTOP_API_BASE_URL ?? "http://localhost:8080",
clientToken: null,
};
const transportState = {
baseURL: transportSession.baseURL,
initializedAt: 0,
mode: "rabbitmq-first" as const,
requestDebugMode: "main-process" as "main-process" | "renderer-devtools",
auth: "pending" as TransportAuthState,
sseState: "idle" as TransportSseState,
lastHeartbeatAt: 0,
lastHeartbeatStatus: "idle" as "idle" | "success" | "failed",
lastPullAt: 0,
lastPullStatus: "idle" as "idle" | "success" | "failed",
};
function createDesktopClient(baseURL: string): ApiClient {
const client = createApiClient({
baseURL,
timeoutMs: 15000,
});
client.raw.interceptors.request.use((config) => {
if (transportSession.clientToken) {
config.headers = config.headers ?? {};
(config.headers as Record<string, string>).Authorization = `Bearer ${transportSession.clientToken}`;
}
return config;
});
return client;
}
function currentRequestDebugMode(): "main-process" | "renderer-devtools" {
return canUseRendererDevtoolsProxy() ? "renderer-devtools" : "main-process";
}
function resolveDesktopURL(path: string): string {
return new URL(path, transportSession.baseURL).toString();
}
async function proxyDesktopRequest<T, B = unknown>(
method: "GET" | "POST" | "DELETE",
path: string,
body?: B,
): Promise<T> {
const headers: Record<string, string> = {
Accept: "application/json",
};
let serializedBody: string | undefined;
if (body !== undefined) {
headers["Content-Type"] = "application/json";
serializedBody = JSON.stringify(body);
}
if (transportSession.clientToken) {
headers.Authorization = `Bearer ${transportSession.clientToken}`;
}
const response = await rendererDevtoolsFetch({
url: resolveDesktopURL(path),
method,
headers,
body: serializedBody,
});
if (response.error) {
throw new ApiClientError({ message: response.error });
}
let payload: ApiEnvelope<T> | Partial<ApiErrorEnvelope> | null = null;
if (response.bodyText) {
try {
payload = JSON.parse(response.bodyText) as ApiEnvelope<T> | Partial<ApiErrorEnvelope>;
} catch {
payload = null;
}
}
if (!response.ok) {
const errorPayload = payload as Partial<ApiErrorEnvelope> | null;
throw new ApiClientError({
message: errorPayload?.message ?? `desktop_http_${response.status}`,
code: errorPayload?.code ?? 50000,
status: response.status,
detail: errorPayload?.detail,
requestId: errorPayload?.request_id,
});
}
if (!payload || typeof payload !== "object" || !("data" in payload)) {
throw new ApiClientError({
message: "desktop_invalid_api_envelope",
status: response.status,
});
}
return payload.data as T;
}
async function desktopGet<T>(path: string): Promise<T> {
transportState.requestDebugMode = currentRequestDebugMode();
if (transportState.requestDebugMode === "renderer-devtools") {
return proxyDesktopRequest<T>("GET", path);
}
return getDesktopApiClient().get<T>(path);
}
async function desktopPost<T, B = unknown>(path: string, body?: B): Promise<T> {
transportState.requestDebugMode = currentRequestDebugMode();
if (transportState.requestDebugMode === "renderer-devtools") {
return proxyDesktopRequest<T, B>("POST", path, body);
}
return getDesktopApiClient().post<T, B>(path, body);
}
async function desktopDelete<T>(path: string): Promise<T> {
transportState.requestDebugMode = currentRequestDebugMode();
if (transportState.requestDebugMode === "renderer-devtools") {
return proxyDesktopRequest<T>("DELETE", path);
}
return getDesktopApiClient().remove<T>(path);
}
export function initTransport(): ApiClient {
if (desktopApiClient) {
return desktopApiClient;
}
transportState.initializedAt = transportState.initializedAt || Date.now();
transportState.baseURL = transportSession.baseURL;
transportState.requestDebugMode = currentRequestDebugMode();
desktopApiClient = createDesktopClient(transportSession.baseURL);
return desktopApiClient;
}
export function configureTransport(session: DesktopRuntimeSessionSyncRequest | null): ApiClient | null {
if (!session) {
transportSession = {
baseURL: process.env.DESKTOP_API_BASE_URL ?? "http://localhost:8080",
clientToken: null,
};
transportState.baseURL = transportSession.baseURL;
transportState.requestDebugMode = currentRequestDebugMode();
transportState.auth = "pending";
desktopApiClient = null;
return null;
}
transportSession = {
baseURL: session.api_base_url.trim() || "http://localhost:8080",
clientToken: session.client_token,
};
transportState.baseURL = transportSession.baseURL;
transportState.requestDebugMode = currentRequestDebugMode();
transportState.auth = session.client_token ? "registered" : "pending";
transportState.initializedAt = Date.now();
desktopApiClient = createDesktopClient(transportSession.baseURL);
return desktopApiClient;
}
export function setTransportAuthState(next: TransportAuthState): void {
transportState.auth = next;
}
export function setTransportSseState(next: TransportSseState): void {
transportState.sseState = next;
}
export function noteTransportHeartbeat(success: boolean): void {
transportState.lastHeartbeatAt = Date.now();
transportState.lastHeartbeatStatus = success ? "success" : "failed";
}
export function noteTransportPull(success: boolean): void {
transportState.lastPullAt = Date.now();
transportState.lastPullStatus = success ? "success" : "failed";
}
export function getDesktopApiClient(): ApiClient {
return desktopApiClient ?? initTransport();
}
export function getTransportSnapshot() {
return {
...transportState,
hasClient: desktopApiClient !== null,
hasClientToken: Boolean(transportSession.clientToken),
};
}
export async function heartbeatDesktopClient(
payload: DesktopClientHeartbeatRequest,
): Promise<DesktopClientHeartbeatResponse> {
return desktopPost<DesktopClientHeartbeatResponse, DesktopClientHeartbeatRequest>(
"/api/desktop/clients/heartbeat",
payload,
);
}
export async function offlineDesktopClient(): Promise<void> {
await desktopPost<{ server_time: string }, Record<string, never>>(
"/api/desktop/clients/offline",
{},
);
}
export async function revokeDesktopClient(): Promise<void> {
await desktopPost<{ id: string }, Record<string, never>>(
"/api/desktop/clients/revoke",
{},
);
}
export async function listDesktopAccounts(): Promise<DesktopAccountInfo[]> {
return desktopGet<DesktopAccountInfo[]>("/api/desktop/accounts");
}
export async function getDesktopArticleContent(articleId: number): Promise<DesktopArticleContent> {
return desktopGet<DesktopArticleContent>(`/api/desktop/content/articles/${articleId}`);
}
export async function listDesktopPublishTasks(
params: ListDesktopPublishTasksParams = {},
): Promise<DesktopPublishTaskListResponse> {
const search = new URLSearchParams();
if (params.page) {
search.set("page", String(params.page));
}
if (params.page_size) {
search.set("page_size", String(params.page_size));
}
if (params.title?.trim()) {
search.set("title", params.title.trim());
}
const query = search.toString();
const suffix = query ? `?${query}` : "";
return desktopGet<DesktopPublishTaskListResponse>(`/api/desktop/publish-tasks${suffix}`);
}
export async function retryDesktopPublishTask(taskId: string): Promise<CreatePublishJobResponse> {
return desktopPost<CreatePublishJobResponse, Record<string, never>>(
`/api/desktop/publish-tasks/${encodeURIComponent(taskId)}/retry`,
{},
);
}
export async function upsertDesktopAccount(
payload: UpsertDesktopAccountRequest,
): Promise<DesktopAccountInfo> {
return desktopPost<DesktopAccountInfo, UpsertDesktopAccountRequest>(
"/api/desktop/accounts",
payload,
);
}
export async function deleteDesktopAccount(
desktopId: string,
ifSyncVersion: number,
): Promise<DesktopAccountInfo> {
const params = new URLSearchParams({ if_sync_version: String(ifSyncVersion) });
return desktopDelete<DesktopAccountInfo>(
`/api/desktop/accounts/${encodeURIComponent(desktopId)}?${params.toString()}`,
);
}
export async function leaseDesktopTask(
request: LeaseDesktopTaskRequest,
): Promise<LeaseDesktopTaskResponse> {
const taskId = request.task_id?.trim();
const path = taskId ? `/api/desktop/tasks/${taskId}/lease` : "/api/desktop/tasks/lease";
return desktopPost<LeaseDesktopTaskResponse, LeaseDesktopTaskRequest>(
path,
taskId ? { kind: request.kind } : request,
);
}
export async function extendDesktopTask(
taskId: string,
payload: ExtendDesktopTaskRequest,
): Promise<DesktopTaskInfo> {
return desktopPost<DesktopTaskInfo, ExtendDesktopTaskRequest>(
`/api/desktop/tasks/${taskId}/extend`,
payload,
);
}
export async function completeDesktopTask(
taskId: string,
payload: CompleteDesktopTaskRequest,
): Promise<DesktopTaskInfo> {
return desktopPost<DesktopTaskInfo, CompleteDesktopTaskRequest>(
`/api/desktop/tasks/${taskId}/result`,
payload,
);
}