@@ -0,0 +1,144 @@
import type { ApiErrorBody } from "../types/auth" ;
export const AUTH_EXPIRED_EVENT = "tallynote-auth-expired" ;
const UNSAFE_METHODS = new Set ( [ "POST" , "PUT" , "PATCH" , "DELETE" ] ) ;
const AUTH_EXEMPT_PATHS = new Set ( [
"/api/auth/login" ,
"/api/auth/session" ,
] ) ;
const DEFAULT_REQUEST_TIMEOUT_MS = 30 _000 ;
const UPLOAD_REQUEST_TIMEOUT_MS = 120 _000 ;
export interface ApiRequestInit extends RequestInit {
timeoutMs? : number ;
}
export class ApiError extends Error {
readonly status : number ;
readonly code? : string ;
readonly details? : unknown ;
readonly requestId? : string ;
readonly retryAfter? : number ;
constructor ( status : number , message : string , code? : string , details? : unknown , requestId? : string , retryAfter? : number ) {
super ( message ) ;
this . name = "ApiError" ;
this . status = status ;
this . code = code ;
this . details = details ;
this . requestId = requestId ;
this . retryAfter = retryAfter ;
}
}
function readCookie ( name : string ) : string {
if ( typeof document === "undefined" ) return "" ;
const prefix = ` ${ name } = ` ;
const value = document . cookie . split ( ";" ) . map ( ( part ) = > part . trim ( ) ) . find ( ( part ) = > part . startsWith ( prefix ) ) ;
if ( ! value ) return "" ;
const raw = value . slice ( prefix . length ) ;
try {
return decodeURIComponent ( raw ) ;
} catch {
return raw ;
}
}
function requestPath ( url : string ) : string {
try {
return new URL ( url , typeof window === "undefined" ? "http://localhost" : window . location . origin ) . pathname ;
} catch {
return url . split ( "?" , 1 ) [ 0 ] ? ? url ;
}
}
function emitAuthExpired ( message : string ) : void {
if ( typeof window !== "undefined" ) {
window . dispatchEvent ( new CustomEvent ( AUTH_EXPIRED_EVENT , { detail : message } ) ) ;
}
}
export async function api < T = unknown > ( url : string , init : ApiRequestInit = { } ) : Promise < T > {
const { timeoutMs : requestedTimeout , signal : externalSignal , . . . requestInit } = init ;
const method = ( init . method ? ? "GET" ) . toUpperCase ( ) ;
const headers = new Headers ( init . headers ) ;
if ( init . body && ! ( init . body instanceof FormData ) && ! headers . has ( "Content-Type" ) ) {
headers . set ( "Content-Type" , "application/json" ) ;
}
if ( UNSAFE_METHODS . has ( method ) ) {
const csrf = readCookie ( "tally_csrf" ) ;
if ( csrf ) headers . set ( "X-CSRF-Token" , csrf ) ;
}
const controller = new AbortController ( ) ;
let timedOut = false ;
const timeoutMs = requestedTimeout ? ? ( init . body instanceof FormData ? UPLOAD_REQUEST_TIMEOUT_MS : DEFAULT_REQUEST_TIMEOUT_MS ) ;
const abortFromCaller = ( ) = > controller . abort ( externalSignal ? . reason ) ;
if ( externalSignal ? . aborted ) abortFromCaller ( ) ;
else externalSignal ? . addEventListener ( "abort" , abortFromCaller , { once : true } ) ;
const timeout = setTimeout ( ( ) = > {
timedOut = true ;
controller . abort ( ) ;
} , timeoutMs ) ;
try {
const response = await fetch ( url , { . . . requestInit , credentials : "include" , headers , signal : controller.signal } ) ;
if ( response . status === 204 ) return undefined as T ;
const contentType = response . headers . get ( "content-type" ) ? ? "" ;
const data : unknown = contentType . includes ( "json" )
? await response . json ( ) . catch ( ( ) = > ( { } ) )
: await response . text ( ) . catch ( ( ) = > "" ) ;
if ( ! response . ok ) {
const body = ( data && typeof data === "object" ? data : { } ) as ApiErrorBody ;
const error = body . error ;
if ( response . status === 401 && error ? . code === "AUTH_REQUIRED" && ! AUTH_EXEMPT_PATHS . has ( requestPath ( url ) ) ) {
emitAuthExpired ( error . message || "登录已失效,请重新登录" ) ;
}
throw new ApiError (
response . status ,
error ? . message || ` 请求失败( ${ response . status } ) ` ,
error ? . code ,
error ? . details ,
error ? . requestId ,
Number ( response . headers . get ( "retry-after" ) || 0 ) || undefined ,
) ;
}
return data as T ;
} catch ( caught ) {
if ( caught instanceof ApiError ) throw caught ;
if ( timedOut ) throw new ApiError ( 408 , "请求超时,请稍后重试" , "REQUEST_TIMEOUT" ) ;
if ( externalSignal ? . aborted ) throw new ApiError ( 0 , "请求已取消" , "REQUEST_CANCELED" ) ;
throw new ApiError ( 0 , "网络连接失败,请确认服务仍在运行" ) ;
} finally {
clearTimeout ( timeout ) ;
externalSignal ? . removeEventListener ( "abort" , abortFromCaller ) ;
}
}
export const apiGet = < T > ( url : string , init? : ApiRequestInit ) = > api < T > ( url , { . . . init , method : "GET" } ) ;
export const apiPost = < T > ( url : string , body? : unknown , init : ApiRequestInit = { } ) = > api < T > ( url , {
. . . init ,
method : "POST" ,
body : body === undefined ? init.body : body instanceof FormData ? body : JSON.stringify ( body ) ,
} ) ;
export const apiPut = < T > ( url : string , body? : unknown , init : ApiRequestInit = { } ) = > api < T > ( url , {
. . . init ,
method : "PUT" ,
body : body === undefined ? init.body : body instanceof FormData ? body : JSON.stringify ( body ) ,
} ) ;
export const apiPatch = < T > ( url : string , body? : unknown , init : ApiRequestInit = { } ) = > api < T > ( url , {
. . . init ,
method : "PATCH" ,
body : body === undefined ? init.body : body instanceof FormData ? body : JSON.stringify ( body ) ,
} ) ;
export const apiDelete = < T > ( url : string , body? : unknown , init : ApiRequestInit = { } ) = > api < T > ( url , {
. . . init ,
method : "DELETE" ,
body : body === undefined ? init.body : body instanceof FormData ? body : JSON.stringify ( body ) ,
} ) ;
export const fetchApi = api ;
export const request = api ;