chore(frontend): introduce prettier + eslint and prune unused code
Backend CI / Backend (push) Has been cancelled
Frontend CI / Frontend (push) Failing after 1m39s

- Add Prettier 3 with prettier-plugin-organize-imports (sorts/removes unused imports)
- Add ESLint 10 flat config with typescript-eslint + eslint-plugin-vue + eslint-config-prettier
- Add root scripts: format, format:check, lint, lint:fix
- Reformat 257 files across admin-web, ops-web, desktop-client, packages
- Remove unused locals/exports flagged by --noUnusedLocals/--noUnusedParameters
- Fix duplicate localTabLabel key, surrogate-pair regex u-flag, NBSP literal in regex
- Skip server/ (Go) and apps/browser-extension/ (deprecated per ADR)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-01 20:39:09 +08:00
parent 21c7892ee4
commit 162abdc97c
258 changed files with 42261 additions and 37556 deletions
@@ -1,23 +1,21 @@
import { ApiClientError, createApiClient, type ApiClient } from "@geo/http-client";
import { ApiClientError, createApiClient, type ApiClient } from '@geo/http-client'
import type {
ApiEnvelope,
ApiErrorEnvelope,
CancelDesktopTaskRequest,
CompleteDesktopTaskRequest,
CreatePublishJobResponse,
DesktopArticleContent,
DesktopAccountInfo,
DesktopArticleContent,
DesktopClientHeartbeatRequest,
DesktopClientHeartbeatResponse,
DesktopPublishTaskListResponse,
DesktopTaskInfo,
DesktopRuntimeSessionSyncRequest,
DesktopTaskInfo,
ExtendDesktopTaskRequest,
LeaseDesktopTaskRequest,
LeaseDesktopTaskResponse,
ListDesktopPublishTasksParams,
ReportDesktopAccountHealthRequest,
ReportDesktopAccountHealthResponse,
MonitoringLeaseTasksPayload,
MonitoringLeaseTasksResponse,
MonitoringResumeTasksPayload,
@@ -26,145 +24,145 @@ import type {
MonitoringSkipTaskResponse,
MonitoringTaskResultPayload,
MonitoringTaskResultResponse,
ReportDesktopAccountHealthRequest,
ReportDesktopAccountHealthResponse,
UpsertDesktopAccountRequest,
} from "@geo/shared-types";
} from '@geo/shared-types'
import { canUseRendererDevtoolsProxy, rendererDevtoolsFetch } from "../renderer-devtools-proxy";
import {
failObservedRequest,
resolveObservedRequest,
startObservedRequest,
} from "../network-observer";
} from '../network-observer'
import { canUseRendererDevtoolsProxy, rendererDevtoolsFetch } from '../renderer-devtools-proxy'
type TransportAuthState = "pending" | "registered" | "expired";
type TransportDispatchState = "idle" | "connecting" | "streaming";
type TransportAuthState = 'pending' | 'registered' | 'expired'
type TransportDispatchState = 'idle' | 'connecting' | 'streaming'
type ObservedAxiosConfig = {
method?: string;
url?: string;
baseURL?: string;
headers?: Record<string, unknown>;
__observedRequestId?: string;
};
method?: string
url?: string
baseURL?: string
headers?: Record<string, unknown>
__observedRequestId?: string
}
interface DesktopTransportSession {
baseURL: string;
clientToken: string | null;
baseURL: string
clientToken: string | null
}
interface MonitoringCancelTaskPayload {
lease_token: string;
reason?: string | null;
lease_token: string
reason?: string | null
}
interface MonitoringCancelTaskResponse {
task_id: number;
task_status: string;
task_id: number
task_status: string
}
let desktopApiClient: ApiClient | null = null;
let desktopApiClient: ApiClient | null = null
let transportSession: DesktopTransportSession = {
baseURL: process.env.DESKTOP_API_BASE_URL ?? "http://localhost:8080",
baseURL: process.env.DESKTOP_API_BASE_URL ?? 'http://localhost:8080',
clientToken: null,
};
const ACCOUNT_UPSERT_CACHE_TTL_MS = 5 * 60_000;
}
const ACCOUNT_UPSERT_CACHE_TTL_MS = 5 * 60_000
interface AccountUpsertCacheEntry {
signature: string;
expiresAt: number;
response: DesktopAccountInfo;
inFlight: Promise<DesktopAccountInfo> | null;
signature: string
expiresAt: number
response: DesktopAccountInfo
inFlight: Promise<DesktopAccountInfo> | null
}
const accountUpsertCache = new Map<string, AccountUpsertCacheEntry>();
const accountUpsertCache = new Map<string, AccountUpsertCacheEntry>()
const transportState = {
baseURL: transportSession.baseURL,
initializedAt: 0,
mode: "rabbitmq-first" as const,
requestDebugMode: "main-process" as "main-process" | "renderer-devtools",
auth: "pending" as TransportAuthState,
dispatchState: "idle" as TransportDispatchState,
mode: 'rabbitmq-first' as const,
requestDebugMode: 'main-process' as 'main-process' | 'renderer-devtools',
auth: 'pending' as TransportAuthState,
dispatchState: 'idle' as TransportDispatchState,
lastHeartbeatAt: 0,
lastHeartbeatStatus: "idle" as "idle" | "success" | "failed",
lastHeartbeatStatus: 'idle' as 'idle' | 'success' | 'failed',
lastPullAt: 0,
lastPullStatus: "idle" as "idle" | "success" | "failed",
};
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}`;
config.headers = config.headers ?? {}
;(config.headers as Record<string, string>).Authorization =
`Bearer ${transportSession.clientToken}`
}
const observedConfig = config as ObservedAxiosConfig;
const observedConfig = config as ObservedAxiosConfig
observedConfig.__observedRequestId = startObservedRequest({
source: "transport",
label: "desktop.api",
source: 'transport',
label: 'desktop.api',
method: config.method,
url: resolveObservedRequestURL(config.url, config.baseURL ?? baseURL),
});
return config;
});
})
return config
})
client.raw.interceptors.response.use(
(response) => {
const observedConfig = response.config as ObservedAxiosConfig;
const observedConfig = response.config as ObservedAxiosConfig
if (observedConfig.__observedRequestId) {
resolveObservedRequest(observedConfig.__observedRequestId, {
phase: "response",
phase: 'response',
status: response.status,
});
})
}
return response;
return response
},
(error: unknown) => {
const responseError = error as {
config?: ObservedAxiosConfig;
response?: { status?: number };
};
const requestId = responseError.config?.__observedRequestId;
config?: ObservedAxiosConfig
response?: { status?: number }
}
const requestId = responseError.config?.__observedRequestId
if (requestId) {
if (typeof responseError.response?.status === "number") {
if (typeof responseError.response?.status === 'number') {
resolveObservedRequest(requestId, {
phase: "response",
phase: 'response',
status: responseError.response.status,
});
})
} else {
failObservedRequest(requestId, error);
failObservedRequest(requestId, error)
}
}
return Promise.reject(error);
return Promise.reject(error)
},
);
)
return client;
return client
}
function stableJson(value: unknown): string {
if (Array.isArray(value)) {
return `[${value.map((item) => stableJson(item)).join(",")}]`;
return `[${value.map((item) => stableJson(item)).join(',')}]`
}
if (value && typeof value === "object") {
if (value && typeof value === 'object') {
return `{${Object.entries(value as Record<string, unknown>)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, item]) => `${JSON.stringify(key)}:${stableJson(item)}`)
.join(",")}}`;
.join(',')}}`
}
return JSON.stringify(value);
return JSON.stringify(value)
}
function accountUpsertCacheKey(payload: UpsertDesktopAccountRequest): string {
return [
payload.platform.trim(),
payload.platform_uid.trim(),
].join("::");
return [payload.platform.trim(), payload.platform_uid.trim()].join('::')
}
function accountUpsertSignature(payload: UpsertDesktopAccountRequest): string {
@@ -178,29 +176,32 @@ function accountUpsertSignature(payload: UpsertDesktopAccountRequest): string {
verified_at: payload.verified_at ?? null,
tags: payload.tags ?? [],
if_sync_version: payload.if_sync_version ?? null,
});
})
}
function clearAccountUpsertCache(): void {
accountUpsertCache.clear();
accountUpsertCache.clear()
}
function currentRequestDebugMode(): "main-process" | "renderer-devtools" {
return canUseRendererDevtoolsProxy() ? "renderer-devtools" : "main-process";
function currentRequestDebugMode(): 'main-process' | 'renderer-devtools' {
return canUseRendererDevtoolsProxy() ? 'renderer-devtools' : 'main-process'
}
function resolveDesktopURL(path: string): string {
return new URL(path, transportSession.baseURL).toString();
return new URL(path, transportSession.baseURL).toString()
}
export function resolveDesktopApiURL(path: string): string {
return resolveDesktopURL(path);
return resolveDesktopURL(path)
}
export async function fetchDesktopApiURL(input: string | URL, init: RequestInit = {}): Promise<Response> {
const headers = new Headers(init.headers);
if (transportSession.clientToken && !headers.has("Authorization")) {
headers.set("Authorization", `Bearer ${transportSession.clientToken}`);
export async function fetchDesktopApiURL(
input: string | URL,
init: RequestInit = {},
): Promise<Response> {
const headers = new Headers(init.headers)
if (transportSession.clientToken && !headers.has('Authorization')) {
headers.set('Authorization', `Bearer ${transportSession.clientToken}`)
}
const url =
@@ -208,56 +209,56 @@ export async function fetchDesktopApiURL(input: string | URL, init: RequestInit
? input.toString()
: /^https?:\/\//i.test(input)
? input
: resolveDesktopURL(input);
: resolveDesktopURL(input)
return fetch(url, {
...init,
headers,
});
})
}
function resolveObservedRequestURL(path: string | undefined, baseURL: string): string {
if (!path) {
return baseURL;
return baseURL
}
try {
return new URL(path, baseURL).toString();
return new URL(path, baseURL).toString()
} catch {
return path;
return path
}
}
function shouldFallbackToMainProcess(error: unknown): boolean {
if (error instanceof ApiClientError) {
if (typeof error.status === "number" && error.status > 0) {
return false;
if (typeof error.status === 'number' && error.status > 0) {
return false
}
return /Failed to fetch|renderer_devtools_proxy_/i.test(error.message);
return /Failed to fetch|renderer_devtools_proxy_/i.test(error.message)
}
if (error instanceof Error) {
return /Failed to fetch|renderer_devtools_proxy_/i.test(error.message);
return /Failed to fetch|renderer_devtools_proxy_/i.test(error.message)
}
return false;
return false
}
async function proxyDesktopRequest<T, B = unknown>(
method: "GET" | "POST" | "DELETE",
method: 'GET' | 'POST' | 'DELETE',
path: string,
body?: B,
): Promise<T> {
const headers: Record<string, string> = {
Accept: "application/json",
};
Accept: 'application/json',
}
let serializedBody: string | undefined;
let serializedBody: string | undefined
if (body !== undefined) {
headers["Content-Type"] = "application/json";
serializedBody = JSON.stringify(body);
headers['Content-Type'] = 'application/json'
serializedBody = JSON.stringify(body)
}
if (transportSession.clientToken) {
headers.Authorization = `Bearer ${transportSession.clientToken}`;
headers.Authorization = `Bearer ${transportSession.clientToken}`
}
const response = await rendererDevtoolsFetch({
@@ -265,148 +266,150 @@ async function proxyDesktopRequest<T, B = unknown>(
method,
headers,
body: serializedBody,
});
})
if (response.error) {
throw new ApiClientError({ message: response.error });
throw new ApiClientError({ message: response.error })
}
let payload: ApiEnvelope<T> | Partial<ApiErrorEnvelope> | null = null;
let payload: ApiEnvelope<T> | Partial<ApiErrorEnvelope> | null = null
if (response.bodyText) {
try {
payload = JSON.parse(response.bodyText) as ApiEnvelope<T> | Partial<ApiErrorEnvelope>;
payload = JSON.parse(response.bodyText) as ApiEnvelope<T> | Partial<ApiErrorEnvelope>
} catch {
payload = null;
payload = null
}
}
if (!response.ok) {
const errorPayload = payload as Partial<ApiErrorEnvelope> | null;
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)) {
if (!payload || typeof payload !== 'object' || !('data' in payload)) {
throw new ApiClientError({
message: "desktop_invalid_api_envelope",
message: 'desktop_invalid_api_envelope',
status: response.status,
});
})
}
return payload.data as T;
return payload.data as T
}
async function desktopGet<T>(path: string): Promise<T> {
transportState.requestDebugMode = currentRequestDebugMode();
if (transportState.requestDebugMode === "renderer-devtools") {
transportState.requestDebugMode = currentRequestDebugMode()
if (transportState.requestDebugMode === 'renderer-devtools') {
try {
return await proxyDesktopRequest<T>("GET", path);
return await proxyDesktopRequest<T>('GET', path)
} catch (error) {
if (!shouldFallbackToMainProcess(error)) {
throw error;
throw error
}
transportState.requestDebugMode = "main-process";
transportState.requestDebugMode = 'main-process'
}
}
return getDesktopApiClient().get<T>(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") {
transportState.requestDebugMode = currentRequestDebugMode()
if (transportState.requestDebugMode === 'renderer-devtools') {
try {
return await proxyDesktopRequest<T, B>("POST", path, body);
return await proxyDesktopRequest<T, B>('POST', path, body)
} catch (error) {
if (!shouldFallbackToMainProcess(error)) {
throw error;
throw error
}
transportState.requestDebugMode = "main-process";
transportState.requestDebugMode = 'main-process'
}
}
return getDesktopApiClient().post<T, B>(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") {
transportState.requestDebugMode = currentRequestDebugMode()
if (transportState.requestDebugMode === 'renderer-devtools') {
try {
return await proxyDesktopRequest<T>("DELETE", path);
return await proxyDesktopRequest<T>('DELETE', path)
} catch (error) {
if (!shouldFallbackToMainProcess(error)) {
throw error;
throw error
}
transportState.requestDebugMode = "main-process";
transportState.requestDebugMode = 'main-process'
}
}
return getDesktopApiClient().remove<T>(path);
return getDesktopApiClient().remove<T>(path)
}
export function initTransport(): ApiClient {
if (desktopApiClient) {
return desktopApiClient;
return desktopApiClient
}
transportState.initializedAt = transportState.initializedAt || Date.now();
transportState.baseURL = transportSession.baseURL;
transportState.requestDebugMode = currentRequestDebugMode();
desktopApiClient = createDesktopClient(transportSession.baseURL);
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 {
export function configureTransport(
session: DesktopRuntimeSessionSyncRequest | null,
): ApiClient | null {
if (!session) {
transportSession = {
baseURL: process.env.DESKTOP_API_BASE_URL ?? "http://localhost:8080",
baseURL: process.env.DESKTOP_API_BASE_URL ?? 'http://localhost:8080',
clientToken: null,
};
transportState.baseURL = transportSession.baseURL;
transportState.requestDebugMode = currentRequestDebugMode();
transportState.auth = "pending";
transportState.dispatchState = "idle";
desktopApiClient = null;
clearAccountUpsertCache();
return null;
}
transportState.baseURL = transportSession.baseURL
transportState.requestDebugMode = currentRequestDebugMode()
transportState.auth = 'pending'
transportState.dispatchState = 'idle'
desktopApiClient = null
clearAccountUpsertCache()
return null
}
transportSession = {
baseURL: session.api_base_url.trim() || "http://localhost:8080",
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.dispatchState = session.client_token ? "connecting" : "idle";
transportState.initializedAt = Date.now();
desktopApiClient = createDesktopClient(transportSession.baseURL);
clearAccountUpsertCache();
return desktopApiClient;
}
transportState.baseURL = transportSession.baseURL
transportState.requestDebugMode = currentRequestDebugMode()
transportState.auth = session.client_token ? 'registered' : 'pending'
transportState.dispatchState = session.client_token ? 'connecting' : 'idle'
transportState.initializedAt = Date.now()
desktopApiClient = createDesktopClient(transportSession.baseURL)
clearAccountUpsertCache()
return desktopApiClient
}
export function setTransportAuthState(next: TransportAuthState): void {
transportState.auth = next;
transportState.auth = next
}
export function setTransportDispatchState(next: TransportDispatchState): void {
transportState.dispatchState = next;
transportState.dispatchState = next
}
export function noteTransportHeartbeat(success: boolean): void {
transportState.lastHeartbeatAt = Date.now();
transportState.lastHeartbeatStatus = success ? "success" : "failed";
transportState.lastHeartbeatAt = Date.now()
transportState.lastHeartbeatStatus = success ? 'success' : 'failed'
}
export function noteTransportPull(success: boolean): void {
transportState.lastPullAt = Date.now();
transportState.lastPullStatus = success ? "success" : "failed";
transportState.lastPullAt = Date.now()
transportState.lastPullStatus = success ? 'success' : 'failed'
}
export function getDesktopApiClient(): ApiClient {
return desktopApiClient ?? initTransport();
return desktopApiClient ?? initTransport()
}
export function getTransportSnapshot() {
@@ -414,105 +417,102 @@ export function getTransportSnapshot() {
...transportState,
hasClient: desktopApiClient !== null,
hasClientToken: Boolean(transportSession.clientToken),
};
}
}
export async function heartbeatDesktopClient(
payload: DesktopClientHeartbeatRequest,
): Promise<DesktopClientHeartbeatResponse> {
return desktopPost<DesktopClientHeartbeatResponse, DesktopClientHeartbeatRequest>(
"/api/desktop/clients/heartbeat",
'/api/desktop/clients/heartbeat',
payload,
);
)
}
export async function offlineDesktopClient(): Promise<void> {
await desktopPost<{ server_time: string }, Record<string, never>>(
"/api/desktop/clients/offline",
'/api/desktop/clients/offline',
{},
);
)
}
export async function revokeDesktopClient(): Promise<void> {
await desktopPost<{ id: string }, Record<string, never>>(
"/api/desktop/clients/revoke",
{},
);
await desktopPost<{ id: string }, Record<string, never>>('/api/desktop/clients/revoke', {})
}
export async function listDesktopAccounts(): Promise<DesktopAccountInfo[]> {
return desktopGet<DesktopAccountInfo[]>("/api/desktop/accounts");
return desktopGet<DesktopAccountInfo[]>('/api/desktop/accounts')
}
export async function getDesktopArticleContent(articleId: number): Promise<DesktopArticleContent> {
return desktopGet<DesktopArticleContent>(`/api/desktop/content/articles/${articleId}`);
return desktopGet<DesktopArticleContent>(`/api/desktop/content/articles/${articleId}`)
}
export async function listDesktopPublishTasks(
params: ListDesktopPublishTasksParams = {},
): Promise<DesktopPublishTaskListResponse> {
const search = new URLSearchParams();
const search = new URLSearchParams()
if (params.page) {
search.set("page", String(params.page));
search.set('page', String(params.page))
}
if (params.page_size) {
search.set("page_size", String(params.page_size));
search.set('page_size', String(params.page_size))
}
if (params.title?.trim()) {
search.set("title", 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}`);
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> {
const now = Date.now();
const cacheKey = accountUpsertCacheKey(payload);
const signature = accountUpsertSignature(payload);
const cached = accountUpsertCache.get(cacheKey);
const now = Date.now()
const cacheKey = accountUpsertCacheKey(payload)
const signature = accountUpsertSignature(payload)
const cached = accountUpsertCache.get(cacheKey)
if (cached && cached.signature === signature && cached.expiresAt > now) {
if (cached.inFlight) {
return cached.inFlight;
return cached.inFlight
}
return cached.response;
return cached.response
}
const request = desktopPost<DesktopAccountInfo, UpsertDesktopAccountRequest>(
"/api/desktop/accounts",
'/api/desktop/accounts',
payload,
);
)
accountUpsertCache.set(cacheKey, {
signature,
expiresAt: now + ACCOUNT_UPSERT_CACHE_TTL_MS,
response: cached?.response ?? ({} as DesktopAccountInfo),
inFlight: request,
});
})
try {
const response = await request;
const response = await request
accountUpsertCache.set(cacheKey, {
signature,
expiresAt: Date.now() + ACCOUNT_UPSERT_CACHE_TTL_MS,
response,
inFlight: null,
});
return response;
})
return response
} catch (error) {
if (accountUpsertCache.get(cacheKey)?.inFlight === request) {
accountUpsertCache.delete(cacheKey);
accountUpsertCache.delete(cacheKey)
}
throw error;
throw error
}
}
@@ -520,32 +520,32 @@ export async function reportDesktopAccountHealth(
payload: ReportDesktopAccountHealthRequest,
): Promise<ReportDesktopAccountHealthResponse> {
return desktopPost<ReportDesktopAccountHealthResponse, ReportDesktopAccountHealthRequest>(
"/api/desktop/accounts/health-reports",
'/api/desktop/accounts/health-reports',
payload,
);
)
}
export async function deleteDesktopAccount(
desktopId: string,
ifSyncVersion: number,
): Promise<DesktopAccountInfo> {
const params = new URLSearchParams({ if_sync_version: String(ifSyncVersion) });
const params = new URLSearchParams({ if_sync_version: String(ifSyncVersion) })
const response = await desktopDelete<DesktopAccountInfo>(
`/api/desktop/accounts/${encodeURIComponent(desktopId)}?${params.toString()}`,
);
clearAccountUpsertCache();
return response;
)
clearAccountUpsertCache()
return response
}
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";
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(
@@ -555,7 +555,7 @@ export async function extendDesktopTask(
return desktopPost<DesktopTaskInfo, ExtendDesktopTaskRequest>(
`/api/desktop/tasks/${taskId}/extend`,
payload,
);
)
}
export async function completeDesktopTask(
@@ -565,7 +565,7 @@ export async function completeDesktopTask(
return desktopPost<DesktopTaskInfo, CompleteDesktopTaskRequest>(
`/api/desktop/tasks/${taskId}/result`,
payload,
);
)
}
export async function cancelDesktopTask(
@@ -575,25 +575,25 @@ export async function cancelDesktopTask(
return desktopPost<DesktopTaskInfo, CancelDesktopTaskRequest>(
`/api/desktop/tasks/${taskId}/cancel`,
payload,
);
)
}
export async function leaseMonitoringTasks(
payload: MonitoringLeaseTasksPayload = {},
): Promise<MonitoringLeaseTasksResponse> {
return desktopPost<MonitoringLeaseTasksResponse, MonitoringLeaseTasksPayload>(
"/api/desktop/monitoring/tasks/lease",
'/api/desktop/monitoring/tasks/lease',
payload,
);
)
}
export async function resumeMonitoringTasks(
payload: MonitoringResumeTasksPayload = {},
): Promise<MonitoringResumeTasksResponse> {
return desktopPost<MonitoringResumeTasksResponse, MonitoringResumeTasksPayload>(
"/api/desktop/monitoring/tasks/resume",
'/api/desktop/monitoring/tasks/resume',
payload,
);
)
}
export async function submitMonitoringTaskResult(
@@ -603,7 +603,7 @@ export async function submitMonitoringTaskResult(
return desktopPost<MonitoringTaskResultResponse, MonitoringTaskResultPayload>(
`/api/desktop/monitoring/tasks/${taskId}/result`,
payload,
);
)
}
export async function skipMonitoringTask(
@@ -613,7 +613,7 @@ export async function skipMonitoringTask(
return desktopPost<MonitoringSkipTaskResponse, MonitoringSkipTaskPayload>(
`/api/desktop/monitoring/tasks/${taskId}/skip`,
payload,
);
)
}
export async function cancelMonitoringTask(
@@ -623,5 +623,5 @@ export async function cancelMonitoringTask(
return desktopPost<MonitoringCancelTaskResponse, MonitoringCancelTaskPayload>(
`/api/desktop/monitoring/tasks/${taskId}/cancel`,
payload,
);
)
}
@@ -1,21 +1,21 @@
import WebSocket, { type RawData } from "ws";
import WebSocket, { type RawData } from 'ws'
import {
failObservedRequest,
resolveObservedRequest,
startObservedRequest,
} from "../network-observer";
} from '../network-observer'
export type DispatchEventHandler = (payload: unknown) => void;
export type DispatchEventHandler = (payload: unknown) => void
export interface DispatchWsClientOptions {
url: string;
headers?: Record<string, string>;
initialRetryDelayMs?: number;
maxRetryDelayMs?: number;
url: string
headers?: Record<string, string>
initialRetryDelayMs?: number
maxRetryDelayMs?: number
}
type DispatchWsState = "idle" | "connecting" | "open";
type DispatchWsState = 'idle' | 'connecting' | 'open'
/**
* DispatchWsClient receives per-client task dispatch messages from the server
@@ -34,57 +34,57 @@ type DispatchWsState = "idle" | "connecting" | "open";
* observe the auth + transport handshake is healthy.
*/
export class DispatchWsClient {
readonly #listeners = new Map<string, Set<DispatchEventHandler>>();
readonly #options: DispatchWsClientOptions;
#running = false;
#socket: WebSocket | null = null;
#retryDelayMs: number;
#reconnectTimer: ReturnType<typeof globalThis.setTimeout> | null = null;
#observedRequestId: string | null = null;
readonly #listeners = new Map<string, Set<DispatchEventHandler>>()
readonly #options: DispatchWsClientOptions
#running = false
#socket: WebSocket | null = null
#retryDelayMs: number
#reconnectTimer: ReturnType<typeof globalThis.setTimeout> | null = null
#observedRequestId: string | null = null
constructor(options: DispatchWsClientOptions) {
this.#options = options;
this.#retryDelayMs = options.initialRetryDelayMs ?? 1_000;
this.#options = options
this.#retryDelayMs = options.initialRetryDelayMs ?? 1_000
}
on(event: string, handler: DispatchEventHandler): () => void {
const bucket = this.#listeners.get(event) ?? new Set<DispatchEventHandler>();
bucket.add(handler);
this.#listeners.set(event, bucket);
const bucket = this.#listeners.get(event) ?? new Set<DispatchEventHandler>()
bucket.add(handler)
this.#listeners.set(event, bucket)
return () => {
bucket.delete(handler);
bucket.delete(handler)
if (bucket.size === 0) {
this.#listeners.delete(event);
this.#listeners.delete(event)
}
};
}
}
start(): void {
if (this.#running) {
return;
return
}
this.#running = true;
this.#connect();
this.#running = true
this.#connect()
}
stop(): void {
this.#running = false;
this.#clearReconnect();
this.#setState("idle");
this.#running = false
this.#clearReconnect()
this.#setState('idle')
if (this.#observedRequestId) {
resolveObservedRequest(this.#observedRequestId, {
phase: "close",
phase: 'close',
status: 1000,
error: "client_stop",
});
this.#observedRequestId = null;
error: 'client_stop',
})
this.#observedRequestId = null
}
const socket = this.#socket;
this.#socket = null;
const socket = this.#socket
this.#socket = null
if (!socket) {
return;
return
}
// Detach every listener we registered so their handlers don't re-fire our
@@ -92,21 +92,21 @@ export class DispatchWsClient {
// listener around: `ws` emits an asynchronous "error" when a socket still
// in the CONNECTING state is closed, and without a listener Node turns it
// into an uncaughtException.
socket.removeAllListeners("open");
socket.removeAllListeners("message");
socket.removeAllListeners("close");
socket.removeAllListeners("error");
socket.on("error", () => {
socket.removeAllListeners('open')
socket.removeAllListeners('message')
socket.removeAllListeners('close')
socket.removeAllListeners('error')
socket.on('error', () => {
// swallow: teardown path, we no longer care about the socket's fate.
});
})
try {
if (socket.readyState === WebSocket.CONNECTING) {
// `close()` on a CONNECTING socket throws asynchronously; `terminate()`
// destroys the underlying TCP connection and is always safe.
socket.terminate();
socket.terminate()
} else if (socket.readyState === WebSocket.OPEN) {
socket.close(1000, "client_stop");
socket.close(1000, 'client_stop')
}
} catch {
// Fallback: ignore; the reference is already detached.
@@ -116,7 +116,7 @@ export class DispatchWsClient {
emit(event: string, payload: unknown): void {
for (const handler of this.#listeners.get(event) ?? []) {
try {
handler(payload);
handler(payload)
} catch {
// Swallow handler errors; listeners must not kill the transport.
}
@@ -125,133 +125,133 @@ export class DispatchWsClient {
#connect(): void {
if (!this.#running) {
return;
return
}
this.#setState("connecting");
this.#setState('connecting')
this.#observedRequestId = startObservedRequest({
kind: "ws",
source: "transport",
label: "desktop.dispatch-ws",
method: "GET",
kind: 'ws',
source: 'transport',
label: 'desktop.dispatch-ws',
method: 'GET',
url: this.#options.url,
});
})
let socket: WebSocket;
let socket: WebSocket
try {
socket = new WebSocket(this.#options.url, {
headers: this.#options.headers,
handshakeTimeout: 15_000,
});
})
} catch (error) {
if (this.#observedRequestId) {
failObservedRequest(this.#observedRequestId, error);
this.#observedRequestId = null;
failObservedRequest(this.#observedRequestId, error)
this.#observedRequestId = null
}
this.emit("error", error);
this.#scheduleReconnect();
return;
this.emit('error', error)
this.#scheduleReconnect()
return
}
this.#socket = socket;
this.#socket = socket
socket.on("open", () => {
this.#retryDelayMs = this.#options.initialRetryDelayMs ?? 1_000;
this.#setState("open");
socket.on('open', () => {
this.#retryDelayMs = this.#options.initialRetryDelayMs ?? 1_000
this.#setState('open')
if (this.#observedRequestId) {
resolveObservedRequest(this.#observedRequestId, {
phase: "response",
phase: 'response',
status: 101,
keepActive: true,
});
})
}
this.emit("open", null);
});
this.emit('open', null)
})
socket.on("message", (data: RawData, isBinary: boolean) => {
socket.on('message', (data: RawData, isBinary: boolean) => {
if (isBinary) {
return;
return
}
const raw = typeof data === "string" ? data : data.toString("utf-8");
const raw = typeof data === 'string' ? data : data.toString('utf-8')
if (!raw) {
return;
return
}
let parsed: unknown;
let parsed: unknown
try {
parsed = JSON.parse(raw);
parsed = JSON.parse(raw)
} catch {
this.emit("message", { event: "message", data: raw });
return;
this.emit('message', { event: 'message', data: raw })
return
}
const event = extractEventType(parsed) ?? "message";
this.emit(event, parsed);
this.emit("message", { event, data: parsed });
});
const event = extractEventType(parsed) ?? 'message'
this.emit(event, parsed)
this.emit('message', { event, data: parsed })
})
socket.on("error", (error: Error) => {
socket.on('error', (error: Error) => {
if (this.#observedRequestId) {
failObservedRequest(this.#observedRequestId, error);
this.#observedRequestId = null;
failObservedRequest(this.#observedRequestId, error)
this.#observedRequestId = null
}
this.emit("error", error);
});
this.emit('error', error)
})
socket.on("close", (code: number, reason: Buffer) => {
this.#socket = null;
socket.on('close', (code: number, reason: Buffer) => {
this.#socket = null
if (this.#observedRequestId) {
resolveObservedRequest(this.#observedRequestId, {
phase: "close",
phase: 'close',
status: code,
error: reason.toString() || null,
});
this.#observedRequestId = null;
})
this.#observedRequestId = null
}
this.emit("close", { code, reason: reason.toString() });
this.emit('close', { code, reason: reason.toString() })
if (!this.#running) {
return;
return
}
this.#scheduleReconnect();
});
this.#scheduleReconnect()
})
}
#scheduleReconnect(): void {
if (!this.#running) {
return;
return
}
this.#clearReconnect();
this.#clearReconnect()
const baseDelay = this.#retryDelayMs;
const maxDelay = this.#options.maxRetryDelayMs ?? 30_000;
const jitter = Math.floor(baseDelay * 0.2 * Math.random());
const delay = Math.min(baseDelay + jitter, maxDelay);
const baseDelay = this.#retryDelayMs
const maxDelay = this.#options.maxRetryDelayMs ?? 30_000
const jitter = Math.floor(baseDelay * 0.2 * Math.random())
const delay = Math.min(baseDelay + jitter, maxDelay)
this.emit("reconnect", { delayMs: delay });
this.emit('reconnect', { delayMs: delay })
this.#reconnectTimer = globalThis.setTimeout(() => {
this.#reconnectTimer = null;
this.#connect();
}, delay);
this.#reconnectTimer = null
this.#connect()
}, delay)
this.#retryDelayMs = Math.min(this.#retryDelayMs * 2, maxDelay);
this.#retryDelayMs = Math.min(this.#retryDelayMs * 2, maxDelay)
}
#clearReconnect(): void {
if (this.#reconnectTimer) {
globalThis.clearTimeout(this.#reconnectTimer);
this.#reconnectTimer = null;
globalThis.clearTimeout(this.#reconnectTimer)
this.#reconnectTimer = null
}
}
#setState(state: DispatchWsState): void {
this.emit("state", state);
this.emit('state', state)
}
}
function extractEventType(payload: unknown): string | null {
if (!payload || typeof payload !== "object") {
return null;
if (!payload || typeof payload !== 'object') {
return null
}
const maybe = (payload as { type?: unknown }).type;
if (typeof maybe === "string" && maybe.length > 0) {
return maybe;
const maybe = (payload as { type?: unknown }).type
if (typeof maybe === 'string' && maybe.length > 0) {
return maybe
}
return null;
return null
}
@@ -1,237 +1,237 @@
export type SseEventHandler = (payload: unknown) => void;
export type SseEventHandler = (payload: unknown) => void
export interface SseClientOptions {
url: string;
headers?: Record<string, string>;
initialRetryDelayMs?: number;
maxRetryDelayMs?: number;
url: string
headers?: Record<string, string>
initialRetryDelayMs?: number
maxRetryDelayMs?: number
}
export class SseClientError extends Error {
status?: number;
status?: number
constructor(message: string, options: { status?: number } = {}) {
super(message);
this.name = "SseClientError";
this.status = options.status;
super(message)
this.name = 'SseClientError'
this.status = options.status
}
}
type SseClientState = "idle" | "connecting" | "streaming";
type SseClientState = 'idle' | 'connecting' | 'streaming'
interface ParsedSseEvent {
event: string;
data: unknown;
event: string
data: unknown
}
export class SseClient {
#listeners = new Map<string, Set<SseEventHandler>>();
#options: SseClientOptions;
#running = false;
#abortController: AbortController | null = null;
#retryDelayMs: number;
#listeners = new Map<string, Set<SseEventHandler>>()
#options: SseClientOptions
#running = false
#abortController: AbortController | null = null
#retryDelayMs: number
constructor(options: SseClientOptions) {
this.#options = options;
this.#retryDelayMs = options.initialRetryDelayMs ?? 1000;
this.#options = options
this.#retryDelayMs = options.initialRetryDelayMs ?? 1000
}
on(event: string, handler: SseEventHandler): () => void {
const bucket = this.#listeners.get(event) ?? new Set<SseEventHandler>();
bucket.add(handler);
this.#listeners.set(event, bucket);
const bucket = this.#listeners.get(event) ?? new Set<SseEventHandler>()
bucket.add(handler)
this.#listeners.set(event, bucket)
return () => {
bucket.delete(handler);
bucket.delete(handler)
if (bucket.size === 0) {
this.#listeners.delete(event);
this.#listeners.delete(event)
}
};
}
}
start(): void {
if (this.#running) {
return;
return
}
this.#running = true;
void this.#run();
this.#running = true
void this.#run()
}
stop(): void {
this.#running = false;
this.#setState("idle");
this.#abortController?.abort();
this.#abortController = null;
this.#running = false
this.#setState('idle')
this.#abortController?.abort()
this.#abortController = null
}
emit(event: string, payload: unknown): void {
for (const handler of this.#listeners.get(event) ?? []) {
handler(payload);
handler(payload)
}
}
async #run(): Promise<void> {
while (this.#running) {
try {
await this.#connectOnce();
this.#retryDelayMs = this.#options.initialRetryDelayMs ?? 1000;
await this.#connectOnce()
this.#retryDelayMs = this.#options.initialRetryDelayMs ?? 1000
} catch (error) {
if (!this.#running) {
break;
break
}
this.emit("error", error);
this.#setState("connecting");
this.emit('error', error)
this.#setState('connecting')
const waitMs = this.#retryDelayMs;
this.emit("reconnect", { delayMs: waitMs });
await sleep(waitMs);
const waitMs = this.#retryDelayMs
this.emit('reconnect', { delayMs: waitMs })
await sleep(waitMs)
this.#retryDelayMs = Math.min(
this.#retryDelayMs * 2,
this.#options.maxRetryDelayMs ?? 30_000,
);
)
}
}
}
async #connectOnce(): Promise<void> {
this.#setState("connecting");
this.#abortController = new AbortController();
this.#setState('connecting')
this.#abortController = new AbortController()
const response = await fetch(this.#options.url, {
method: "GET",
method: 'GET',
headers: {
Accept: "text/event-stream",
Accept: 'text/event-stream',
...(this.#options.headers ?? {}),
},
signal: this.#abortController.signal,
});
})
if (!response.ok) {
throw new SseClientError(`desktop_events_http_${response.status}`, {
status: response.status,
});
})
}
if (!response.body) {
throw new SseClientError("desktop_events_body_missing");
throw new SseClientError('desktop_events_body_missing')
}
this.#setState("streaming");
this.emit("open", null);
this.#setState('streaming')
this.emit('open', null)
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (this.#running) {
const { done, value } = await reader.read();
const { done, value } = await reader.read()
if (done) {
break;
break
}
buffer += decoder.decode(value, { stream: true });
buffer = this.#flushBuffer(buffer);
buffer += decoder.decode(value, { stream: true })
buffer = this.#flushBuffer(buffer)
}
buffer += decoder.decode();
this.#flushRemaining(buffer);
buffer += decoder.decode()
this.#flushRemaining(buffer)
if (this.#running && !this.#abortController.signal.aborted) {
throw new SseClientError("desktop_events_stream_closed");
throw new SseClientError('desktop_events_stream_closed')
}
}
#flushBuffer(buffer: string): string {
const normalized = buffer.replace(/\r\n/g, "\n");
let cursor = 0;
const normalized = buffer.replace(/\r\n/g, '\n')
let cursor = 0
while (cursor >= 0) {
const boundary = normalized.indexOf("\n\n", cursor);
const boundary = normalized.indexOf('\n\n', cursor)
if (boundary === -1) {
return normalized.slice(cursor);
return normalized.slice(cursor)
}
const chunk = normalized.slice(cursor, boundary);
cursor = boundary + 2;
const chunk = normalized.slice(cursor, boundary)
cursor = boundary + 2
const parsed = parseSseChunk(chunk);
const parsed = parseSseChunk(chunk)
if (!parsed) {
continue;
continue
}
this.emit(parsed.event, parsed.data);
this.emit("message", parsed);
this.emit(parsed.event, parsed.data)
this.emit('message', parsed)
}
return "";
return ''
}
#flushRemaining(buffer: string): void {
const normalized = buffer.replace(/\r\n/g, "\n").trim();
const normalized = buffer.replace(/\r\n/g, '\n').trim()
if (!normalized) {
return;
return
}
const parsed = parseSseChunk(normalized);
const parsed = parseSseChunk(normalized)
if (!parsed) {
return;
return
}
this.emit(parsed.event, parsed.data);
this.emit("message", parsed);
this.emit(parsed.event, parsed.data)
this.emit('message', parsed)
}
#setState(state: SseClientState): void {
this.emit("state", state);
this.emit('state', state)
}
}
function parseSseChunk(chunk: string): ParsedSseEvent | null {
if (!chunk.trim()) {
return null;
return null
}
let event = "message";
const dataLines: string[] = [];
let event = 'message'
const dataLines: string[] = []
for (const rawLine of chunk.split("\n")) {
const line = rawLine.trimEnd();
if (!line || line.startsWith(":")) {
continue;
for (const rawLine of chunk.split('\n')) {
const line = rawLine.trimEnd()
if (!line || line.startsWith(':')) {
continue
}
if (line.startsWith("event:")) {
event = line.slice(6).trim() || "message";
continue;
if (line.startsWith('event:')) {
event = line.slice(6).trim() || 'message'
continue
}
if (line.startsWith("data:")) {
dataLines.push(line.slice(5).trimStart());
if (line.startsWith('data:')) {
dataLines.push(line.slice(5).trimStart())
}
}
const rawData = dataLines.join("\n");
const rawData = dataLines.join('\n')
if (!rawData) {
return { event, data: null };
return { event, data: null }
}
try {
return {
event,
data: JSON.parse(rawData),
};
}
} catch {
return {
event,
data: rawData,
};
}
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
globalThis.setTimeout(resolve, ms);
});
globalThis.setTimeout(resolve, ms)
})
}