UNPKG

@nitra/vite-boot

Version:
205 lines (182 loc) 7.73 kB
import { ApolloClient, ApolloLink, HttpLink, InMemoryCache } from '@apollo/client/core' import { CombinedGraphQLErrors, ServerError } from '@apollo/client/errors' import { ErrorLink } from '@apollo/client/link/error' import { GraphQLWsLink } from '@apollo/client/link/subscriptions' import { getMainDefinition, Observable } from '@apollo/client/utilities' import { createClient } from 'graphql-ws' import { checkedToken, cleanToken, user } from './user.js' export { gql } from '@apollo/client/core' export { apolloPlugin, useApolloClient, useMutation, useQuery, useSubscription } from '@vue3-apollo/core' /** * Формує заголовки авторизації для запиту * @returns {Record<string, string>} Заголовки з токеном та роллю або порожній обʼєкт */ function getAuthHeaders() { // Якщо є токен - додаємо його в заголовки if (checkedToken) { return { Authorization: `Bearer ${checkedToken}`, 'x-hasura-role': user.role } } return {} } const httpLink = new HttpLink({ uri: `${import.meta.env.VITE_HASURA_PROTOCOL === 'wss' ? 'https' : 'http'}://${ import.meta.env.VITE_HASURA_HOST }/v1/graphql` }) /** * Ініціалізуємо логаут * @returns {void} */ export function logout() { cleanToken() // Очищаємо Apollo Client кеш і закриваємо зʼєднання resetApolloClient() const logoutUrl = `${import.meta.env.BASE_URL}logout` // Якщо не на сторінці логауту, то перенаправляємо на сторінку логауту if (globalThis.location.pathname !== logoutUrl) { globalThis.location.href = logoutUrl } } /** * Готує заголовки з JWT та роллю Hasura * @param {Record<string, string>} headers - Початкові заголовки запиту * @returns {{headers: Record<string, string>}} - Обʼєкт з підготовленими заголовками */ // Додаємо токен і роль до кожного HTTP-запиту за допомогою ApolloLink const authLink = new ApolloLink((operation, forward) => { const existingHeaders = operation.getContext().headers || {} operation.setContext({ headers: { ...existingHeaders, ...getAuthHeaders() } }) return forward(operation) }) /** * @typedef {object} AuthRefreshResult * @property {boolean} ok - Чи вдалося оновити токен. * @property {Error} [error] - Помилка для проброса замість дефолтної (напр. заблокований акаунт). */ /** @type {null | ((ctx: { error: unknown }) => Promise<AuthRefreshResult>)} */ let authRefreshHandler = null /** * Реєструє reactive refresh-обробник, який викликається при 401 / JWTExpired. * Поки обробник не зареєстровано, errorLink не робить нічого — поведінка * лишається попередньою (backward-compatible для всіх консюмерів бібліотеки). * @param {(ctx: { error: unknown }) => Promise<AuthRefreshResult>} fn - Refresh-функція застосунку. * @returns {void} */ export function setAuthRefresh(fn) { authRefreshHandler = fn } /** * Чи свідчить помилка про протермінований / невалідний JWT (401 або GraphQL JWT-помилка). * @param {unknown} error - Помилка з Apollo ErrorLink. * @returns {boolean} - true, якщо потрібен refresh токена. */ function isAuthError(error) { if (ServerError.is(error) && error.statusCode === 401) { return true } if (CombinedGraphQLErrors.is(error)) { return error.errors.some((e) => /jwt|JWTExpired|Could not verify JWT/i.test(String(e?.message || e?.extensions?.code || '')) ) } return false } // На 401 / JWTExpired викликаємо інжектований refresh і прозоро ретраїмо операцію. // Контекст `skipAuthRefresh` дозволяє вимкнути refresh для конкретного запиту; // `authRefreshAttempted` боронить від нескінченного циклу повторів. const errorLink = new ErrorLink(({ error, operation, forward }) => { if (!authRefreshHandler || !isAuthError(error)) { return } const ctx = operation.getContext() if (ctx.skipAuthRefresh || ctx.authRefreshAttempted) { return } return new Observable((observer) => { /** @type {{ unsubscribe(): void } | undefined} */ let sub Promise.resolve(authRefreshHandler({ error })) .then((result) => { if (!result?.ok) { observer.error(result?.error || error) return } // authLink далі по ланцюгу підставить свіжий checkedToken у заголовки. operation.setContext({ ...operation.getContext(), authRefreshAttempted: true }) sub = forward(operation).subscribe(observer) }) .catch((refreshError) => observer.error(refreshError)) return () => { if (sub) { sub.unsubscribe() } } }) }) export const wsClient = createClient({ url: `${import.meta.env.VITE_HASURA_PROTOCOL}://${import.meta.env.VITE_HASURA_HOST}/v1/graphql`, connectionParams: () => ({ headers: getAuthHeaders() }), // Обробник успішного підключення on: { // Обробник помилок підключення /** @param {unknown} error - Помилка WebSocket з'єднання */ error: (error) => { console.error('WebSocket помилка:', error) }, // Обробник закриття з'єднання /** @param {{ code: number, reason: string }} event - Подія закриття WebSocket */ closed: (event) => { console.info('WebSocket закрито:', event.code, event.reason) // Коди помилок аутентифікації if (event.code === 4401) { console.error('Неавторизований: неправильний токен') } else if (event.code === 4403) { console.error('Заборонено: недостатньо прав') } } }, lazy: true, // відкрити сокет тільки при першій subscription lazyCloseTimeout: 15_000, // дочекатися 15 с після останньої відписки і лише тоді закрити сокет keepAlive: 10_000, // ping кожні 10 с, щоб тримати з’єднання живим retryAttempts: Infinity // автоперепідключення без ліміту }) const wsLink = new GraphQLWsLink(wsClient) // Розділяємо трафік: subscription -> WS, інше -> HTTP const link = ApolloLink.split( ({ query }) => { const def = getMainDefinition(query) return def.kind === 'OperationDefinition' && def.operation === 'subscription' }, wsLink, ApolloLink.from([errorLink, authLink, httpLink]) ) // Ініціалізуємо клієнт export const apolloClient = new ApolloClient({ link, cache: new InMemoryCache() }) /** * Повне скидання Apollo Client (для критичних випадків) */ export function resetApolloClient() { if (apolloClient) { // Очищаємо кеш apolloClient.clearStore() // Закриваємо всі активні запити apolloClient.stop() } // Закриваємо WebSocket з'єднання if (wsClient && wsClient.dispose) { wsClient.dispose() } }