XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

XFEServerManager

【Java】我的世界XFE服务器管理器

公开
关注 0 Fork 0 Star 0
UTF-8
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();