main
ts 213 lines 8.33 KB
Raw
1 // This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
3 import _ from 'lodash';
4 import { useCallback, useEffect, useMemo, useRef } from 'react';
5 import {
6 Callback, Dict, Falsy, getPrefixUrl, pendingPromise, useStateMounted, wait, buildUrlQueryString, Jsonify, formatTime
7 } from '.'
8 import { BetterEventEmitter } from '../src/events'
9 import { ApiHandler } from '../src/apiMiddleware'
10 import type { ApiError as BackendApiError } from '../src/apiMiddleware'
11 import type { Readable } from 'stream'
12
13 export const API_URL = '/~/api/'
14
15 const timeoutByApi: Dict = {
16 loginSrp1: 90, // support antibrute
17 login: 90,
18 get_status: 20, // can be lengthy on slow machines because of the find-process-on-busy-port feature
19 check_update: 20,
20 get_vfs: 20, // multiple sources may be slow
21 self_check: 15,
22 }
23
24 interface ApiCallOptions {
25 timeout?: number | false // seconds
26 modal?: undefined | ((cmd: string, params?: Dict) => (() => unknown))
27 onResponse?: (res: Response, body: any) => any
28 method?: string
29 skipParse?: boolean
30 skipLog?: boolean
31 restUri?: string
32 }
33
34 const defaultApiCallOptions: ApiCallOptions = {}
35 export function setDefaultApiCallOptions(options: Partial<ApiCallOptions>) {
36 Object.assign(defaultApiCallOptions, options)
37 }
38
39 // shortcut: if it's a function, consider its return type (without ApiError which is thrown instead, and others similarly)
40 type ApiData<FT> = Jsonify<FT extends (...args: any[]) => infer R ? SanitizeReturn<R> : SanitizeReturn<FT>>
41 type SanitizeReturn<R> = Exclude<Awaited<R>, BackendApiError | Readable | AsyncGenerator<any>>
42 export function apiCall<FT=any>(cmd: string, params?: Dict, options: ApiCallOptions={}) {
43 _.defaults(options, defaultApiCallOptions)
44 const stop = options.modal?.(cmd, params)
45 const controller = window.AbortController ? new AbortController() : undefined
46 let aborted = ''
47 const ms = 1000 * (timeoutByApi[cmd] ?? options.timeout ?? 10)
48 const timeout = ms && setTimeout(() => {
49 controller?.abort(aborted = 'timeout')
50 console.debug('API TIMEOUT', cmd, params??'')
51 }, ms)
52 const asRest = options.restUri
53 const started = new Date
54 // rebuilding the whole url makes it resistant to url-with-credentials
55 return Object.assign(fetch(`${location.origin}${asRest || (getPrefixUrl() + API_URL + cmd)}`, {
56 method: asRest ? cmd : (options.method || 'POST'),
57 headers: { 'content-type': 'application/json', 'x-hfs-anti-csrf': '1' },
58 signal: controller?.signal,
59 body: params && JSON.stringify(params),
60 }).then(async res => {
61 stop?.()
62 let body: any = await res.text()
63 let data: ApiData<FT>
64 try { data = options.skipParse ? body : JSON.parse(body) }
65 catch { data = body }
66 if (!options?.skipLog)
67 console.debug(res.ok ? 'API' : 'API FAILED', cmd, params??'', '>>', data, { started: formatTime(started), duration: (Date.now() - +started) })
68 await options.onResponse?.(res, data)
69 if (!res.ok)
70 throw new ApiError(res.status, data === body ? body : `Failed API ${cmd}: ${res.statusText}`, data)
71 return data
72 }, err => {
73 stop?.()
74 if (err?.message?.includes('fetch')) {
75 console.error(err.message)
76 throw Error("Server unreachable")
77 }
78 throw aborted || err
79 }).finally(() => clearTimeout(timeout)), {
80 abort() {
81 controller?.abort(aborted='cancel')
82 },
83 aborted: () => controller?.signal.aborted
84 })
85 }
86
87 export class ApiError extends Error {
88 constructor(readonly code:number, message: string, data?: any) {
89 super(message, { cause: data });
90 }
91 get data() {
92 return this.cause
93 }
94 }
95
96
97 export type UseApi<FT extends ApiHandler=ApiHandler> = ReturnType<typeof useApi<FT>>
98 // FT is the type of the server-side function
99 export function useApi<FT extends ApiHandler>(cmd: string | Falsy, params?: object, options: ApiCallOptions={}) {
100 type ApiReq = Promise<ApiData<FT>> & { abort(): void, aborted: () => boolean | undefined }
101 const [data, setData, getData] = useStateMounted<ApiData<FT> | undefined>(undefined)
102 const [error, setError] = useStateMounted<Error | undefined>(undefined)
103 const [forcer, setForcer] = useStateMounted(0)
104 const [loading, setLoading, getLoading] = useStateMounted<undefined | ApiReq>(undefined)
105 const reloadPromise = useRef<any>()
106 useEffect(() => {
107 setError(undefined)
108 let undone = false
109 let currentReq: ApiReq | undefined
110 const isAborted = () => undone || currentReq?.aborted()
111 const wholePromise = wait(0) // postpone a bit so that if it is aborted immediately, it is never really fired (happens mostly in dev mode)
112 .then(() => {
113 if (undone) return
114 currentReq = !cmd || isAborted() ? undefined : apiCall<FT>(cmd, params, options)
115 setLoading(currentReq)
116 return currentReq
117 })
118 .then(res => {
119 setData(isAborted() ? undefined : res)
120 setError(undefined)
121 }, err => {
122 setError(isAborted() ? undefined : err)
123 setData(undefined)
124 })
125 .finally(() => {
126 if (currentReq === getLoading()) // update loading only if it's only if it's still the current request
127 setLoading(undefined)
128 reloadPromise.current = undefined
129 })
130 reloadPromise.current?.resolve(wholePromise)
131 return () => {
132 undone = true
133 currentReq?.abort()
134 }
135 }, [cmd, JSON.stringify(params), JSON.stringify(options), forcer]) //eslint-disable-line -- json-ize to detect deep changes
136 const reload = useCallback(() => {
137 if (reloadPromise.current) return
138 reloadPromise.current = pendingPromise()
139 setForcer(v => v + 1)
140 }, [setForcer])
141 const ee = useMemo(() => new BetterEventEmitter, [])
142 const sub = useCallback((cb: Callback) => ee.on('data', cb), [ee])
143 useEffect(() => { ee.emit('data') }, [data])
144 return { data, setData, getData, error, reload, sub, loading }
145 }
146
147 type EventHandler = (type:string, data?:any) => void
148
149 export function apiEvents(cmd: string, params: Dict, cb:EventHandler) {
150 params = _.omitBy(params, _.isUndefined)
151 const source = new EventSource(getPrefixUrl() + API_URL + cmd + buildUrlQueryString(params))
152 source.onopen = () => {
153 console.debug('API EVENTS', cmd, params)
154 cb('connected')
155 }
156 source.onerror = err => cb('error', err)
157 source.onmessage = ({ data }) => {
158 if (!data) {
159 cb('closed')
160 return source.close()
161 }
162 try { data = JSON.parse(data) }
163 catch {
164 return cb('string', data)
165 }
166 console.debug('SSE msg', data)
167 cb('msg', data)
168 }
169 return source
170 }
171
172 export function useApiEvents<T=any>(cmd: string, params: Dict={}) {
173 const [data, setData] = useStateMounted<T | undefined>(undefined)
174 const [error, setError] = useStateMounted<undefined | string>(undefined)
175 const [loading, setLoading] = useStateMounted(false)
176 useEffect(() => {
177 const src = apiEvents(cmd, params, (type, data) => {
178 switch (type) {
179 case 'error':
180 setError("Connection error")
181 return stop()
182 case 'closed':
183 return stop()
184 case 'msg':
185 if (src.readyState === src.CLOSED)
186 return stop()
187 return setData(data)
188 }
189 })
190 return () => {
191 src.close()
192 stop()
193 }
194
195 function stop() {
196 setLoading(false)
197 }
198 }, [cmd, JSON.stringify(params)]) //eslint-disable-line
199 return { data, loading, error }
200 }
201
202 export async function getNotifications(channel: string, cb: (name: string, data:any) => void): Promise<EventSource> {
203 return new Promise(resolve => {
204 const ret = apiEvents('get_notifications', { channel }, (type, entries) => {
205 if (type === 'connected')
206 return resolve(ret)
207 if (type !== 'msg') return
208 for (const [name, data] of entries)
209 if (name)
210 cb(name, data)
211 })
212 })
213 }