| 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 | } |