improve api structure
This commit is contained in:
parent
29eb856a45
commit
fb1c015d9a
10
README.md
10
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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue