92 lines
5.2 KiB
TypeScript
92 lines
5.2 KiB
TypeScript
import { ApiError, decodeEnvelope } from "./protocol";
|
||
import type { Envelope, HeaderNavigation, HomeData } from "./types";
|
||
import { cache } from "react";
|
||
import { headers } from 'next/headers';
|
||
import { publicCachePolicy, publicDataCache } from './public-cache';
|
||
import { getApiBaseUrl, usesCurrentApiOrigin, UPSTREAM_HOP_HEADER, type ApiRequestContext } from './config';
|
||
|
||
export { DEFAULT_API_BASE_URL } from './config';
|
||
|
||
export const PUBLIC_PATHS = new Set([
|
||
"playlet/home", "config/info", "config/footer/info", "config/richtext/list",
|
||
"navigation/header", "theme/list", "playlet/tag/options", "translation/category/list",
|
||
"applist/list", "playlet/all/list", "playlet/hot/list",
|
||
"playlet/hot/rank/list", "playlet/topic/list", "playlet/topic/detail", "playlet/tag/list",
|
||
"playlet/guess-like/list", "playlet/hot-like",
|
||
]);
|
||
const GET_PATHS = [
|
||
...PUBLIC_PATHS, "auth/getInfo", "playlet/detail", "playlet/chapter/list", "playlet/chapter/detail",
|
||
"playlet/search", "playlet/search/suggest",
|
||
];
|
||
const POST_PATHS = [
|
||
"auth/guest",
|
||
"playlet/collect/set", "playlet/like/set", "playlet/chapter/collect/set", "playlet/chapter/like/set",
|
||
"playlet/chapter/watch/report", "playlet/forward/heat", "ads/click",
|
||
];
|
||
export function allowedRequest(path: string, method: string): boolean {
|
||
return (method === "GET" ? GET_PATHS : method === "POST" ? POST_PATHS : []).includes(path);
|
||
}
|
||
|
||
export async function getServerApiBaseUrl(request?: ApiRequestContext): Promise<string> {
|
||
return getApiBaseUrl(request || (usesCurrentApiOrigin() ? {headers: await headers()} : undefined));
|
||
}
|
||
|
||
export async function upstream<T>(path: string, options: {method?: string; body?: unknown; token?: string; device?: string; userAgent?: string; clientIp?: string; timeoutMs?:number; request?:ApiRequestContext} = {}): Promise<Envelope<T>> {
|
||
const url=new URL(`/api/app/${path}`,await getServerApiBaseUrl(options.request));
|
||
const policy=publicCachePolicy(url.pathname.slice('/api/app/'.length));
|
||
if(policy&&(!options.method||options.method==='GET')&&!options.token&&options.body===undefined){
|
||
url.searchParams.sort();
|
||
return publicDataCache.read(url.href,policy.ttl,()=>requestUpstream<T>(url,options),{stale:policy.stale,accept:result=>result.code===200&&!!result.data&&typeof result.data==='object'&&!Array.isArray(result.data)});
|
||
}
|
||
return requestUpstream<T>(url,options);
|
||
}
|
||
|
||
async function requestUpstream<T>(url:URL,options:Parameters<typeof upstream>[1]={}):Promise<Envelope<T>> {
|
||
const device = (options.device || "server-public").replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 100);
|
||
const rawUA = options.userAgent || "91-drama-web/1.0";
|
||
const sys = /Android/i.test(rawUA) ? "h5_android" : /iPhone|iPad|iPod/i.test(rawUA) ? "h5_ios" : "h5_pc";
|
||
const headers: Record<string,string> = {
|
||
Accept: "application/json", "User-Agent": rawUA,
|
||
[UPSTREAM_HOP_HEADER]: '1',
|
||
"X-User-Agent": `DevID=${device};DevType=OTHER;SysType=${sys};Ver=1.0.0;BuildID=web`,
|
||
};
|
||
if (options.token) headers.Authorization = options.token;
|
||
if (options.clientIp) headers['X-Real-IP'] = options.clientIp;
|
||
const multipart = options.body instanceof FormData;
|
||
if (options.body && !multipart) headers["Content-Type"] = "application/json";
|
||
let response: Response;
|
||
try {
|
||
response = await fetch(url, {
|
||
method: options.method || "GET", headers, cache: "no-store", redirect: "error",
|
||
body: options.body ? multipart ? options.body as FormData : JSON.stringify(options.body) : undefined,
|
||
signal: AbortSignal.timeout(options.timeoutMs||20000),
|
||
});
|
||
} catch (error) {
|
||
const timeout = error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError");
|
||
throw new ApiError(timeout ? "接口请求超时,请稍后重试" : "接口连接失败,请稍后重试", timeout ? 504 : 502);
|
||
}
|
||
if (!response.ok) {
|
||
if (response.status === 502 && response.headers.get(UPSTREAM_HOP_HEADER) === '1') throw new ApiError('上游请求返回本站,请检查 Nginx 的 /api/app/ 后端转发配置',502);
|
||
throw new ApiError(`接口暂时不可用(${response.status})`, response.status);
|
||
}
|
||
try { return decodeEnvelope<T>(await response.json()); }
|
||
catch (error) { if (error instanceof ApiError) throw error; throw new ApiError("接口返回数据无法解析", 502); }
|
||
}
|
||
|
||
export const getHome = cache(async (): Promise<{data: HomeData; error?: string}> => {
|
||
try {
|
||
const result = await upstream<HomeData>("playlet/home");
|
||
if (result.code !== 200) throw new ApiError(result.tip || result.msg || "首页加载失败",result.code);
|
||
if (!result.data || typeof result.data !== 'object' || Array.isArray(result.data)) throw new ApiError('首页返回数据无效',502);
|
||
return {data: result.data};
|
||
} catch (error) { return {data: {}, error: error instanceof Error ? error.message : "首页加载失败"}; }
|
||
});
|
||
|
||
export const getHeaderNavigation=cache(async ():Promise<{data:HeaderNavigation;error?:boolean}>=>{
|
||
try{
|
||
const response=await upstream<HeaderNavigation>('navigation/header',{timeoutMs:5000});
|
||
if(response.code!==200||!response.data||typeof response.data!=='object')throw new Error('导航加载失败');
|
||
return {data:response.data};
|
||
}catch{return {data:{categoryList:[]},error:true};}
|
||
});
|