import type {
AccountView,
ApiProblem,
ApiMeta,
AuditEvent,
ClaimSummary,
CommandPolicyDetail,
PolicyCommandCatalog,
CommandPolicyRule,
CommandPolicySummary,
CurrencyDefinition,
EconomyAccount,
EconomyChange,
EconomyTransaction,
EconomyWorkspace,
FeatureFlags,
InventorySlot,
InventorySnapshot,
InventoryUpdateResult,
MaintenanceState,
ModerationCase,
OperationStatus,
PageResult,
PlayerSummary,
PlayerContainer,
PolicySimulation,
PolicyValidation,
RollbackPreview,
ServerSettings,
ServerStatus,
SessionInfo,
StreamEvent,
TotpConfirmation,
TotpEnrollment,
TriggerCatalog,
TriggerDefinition,
TriggerGroup,
TriggerProgram,
TriggerValidation,
TriggerVariableValues,
TriggerWorkspace,
MenuCatalog,
MenuDefinition,
MenuImageCatalogItem,
MenuWorkspace,
WorldChangeSummary,
} from '../types';
type RequestOptions = Omit<RequestInit, 'body'> & {
body?: unknown;
idempotent?: boolean;
idempotencyKey?: string;
retryAttempt?: number;
timeoutMs?: number;
skipReauthentication?: boolean;
};
export class ApiError extends Error {
readonly status: number;
readonly problem?: ApiProblem;
constructor(message: string, status = 0, problem?: ApiProblem) {
super(message);
this.name = 'ApiError';
this.status = status;
this.problem = problem;
}
}
function requestId(): string {
return globalThis.crypto?.randomUUID?.() ?? `xfe-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
function normalizeBase(base: string): string {
if (/^https?:\/\//i.test(base)) return base.replace(/\/+$/, '');
return `/${base.replace(/^\/+|\/+$/g, '')}`;
}
function usesChineseUi(): boolean {
const saved = typeof localStorage !== 'undefined' ? localStorage.getItem('xfesm.locale') : null;
const language = saved ?? (typeof navigator !== 'undefined' ? navigator.language : 'en-US');
return /^(zh|lzh|yue)(?:[-_]|$)/i.test(language);
}
function localizedProblem(problem: ApiProblem | undefined, status: number, fallback: string): string {
if (!usesChineseUi()) return problem?.detail ?? problem?.title ?? fallback;
const title = (() => {
switch (status) {
case 400: return '请求参数无效';
case 401: return '身份验证失败';
case 403: return '没有执行此操作的权限';
case 404: return '请求的资源不存在';
case 405: return '不支持此请求方式';
case 409: return '当前状态发生冲突,请刷新后重试';
case 413: return '提交的数据过大';
case 428: return '需要重新进行双因素认证';
case 429: return '请求过于频繁,请稍后重试';
case 501: return '此功能尚未实现';
case 503: return '服务器当前繁忙或服务不可用';
case 504: return '操作仍在处理中,请稍后重试';
default: return status >= 500 ? '服务器内部错误' : '操作失败';
}
})();
return problem?.requestId ? `${title}(请求 ID:${problem.requestId})` : title;
}
export function defaultApiBase(): string {
if (typeof window !== 'undefined' && window.__XFESM_API_BASE__) {
return normalizeBase(window.__XFESM_API_BASE__);
}
return '/api/v1';
}
function unwrap<T>(value: unknown): T {
const result = value && typeof value === 'object' && 'data' in value
? (value as { data: T }).data : value as T;
if (result && typeof result === 'object' && 'kind' in result && 'state' in result
&& 'message' in result && typeof result.message === 'string') {
const first = result.message.indexOf(' / ');
if (first >= 0) {
const separator = usesChineseUi() ? result.message.lastIndexOf(' / ') : first;
return { ...result, message: usesChineseUi()
? result.message.slice(separator + 3) : result.message.slice(0, separator) } as T;
}
}
return result;
}
function page<T>(value: PageResult<T> | T[]): PageResult<T> {
return Array.isArray(value) ? { items: value } : value;
}
function queryString(values: Record<string, string | number | boolean | undefined>): string {
const query = new URLSearchParams();
Object.entries(values).forEach(([key, value]) => {
if (value !== undefined && value !== '') query.set(key, String(value));
});
const encoded = query.toString();
return encoded ? `?${encoded}` : '';
}
export class ApiClient {
readonly baseUrl: string;
private reauthenticationHandler?: () => Promise<void>;
constructor(baseUrl = defaultApiBase()) {
this.baseUrl = normalizeBase(baseUrl);
}
setReauthenticationHandler(handler?: () => Promise<void>): void {
this.reauthenticationHandler = handler;
}
async request<T>(path: string, options: RequestOptions = {}): Promise<T> {
const {
body: rawBody, idempotent, idempotencyKey, retryAttempt = 0,
timeoutMs, skipReauthentication, ...requestInit
} = options;
const controller = new AbortController();
const timeout = globalThis.setTimeout(() => controller.abort(), timeoutMs ?? 10_000);
const headers = new Headers(options.headers);
headers.set('Accept', 'application/json');
headers.set('X-XFESM-Request-ID', requestId());
let body: BodyInit | undefined;
if (rawBody !== undefined) {
headers.set('Content-Type', 'application/json');
body = JSON.stringify(rawBody);
}
const method = (options.method ?? 'GET').toUpperCase();
const mutation = !['GET', 'HEAD', 'OPTIONS'].includes(method);
const operationKey = idempotencyKey ?? ((idempotent || mutation) ? requestId() : undefined);
if (operationKey) {
headers.set('Idempotency-Key', operationKey);
}
const csrfToken = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('xfesm.csrf') : null;
if (csrfToken && !['GET', 'HEAD', 'OPTIONS'].includes(method)) {
headers.set('X-CSRF-Token', csrfToken);
}
try {
const response = await fetch(`${this.baseUrl}/${path.replace(/^\//, '')}`, {
...requestInit,
method,
body,
headers,
credentials: 'same-origin',
signal: controller.signal,
});
if (response.status === 204) return undefined as T;
const contentType = response.headers.get('content-type') ?? '';
const payload: unknown = contentType.includes('json') ? await response.json() : await response.text();
if (!response.ok) {
const problem = payload && typeof payload === 'object' ? (payload as ApiProblem) : undefined;
if (response.status === 428 && !skipReauthentication && this.reauthenticationHandler
&& !path.replace(/^\//, '').startsWith('auth/reauthenticate')) {
await this.reauthenticationHandler();
return this.request<T>(path, {
...options, idempotencyKey: operationKey, retryAttempt, skipReauthentication: true,
});
}
if (response.status === 504 && idempotent && retryAttempt < 1) {
return this.request<T>(path, {
...options, idempotencyKey: operationKey, retryAttempt: retryAttempt + 1,
});
}
throw new ApiError(localizedProblem(problem, response.status,
String(payload || response.statusText)), response.status, problem);
}
return unwrap<T>(payload);
} catch (error) {
if (error instanceof ApiError) throw error;
const transportFailure = error instanceof TypeError
|| (error instanceof DOMException && error.name === 'AbortError');
if (idempotent && retryAttempt < 1 && transportFailure) {
return this.request<T>(path, {
...options, idempotencyKey: operationKey, retryAttempt: retryAttempt + 1,
});
}
if (error instanceof DOMException && error.name === 'AbortError') {
throw new ApiError(usesChineseUi()
? '服务器未在规定时间内响应。' : 'The server did not respond before the request timed out.');
}
throw new ApiError(usesChineseUi()
? '无法连接服务器管理接口。'
: error instanceof Error ? error.message : 'Unable to reach the management API.');
} finally {
globalThis.clearTimeout(timeout);
}
}
status = () => this.request<ServerStatus>('status', { timeoutMs: 4_000 });
meta = () => this.request<ApiMeta>('meta', { timeoutMs: 4_000 });
session = () => this.request<SessionInfo>('auth/session', { timeoutMs: 4_000 });
setup = (bootstrapToken: string, username: string, password: string) =>
this.request<SessionInfo>('setup', {
method: 'POST',
body: { bootstrapToken, username, password },
});
login = (username: string, password: string, secondFactor?: string, recovery = false) =>
this.request<SessionInfo>('auth/login', {
method: 'POST',
body: recovery
? { username, password, recoveryCode: secondFactor }
: { username, password, totp: secondFactor },
});
logout = () => this.request<void>('auth/logout', { method: 'POST' });
beginTotpEnrollment = (password: string) =>
this.request<TotpEnrollment>('auth/totp/enrollment', { method: 'POST', body: { password } });
confirmTotpEnrollment = (code: string) =>
this.request<TotpConfirmation>('auth/totp/confirm', { method: 'POST', body: { code } });
reauthenticate = (password: string, totp?: string) =>
this.request<void>('auth/reauthenticate', { method: 'POST', body: { password, totp } });
reauthenticateWithTotp = (totp: string) =>
this.request<void>('auth/reauthenticate', {
method: 'POST',
body: { totp },
skipReauthentication: true,
});
accounts = () => this.request<AccountView[]>('accounts');
createAccount = (username: string, password: string, role: AccountView['role']) =>
this.request<AccountView>('accounts', { method: 'POST', body: { username, password, role } });
updateAccount = (id: string, change: { role?: AccountView['role']; disabled?: boolean }) =>
this.request<AccountView>(`accounts/${encodeURIComponent(id)}`, { method: 'PATCH', body: change });
deleteAccount = (id: string) =>
this.request<void>(`accounts/${encodeURIComponent(id)}`, { method: 'DELETE' });
players = (cursor?: string, query?: string) =>
this.request<PageResult<PlayerSummary> | PlayerSummary[]>(`players${queryString({ cursor, query })}`).then(page);
playerAction = (uuid: string, action: string, payload: Record<string, unknown>) =>
this.request<OperationStatus>(`players/${encodeURIComponent(uuid)}/actions`, {
method: 'POST',
body: { action, ...payload },
});
playerInventory = (uuid: string, container: PlayerContainer) =>
this.request<InventorySnapshot>(`players/${encodeURIComponent(uuid)}/${container}`);
updatePlayerInventory = (
uuid: string,
container: PlayerContainer,
expectedRevision: number,
slot: InventorySlot,
reason: string,
) => this.request<InventoryUpdateResult>(`players/${encodeURIComponent(uuid)}/${container}`, {
method: 'PATCH',
body: {
expectedRevision,
reason,
rawEditingRequested: false,
updates: [{
slot: slot.slot,
itemId: slot.itemId,
count: slot.count,
structuredData: slot.structuredData,
}],
},
});
policies = () =>
this.request<PageResult<CommandPolicySummary> | CommandPolicySummary[]>('policies').then(page);
policyCommands = () => this.request<PolicyCommandCatalog>('policies/commands');
policy = (id: string) =>
this.request<CommandPolicyDetail>(`policies/${encodeURIComponent(id)}`);
stagePolicy = (expectedActiveVersion: number, description: string, rules: CommandPolicyRule[]) =>
this.request<OperationStatus>('policies', {
method: 'POST',
body: { expectedActiveVersion, description, rules },
});
validatePolicy = (id: string) =>
this.request<PolicyValidation>(`policies/${encodeURIComponent(id)}/validate`, { method: 'POST' });
simulatePolicy = (command: string, playerUuid?: string) =>
this.request<PolicySimulation>('policies/simulate', { method: 'POST', body: { command, playerUuid } });
publishPolicy = (id: string, version: number, reason: string) =>
this.request<OperationStatus>(`policies/${encodeURIComponent(id)}/publish`, {
method: 'POST',
body: { version, reason },
});
audit = (cursor?: string, query?: string) =>
this.request<PageResult<AuditEvent> | AuditEvent[]>(`audit${queryString({ cursor, query, limit: 50 })}`).then(page);
executeConsole = (command: string, reason: string) =>
this.request<OperationStatus>('console/commands', { method: 'POST', body: { command, reason } });
settings = () => this.request<ServerSettings>('settings');
updateSettings = (settings: Partial<ServerSettings>) =>
this.request<OperationStatus>('settings', { method: 'PUT', body: settings });
updateMessageSettings = (settings: Pick<ServerSettings,
'joinWelcomeEnabled' | 'joinWelcomeMessage' | 'firstJoinMessageEnabled' | 'firstJoinMessage'
| 'dailyAnnouncementsEnabled' | 'dailyAnnouncements' | 'announcementTimeZone'
| 'rulesReminderEnabled' | 'rulesMessage' | 'maintenanceJoinReminderEnabled'>) =>
this.request<OperationStatus>('settings', { method: 'PUT', body: settings });
triggerWorkspace = () => this.request<TriggerWorkspace>('triggers');
triggerCatalog = () => this.request<TriggerCatalog>('triggers/catalog');
triggerVariableValues = (triggerId: string) =>
this.request<TriggerVariableValues>(`triggers/${encodeURIComponent(triggerId)}/variables`);
createTriggerGroup = (group: Pick<TriggerGroup, 'name' | 'description' | 'enabled'>) =>
this.request<TriggerWorkspace>('trigger-groups', { method: 'POST', body: group, idempotent: true });
updateTriggerGroup = (group: Pick<TriggerGroup, 'id' | 'name' | 'description' | 'enabled' | 'revision'>) =>
this.request<TriggerWorkspace>(`trigger-groups/${encodeURIComponent(group.id)}`, {
method: 'PUT', idempotent: true, body: { name: group.name, description: group.description, enabled: group.enabled,
expectedRevision: group.revision },
});
deleteTriggerGroup = (id: string, expectedRevision: number) =>
this.request<{ deleted: boolean }>(`trigger-groups/${encodeURIComponent(id)}`, {
method: 'DELETE', body: { expectedRevision }, idempotent: true,
});
createTrigger = (trigger: Omit<TriggerDefinition,
'id' | 'revision' | 'createdBy' | 'createdAt' | 'updatedAt' | 'migrated'>) =>
this.request<{ trigger: TriggerDefinition; workspace: TriggerWorkspace }>('triggers', {
method: 'POST', body: trigger, idempotent: true,
});
updateTrigger = (trigger: TriggerDefinition) =>
this.request<{ trigger: TriggerDefinition; workspace: TriggerWorkspace }>(
`triggers/${encodeURIComponent(trigger.id)}`, {
method: 'PUT', body: { ...trigger, expectedRevision: trigger.revision }, idempotent: true,
});
deleteTrigger = (id: string, expectedRevision: number) =>
this.request<{ deleted: boolean }>(`triggers/${encodeURIComponent(id)}`, {
method: 'DELETE', body: { expectedRevision }, idempotent: true,
});
validateTriggerScript = (script: string) => this.request<TriggerValidation>('triggers/validate', {
method: 'POST', body: { script },
});
validateVisualTrigger = (program: TriggerProgram) => this.request<TriggerValidation>('triggers/validate', {
method: 'POST', body: program,
});
menuWorkspace = () => this.request<MenuWorkspace>('menus');
menuCatalog = () => this.request<MenuCatalog>('menus/catalog');
menuImages = (query = '') => this.request<{ items: MenuImageCatalogItem[] }>(
`menus/images${queryString({ query })}`,
).then((result) => result.items);
menuImagePreview = (reference: string) => this.request<{ reference: string; mimeType: string; dataUrl: string }>(
`menus/images/preview${queryString({ reference })}`,
);
menuImageContent = (id: string) => this.request<{ id: string; mimeType: string; dataUrl: string }>(
`menu-assets/${encodeURIComponent(id)}/content`,
);
uploadMenuImage = (name: string, dataUrl: string) => this.request<MenuImageCatalogItem>('menu-assets', {
method: 'POST', body: { name, dataUrl }, idempotent: true,
});
deleteMenuImage = (id: string) => this.request<{ deleted: boolean }>(
`menu-assets/${encodeURIComponent(id)}`, { method: 'DELETE', body: {}, idempotent: true },
);
createMenu = (menu: Omit<MenuDefinition,
'id' | 'revision' | 'createdBy' | 'createdAt' | 'updatedAt'>) =>
this.request<{ menu: MenuDefinition; workspace: MenuWorkspace }>('menus', {
method: 'POST', body: menu, idempotent: true,
});
updateMenu = (menu: MenuDefinition) =>
this.request<{ menu: MenuDefinition; workspace: MenuWorkspace }>(`menus/${encodeURIComponent(menu.id)}`, {
method: 'PUT', body: { ...menu, expectedRevision: menu.revision }, idempotent: true,
});
deleteMenu = (id: string, expectedRevision: number) => this.request<{ deleted: boolean }>(
`menus/${encodeURIComponent(id)}`, {
method: 'DELETE', body: { expectedRevision }, idempotent: true,
},
);
economy = () => this.request<EconomyWorkspace>('economy');
economyAccounts = (query = '', limit = 100) => this.request<PageResult<EconomyAccount>>(
`economy/accounts${queryString({ query, limit })}`,
);
economyTransactions = (filters: {
query?: string; player?: string; currency?: string; kind?: string; before?: string; limit?: number;
} = {}) => this.request<PageResult<EconomyTransaction>>(`economy/transactions${queryString({
query: filters.query, player: filters.player, currency: filters.currency, kind: filters.kind,
before: filters.before, limit: filters.limit ?? 100,
})}`);
createCurrency = (currency: Omit<CurrencyDefinition,
'id' | 'revision' | 'createdBy' | 'createdAt' | 'updatedAt'>, reason: string) =>
this.request<{ currency: CurrencyDefinition }>('economy/currencies', {
method: 'POST', body: { ...currency, reason }, idempotent: true,
});
updateCurrency = (currency: CurrencyDefinition, reason: string) =>
this.request<{ currency: CurrencyDefinition }>(`economy/currencies/${encodeURIComponent(currency.id)}`, {
method: 'PUT', body: { ...currency, expectedRevision: currency.revision, reason }, idempotent: true,
});
deleteCurrency = (currency: CurrencyDefinition, reason: string) =>
this.request<{ deleted: boolean }>(`economy/currencies/${encodeURIComponent(currency.id)}`, {
method: 'DELETE', body: { expectedRevision: currency.revision, reason }, idempotent: true,
});
adjustEconomy = (payload: {
playerId: string; playerName: string; currency: string; operation: 'deposit' | 'withdraw' | 'set';
amount: string; reason: string; expectedRevision?: number;
}) => this.request<EconomyChange>('economy/adjustments', {
method: 'POST', body: payload, idempotent: true,
});
transferEconomy = (payload: {
sourcePlayerId: string; sourcePlayerName: string; targetPlayerId: string; targetPlayerName: string;
currency: string; amount: string; reason: string; expectedSourceRevision?: number;
}) => this.request<EconomyChange>('economy/transfers', {
method: 'POST', body: payload, idempotent: true,
});
submitOperation = (action: string, reason: string, previewToken = '') =>
this.request<OperationStatus>('operations', {
method: 'POST',
body: { action, target: null, reason, parameters: {}, previewToken },
});
features = () => this.request<FeatureFlags>('features');
moderationCases = (cursor?: string, query?: string) =>
this.request<PageResult<ModerationCase> | ModerationCase[]>(
`moderation/cases${queryString({ cursor, query, limit: 50 })}`,
).then(page);
createModerationCase = (payload: Record<string, unknown>) =>
this.request<ModerationCase>('moderation/cases', { method: 'POST', body: payload });
revokeModerationAction = (caseId: string, actionId: string, reason: string) =>
this.request<ModerationCase>(`moderation/cases/${encodeURIComponent(caseId)}/actions/${encodeURIComponent(actionId)}/revoke`, {
method: 'POST', body: { reason },
});
maintenance = () => this.request<MaintenanceState>('maintenance');
updateMaintenance = (enabled: boolean, message: string, allowedPlayers: string[], reason: string) =>
this.request<MaintenanceState>('maintenance', {
method: 'PUT', body: { enabled, message, allowedPlayers, reason },
});
announce = (message: string, reason: string) =>
this.request<{ announcement: string }>('maintenance/announcements', { method: 'POST', body: { message, reason } });
scheduleMaintenance = (executeAt: string, kind: string, reason: string, parameters: Record<string, unknown>) =>
this.request<Record<string, unknown>>('maintenance/schedules', {
method: 'POST', body: { executeAt, kind, reason, ...parameters },
});
claims = (cursor?: string, query?: string) =>
this.request<PageResult<ClaimSummary> | ClaimSummary[]>(`claims${queryString({ cursor, query, limit: 50 })}`).then(page);
createClaim = (payload: Record<string, unknown>) =>
this.request<ClaimSummary>('claims', { method: 'POST', body: payload });
updateClaim = (id: string, payload: Record<string, unknown>) =>
this.request<ClaimSummary>(`claims/${encodeURIComponent(id)}`, { method: 'PUT', body: payload });
deleteClaim = (id: string, revision: number, reason: string) =>
this.request<ClaimSummary>(`claims/${encodeURIComponent(id)}`, {
method: 'DELETE', body: { revision, reason },
});
beginClaimTransfer = (id: string, actorId: string, newOwnerId: string, reason: string) =>
this.request<{ token: string; expiresAt: string }>(`claims/${encodeURIComponent(id)}/transfer`, {
method: 'POST', body: { actorId, newOwnerId, validity: '10m', reason },
});
confirmClaimTransfer = (token: string, acceptingPlayerId: string, reason: string) =>
this.request<ClaimSummary>(`claims/transfers/${encodeURIComponent(token)}/confirm`, {
method: 'POST', body: { acceptingPlayerId, reason },
});
worldChanges = (cursor?: string, filter: { actor?: string; since?: string; radius?: number; action?: string; dimension?: string; x?: number; y?: number; z?: number } = {}) =>
this.request<PageResult<WorldChangeSummary> | WorldChangeSummary[]>(
`world/changes${queryString({ cursor, actorId: filter.actor, from: filter.since,
radius: filter.radius, kinds: filter.action, dimension: filter.dimension,
centerX: filter.x, centerZ: filter.z, limit: 50 })}`,
).then(page);
previewRollback = (filter: object) =>
this.request<RollbackPreview>('rollbacks/preview', { method: 'POST', body: filter });
startRollback = (previewId: string, confirmationToken: string, reason: string, forceConflicts = false) =>
this.request<OperationStatus>('rollbacks', {
method: 'POST',
body: { previewId, confirmationToken, reason, forceConflicts },
});
rollbackOperation = (id: string) =>
this.request<OperationStatus>(`rollbacks/${encodeURIComponent(id)}`);
pauseRollback = (id: string) =>
this.request<OperationStatus>(`rollbacks/${encodeURIComponent(id)}/pause`, { method: 'POST' });
resumeRollback = (id: string) =>
this.request<OperationStatus>(`rollbacks/${encodeURIComponent(id)}/resume`, { method: 'POST' });
cancelRollback = (id: string) =>
this.request<OperationStatus>(`rollbacks/${encodeURIComponent(id)}/cancel`, { method: 'POST' });
previewRedo = (id: string, force = false, loadChunks = false) =>
this.request<RollbackPreview>(`rollbacks/${encodeURIComponent(id)}/redo/preview`, {
method: 'POST', body: { force, loadChunks },
});
eventUrl(): string {
return `${this.baseUrl}/events`;
}
subscribe(
onEvent: (event: StreamEvent) => void,
onState: (state: 'open' | 'error') => void,
): () => void {
if (typeof EventSource === 'undefined') return () => undefined;
const source = new EventSource(this.eventUrl(), { withCredentials: true });
source.onopen = () => onState('open');
source.onerror = () => onState('error');
const receive = (type: string) => (event: MessageEvent<string>) => {
try {
const parsed = JSON.parse(event.data) as unknown;
const eventType = parsed && typeof parsed === 'object' && 'type' in parsed && typeof (parsed as { type: unknown }).type === 'string'
? (parsed as { type: string }).type
: type;
const data = parsed && typeof parsed === 'object' && 'data' in parsed
? (parsed as { data: unknown }).data
: parsed;
onEvent({ type: eventType, id: event.lastEventId || undefined, data });
} catch {
onEvent({ type: 'malformed', id: event.lastEventId || undefined, data: event.data });
}
};
source.onmessage = receive('message');
[
'server-status',
'player-changed',
'policy-published',
'audit',
'operation',
'moderation',
'claim',
'world-change',
'triggers-changed',
'menus-changed',
'economy-changed',
'trigger-executed',
].forEach((type) => source.addEventListener(type, receive(type)));
return () => source.close();
}
}
export const api = new ApiClient();
import type {
AccountView,
ApiProblem,
ApiMeta,
AuditEvent,
ClaimSummary,
CommandPolicyDetail,
PolicyCommandCatalog,
CommandPolicyRule,
CommandPolicySummary,
CurrencyDefinition,
EconomyAccount,
EconomyChange,
EconomyTransaction,
EconomyWorkspace,
FeatureFlags,
InventorySlot,
InventorySnapshot,
InventoryUpdateResult,
MaintenanceState,
ModerationCase,
OperationStatus,
PageResult,
PlayerSummary,
PlayerContainer,
PolicySimulation,
PolicyValidation,
RollbackPreview,
ServerSettings,
ServerStatus,
SessionInfo,
StreamEvent,
TotpConfirmation,
TotpEnrollment,
TriggerCatalog,
TriggerDefinition,
TriggerGroup,
TriggerProgram,
TriggerValidation,
TriggerVariableValues,
TriggerWorkspace,
MenuCatalog,
MenuDefinition,
MenuImageCatalogItem,
MenuWorkspace,
WorldChangeSummary,
} from '../types';
type RequestOptions = Omit<RequestInit, 'body'> & {
body?: unknown;
idempotent?: boolean;
idempotencyKey?: string;
retryAttempt?: number;
timeoutMs?: number;
skipReauthentication?: boolean;
};
export class ApiError extends Error {
readonly status: number;
readonly problem?: ApiProblem;
constructor(message: string, status = 0, problem?: ApiProblem) {
super(message);
this.name = 'ApiError';
this.status = status;
this.problem = problem;
}
}
function requestId(): string {
return globalThis.crypto?.randomUUID?.() ?? `xfe-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
function normalizeBase(base: string): string {
if (/^https?:\/\//i.test(base)) return base.replace(/\/+$/, '');
return `/${base.replace(/^\/+|\/+$/g, '')}`;
}
function usesChineseUi(): boolean {
const saved = typeof localStorage !== 'undefined' ? localStorage.getItem('xfesm.locale') : null;
const language = saved ?? (typeof navigator !== 'undefined' ? navigator.language : 'en-US');
return /^(zh|lzh|yue)(?:[-_]|$)/i.test(language);
}
function localizedProblem(problem: ApiProblem | undefined, status: number, fallback: string): string {
if (!usesChineseUi()) return problem?.detail ?? problem?.title ?? fallback;
const title = (() => {
switch (status) {
case 400: return '请求参数无效';
case 401: return '身份验证失败';
case 403: return '没有执行此操作的权限';
case 404: return '请求的资源不存在';
case 405: return '不支持此请求方式';
case 409: return '当前状态发生冲突,请刷新后重试';
case 413: return '提交的数据过大';
case 428: return '需要重新进行双因素认证';
case 429: return '请求过于频繁,请稍后重试';
case 501: return '此功能尚未实现';
case 503: return '服务器当前繁忙或服务不可用';
case 504: return '操作仍在处理中,请稍后重试';
default: return status >= 500 ? '服务器内部错误' : '操作失败';
}
})();
return problem?.requestId ? `${title}(请求 ID:${problem.requestId})` : title;
}
export function defaultApiBase(): string {
if (typeof window !== 'undefined' && window.__XFESM_API_BASE__) {
return normalizeBase(window.__XFESM_API_BASE__);
}
return '/api/v1';
}
function unwrap<T>(value: unknown): T {
const result = value && typeof value === 'object' && 'data' in value
? (value as { data: T }).data : value as T;
if (result && typeof result === 'object' && 'kind' in result && 'state' in result
&& 'message' in result && typeof result.message === 'string') {
const first = result.message.indexOf(' / ');
if (first >= 0) {
const separator = usesChineseUi() ? result.message.lastIndexOf(' / ') : first;
return { ...result, message: usesChineseUi()
? result.message.slice(separator + 3) : result.message.slice(0, separator) } as T;
}
}
return result;
}
function page<T>(value: PageResult<T> | T[]): PageResult<T> {
return Array.isArray(value) ? { items: value } : value;
}
function queryString(values: Record<string, string | number | boolean | undefined>): string {
const query = new URLSearchParams();
Object.entries(values).forEach(([key, value]) => {
if (value !== undefined && value !== '') query.set(key, String(value));
});
const encoded = query.toString();
return encoded ? `?${encoded}` : '';
}
export class ApiClient {
readonly baseUrl: string;
private reauthenticationHandler?: () => Promise<void>;
constructor(baseUrl = defaultApiBase()) {
this.baseUrl = normalizeBase(baseUrl);
}
setReauthenticationHandler(handler?: () => Promise<void>): void {
this.reauthenticationHandler = handler;
}
async request<T>(path: string, options: RequestOptions = {}): Promise<T> {
const {
body: rawBody, idempotent, idempotencyKey, retryAttempt = 0,
timeoutMs, skipReauthentication, ...requestInit
} = options;
const controller = new AbortController();
const timeout = globalThis.setTimeout(() => controller.abort(), timeoutMs ?? 10_000);
const headers = new Headers(options.headers);
headers.set('Accept', 'application/json');
headers.set('X-XFESM-Request-ID', requestId());
let body: BodyInit | undefined;
if (rawBody !== undefined) {
headers.set('Content-Type', 'application/json');
body = JSON.stringify(rawBody);
}
const method = (options.method ?? 'GET').toUpperCase();
const mutation = !['GET', 'HEAD', 'OPTIONS'].includes(method);
const operationKey = idempotencyKey ?? ((idempotent || mutation) ? requestId() : undefined);
if (operationKey) {
headers.set('Idempotency-Key', operationKey);
}
const csrfToken = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('xfesm.csrf') : null;
if (csrfToken && !['GET', 'HEAD', 'OPTIONS'].includes(method)) {
headers.set('X-CSRF-Token', csrfToken);
}
try {
const response = await fetch(`${this.baseUrl}/${path.replace(/^\//, '')}`, {
...requestInit,
method,
body,
headers,
credentials: 'same-origin',
signal: controller.signal,
});
if (response.status === 204) return undefined as T;
const contentType = response.headers.get('content-type') ?? '';
const payload: unknown = contentType.includes('json') ? await response.json() : await response.text();
if (!response.ok) {
const problem = payload && typeof payload === 'object' ? (payload as ApiProblem) : undefined;
if (response.status === 428 && !skipReauthentication && this.reauthenticationHandler
&& !path.replace(/^\//, '').startsWith('auth/reauthenticate')) {
await this.reauthenticationHandler();
return this.request<T>(path, {
...options, idempotencyKey: operationKey, retryAttempt, skipReauthentication: true,
});
}
if (response.status === 504 && idempotent && retryAttempt < 1) {
return this.request<T>(path, {
...options, idempotencyKey: operationKey, retryAttempt: retryAttempt + 1,
});
}
throw new ApiError(localizedProblem(problem, response.status,
String(payload || response.statusText)), response.status, problem);
}
return unwrap<T>(payload);
} catch (error) {
if (error instanceof ApiError) throw error;
const transportFailure = error instanceof TypeError
|| (error instanceof DOMException && error.name === 'AbortError');
if (idempotent && retryAttempt < 1 && transportFailure) {
return this.request<T>(path, {
...options, idempotencyKey: operationKey, retryAttempt: retryAttempt + 1,
});
}
if (error instanceof DOMException && error.name === 'AbortError') {
throw new ApiError(usesChineseUi()
? '服务器未在规定时间内响应。' : 'The server did not respond before the request timed out.');
}
throw new ApiError(usesChineseUi()
? '无法连接服务器管理接口。'
: error instanceof Error ? error.message : 'Unable to reach the management API.');
} finally {
globalThis.clearTimeout(timeout);
}
}
status = () => this.request<ServerStatus>('status', { timeoutMs: 4_000 });
meta = () => this.request<ApiMeta>('meta', { timeoutMs: 4_000 });
session = () => this.request<SessionInfo>('auth/session', { timeoutMs: 4_000 });
setup = (bootstrapToken: string, username: string, password: string) =>
this.request<SessionInfo>('setup', {
method: 'POST',
body: { bootstrapToken, username, password },
});
login = (username: string, password: string, secondFactor?: string, recovery = false) =>
this.request<SessionInfo>('auth/login', {
method: 'POST',
body: recovery
? { username, password, recoveryCode: secondFactor }
: { username, password, totp: secondFactor },
});
logout = () => this.request<void>('auth/logout', { method: 'POST' });
beginTotpEnrollment = (password: string) =>
this.request<TotpEnrollment>('auth/totp/enrollment', { method: 'POST', body: { password } });
confirmTotpEnrollment = (code: string) =>
this.request<TotpConfirmation>('auth/totp/confirm', { method: 'POST', body: { code } });
reauthenticate = (password: string, totp?: string) =>
this.request<void>('auth/reauthenticate', { method: 'POST', body: { password, totp } });
reauthenticateWithTotp = (totp: string) =>
this.request<void>('auth/reauthenticate', {
method: 'POST',
body: { totp },
skipReauthentication: true,
});
accounts = () => this.request<AccountView[]>('accounts');
createAccount = (username: string, password: string, role: AccountView['role']) =>
this.request<AccountView>('accounts', { method: 'POST', body: { username, password, role } });
updateAccount = (id: string, change: { role?: AccountView['role']; disabled?: boolean }) =>
this.request<AccountView>(`accounts/${encodeURIComponent(id)}`, { method: 'PATCH', body: change });
deleteAccount = (id: string) =>
this.request<void>(`accounts/${encodeURIComponent(id)}`, { method: 'DELETE' });
players = (cursor?: string, query?: string) =>
this.request<PageResult<PlayerSummary> | PlayerSummary[]>(`players${queryString({ cursor, query })}`).then(page);
playerAction = (uuid: string, action: string, payload: Record<string, unknown>) =>
this.request<OperationStatus>(`players/${encodeURIComponent(uuid)}/actions`, {
method: 'POST',
body: { action, ...payload },
});
playerInventory = (uuid: string, container: PlayerContainer) =>
this.request<InventorySnapshot>(`players/${encodeURIComponent(uuid)}/${container}`);
updatePlayerInventory = (
uuid: string,
container: PlayerContainer,
expectedRevision: number,
slot: InventorySlot,
reason: string,
) => this.request<InventoryUpdateResult>(`players/${encodeURIComponent(uuid)}/${container}`, {
method: 'PATCH',
body: {
expectedRevision,
reason,
rawEditingRequested: false,
updates: [{
slot: slot.slot,
itemId: slot.itemId,
count: slot.count,
structuredData: slot.structuredData,
}],
},
});
policies = () =>
this.request<PageResult<CommandPolicySummary> | CommandPolicySummary[]>('policies').then(page);
policyCommands = () => this.request<PolicyCommandCatalog>('policies/commands');
policy = (id: string) =>
this.request<CommandPolicyDetail>(`policies/${encodeURIComponent(id)}`);
stagePolicy = (expectedActiveVersion: number, description: string, rules: CommandPolicyRule[]) =>
this.request<OperationStatus>('policies', {
method: 'POST',
body: { expectedActiveVersion, description, rules },
});
validatePolicy = (id: string) =>
this.request<PolicyValidation>(`policies/${encodeURIComponent(id)}/validate`, { method: 'POST' });
simulatePolicy = (command: string, playerUuid?: string) =>
this.request<PolicySimulation>('policies/simulate', { method: 'POST', body: { command, playerUuid } });
publishPolicy = (id: string, version: number, reason: string) =>
this.request<OperationStatus>(`policies/${encodeURIComponent(id)}/publish`, {
method: 'POST',
body: { version, reason },
});
audit = (cursor?: string, query?: string) =>
this.request<PageResult<AuditEvent> | AuditEvent[]>(`audit${queryString({ cursor, query, limit: 50 })}`).then(page);
executeConsole = (command: string, reason: string) =>
this.request<OperationStatus>('console/commands', { method: 'POST', body: { command, reason } });
settings = () => this.request<ServerSettings>('settings');
updateSettings = (settings: Partial<ServerSettings>) =>
this.request<OperationStatus>('settings', { method: 'PUT', body: settings });
updateMessageSettings = (settings: Pick<ServerSettings,
'joinWelcomeEnabled' | 'joinWelcomeMessage' | 'firstJoinMessageEnabled' | 'firstJoinMessage'
| 'dailyAnnouncementsEnabled' | 'dailyAnnouncements' | 'announcementTimeZone'
| 'rulesReminderEnabled' | 'rulesMessage' | 'maintenanceJoinReminderEnabled'>) =>
this.request<OperationStatus>('settings', { method: 'PUT', body: settings });
triggerWorkspace = () => this.request<TriggerWorkspace>('triggers');
triggerCatalog = () => this.request<TriggerCatalog>('triggers/catalog');
triggerVariableValues = (triggerId: string) =>
this.request<TriggerVariableValues>(`triggers/${encodeURIComponent(triggerId)}/variables`);
createTriggerGroup = (group: Pick<TriggerGroup, 'name' | 'description' | 'enabled'>) =>
this.request<TriggerWorkspace>('trigger-groups', { method: 'POST', body: group, idempotent: true });
updateTriggerGroup = (group: Pick<TriggerGroup, 'id' | 'name' | 'description' | 'enabled' | 'revision'>) =>
this.request<TriggerWorkspace>(`trigger-groups/${encodeURIComponent(group.id)}`, {
method: 'PUT', idempotent: true, body: { name: group.name, description: group.description, enabled: group.enabled,
expectedRevision: group.revision },
});
deleteTriggerGroup = (id: string, expectedRevision: number) =>
this.request<{ deleted: boolean }>(`trigger-groups/${encodeURIComponent(id)}`, {
method: 'DELETE', body: { expectedRevision }, idempotent: true,
});
createTrigger = (trigger: Omit<TriggerDefinition,
'id' | 'revision' | 'createdBy' | 'createdAt' | 'updatedAt' | 'migrated'>) =>
this.request<{ trigger: TriggerDefinition; workspace: TriggerWorkspace }>('triggers', {
method: 'POST', body: trigger, idempotent: true,
});
updateTrigger = (trigger: TriggerDefinition) =>
this.request<{ trigger: TriggerDefinition; workspace: TriggerWorkspace }>(
`triggers/${encodeURIComponent(trigger.id)}`, {
method: 'PUT', body: { ...trigger, expectedRevision: trigger.revision }, idempotent: true,
});
deleteTrigger = (id: string, expectedRevision: number) =>
this.request<{ deleted: boolean }>(`triggers/${encodeURIComponent(id)}`, {
method: 'DELETE', body: { expectedRevision }, idempotent: true,
});
validateTriggerScript = (script: string) => this.request<TriggerValidation>('triggers/validate', {
method: 'POST', body: { script },
});
validateVisualTrigger = (program: TriggerProgram) => this.request<TriggerValidation>('triggers/validate', {
method: 'POST', body: program,
});
menuWorkspace = () => this.request<MenuWorkspace>('menus');
menuCatalog = () => this.request<MenuCatalog>('menus/catalog');
menuImages = (query = '') => this.request<{ items: MenuImageCatalogItem[] }>(
`menus/images${queryString({ query })}`,
).then((result) => result.items);
menuImagePreview = (reference: string) => this.request<{ reference: string; mimeType: string; dataUrl: string }>(
`menus/images/preview${queryString({ reference })}`,
);
menuImageContent = (id: string) => this.request<{ id: string; mimeType: string; dataUrl: string }>(
`menu-assets/${encodeURIComponent(id)}/content`,
);
uploadMenuImage = (name: string, dataUrl: string) => this.request<MenuImageCatalogItem>('menu-assets', {
method: 'POST', body: { name, dataUrl }, idempotent: true,
});
deleteMenuImage = (id: string) => this.request<{ deleted: boolean }>(
`menu-assets/${encodeURIComponent(id)}`, { method: 'DELETE', body: {}, idempotent: true },
);
createMenu = (menu: Omit<MenuDefinition,
'id' | 'revision' | 'createdBy' | 'createdAt' | 'updatedAt'>) =>
this.request<{ menu: MenuDefinition; workspace: MenuWorkspace }>('menus', {
method: 'POST', body: menu, idempotent: true,
});
updateMenu = (menu: MenuDefinition) =>
this.request<{ menu: MenuDefinition; workspace: MenuWorkspace }>(`menus/${encodeURIComponent(menu.id)}`, {
method: 'PUT', body: { ...menu, expectedRevision: menu.revision }, idempotent: true,
});
deleteMenu = (id: string, expectedRevision: number) => this.request<{ deleted: boolean }>(
`menus/${encodeURIComponent(id)}`, {
method: 'DELETE', body: { expectedRevision }, idempotent: true,
},
);
economy = () => this.request<EconomyWorkspace>('economy');
economyAccounts = (query = '', limit = 100) => this.request<PageResult<EconomyAccount>>(
`economy/accounts${queryString({ query, limit })}`,
);
economyTransactions = (filters: {
query?: string; player?: string; currency?: string; kind?: string; before?: string; limit?: number;
} = {}) => this.request<PageResult<EconomyTransaction>>(`economy/transactions${queryString({
query: filters.query, player: filters.player, currency: filters.currency, kind: filters.kind,
before: filters.before, limit: filters.limit ?? 100,
})}`);
createCurrency = (currency: Omit<CurrencyDefinition,
'id' | 'revision' | 'createdBy' | 'createdAt' | 'updatedAt'>, reason: string) =>
this.request<{ currency: CurrencyDefinition }>('economy/currencies', {
method: 'POST', body: { ...currency, reason }, idempotent: true,
});
updateCurrency = (currency: CurrencyDefinition, reason: string) =>
this.request<{ currency: CurrencyDefinition }>(`economy/currencies/${encodeURIComponent(currency.id)}`, {
method: 'PUT', body: { ...currency, expectedRevision: currency.revision, reason }, idempotent: true,
});
deleteCurrency = (currency: CurrencyDefinition, reason: string) =>
this.request<{ deleted: boolean }>(`economy/currencies/${encodeURIComponent(currency.id)}`, {
method: 'DELETE', body: { expectedRevision: currency.revision, reason }, idempotent: true,
});
adjustEconomy = (payload: {
playerId: string; playerName: string; currency: string; operation: 'deposit' | 'withdraw' | 'set';
amount: string; reason: string; expectedRevision?: number;
}) => this.request<EconomyChange>('economy/adjustments', {
method: 'POST', body: payload, idempotent: true,
});
transferEconomy = (payload: {
sourcePlayerId: string; sourcePlayerName: string; targetPlayerId: string; targetPlayerName: string;
currency: string; amount: string; reason: string; expectedSourceRevision?: number;
}) => this.request<EconomyChange>('economy/transfers', {
method: 'POST', body: payload, idempotent: true,
});
submitOperation = (action: string, reason: string, previewToken = '') =>
this.request<OperationStatus>('operations', {
method: 'POST',
body: { action, target: null, reason, parameters: {}, previewToken },
});
features = () => this.request<FeatureFlags>('features');
moderationCases = (cursor?: string, query?: string) =>
this.request<PageResult<ModerationCase> | ModerationCase[]>(
`moderation/cases${queryString({ cursor, query, limit: 50 })}`,
).then(page);
createModerationCase = (payload: Record<string, unknown>) =>
this.request<ModerationCase>('moderation/cases', { method: 'POST', body: payload });
revokeModerationAction = (caseId: string, actionId: string, reason: string) =>
this.request<ModerationCase>(`moderation/cases/${encodeURIComponent(caseId)}/actions/${encodeURIComponent(actionId)}/revoke`, {
method: 'POST', body: { reason },
});
maintenance = () => this.request<MaintenanceState>('maintenance');
updateMaintenance = (enabled: boolean, message: string, allowedPlayers: string[], reason: string) =>
this.request<MaintenanceState>('maintenance', {
method: 'PUT', body: { enabled, message, allowedPlayers, reason },
});
announce = (message: string, reason: string) =>
this.request<{ announcement: string }>('maintenance/announcements', { method: 'POST', body: { message, reason } });
scheduleMaintenance = (executeAt: string, kind: string, reason: string, parameters: Record<string, unknown>) =>
this.request<Record<string, unknown>>('maintenance/schedules', {
method: 'POST', body: { executeAt, kind, reason, ...parameters },
});
claims = (cursor?: string, query?: string) =>
this.request<PageResult<ClaimSummary> | ClaimSummary[]>(`claims${queryString({ cursor, query, limit: 50 })}`).then(page);
createClaim = (payload: Record<string, unknown>) =>
this.request<ClaimSummary>('claims', { method: 'POST', body: payload });
updateClaim = (id: string, payload: Record<string, unknown>) =>
this.request<ClaimSummary>(`claims/${encodeURIComponent(id)}`, { method: 'PUT', body: payload });
deleteClaim = (id: string, revision: number, reason: string) =>
this.request<ClaimSummary>(`claims/${encodeURIComponent(id)}`, {
method: 'DELETE', body: { revision, reason },
});
beginClaimTransfer = (id: string, actorId: string, newOwnerId: string, reason: string) =>
this.request<{ token: string; expiresAt: string }>(`claims/${encodeURIComponent(id)}/transfer`, {
method: 'POST', body: { actorId, newOwnerId, validity: '10m', reason },
});
confirmClaimTransfer = (token: string, acceptingPlayerId: string, reason: string) =>
this.request<ClaimSummary>(`claims/transfers/${encodeURIComponent(token)}/confirm`, {
method: 'POST', body: { acceptingPlayerId, reason },
});
worldChanges = (cursor?: string, filter: { actor?: string; since?: string; radius?: number; action?: string; dimension?: string; x?: number; y?: number; z?: number } = {}) =>
this.request<PageResult<WorldChangeSummary> | WorldChangeSummary[]>(
`world/changes${queryString({ cursor, actorId: filter.actor, from: filter.since,
radius: filter.radius, kinds: filter.action, dimension: filter.dimension,
centerX: filter.x, centerZ: filter.z, limit: 50 })}`,
).then(page);
previewRollback = (filter: object) =>
this.request<RollbackPreview>('rollbacks/preview', { method: 'POST', body: filter });
startRollback = (previewId: string, confirmationToken: string, reason: string, forceConflicts = false) =>
this.request<OperationStatus>('rollbacks', {
method: 'POST',
body: { previewId, confirmationToken, reason, forceConflicts },
});
rollbackOperation = (id: string) =>
this.request<OperationStatus>(`rollbacks/${encodeURIComponent(id)}`);
pauseRollback = (id: string) =>
this.request<OperationStatus>(`rollbacks/${encodeURIComponent(id)}/pause`, { method: 'POST' });
resumeRollback = (id: string) =>
this.request<OperationStatus>(`rollbacks/${encodeURIComponent(id)}/resume`, { method: 'POST' });
cancelRollback = (id: string) =>
this.request<OperationStatus>(`rollbacks/${encodeURIComponent(id)}/cancel`, { method: 'POST' });
previewRedo = (id: string, force = false, loadChunks = false) =>
this.request<RollbackPreview>(`rollbacks/${encodeURIComponent(id)}/redo/preview`, {
method: 'POST', body: { force, loadChunks },
});
eventUrl(): string {
return `${this.baseUrl}/events`;
}
subscribe(
onEvent: (event: StreamEvent) => void,
onState: (state: 'open' | 'error') => void,
): () => void {
if (typeof EventSource === 'undefined') return () => undefined;
const source = new EventSource(this.eventUrl(), { withCredentials: true });
source.onopen = () => onState('open');
source.onerror = () => onState('error');
const receive = (type: string) => (event: MessageEvent<string>) => {
try {
const parsed = JSON.parse(event.data) as unknown;
const eventType = parsed && typeof parsed === 'object' && 'type' in parsed && typeof (parsed as { type: unknown }).type === 'string'
? (parsed as { type: string }).type
: type;
const data = parsed && typeof parsed === 'object' && 'data' in parsed
? (parsed as { data: unknown }).data
: parsed;
onEvent({ type: eventType, id: event.lastEventId || undefined, data });
} catch {
onEvent({ type: 'malformed', id: event.lastEventId || undefined, data: event.data });
}
};
source.onmessage = receive('message');
[
'server-status',
'player-changed',
'policy-published',
'audit',
'operation',
'moderation',
'claim',
'world-change',
'triggers-changed',
'menus-changed',
'economy-changed',
'trigger-executed',
].forEach((type) => source.addEventListener(type, receive(type)));
return () => source.close();
}
}
export const api = new ApiClient();