Перейти к основному содержимому

@mineflow/client-core

L1-ядро client-SDK (ADR-0042): типизированный fetch поверх @mineflow/api-client, авто Idempotency-Key, нормализация ошибок (RFC 7807 → typed по code), polling саг, cursor-пагинация, fetch-based SSE и чтение JWT-клеймов. Чистый platform-agnostic слой — без DOM- и Node-зависимостей в рантайме, поэтому один и тот же код работает в React web и React Native.

Обычно ты этот пакет напрямую не импортируешь

В 95% случаев ядро используется через @mineflow/client-react: <MineflowProvider> сам строит клиент и аутентифицированный fetch, а хуки (useAssets, useApproveShiftReport, useNotificationFeed, …) оборачивают pollSaga/collectAllPages/subscribeToSse. Прямой импорт из client-core нужен для не-React кода: скриптов, CLI, фоновых задач и продвинутых сценариев, где провайдера нет.

Что это и зачем

В слоистой архитектуре SDK (см. обзор) ядро лежит между codegen-типами L0 и React-хуками L2. Оно реализует то, что одинаково для всех платформ и UI: как формируется аутентифицированный запрос, как из ответа извлекаются данные или ошибка, как опрашивается async-сага, как стягиваются страницы и как читается live-фид.

Всё платформо-специфичное (источник токена, генератор UUID, хранилище) инъектируется через портыTokenProvider, IdGenerator, TokenStorage. Ядро ничего не дёргает из crypto, localStorage или EventSource, поэтому остаётся переносимым между web, RN и Node.

Установка

pnpm add @mineflow/client-core
pnpm add zod # peer (для типов; рантайм-зависимость — openapi-fetch — встроена)

Рантайм-зависимости (openapi-fetch, @mineflow/api-client, @mineflow/api-schemas) тянутся автоматически. В монорепо MineFlow пакет резолвится как workspace:*.

Использование

REST-клиент

createMineflowClient — all-in-one: строит аутентифицированный fetch и openapi-fetch-клиент за один вызов. Пути из @mineflow/api-client уже содержат префикс /api/v1, поэтому baseUrl — это голый origin API.

import { createMineflowClient, unwrap } from '@mineflow/client-core';

const client = createMineflowClient({
baseUrl: 'https://api.mineflow.local', // origin; пути включают /api/v1
tokenProvider: { getToken: () => keycloak.token ?? null },
generateId: () => crypto.randomUUID(),
});

// openapi-fetch: типобезопасные методы (.GET / .POST / .PATCH / .DELETE)
const res = await client.GET('/api/v1/eam/assets', { params: { query: { limit: 50 } } });
const page = unwrap(res); // → данные или throw MineflowApiError

createMineflowClient принимает опциональный fetchImpl — базовый fetch для тестов или RN-полифиллов.

Если аутентифицированный fetch уже собран (так делает <MineflowProvider>, чтобы переиспользовать один fetch и для REST, и для SSE), бери низкоуровневый createRestClient:

import { createRestClient, makeAuthenticatedFetch } from '@mineflow/client-core';

const authedFetch = makeAuthenticatedFetch({
tokenProvider,
generateId: () => crypto.randomUUID(),
});
const client = createRestClient('https://api.mineflow.local', authedFetch);
Idempotency-Key проставляется автоматически

Все write-методы (POST/PUT/PATCH/DELETE) получают Authorization: Bearer … и Idempotency-Key (ADR-0012) внутри makeAuthenticatedFetch — руками передавать не нужно. Если вызывающий задал непустой ключ сам, он сохраняется (retry-with-same-key). Сгенерированный ключ переиспользуется при повторе после 401 — это та же логическая операция, бэк дедуплицирует по нему.

Ошибки

Бэк отдаёт RFC 7807 Problem Details. unwrap() превращает openapi-fetch-результат { data, error, response } в данные либо бросает типизированную MineflowApiError.

import { MineflowApiError, unwrap } from '@mineflow/client-core';

try {
unwrap(await client.POST('/api/v1/eam/assets/{id}/decommission', { /* ... */ }));
} catch (e) {
if (e instanceof MineflowApiError) {
e.status; // 409
e.code; // 'ASSET_INVALID_STATUS_TRANSITION' — стабильный машинный код
e.is('ASSET_INVALID_STATUS_TRANSITION'); // true
e.detail; // для 422 — { errors: [...] } (Zod-ошибки полей)
e.message; // человекочитаемый title
e.problem; // полный объект ProblemDetails
}
}
warning
Гейти UI по code, не по message

e.message (= title) локализован и может меняться. Стабилен только e.code — машинный код домена из бэковых *.errors.ts. Для прямого сравнения есть e.is(code). Подробный разбор статусов и кодов — в рецепте «Обработка ошибок».

Если нужна нормализация вне openapi-fetch (например, для своего HTTP-вызова), используй normalizeError(status, body, url?) напрямую — он распознаёт RFC 7807 и деградирует к { message }/{ code }-телам.

Саги (async-операции)

Некоторые POST возвращают 202 + sagaId и выполняются асинхронно. pollSaga опрашивает GET /api/v1/sagas/{sagaId}/status до терминального состояния.

import { pollSaga, SagaTimeoutError, unwrap } from '@mineflow/client-core';

const { sagaId } = unwrap(
await client.POST('/api/v1/prd/shift-reports/{id}/approve', { /* ... */ }),
);

try {
const final = await pollSaga(client, sagaId, { intervalMs: 1000, timeoutMs: 60_000 });
// final.steps — хронология шагов
} catch (e) {
if (e instanceof SagaTimeoutError) {
e.sagaId; // зависшая сага
e.timeoutMs; // лимит, который не уложился
}
}
примечание
saga_log — append-only

Для одного шага в ответе сосуществуют строки running и completed, поэтому статус саги вычисляется по последнему статусу каждого шага. За это отвечают экспортируемые latestStatusByStep (сворачивает строки в Map<stepName, status>) и defaultIsSettled (терминал = любой шаг failed/compensated, иначе все шаги completed). При нестандартной семантике передай свой предикат через PollSagaOptions.isSettled.

В React эту механику делает useSagaMutation / useApproveShiftReport из @mineflow/client-react. Детали полного жизненного цикла — в рецепте «Саги».

Cursor-пагинация

Все list-эндпоинты MineFlow отдают { items, nextCursor }. collectAllPages стягивает все страницы в один массив с предохранителем maxPages (по умолчанию 1000) от битого курсора.

import { collectAllPages, unwrap } from '@mineflow/client-core';

const all = await collectAllPages((cursor) =>
unwrap(client.GET('/api/v1/eam/assets', { params: { query: { cursor: cursor ?? undefined } } })),
);

Постраничная навигация для UI (а не стягивание всего) — в рецепте «Пагинация».

SSE (live-фид)

subscribeToSse читает text/event-stream из ReadableStream обычным fetch — потому что браузерный EventSource не умеет слать Authorization. Передай аутентифицированный fetch через fetchImpl.

import { subscribeToSse } from '@mineflow/client-core';

const sub = subscribeToSse(
`${baseUrl}/api/v1/notifications/stream`,
{
onMessage: (n) => console.log(n),
onOpen: () => console.log('connected'),
onError: (e) => console.warn(e),
},
{ fetchImpl: authenticatedFetch }, // fetch с Bearer
);
sub.close();

Авто-reconnect — экспоненциальный backoff с jitter (reconnectDelayMs=1000 по умолчанию, потолок maxReconnectDelayMs=30000, backoffFactor=2); счётчик сбрасывается после успешного onOpen. reconnectDelayMs: 0 отключает переподключение. В React — useNotificationFeed. Полный поток с REST-backlog для пропущенного — в рецепте «SSE».

JWT и роли

Декодер decodeJwt — чистый base64url→UTF-8 без atob/Buffer (читает не-ASCII клеймы, напр. кириллицу в ФИО). Подпись не проверяется — это делает сервер; фронт читает клеймы только для UI/scope.

import { decodeJwt, orgIdFromToken, realmRolesFromToken } from '@mineflow/client-core';

orgIdFromToken(token); // organization_id (claim org_id, ADR-0020) | null
realmRolesFromToken(token); // ['foreman', ...] (lowercase realm-роли)
decodeJwt(token); // полный JwtClaims

Realm-роли приходят как lowercase-алиасы Keycloak; канонические SystemRole (PascalCase) получаются маппингом:

import { mapKeycloakRoles, KEYCLOAK_ROLE_ALIASES } from '@mineflow/client-core';

mapKeycloakRoles(['foreman', 'mechanic']); // → ['Foreman', 'Mechanic'] (дедуп, неизвестные отброшены)
KEYCLOAK_ROLE_ALIASES; // { ceo:'CEO', engineer:'Engineer', foreman:'Foreman', ... } — 7 системных ролей
Словарь ролей живёт в ядре намеренно

KEYCLOAK_ROLE_ALIASES — единственная допущенная во фронт-SDK копия серверного keycloak-role-map, чтобы web (auth-web) и RN (auth-native) адаптеры не дублировали словарь и не было RN→web зависимости. При добавлении 8-й роли на бэке его надо обновить — это сторожит тест role-map.spec. Гейтинг UI по ролям — в рецепте «RBAC».

Ключевые экспорты

СимволНазначение
createMineflowClient(opts)All-in-one: аутентифицированный fetch + REST-клиент
createRestClient(baseUrl, fetch)Клиент поверх готового fetch (переиспользование для REST+SSE)
makeAuthenticatedFetch(opts)fetch-декоратор: Bearer + авто Idempotency-Key + retry-once на 401
MineflowClientТип openapi-fetch-клиента (Client<paths>)
MineflowApiError, ProblemDetailsТипизированная ошибка (RFC 7807) и её форма
normalizeError(status, body, url?), unwrap(result)Нормализация и извлечение данных/ошибки
pollSaga(client, sagaId, opts?)Опрос async-саги до терминала
defaultIsSettled, latestStatusByStep, hasTerminalFailureПредикаты статуса саги
SagaTimeoutError, SagaStatus, SagaStepStatus, SagaStatusClient, PollSagaOptionsТипы/ошибка саг
collectAllPages(fetchPage, opts?), CursorPageCursor-пагинация
subscribeToSse(url, handlers, opts?), parseSseData, reconnectDelaySSE-консьюмер и хелперы
SseHandlers, SseOptions, SseSubscriptionТипы SSE
decodeJwt, orgIdFromToken, realmRolesFromToken, JwtClaimsЧтение JWT-клеймов
mapKeycloakRoles, KEYCLOAK_ROLE_ALIASESKeycloak-роли → canonical SystemRole
TokenProvider, IdGenerator, TokenStorage, GetTokenOptions, SystemRoleПорты адаптеров

Полный список сигнатур и типов — в API-референсе из кода.

Порты (адаптеры)

Платформо-специфичное инъектируется через три порта:

import type { TokenProvider, IdGenerator, TokenStorage } from '@mineflow/client-core';

// Поставщик access-token'а. web: keycloak-js; RN: react-native-app-auth.
interface TokenProvider {
getToken(opts?: { forceRefresh?: boolean }): Promise<string | null> | string | null;
getRoles?(): readonly SystemRole[] | Promise<readonly SystemRole[]>;
}

// Генератор Idempotency-Key. web: crypto.randomUUID; RN: react-native-get-random-values + uuid.
type IdGenerator = () => string;

// Хранилище. web: localStorage; RN: expo-secure-store.
interface TokenStorage {
get(key: string): string | null | Promise<string | null>;
set(key: string, value: string): void | Promise<void>;
remove(key: string): void | Promise<void>;
}

getToken принимает { forceRefresh: true } — ядро выставляет этот флаг реактивно при ответе 401, чтобы адаптер форснул refresh токена и makeAuthenticatedFetch повторил запрос ровно один раз. Адаптеры без force-refresh вправе игнорировать флаг — деградация безопасна. Готовые реализации TokenProvider@mineflow/auth-web и @mineflow/auth-native.

Подводные камни

baseUrl — это origin, без пути

Пути из @mineflow/api-client уже содержат /api/v1. Передавай только origin (https://api.mineflow.local) — иначе получишь /api/v1/api/v1/....

Object-scope и multi-tenancy делает сервер

org_id в URL не передаётся — backend применяет scope по JWT (ADR-0020). orgIdFromToken нужен только для UI (показать организацию), не для авторизации.

warning
openapi-fetch вызывает кастомный fetch как fetch(Request, undefined)

Метод, заголовки и тело лежат в объекте Request, а не в init (он undefined в web/RN). makeAuthenticatedFetch это уже нормализует — но если пишешь свой fetch-декоратор поверх ядра, читай метод/заголовки из new Request(input, init), иначе на write не поставится Idempotency-Key и потеряется Content-Type.

React Native

Ядро polyfill-friendly: всё нативно-зависимое инъектируется. Для RN:

  • generateIdreact-native-get-random-values + uuid (вместо crypto.randomUUID);
  • TokenProvider@mineflow/auth-native (react-native-app-auth);
  • subscribeToSse — дефолтный RN-fetch не отдаёт ReadableStream body, поэтому передай fetchImpl с потоковой поддержкой (react-native-fetch-api);
  • декодер JWT и нормализация ошибок работают без полифиллов (чистый JS).

Полная настройка RN-стенда — в рецепте «React Native».

Ссылки