Files

1001 lines
42 KiB
TypeScript
Raw Permalink 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,
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 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, options: { jobId?: string } = {}): PlatformEventStream {
const url = this.serverLogEventsUrl(id, options);
const sessionToken = this.sessionTokenProvider();
if (!sessionToken) {
return new EventSource(url, { withCredentials: true });
}
return new FetchServerSentEventStream(url, sessionToken);
}
serverLogEventsUrl(id: string, options: { jobId?: string } = {}): string {
const query = options.jobId ? `?jobId=${encodeURIComponent(options.jobId)}` : "";
return `${this.baseUrl}/server-instances/${encodeURIComponent(id)}/logs/events${query}`;
}
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);