@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);
Все 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
}
}
code, не по messagee.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?), CursorPage | Cursor-пагинация |
subscribeToSse(url, handlers, opts?), parseSseData, reconnectDelay | SSE-консьюмер и хелперы |
SseHandlers, SseOptions, SseSubscription | Типы SSE |
decodeJwt, orgIdFromToken, realmRolesFromToken, JwtClaims | Чтение JWT-клеймов |
mapKeycloakRoles, KEYCLOAK_ROLE_ALIASES | Keycloak-роли → 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.
Подводные камни
Пути из @mineflow/api-client уже содержат /api/v1. Передавай только origin (https://api.mineflow.local) — иначе получишь /api/v1/api/v1/....
org_id в URL не передаётся — backend применяет scope по JWT (ADR-0020). orgIdFromToken нужен только для UI (показать организацию), не для авторизации.
fetch(Request, undefined)Метод, заголовки и тело лежат в объекте Request, а не в init (он undefined в web/RN). makeAuthenticatedFetch это уже нормализует — но если пишешь свой fetch-декоратор поверх ядра, читай метод/заголовки из new Request(input, init), иначе на write не поставится Idempotency-Key и потеряется Content-Type.
React Native
Ядро polyfill-friendly: всё нативно-зависимое инъектируется. Для RN:
generateId—react-native-get-random-values+ uuid (вместоcrypto.randomUUID);TokenProvider—@mineflow/auth-native(react-native-app-auth);subscribeToSse— дефолтный RN-fetch не отдаётReadableStreambody, поэтому передайfetchImplс потоковой поддержкой (react-native-fetch-api);- декодер JWT и нормализация ошибок работают без полифиллов (чистый JS).
Полная настройка RN-стенда — в рецепте «React Native».
Ссылки
- Полный API-референс из кода → /api/client-core/
- REST-референс (OpenAPI) → /rest/
- Обзор всего SDK → docs/frontend/client-sdk.md
- Связанные гайды:
@mineflow/client-react·@mineflow/api-client·@mineflow/auth-web·@mineflow/auth-native - Рецепты: