React Native
SDK MineFlow — platform-agnostic (ADR-0042): ядро (@mineflow/client-core) и хуки (@mineflow/client-react) одинаково работают в web и React Native. Всё, что зависит от платформы, ядро не знает и получает через инъектируемые порты:
| Порт | web | React Native |
|---|---|---|
tokenProvider | KeycloakTokenProvider | ReactNativeTokenProvider (react-native-app-auth) |
generateId | crypto.randomUUID | react-native-get-random-values / expo-crypto |
fetchImpl | глобальный fetch | дефолтный RN-fetch (опц.; realtime — нативный WebSocket) |
Хуки (useAssets, useCreateShiftReport, useRealtimeNotifications, …) переиспользуются как есть — TanStack Query работает в RN. UI пишется на RN-примитивах (View/Text/Pressable), бизнес-логика не дублируется: «share logic, not pixels».
Этот рецепт — про сборку RN-провайдера из портов. Сами хуки описаны в гайде client-react; auth-адаптер — в auth-native. Полный список экспортов ядра — /api/client-core/.
1. tokenProvider — нативный OIDC
keycloak-js работает только в браузере, поэтому в RN логин идёт нативным OIDC-флоу (react-native-app-auth), а SDK получает токен через ReactNativeTokenProvider из @mineflow/auth-native. Адаптер не зависит от react-native-app-auth напрямую — приложение прокидывает текущее состояние (getState) и функцию refresh; так адаптер тестируем и не привязан к конкретной OIDC-библиотеке (можно expo-auth-session).
pnpm add @mineflow/auth-native
pnpm add react-native-app-auth
import { authorize, refresh } from 'react-native-app-auth';
import { ReactNativeTokenProvider } from '@mineflow/auth-native';
const config = {
issuer: 'https://auth.mineflow.local/realms/mineflow',
clientId: 'mineflow-mobile',
redirectUrl: 'com.mineflow://oauthredirect',
scopes: ['openid', 'profile', 'roles'],
};
// Состояние хранит приложение (результат authorize/refresh).
let authState = await authorize(config);
const tokenProvider = new ReactNativeTokenProvider({
getState: () => authState,
refresh: async () => {
authState = await refresh(config, { refreshToken: authState.refreshToken! });
return authState;
},
// minValiditySeconds?: 30 — запас до истечения для упреждающего refresh
});
ReactNativeTokenProvider реализует порт TokenProvider:
getToken({ forceRefresh })— рефрешит токен, если он истекает в ближайшиеminValiditySeconds(по умолчанию 30) или приforceRefresh(его реактивно выставляет fetch-слой при ответе 401). Если refresh упал — отдаёт текущий токен, API ответит 401, UI инициирует повторный login.getRoles()— декодирует realm-роли из access-token (JWT) и маппит в каноническиеSystemRole(PascalCase). Используй для пропаrolesпровайдера.
tokenProvider и generateId провайдер сравнивает по ссылке — создавай ReactNativeTokenProvider один раз вне рендера (или в useState/useRef), иначе транспорт (REST-клиент) пересобирается при каждом ре-рендере.
Подробнее об опциях и NativeAuthState — auth-native и /api/auth-native/.
2. generateId — UUID для Idempotency-Key
Каждая write-операция несёт Idempotency-Key (ADR-0012); это свежий UUID на каждый вызов через порт generateId. В RN нет гарантированного crypto.randomUUID, поэтому его полифиллят.
pnpm add react-native-get-random-values
import 'react-native-get-random-values'; // импортируй в entrypoint, до всего остального
const generateId = () => crypto.randomUUID();
react-native-get-random-values полифиллит только crypto.getRandomValues, но не crypto.randomUUID. На Expo (Hermes) полагаться на нативный crypto.randomUUID небезопасно — используй expo-crypto, где randomUUID() гарантирован:
import * as Crypto from 'expo-crypto';
const generateId = () => Crypto.randomUUID();
3. fetchImpl — обычно не нужен
fetchImpl — опциональный проп MineflowProvider: он подменяет базовый REST-fetch. Дефолтный RN-fetch отлично справляется с REST, поэтому в большинстве приложений fetchImpl передавать не нужно — оставь дефолт.
Раньше здесь требовался потоковый fetchImpl для SSE-фида. С переходом на Centrifugo (ADR-0054) это неактуально: live-доставка идёт через нативный WebSocket (centrifuge), который RN/Hermes поддерживают из коробки. RealtimeProvider работает в RN без каких-либо настроек транспорта — см. рецепт «Realtime».
4. Сборка провайдера
Соберите три порта в один MineflowProvider. roles берётся из tokenProvider.getRoles() — провайдер сравнивает их по содержимому, поэтому inline-набор не вызывает лишних перестроений транспорта.
import { MineflowProvider } from '@mineflow/client-react';
import { useState, type ReactNode } from 'react';
export function AppProvider({ children }: { children: ReactNode }) {
// tokenProvider/generateId создаём один раз — сравниваются по ссылке.
const [provider] = useState(() => ({
tokenProvider,
generateId,
roles: tokenProvider.getRoles(),
}));
return (
<MineflowProvider
baseUrl={API_ORIGIN} // origin, без пути: эндпоинты уже содержат /api/v1
tokenProvider={provider.tokenProvider}
generateId={provider.generateId}
roles={provider.roles}
// fetchImpl не нужен: REST на дефолтном RN-fetch, realtime — нативный WebSocket
>
{children}
</MineflowProvider>
);
}
После этого все хуки работают как в web: чтение/мутации/саги/RBAC/FSM. Список — в гайде client-react.
5. Offline-gate — эталон оркестрации
Полевой пользователь работает без стабильной сети. Write-операции рапорта (ADR-0049) офлайн паузятся (не падают), очередь персистится и доигрывается строго по порядку при реконнекте/после рестарта. Механика — в объекте shiftReportDomain из @mineflow/client-react (подробнее — рецепт offline).
Эталон RN-оркестрации — apps/mobile/src/app/providers/offline-gate.tsx. Компонент рендерится внутри MineflowProvider, потому что ему нужны и QueryClient (useQueryClient), и API-клиент (useMineflowClient):
import { shiftReportDomain, useMineflowClient } from '@mineflow/client-react';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister';
import { onlineManager, useQueryClient, type Mutation } from '@tanstack/react-query';
import {
persistQueryClientRestore,
persistQueryClientSave,
persistQueryClientSubscribe,
} from '@tanstack/react-query-persist-client';
import { useEffect, type ReactNode } from 'react';
import { AppState } from 'react-native';
// Персистим только paused (офлайн) и failed write'ы с mutationKey.
// Саги mutationKey не несут → исключены.
function shouldDehydrateMutation(m: Mutation): boolean {
return (m.state.isPaused || m.state.status === 'error') && Array.isArray(m.options.mutationKey);
}
export function OfflineGate({ children }: { children: ReactNode }) {
const queryClient = useQueryClient();
const client = useMineflowClient();
// (1) replay-defaults — нужен API-клиент; idempotent при повторе.
// Зовётся ДО restore/resume: восстановленные мутации знают, как себя проиграть.
useEffect(() => {
shiftReportDomain.registerDefaults(queryClient, client);
}, [queryClient, client]);
// (2) restore + persist + resume — ровно один раз (queryClient стабилен).
useEffect(() => {
const persister = createAsyncStoragePersister({ storage: AsyncStorage });
const opts = {
queryClient,
persister,
maxAge: Infinity, // write-outbox НЕ должен протухать (worker мог быть офлайн >24ч)
buster: 'v2',
dehydrateOptions: { shouldDehydrateMutation },
};
let active = true;
let unsubscribePersist = (): void => {};
void persistQueryClientRestore(opts).then(() => {
if (!active) return;
unsubscribePersist = persistQueryClientSubscribe(opts);
void queryClient.resumePausedMutations();
});
// При возврате связи — повторно пнуть очередь.
const unsubscribeOnline = onlineManager.subscribe(() => {
if (onlineManager.isOnline()) void queryClient.resumePausedMutations();
});
// Flush при уходе в фон — чтобы только что добавленный write пережил kill.
const appStateSub = AppState.addEventListener('change', (s) => {
if (s === 'background' || s === 'inactive') void persistQueryClientSave(opts);
});
return () => {
active = false;
unsubscribePersist();
unsubscribeOnline();
appStateSub.remove();
};
}, [queryClient]);
return <>{children}</>;
}
registerDefaults зависит от API-клиента (re-run безопасен, setMutationDefaults идемпотентен). А restore/subscribe/resume зависит только от стабильного queryClient и выполняется ровно один раз: смена identity клиента (например, резолв Keycloak discovery) не должна повторно гидрировать персистнутый outbox — у гидрации нет per-mutation дедупа, она бы задвоила каждый write.
Подключение TanStack onlineManager к сети
Без явной привязки RN onlineManager считает приложение всегда онлайн → офлайн-мутации падали бы вместо паузы. Привяжи его к состоянию сети устройства (один раз, до первой мутации):
import { onlineManager } from '@tanstack/react-query';
import * as Network from 'expo-network';
export function setupOnlineManager(): void {
onlineManager.setEventListener((setOnline) => {
void Network.getNetworkStateAsync()
.then((s) => setOnline(!!s.isConnected && s.isInternetReachable !== false))
.catch(() => setOnline(true));
const sub = Network.addNetworkStateListener((s) =>
setOnline(!!s.isConnected && s.isInternetReachable !== false),
);
return () => sub.remove();
});
}