diff --git a/README.md b/README.md index 0d6babe..008c8b8 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,16 @@ This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. +## Environment variables + +Copy `.env.example` to `.env` (or provide the same variables through your deployment system) and adjust the URLs if needed. All frontend traffic (админка, загрузка, просмотр) завязан на `VITE_API_BASE_URL`, поэтому указывайте полный путь до `/api/v1` нужной ноды. + +``` +VITE_API_BASE_URL=https://my-public-node-8.projscale.dev/api/v1 +``` + +При таком значении фронт автоматически отправляет tus-запросы на `https://my-public-node-8.projscale.dev/tus/files`, а прогрессивные загрузки (обложки, метаданные) — на `https://my-public-node-8.projscale.dev/api/v1.5/storage`. + Currently, two official plugins are available: - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh diff --git a/src/shared/libs/request/index.ts b/src/shared/libs/request/index.ts index 25df920..835721f 100644 --- a/src/shared/libs/request/index.ts +++ b/src/shared/libs/request/index.ts @@ -1,18 +1,8 @@ -import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios'; +import axios, { InternalAxiosRequestConfig } from 'axios'; import { getAdminAuthSnapshot } from '~/shared/libs/admin-auth'; const API_BASE_PATH = '/api/v1'; -const API_ENDPOINTS = [ - 'https://my-public-node-8.projscale.dev', - 'http://localhost:3000', -] as const; - -const API_BASE_URLS = API_ENDPOINTS.map((endpoint) => { - return `${endpoint.replace(/\/$/, '')}${API_BASE_PATH}`; -}); - -const ENDPOINT_INDEX_KEY = '__apiEndpointIndex__'; const isBrowser = typeof window !== 'undefined'; @@ -28,10 +18,23 @@ const readStorage = (key: string) => { } }; -type RequestConfig = InternalAxiosRequestConfig & { - [ENDPOINT_INDEX_KEY]?: number; +type RequestConfig = InternalAxiosRequestConfig; + +const resolveDefaultBaseUrl = () => { + const envBase = (import.meta.env.VITE_API_BASE_URL ?? '').trim(); + if (envBase) { + return envBase.replace(/\/$/, ''); + } + + if (isBrowser) { + return `${window.location.origin.replace(/\/$/, '')}${API_BASE_PATH}`; + } + + return API_BASE_PATH; }; +const DEFAULT_API_BASE_URL = resolveDefaultBaseUrl(); + export const request = axios.create({ withCredentials: true }); request.interceptors.request.use((config: RequestConfig) => { @@ -54,49 +57,11 @@ request.interceptors.request.use((config: RequestConfig) => { } } - const hasCustomBaseUrl = Boolean(config.baseURL && !API_BASE_URLS.includes(config.baseURL)); - if (hasCustomBaseUrl) { - return config; + if (!config.baseURL) { + config.baseURL = DEFAULT_API_BASE_URL; } - const attempt = typeof config[ENDPOINT_INDEX_KEY] === 'number' ? (config[ENDPOINT_INDEX_KEY] as number) : 0; - config[ENDPOINT_INDEX_KEY] = attempt; - config.baseURL = API_BASE_URLS[attempt] ?? API_BASE_URLS[0]; - return config; }); -request.interceptors.response.use( - (response) => response, - async (error: AxiosError) => { - const config = error.config as RequestConfig | undefined; - - if (!config) { - return Promise.reject(error); - } - - const hasCustomBaseUrl = Boolean(config.baseURL && !API_BASE_URLS.includes(config.baseURL)); - if (hasCustomBaseUrl) { - return Promise.reject(error); - } - - const isAbortError = error.code === 'ERR_CANCELED' || error.message === 'canceled'; - const isNetworkError = !error.response && !isAbortError; - - if (!isNetworkError) { - return Promise.reject(error); - } - - const currentIndex = typeof config[ENDPOINT_INDEX_KEY] === 'number' ? (config[ENDPOINT_INDEX_KEY] as number) : 0; - const nextIndex = currentIndex + 1; - - if (nextIndex >= API_BASE_URLS.length) { - return Promise.reject(error); - } - - config[ENDPOINT_INDEX_KEY] = nextIndex; - config.baseURL = API_BASE_URLS[nextIndex]; - - return request(config); - }, -); +request.interceptors.response.use((response) => response); diff --git a/src/shared/services/content/index.ts b/src/shared/services/content/index.ts index fd18224..23fa10f 100644 --- a/src/shared/services/content/index.ts +++ b/src/shared/services/content/index.ts @@ -3,6 +3,15 @@ import { useMutation, useQuery } from "react-query"; import { request } from "~/shared/libs"; import { Royalty } from "~/shared/stores/root"; +const VIEW_CONTENT_NODE_ORIGINS = [ + "https://my-public-node-8.projscale.dev", + "https://my-public-node-7.projscale.dev", + "https://my-public-node-9.projscale.dev", + "https://my-public-node-10.projscale.dev", + "http://localhost:3000", +] as const; +const VIEW_CONTENT_API_PATH = "/api/v1"; + type UseCreateNewContentPayload = { title: string; content: string; @@ -76,12 +85,34 @@ export const useCreateNewContent = () => { export const useViewContent = (contentId: string | null | undefined) => { return useQuery( ["view", "content", contentId], - () => { - return request.get(`/content.view/${contentId}`, { - headers: { - Authorization: localStorage.getItem('auth_v1_token') ?? "" + async () => { + if (!contentId) { + throw new Error("contentId is required"); + } + + let lastError: unknown = null; + for (const origin of VIEW_CONTENT_NODE_ORIGINS) { + const baseURL = `${origin.replace(/\/$/, "")}${VIEW_CONTENT_API_PATH}`; + try { + const response = await request.get(`/content.view/${contentId}`, { + baseURL, + }); + if (origin !== VIEW_CONTENT_NODE_ORIGINS[0]) { + console.info("[App2Client][ContentView] Используем резервную ноду", { + origin, + }); } - }); + return response; + } catch (error) { + lastError = error; + console.warn("[App2Client][ContentView] Нода недоступна", { + origin, + error, + }); + } + } + + throw lastError ?? new Error("All content nodes are unreachable"); }, { enabled: Boolean(contentId), diff --git a/src/shared/services/file/index.ts b/src/shared/services/file/index.ts index 93074cc..857dd02 100644 --- a/src/shared/services/file/index.ts +++ b/src/shared/services/file/index.ts @@ -4,9 +4,50 @@ import { Upload } from "tus-js-client"; import { request } from "~/shared/libs"; -const STORAGE_API_URL = '/api/v1.5/storage'; +const resolveApiBaseUrl = () => { + const envBase = (import.meta.env.VITE_API_BASE_URL ?? "").trim(); + if (envBase) { + return envBase.replace(/\/$/, ""); + } + + if (typeof window !== "undefined") { + return `${window.location.origin.replace(/\/$/, "")}/api/v1`; + } + + return "/api/v1"; +}; + +const DEFAULT_API_BASE_URL = resolveApiBaseUrl(); + +const resolveUploadEndpoints = () => { + try { + const apiUrl = new URL(DEFAULT_API_BASE_URL); + const origin = apiUrl.origin.replace(/\/$/, ""); + return { + storage: `${origin}/api/v1.5/storage`, + tus: `${origin}/tus/files`, + }; + } catch (error) { + if (typeof window !== "undefined") { + const origin = window.location.origin.replace(/\/$/, ""); + return { + storage: `${origin}/api/v1.5/storage`, + tus: `${origin}/tus/files`, + }; + } + + console.warn("[App2Client][Upload] Cannot derive upload endpoints from VITE_API_BASE_URL", { + error, + }); + return { + storage: '/api/v1.5/storage', + tus: '/tus/files', + }; + } +}; + +const { storage: STORAGE_API_URL, tus: TUS_ENDPOINT } = resolveUploadEndpoints(); const MAX_CHUNK_SIZE = 80 * 1024 * 1024; // 80 MB -const TUS_ENDPOINT = '/tus/files'; const TUS_STATUS_POLL_INTERVAL_MS = 2000; const TUS_STATUS_POLL_TIMEOUT_MS = 5 * 60 * 1000;