Files
browser/platform_web/api/client.ts
T
npc0-hue 05f5a97ba9 Add graceful SCUM stop, restart, and version update flow
SCUM 停止/重启/更新以前只有“结束进程”这一条路,插件没有声明任何优雅关闭方式,
平台也没有把停止后重新启动串起来。现在插件声明自己的关闭脚本,run 先执行它,
平台在停止或更新成功后再自动拉起服务。

run:
- lifecycle stop 支持插件声明的 gracefulStop(可执行文件、参数、环境、超时、
  fallback=report|terminate);关闭命令超时且声明 report 时任务失败,不再默默杀进程。
- 新增 steam.update 依赖探针:调用 steamcmd +app_info_print 获取公开分支 buildid,
  与本地 steamapps/appmanifest_<appid>.acf 的 buildid 比较,输出
  installed/latest/update=yes|no|unknown。

platform:
- 新增 POST /api/v1/server-instances/{id}/restart 与 /update。
- restart 派发插件 stop 动作(走优雅关闭),终态成功后入队 start 作业。
- update 派发插件 install 动作;插件在更新前必须先优雅关闭 SCUM,关闭失败直接拒绝
  SteamCMD 更新,成功后平台再拉起服务。
- 依赖检查输入带上插件声明的服务器安装根目录,供 steam.update 读取 appmanifest。

plugin (SCUM server plugin 0.1.16):
- bin/scum-stop.cmd:解析已声明的可执行文件路径,定位同路径正在运行的 SCUMServer.exe,
  通过本地 RCON 公告并发送关闭命令,等待进程自行退出;不再使用 taskkill。
- bin/scum-rcon.ps1:插件自有的 Source RCON 客户端,从 UE4SS mod config.ini 读取
  密码/端口,密钥不离开本机。
- actions/stop.json 声明 gracefulStop;actions/install.json 更新前先执行同一关闭脚本。

platform_web:
- 服务器详情新增“重启”按钮和“SCUM 版本更新”面板;点“检查更新”查询公开分支版本,
  只有检测到更新时“更新版本”按钮才会置为可用并高亮,点击后先确认再派发更新任务。
2026-09-15 13:30:15 +08:00

1013 lines
43 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type {
AiProviderListResponse,
AiProviderModelsResponse,
AiProviderRequest,
AiProviderResponse,
AiProviderStatusRequest,
AiProviderTestResponse,
AiProviderUpdateRequest,
AIInvocationRequest,
AIInvocationResponse,
AIConfigDiffApprovalResponse,
AIConfigDiffListResponse,
ApiErrorResponse,
ArtifactContentChunk,
ArtifactDownloadReferenceResponse,
ArtifactFilterRequest,
ArtifactListResponse,
AuthSessionResponse,
ComponentKeyResponse,
CurrentUserResponse,
DependencyCatalogResponse,
DependencyJobRequest,
DeclaredFileReadSnapshotResponse,
FileOperationDispatchRequest,
FileOperationDispatchResponse,
GameClientBridgeCancelRequest,
GameClientBridgeCancelResponse,
GameClientBridgeCommandFilterRequest,
GameClientBridgeCommandListResponse,
GameClientBridgeCommandResponse,
GameClientBridgeQueueRequest,
GameClientBridgeSnapshotListResponse,
GameClientBridgeSnapshotQuery,
GameClientBridgeStatusResponse,
GamePluginListResponse,
HealthResponse,
JobCreateRequest,
JobListResponse,
JobResponse,
JobState,
LlmConfigSuggestionRequest,
LlmConfigSuggestionResponse,
LogStreamCursorRequest,
LogStreamCursorResponse,
LogStreamListResponse,
LoginRequest,
MarketplacePluginFilterRequest,
MarketplacePluginListResponse,
MarketplacePluginResponse,
MarketplacePluginStateRequest,
PlatformResourceUsageResponse,
PluginLifecycleActionRequest,
PluginLifecycleActionResponse,
PluginLifecycleListResponse,
PluginBridgeAuthorizeRequest,
PluginBridgeAuthorizeResponse,
PluginBridgeExecuteRequest,
PluginBridgeExecuteResponse,
RegisterRequest,
RunDistributionGenerateRequest,
RunDistributionResponse,
RunEndpointListResponse,
RunEndpointStatus,
RunUpdateJobResponse,
RunUpdateJobListResponse,
RunUpdateRequest,
RuntimeBindingResponse,
RuntimeBindingUpdateRequest,
ServerLifecycleCommandRequest,
ServerLifecycleCreateRequest,
ServerLifecycleResponse,
ServerDeploymentRequest,
ServerDeploymentRevealResponse,
ServerDeploymentResponse,
ServerInstanceListResponse,
ServerDeletionRequest,
ServerFileDownloadRequest,
ServerFileDownloadResponse,
ServerFileListRequest,
ServerFileListResponse,
ServerFileReadRequest,
ServerFileUploadResponse,
ServerFileWorkspaceResponse,
ServerFileWriteRequest,
ServerInstanceUpdateRequest,
ServerInstanceResponse,
ServerMemberListResponse,
ServerMemberRequest,
ServerMetricsListResponse,
ScumListRequest,
ScumUserListResponse,
ScumSurfaceResponse,
ScumUserTrajectoryListResponse,
ScumVehicleLockListResponse,
ScumVehicleListResponse,
ScumVehicleTrajectoryListResponse,
SourceRCONCommandRequest,
SourceRCONCommandResponse,
MetricSampleListResponse,
BackupListResponse,
BackupResponse,
RemoteAdapterDeclarationListResponse,
RemoteAdapterRequest,
RemoteAdapterResponse,
ServerRuntimeActionsResponse,
UserCreateRequest,
UserListResponse,
UserProfileUpdateRequest,
UserResponse,
UserThemePreferenceRequest,
UserThemePreferenceResponse,
UserUpdateRequest
} from "./types";
import { readWebRuntimeEnv } from "../schemas/env";
import { parseSafeDependencyCatalog, parseSafeRunUpdate, parseSafeRunUpdateList } from "../schemas/runtimeUpdates";
import {
parseSafeGameClientBridgeCancellation,
parseSafeGameClientBridgeCommand,
parseSafeGameClientBridgeCommandList,
parseSafeGameClientBridgeSnapshotList,
parseSafeGameClientBridgeStatus
} from "../schemas/gameClientBridge";
import { safeDiagnosticText } from "../utils/safeDiagnosticText";
let platformApiSessionToken: string | null = null;
let platformApiAuthFailureHandler: ((error: PlatformApiError) => void) | null = null;
export function setPlatformApiSessionToken(token: string | null) {
platformApiSessionToken = token;
}
export function setPlatformApiAuthFailureHandler(handler: ((error: PlatformApiError) => void) | null) {
platformApiAuthFailureHandler = handler;
}
export class PlatformApiError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string
) {
super(message);
this.name = "PlatformApiError";
}
}
export interface PlatformEventStream {
onerror: ((event: Event) => void) | null;
addEventListener(type: string, listener: (event: MessageEvent) => void): void;
removeEventListener(type: string, listener: (event: MessageEvent) => void): void;
close(): void;
}
interface RunEndpointListFilter {
status?: RunEndpointStatus;
}
interface JobListFilter {
runEndpointId?: string;
state?: JobState;
states?: JobState[];
limit?: number;
}
export class PlatformApiClient {
constructor(private readonly baseUrl = "/api/v1", private readonly sessionTokenProvider: () => string | null = () => platformApiSessionToken) {}
async health(): Promise<HealthResponse> {
return this.request<HealthResponse>("/healthz", { absolute: true });
}
async listGamePlugins(): Promise<GamePluginListResponse> {
return this.request<GamePluginListResponse>("/game-plugins");
}
async listMarketplacePlugins(filter: MarketplacePluginFilterRequest = {}): Promise<MarketplacePluginListResponse> {
return this.request<MarketplacePluginListResponse>(`/plugin-marketplace/plugins${marketplaceQuery(filter)}`);
}
async getMarketplacePlugin(id: string): Promise<MarketplacePluginResponse> {
return this.request<MarketplacePluginResponse>(`/plugin-marketplace/plugins/${encodeURIComponent(id)}`);
}
async setMarketplacePluginState(id: string, request: MarketplacePluginStateRequest): Promise<MarketplacePluginResponse> {
return this.request<MarketplacePluginResponse>(`/plugin-marketplace/plugins/${encodeURIComponent(id)}/state`, {
method: "POST",
body: request
});
}
async listServerInstances(): Promise<ServerInstanceListResponse> {
return this.request<ServerInstanceListResponse>("/server-instances");
}
async createServerWorkflow(request: ServerLifecycleCreateRequest): Promise<ServerLifecycleResponse> {
return this.request<ServerLifecycleResponse>("/server-instances/workflows/create", {
method: "POST",
body: request
});
}
async getServerDeployment(id: string): Promise<ServerDeploymentResponse> {
return this.request<ServerDeploymentResponse>(`/server-instances/${encodeURIComponent(id)}/deployment`);
}
async revealServerDeployment(id: string): Promise<ServerDeploymentRevealResponse> {
return this.request<ServerDeploymentRevealResponse>(`/server-instances/${encodeURIComponent(id)}/deployment/reveal`);
}
async updateServerDeployment(id: string, request: ServerDeploymentRequest): Promise<ServerDeploymentResponse> {
return this.request<ServerDeploymentResponse>(`/server-instances/${encodeURIComponent(id)}/deployment`, { method: "PUT", body: request });
}
async deployServerInstance(id: string, request: ServerLifecycleCommandRequest): Promise<ServerLifecycleResponse> {
return this.request<ServerLifecycleResponse>(`/server-instances/${encodeURIComponent(id)}/deploy`, { method: "POST", body: request });
}
async getServerRuntimeBinding(id: string): Promise<RuntimeBindingResponse> {
return this.request<RuntimeBindingResponse>(`/server-instances/${encodeURIComponent(id)}/runtime-binding`);
}
async updateServerRuntimeBinding(id: string, request: RuntimeBindingUpdateRequest): Promise<RuntimeBindingResponse> {
return this.request<RuntimeBindingResponse>(`/server-instances/${encodeURIComponent(id)}/runtime-binding`, { method: "PUT", body: request });
}
async startServerInstance(id: string, request: ServerLifecycleCommandRequest): Promise<ServerLifecycleResponse> {
return this.request<ServerLifecycleResponse>(`/server-instances/${encodeURIComponent(id)}/start`, {
method: "POST",
body: request
});
}
async stopServerInstance(id: string, request: ServerLifecycleCommandRequest): Promise<ServerLifecycleResponse> {
return this.request<ServerLifecycleResponse>(`/server-instances/${encodeURIComponent(id)}/stop`, {
method: "POST",
body: request
});
}
async restartServerInstance(id: string, request: ServerLifecycleCommandRequest): Promise<ServerLifecycleResponse> {
return this.request<ServerLifecycleResponse>(`/server-instances/${encodeURIComponent(id)}/restart`, {
method: "POST",
body: request
});
}
async updateServerGame(id: string, request: ServerLifecycleCommandRequest): Promise<ServerLifecycleResponse> {
return this.request<ServerLifecycleResponse>(`/server-instances/${encodeURIComponent(id)}/update`, {
method: "POST",
body: request
});
}
async queryServerProcessStatus(id: string, request: ServerLifecycleCommandRequest): Promise<ServerLifecycleResponse> {
return this.request<ServerLifecycleResponse>(`/server-instances/${encodeURIComponent(id)}/process/status`, {
method: "POST",
body: request
});
}
async listServerAdministratorCandidates(id: string): Promise<ServerMemberListResponse> {
return this.request<ServerMemberListResponse>(`/server-instances/${encodeURIComponent(id)}/administrators/candidates`);
}
async addServerAdministrator(id: string, request: ServerMemberRequest): Promise<ServerInstanceResponse> {
return this.request<ServerInstanceResponse>(`/server-instances/${encodeURIComponent(id)}/administrators`, {
method: "POST",
body: request
});
}
async removeServerAdministrator(id: string, userId: string): Promise<ServerInstanceResponse> {
return this.request<ServerInstanceResponse>(`/server-instances/${encodeURIComponent(id)}/administrators/${encodeURIComponent(userId)}`, {
method: "DELETE"
});
}
async listRunEndpoints(filter: RunEndpointListFilter = {}): Promise<RunEndpointListResponse> {
return this.request<RunEndpointListResponse>(`/run/endpoints${runEndpointQuery(filter)}`);
}
async listJobs(serverInstanceId?: string, filter: JobListFilter = {}): Promise<JobListResponse> {
return this.request<JobListResponse>(`/jobs${jobListQuery(serverInstanceId, filter)}`);
}
async listArtifacts(filter: ArtifactFilterRequest = {}): Promise<ArtifactListResponse> {
return this.request<ArtifactListResponse>(`/artifacts${artifactQuery(filter)}`);
}
async openArtifactDownload(id: string): Promise<ArtifactDownloadReferenceResponse> {
return this.request<ArtifactDownloadReferenceResponse>(`/artifacts/${encodeURIComponent(id)}/download`, { method: "POST", body: {} });
}
async readArtifactContent(id: string, offset = 0, limit?: number): Promise<ArtifactContentChunk> {
const params = new URLSearchParams({ offset: String(offset) });
if (limit !== undefined) {
params.set("limit", String(limit));
}
const headers = new Headers();
const sessionToken = this.sessionTokenProvider();
if (sessionToken) {
headers.set("Authorization", `Bearer ${sessionToken}`);
}
const response = await fetch(`${this.baseUrl}/artifacts/${encodeURIComponent(id)}/content?${params.toString()}`, { headers, credentials: "same-origin" });
if (!response.ok) {
throw await responseError(response);
}
const payload = await response.arrayBuffer();
return {
artifactId: response.headers.get("X-Artifact-Id") ?? undefined,
payload,
contentType: response.headers.get("Content-Type") ?? "application/octet-stream",
contentLength: Number(response.headers.get("Content-Length") ?? payload.byteLength),
contentRange: response.headers.get("Content-Range") ?? undefined,
checksum: response.headers.get("X-Artifact-Checksum") ?? undefined,
contentChecksum: response.headers.get("X-Artifact-Content-Checksum") ?? undefined,
storageBehavior: response.headers.get("X-Artifact-Storage") ?? undefined
};
}
async downloadArtifactContent(id: string): Promise<Response> {
const headers = new Headers();
const sessionToken = this.sessionTokenProvider();
if (sessionToken) {
headers.set("Authorization", `Bearer ${sessionToken}`);
}
const response = await fetch(`${this.baseUrl}/artifacts/${encodeURIComponent(id)}/content`, { headers, credentials: "same-origin" });
if (!response.ok) {
throw await responseError(response);
}
return response;
}
async getJob(id: string): Promise<JobResponse> {
return this.request<JobResponse>(`/jobs/${encodeURIComponent(id)}`);
}
async getServerRuntimeActions(id: string): Promise<ServerRuntimeActionsResponse> {
return this.request<ServerRuntimeActionsResponse>(`/server-instances/${encodeURIComponent(id)}/runtime/actions`);
}
async generateRunDistribution(id: string, request: RunDistributionGenerateRequest): Promise<RunDistributionResponse> {
return this.request<RunDistributionResponse>(`/server-instances/${encodeURIComponent(id)}/run/generate`, {
method: "POST",
body: request
});
}
async downloadLatestRunDistribution(id: string): Promise<ArtifactDownloadReferenceResponse> {
return this.request<ArtifactDownloadReferenceResponse>(`/server-instances/${encodeURIComponent(id)}/run/download`, {
method: "POST",
body: {}
});
}
async resetRunKey(id: string): Promise<ComponentKeyResponse> {
return this.request<ComponentKeyResponse>(`/server-instances/${encodeURIComponent(id)}/run/key/reset`, {
method: "POST",
body: {}
});
}
async pushRunUpdate(id: string, request: RunUpdateRequest): Promise<RunUpdateJobResponse> {
const response = await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/run/update`, {
method: "POST",
body: request
});
return parseSafeRunUpdate(response);
}
async listRunUpdates(id: string): Promise<RunUpdateJobListResponse> {
return parseSafeRunUpdateList(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/run/update`));
}
async getGameClientBridgeStatus(id: string): Promise<GameClientBridgeStatusResponse> {
return parseSafeGameClientBridgeStatus(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/game-client-bridge`));
}
async listGameClientBridgeCommands(id: string, filter: GameClientBridgeCommandFilterRequest = {}): Promise<GameClientBridgeCommandListResponse> {
const params = new URLSearchParams();
if (filter.profileKey) params.set("profileKey", filter.profileKey);
if (filter.state) params.set("state", filter.state);
if (filter.commandType) params.set("commandType", filter.commandType);
const query = params.toString();
return parseSafeGameClientBridgeCommandList(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/game-client-bridge/commands${query ? `?${query}` : ""}`));
}
async queueGameClientBridgeCommand(id: string, request: GameClientBridgeQueueRequest): Promise<GameClientBridgeCommandResponse> {
return parseSafeGameClientBridgeCommand(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/game-client-bridge/commands`, { method: "POST", body: request }));
}
async dispatchSourceRCONCommand(id: string, request: SourceRCONCommandRequest): Promise<SourceRCONCommandResponse> {
return this.request<SourceRCONCommandResponse>(`/server-instances/${encodeURIComponent(id)}/rcon/commands`, { method: "POST", body: request });
}
async getGameClientBridgeCommand(id: string, commandId: string): Promise<GameClientBridgeCommandResponse> {
return parseSafeGameClientBridgeCommand(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/game-client-bridge/commands/${encodeURIComponent(commandId)}`));
}
async cancelGameClientBridgeCommand(id: string, commandId: string, request: GameClientBridgeCancelRequest = {}): Promise<GameClientBridgeCancelResponse> {
return parseSafeGameClientBridgeCancellation(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/game-client-bridge/commands/${encodeURIComponent(commandId)}/cancel`, { method: "POST", body: request }));
}
async listGameClientBridgeSnapshots(id: string, query: GameClientBridgeSnapshotQuery = {}): Promise<GameClientBridgeSnapshotListResponse> {
const params = new URLSearchParams();
if (query.profileKey) params.set("profileKey", query.profileKey);
if (query.type) params.set("type", query.type);
if (query.streamKey) params.set("streamKey", query.streamKey);
if (query.observedAfter) params.set("observedAfter", query.observedAfter);
if (query.limit !== undefined) params.set("limit", String(query.limit));
const search = params.toString();
return parseSafeGameClientBridgeSnapshotList(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/game-client-bridge/snapshots${search ? `?${search}` : ""}`));
}
async checkDependencies(id: string, request: DependencyJobRequest): Promise<JobResponse> {
return this.request<JobResponse>(`/server-instances/${encodeURIComponent(id)}/dependencies/check`, {
method: "POST",
body: request
});
}
async getDependencyCatalog(id: string): Promise<DependencyCatalogResponse> {
return parseSafeDependencyCatalog(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/dependencies`));
}
async installDependencies(id: string, request: DependencyJobRequest): Promise<JobResponse> {
return this.request<JobResponse>(`/server-instances/${encodeURIComponent(id)}/dependencies/install`, {
method: "POST",
body: request
});
}
async createJob(request: JobCreateRequest): Promise<JobResponse> {
return this.request<JobResponse>("/jobs", { method: "POST", body: request });
}
async register(request: RegisterRequest): Promise<AuthSessionResponse> {
return this.request<AuthSessionResponse>("/auth/register", { method: "POST", body: request });
}
async login(request: LoginRequest): Promise<AuthSessionResponse> {
return this.request<AuthSessionResponse>("/auth/login", { method: "POST", body: request });
}
async logout(): Promise<void> {
await this.request<void>("/auth/logout", { method: "POST", parseJson: false });
}
async getCurrentUser(): Promise<CurrentUserResponse> {
return this.request<CurrentUserResponse>("/users/current");
}
async listUsers(): Promise<UserListResponse> {
return this.request<UserListResponse>("/users");
}
async createUser(request: UserCreateRequest): Promise<UserResponse> {
return this.request<UserResponse>("/users", { method: "POST", body: request });
}
async updateUser(id: string, request: UserUpdateRequest): Promise<UserResponse> {
return this.request<UserResponse>(`/users/${encodeURIComponent(id)}`, { method: "PUT", body: request });
}
async updateCurrentUserProfile(request: UserProfileUpdateRequest): Promise<CurrentUserResponse> {
return this.request<CurrentUserResponse>("/users/current/profile", { method: "PUT", body: request });
}
async updateCurrentUserTheme(request: UserThemePreferenceRequest): Promise<UserThemePreferenceResponse> {
return this.request<UserThemePreferenceResponse>("/users/current/theme", { method: "PUT", body: request });
}
async getServerInstance(id: string): Promise<ServerInstanceResponse> {
return this.request<ServerInstanceResponse>(`/server-instances/${encodeURIComponent(id)}`);
}
async updateServerInstance(id: string, request: ServerInstanceUpdateRequest): Promise<ServerInstanceResponse> {
return this.request<ServerInstanceResponse>(`/server-instances/${encodeURIComponent(id)}`, {
method: "PUT",
body: request
});
}
async deleteServerInstance(id: string, request: ServerDeletionRequest): Promise<void> {
return this.request<void>(`/server-instances/${encodeURIComponent(id)}`, { method: "DELETE", body: request, parseJson: false });
}
async getPlatformResourceUsage(): Promise<PlatformResourceUsageResponse> {
return this.request<PlatformResourceUsageResponse>("/metrics/platform");
}
async listPluginLifecycles(filter: { pluginId?: string; serverInstanceId?: string; currentState?: string } = {}): Promise<PluginLifecycleListResponse> {
const params = new URLSearchParams();
Object.entries(filter).forEach(([key, value]) => { if (value) params.set(key, value); });
const query = params.toString();
return this.request<PluginLifecycleListResponse>(`/plugin-lifecycles${query ? `?${query}` : ""}`);
}
async runPluginLifecycle(pluginId: string, request: PluginLifecycleActionRequest): Promise<PluginLifecycleActionResponse> {
return this.request<PluginLifecycleActionResponse>(`/plugin-lifecycles/${encodeURIComponent(pluginId)}/actions`, { method: "POST", body: request });
}
async listAIConfigDiffs(filter: { serverInstanceId?: string; pluginId?: string; state?: string } = {}): Promise<AIConfigDiffListResponse> {
const params = new URLSearchParams();
Object.entries(filter).forEach(([key, value]) => { if (value) params.set(key, value); });
const query = params.toString();
return this.request<AIConfigDiffListResponse>(`/ai/config-diffs${query ? `?${query}` : ""}`);
}
async approveAIConfigDiff(id: string, idempotencyKey: string): Promise<AIConfigDiffApprovalResponse> {
return this.request<AIConfigDiffApprovalResponse>(`/ai/config-diffs/${encodeURIComponent(id)}/approve`, { method: "POST", body: { idempotencyKey } });
}
async listServerMetrics(): Promise<ServerMetricsListResponse> {
return this.request<ServerMetricsListResponse>("/metrics/server-instances");
}
async listMetricHistory(serverInstanceId: string, limit = 100): Promise<MetricSampleListResponse> {
const params = new URLSearchParams({ serverInstanceId, limit: String(limit) });
return this.request<MetricSampleListResponse>(`/metrics/server-instances/history?${params.toString()}`);
}
async listBackups(serverInstanceId: string): Promise<BackupListResponse> {
return this.request<BackupListResponse>(`/backups?serverInstanceId=${encodeURIComponent(serverInstanceId)}`);
}
async getBackup(id: string): Promise<BackupResponse> {
return this.request<BackupResponse>(`/backups/${encodeURIComponent(id)}`);
}
async listRemoteAdapters(serverInstanceId: string): Promise<RemoteAdapterDeclarationListResponse> {
return this.request<RemoteAdapterDeclarationListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/remote-adapters`);
}
async listScumUsers(serverInstanceId: string, request: ScumListRequest = {}): Promise<ScumUserListResponse> {
return this.request<ScumUserListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/users${scumListQuery(request)}`);
}
async listScumUserTrajectories(serverInstanceId: string, request: ScumListRequest = {}): Promise<ScumUserTrajectoryListResponse> {
return this.request<ScumUserTrajectoryListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/user-trajectories${scumListQuery(request)}`);
}
async listScumVehicles(serverInstanceId: string, request: ScumListRequest = {}): Promise<ScumVehicleListResponse> {
return this.request<ScumVehicleListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/vehicles${scumListQuery(request)}`);
}
async listScumVehicleTrajectories(serverInstanceId: string, request: ScumListRequest = {}): Promise<ScumVehicleTrajectoryListResponse> {
return this.request<ScumVehicleTrajectoryListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/vehicle-trajectories${scumListQuery(request)}`);
}
async listScumVehicleLocks(serverInstanceId: string, request: ScumListRequest = {}): Promise<ScumVehicleLockListResponse> {
return this.request<ScumVehicleLockListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/vehicle-locks${scumListQuery(request)}`);
}
async getScumSurface(serverInstanceId: string): Promise<ScumSurfaceResponse> {
return this.request<ScumSurfaceResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/surface`);
}
async listPluginData(serverInstanceId: string, collection: string, key?: string): Promise<{ items: Array<{ key: string; value: Record<string, unknown> }>; count: number }> {
const params = new URLSearchParams();
if (key) params.set("key", key);
const query = params.toString();
return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/plugin-data/${encodeURIComponent(collection)}${query ? `?${query}` : ""}`);
}
async putPluginData(serverInstanceId: string, collection: string, key: string, value: Record<string, unknown>): Promise<{ key: string; value: Record<string, unknown> }> {
return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/plugin-data/${encodeURIComponent(collection)}`, { method: "PUT", body: { key, value } });
}
async deletePluginData(serverInstanceId: string, collection: string, key: string): Promise<void> {
const query = new URLSearchParams({ key }).toString();
return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/plugin-data/${encodeURIComponent(collection)}?${query}`, { method: "DELETE" });
}
async transactPluginData(serverInstanceId: string, collection: string, mutations: Array<{ operation: "put" | "delete"; key: string; value?: Record<string, unknown> }>): Promise<{ items: Array<{ key: string; value: Record<string, unknown> }>; count: number }> {
return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/plugin-data/${encodeURIComponent(collection)}/transaction`, { method: "POST", body: { mutations } });
}
async requestRemoteAdapter(serverInstanceId: string, request: RemoteAdapterRequest): Promise<RemoteAdapterResponse> {
return this.request<RemoteAdapterResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/remote-adapters`, { method: "POST", body: request });
}
async dispatchFileOperation(request: FileOperationDispatchRequest): Promise<FileOperationDispatchResponse> {
return this.request<FileOperationDispatchResponse>("/file-operations/dispatch", {
method: "POST",
body: request
});
}
async getServerFileWorkspace(serverInstanceId: string): Promise<ServerFileWorkspaceResponse> {
return normalizeServerFileWorkspace(await this.request<ServerFileWorkspaceResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/files/workspace`));
}
async listServerFiles(serverInstanceId: string, request: Partial<ServerFileListRequest> = {}): Promise<ServerFileListResponse> {
const params = serverFileListQuery(request);
return normalizeServerFileList(await this.request<ServerFileListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/files/list${params}`));
}
async refreshServerFiles(serverInstanceId: string, request: ServerFileListRequest): Promise<ServerFileListResponse> {
return normalizeServerFileList(await this.request<ServerFileListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/files/refresh`, { method: "POST", body: request }));
}
async browseServerFiles(serverInstanceId: string, request: ServerFileListRequest): Promise<ServerFileListResponse> {
return normalizeServerFileList(await this.request<ServerFileListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/files/browse`, { method: "POST", body: request }));
}
async readServerFile(serverInstanceId: string, request: ServerFileReadRequest): Promise<FileOperationDispatchResponse> {
return this.request<FileOperationDispatchResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/files/read`, { method: "POST", body: request });
}
async getServerFileReadSnapshot(serverInstanceId: string, key: string): Promise<DeclaredFileReadSnapshotResponse> {
const params = new URLSearchParams({ key });
return this.request<DeclaredFileReadSnapshotResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/files/read-snapshot?${params.toString()}`);
}
async writeServerFile(serverInstanceId: string, request: ServerFileWriteRequest): Promise<FileOperationDispatchResponse> {
return this.request<FileOperationDispatchResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/files/write`, { method: "POST", body: request });
}
async uploadServerFile(serverInstanceId: string, request: { directoryKey: string; relativePath?: string; file: File; idempotencyKey: string }): Promise<ServerFileUploadResponse> {
const body = new FormData();
body.set("directoryKey", request.directoryKey);
body.set("relativePath", request.relativePath ?? "");
body.set("filename", request.file.name);
body.set("idempotencyKey", request.idempotencyKey);
body.set("file", request.file);
const headers = new Headers();
const sessionToken = this.sessionTokenProvider();
if (sessionToken) headers.set("Authorization", `Bearer ${sessionToken}`);
const response = await fetch(`${this.baseUrl}/server-instances/${encodeURIComponent(serverInstanceId)}/files/upload`, { method: "POST", credentials: "same-origin", headers, body });
if (!response.ok) throw await responseError(response);
return response.json() as Promise<ServerFileUploadResponse>;
}
async prepareServerFileDownload(serverInstanceId: string, request: ServerFileDownloadRequest): Promise<ServerFileDownloadResponse> {
return this.request<ServerFileDownloadResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/files/download`, { method: "POST", body: request });
}
async listLogStreams(serverInstanceId?: string): Promise<LogStreamListResponse> {
const query = serverInstanceId ? `?serverInstanceId=${encodeURIComponent(serverInstanceId)}` : "";
return this.request<LogStreamListResponse>(`/log-streams${query}`);
}
openServerLogEvents(id: string): PlatformEventStream {
const url = this.serverLogEventsUrl(id);
const sessionToken = this.sessionTokenProvider();
if (!sessionToken) {
return new EventSource(url, { withCredentials: true });
}
return new FetchServerSentEventStream(url, sessionToken);
}
serverLogEventsUrl(id: string): string {
return `${this.baseUrl}/server-instances/${encodeURIComponent(id)}/logs/events`;
}
async queryLogStream(request: LogStreamCursorRequest): Promise<LogStreamCursorResponse> {
return this.request<LogStreamCursorResponse>("/log-streams/query", { method: "POST", body: request });
}
async suggestServerConfig(request: LlmConfigSuggestionRequest): Promise<LlmConfigSuggestionResponse> {
return this.request<LlmConfigSuggestionResponse>("/ai/config-suggestions", { method: "POST", body: request });
}
async invokeAI(request: AIInvocationRequest): Promise<AIInvocationResponse> {
return this.request<AIInvocationResponse>("/ai/invocations", { method: "POST", body: request });
}
async authorizePluginBridge(request: PluginBridgeAuthorizeRequest): Promise<PluginBridgeAuthorizeResponse> {
return this.request<PluginBridgeAuthorizeResponse>("/plugin-bridge/authorize", {
method: "POST",
body: request
});
}
async executePluginBridge(request: PluginBridgeExecuteRequest): Promise<PluginBridgeExecuteResponse> {
return this.request<PluginBridgeExecuteResponse>("/plugin-bridge/execute", {
method: "POST",
body: request
});
}
async listAiProviders(): Promise<AiProviderListResponse> {
return this.request<AiProviderListResponse>("/ai-providers");
}
async createAiProvider(request: AiProviderRequest): Promise<AiProviderResponse> {
return this.request<AiProviderResponse>("/ai-providers", {
method: "POST",
body: request
});
}
async updateAiProvider(id: string, request: AiProviderUpdateRequest): Promise<AiProviderResponse> {
return this.request<AiProviderResponse>(`/ai-providers/${encodeURIComponent(id)}`, {
method: "PUT",
body: request
});
}
async setAiProviderStatus(id: string, request: AiProviderStatusRequest): Promise<AiProviderResponse> {
return this.request<AiProviderResponse>(`/ai-providers/${encodeURIComponent(id)}/status`, {
method: "POST",
body: request
});
}
async testAiProvider(id: string): Promise<AiProviderTestResponse> {
return this.request<AiProviderTestResponse>(`/ai-providers/${encodeURIComponent(id)}/test`, {
method: "POST"
});
}
async listAiProviderModels(id: string): Promise<AiProviderModelsResponse> {
return this.request<AiProviderModelsResponse>(`/ai-providers/${encodeURIComponent(id)}/models`);
}
private async request<T>(path: string, options: ApiRequestOptions = {}): Promise<T> {
const headers = new Headers(options.init?.headers);
if (options.body !== undefined) {
headers.set("Content-Type", "application/json");
}
const sessionToken = this.sessionTokenProvider();
if (sessionToken && !headers.has("Authorization")) {
headers.set("Authorization", `Bearer ${sessionToken}`);
}
const response = await fetch(options.absolute ? path : `${this.baseUrl}${path}`, {
...options.init,
credentials: options.init?.credentials ?? "same-origin",
method: options.method ?? options.init?.method ?? "GET",
headers,
body: options.body === undefined ? options.init?.body : JSON.stringify(options.body)
});
if (!response.ok) {
throw await responseError(response);
}
if (options.parseJson === false || response.status === 204) {
return undefined as T;
}
return response.json() as Promise<T>;
}
}
interface ApiRequestOptions {
absolute?: boolean;
method?: string;
body?: unknown;
init?: RequestInit;
parseJson?: boolean;
}
async function safeReadError(response: Response): Promise<ApiErrorResponse | null> {
try {
return (await response.json()) as ApiErrorResponse;
} catch {
return null;
}
}
async function responseError(response: Response): Promise<PlatformApiError> {
const apiError = await safeReadError(response);
const message = response.status === 401
? "会话已失效,请重新登录。"
: apiError?.code === "validation_failed"
? safeValidationMessage(apiError)
: response.status === 403
? safeForbiddenMessage(apiError?.message)
: apiError?.message ?? `request failed: ${response.status}`;
const error = new PlatformApiError(response.status, apiError?.code ?? "request_failed", message);
if (response.status === 401) {
platformApiSessionToken = null;
platformApiAuthFailureHandler?.(error);
}
return error;
}
class FetchServerSentEventStream implements PlatformEventStream {
onerror: ((event: Event) => void) | null = null;
private lastEventId = "";
private readonly controller = new AbortController();
private readonly listeners = new Map<string, Set<(event: MessageEvent) => void>>();
constructor(private readonly url: string, private readonly sessionToken: string) {
void this.connectLoop();
}
addEventListener(type: string, listener: (event: MessageEvent) => void): void {
const listeners = this.listeners.get(type) ?? new Set<(event: MessageEvent) => void>();
listeners.add(listener);
this.listeners.set(type, listeners);
}
removeEventListener(type: string, listener: (event: MessageEvent) => void): void {
this.listeners.get(type)?.delete(listener);
}
close(): void {
this.controller.abort();
this.listeners.clear();
}
private async connectLoop(): Promise<void> {
while (!this.controller.signal.aborted) {
try {
const headers = new Headers({ Accept: "text/event-stream", Authorization: `Bearer ${this.sessionToken}` });
if (this.lastEventId) headers.set("Last-Event-ID", this.lastEventId);
const response = await fetch(this.url, { method: "GET", credentials: "include", headers, signal: this.controller.signal });
if (!response.ok || !response.body) {
throw new Error(`event stream failed: ${response.status}`);
}
await this.read(response.body);
} catch {
if (this.controller.signal.aborted) return;
this.onerror?.(new Event("error"));
}
await this.reconnectPause();
}
}
private async read(body: ReadableStream<Uint8Array>): Promise<void> {
const reader = body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let eventName = "message";
let eventId = "";
let dataLines: string[] = [];
const processLine = (rawLine: string) => {
const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
if (line === "") {
if (dataLines.length > 0) {
this.dispatch(eventName, dataLines.join("\n"), eventId);
}
eventName = "message";
eventId = "";
dataLines = [];
return;
}
if (line.startsWith(":")) return;
const separator = line.indexOf(":");
const field = separator === -1 ? line : line.slice(0, separator);
const value = separator === -1 ? "" : line.slice(separator + 1).replace(/^ /, "");
if (field === "event") eventName = value || "message";
if (field === "id") eventId = value;
if (field === "data") dataLines.push(value);
};
try {
while (!this.controller.signal.aborted) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
lines.forEach(processLine);
}
buffer += decoder.decode();
if (buffer) processLine(buffer);
processLine("");
} finally {
reader.releaseLock();
}
}
private dispatch(type: string, data: string, lastEventId: string): void {
if (lastEventId) this.lastEventId = lastEventId;
const event = new MessageEvent(type, { data, lastEventId });
this.listeners.get(type)?.forEach((listener) => listener(event));
if (type !== "message") {
this.listeners.get("message")?.forEach((listener) => listener(event));
}
}
private reconnectPause(): Promise<void> {
if (this.controller.signal.aborted) return Promise.resolve();
return new Promise((resolve) => {
const timeout = globalThis.setTimeout(resolve, 1000);
this.controller.signal.addEventListener("abort", () => { globalThis.clearTimeout(timeout); resolve(); }, { once: true });
});
}
}
function safeValidationMessage(apiError?: ApiErrorResponse | null): string {
const details = apiError?.details
?.map((detail) => safeValidationDetail(detail))
.filter((detail): detail is string => Boolean(detail));
if (details?.length) {
return details.slice(0, 3).join("");
}
const sanitized = safeDiagnosticText(apiError?.message, "")?.trim();
return sanitized || "请求校验失败。";
}
function safeValidationDetail(detail: string): string | undefined {
const sanitized = safeDiagnosticText(detail, "")?.trim();
switch (sanitized) {
case "password is required":
return "请输入当前登录密码。";
case "running or installing server instances must be stopped before delete":
return "运行中或安装中的服务器必须先停止再删除。";
case "running or installing server instances require forced-delete confirmation":
return "运行中或安装中的服务器需要强制删除确认。";
default:
return sanitized || undefined;
}
}
function safeForbiddenMessage(apiMessage?: string): string {
const sanitized = safeDiagnosticText(apiMessage, "")?.trim();
if (!sanitized || sanitized === "account is not allowed to access this resource") {
return "没有权限访问该资源。";
}
if (sanitized === "password confirmation failed") {
return "当前登录密码不正确,请重新输入。";
}
const missingPermission = sanitized.match(/^plugin does not declare required permission:\s*([a-z0-9._-]+)$/i);
if (missingPermission) {
return `插件未声明所需权限:${missingPermission[1]}`;
}
if (sanitized === "plugin is not installed") {
return "插件未安装,不能执行该操作。";
}
return "没有权限访问该资源。";
}
function marketplaceQuery(filter: MarketplacePluginFilterRequest): string {
const params = new URLSearchParams();
if (filter.status && filter.status !== "all") {
params.set("status", filter.status);
}
if (filter.serverType) {
params.set("serverType", filter.serverType);
}
if (filter.capability) {
params.set("capability", filter.capability);
}
if (filter.keyword) {
params.set("keyword", filter.keyword);
}
const query = params.toString();
return query ? `?${query}` : "";
}
function artifactQuery(filter: ArtifactFilterRequest): string {
const params = new URLSearchParams();
if (filter.ownerKind) {
params.set("ownerKind", filter.ownerKind);
}
if (filter.ownerId) {
params.set("ownerId", filter.ownerId);
}
if (filter.state) {
params.set("state", filter.state);
}
const query = params.toString();
return query ? `?${query}` : "";
}
function runEndpointQuery(filter: RunEndpointListFilter): string {
const params = new URLSearchParams();
if (filter.status) params.set("status", filter.status);
const query = params.toString();
return query ? `?${query}` : "";
}
function jobListQuery(serverInstanceId: string | undefined, filter: JobListFilter): string {
const params = new URLSearchParams();
if (serverInstanceId) params.set("serverInstanceId", serverInstanceId);
if (filter.runEndpointId) params.set("runEndpointId", filter.runEndpointId);
if (filter.state) params.set("state", filter.state);
if (filter.states?.length) params.set("states", filter.states.join(","));
if (filter.limit !== undefined) params.set("limit", String(filter.limit));
const query = params.toString();
return query ? `?${query}` : "";
}
function scumListQuery(request: ScumListRequest): string {
const params = new URLSearchParams();
if (request.limit !== undefined) params.set("limit", String(request.limit));
if (request.changedAfter) params.set("changedAfter", request.changedAfter);
if (request.after) params.set("after", request.after);
if (request.online !== undefined) params.set("online", String(request.online));
if (request.exists !== undefined) params.set("exists", String(request.exists));
if (request.steamId) params.set("steamId", request.steamId);
if (request.scumUserId) params.set("scumUserId", request.scumUserId);
if (request.gameVehicleId) params.set("gameVehicleId", request.gameVehicleId);
if (request.scumVehicleId) params.set("scumVehicleId", request.scumVehicleId);
const query = params.toString();
return query ? `?${query}` : "";
}
function serverFileListQuery(request: Partial<ServerFileListRequest>): string {
const params = new URLSearchParams();
if (request.directoryKey) params.set("directoryKey", request.directoryKey);
if (request.path) params.set("path", request.path);
if (request.query) params.set("query", request.query);
if (request.recursive) params.set("recursive", "true");
const query = params.toString();
return query ? `?${query}` : "";
}
function normalizeServerFileWorkspace(response: ServerFileWorkspaceResponse): ServerFileWorkspaceResponse {
return { ...response, directories: response.directories ?? [], files: response.files ?? [], configFields: response.configFields ?? [], transfer: { ...response.transfer, notes: response.transfer.notes ?? [] } };
}
function normalizeServerFileList(response: ServerFileListResponse): ServerFileListResponse {
return { ...response, entries: response.entries ?? [] };
}
export const platformApiClient = new PlatformApiClient(readWebRuntimeEnv().platformApiBaseUrl);