@nitra/vite-boot
Version:
205 lines (182 loc) • 7.73 kB
JavaScript
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()
}
}