better code: API typing
Massimo Melina committed
Nov 1, 2025 at 21:42 UTC
183f9f1fb99c0f2f7d9cc4f193c9dc08a93c5064
11 files changed
+69
-43
admin/src/ConfigFilePage.ts
+4
-3
@@ -9,16 +9,17 @@ import { Save, Edit, Download } from '@mui/icons-material'
9
import { TextEditor } from './TextEditor';
10
import { state } from './state';
11
import { DisplayField } from '@hfs/mui-grid-form'
12
+import { adminApis } from '../../src/adminApis'
13
14
export default function ConfigFilePage() {
15
state.title = "Config file"
15
- const { data, reload, element } = useApiEx('get_config_text', {})
16
+ const { data, reload, element } = useApiEx<typeof adminApis.get_config_text>('get_config_text', {})
17
const [text, setText] = useState<string | undefined>()
18
const [saved, setSaved] = useState<string | undefined>()
19
const [edit, setEdit] = useState(false)
20
useEffect(() => { setSaved(data?.text) }, [data])
21
useEffect(() => { saved !== undefined && setText(saved || '') }, [saved])
21
- return h(Fragment, {},
22
+ return element || h(Fragment, {},
23
h(Flex, { flexWrap: 'wrap', justifyContent: 'space-between' },
24
h(Btn, { icon: Download, onClick: exportConfig, disabled: !data }, "Export without passwords"),
25
edit ? h(Fragment, {},
@@ -63,7 +64,7 @@ export default function ConfigFilePage() {
64
const s = (text || '')
65
.replace(/^(\s*(\w*password(?!_change)\w*|srp):\s*).+\n/gm, '$1removed\n')
66
.replace(/(:\/\/)[^/@\s]+@/g, '$1removed@')
66
- + prefix('custom_html: | # this is currently ignored by hfs, just here for reference\n', data.customHtml?.replace(/^/gm, ' '))
67
+ + prefix('custom_html: | # this is currently ignored by hfs, just here for reference\n', data!.customHtml?.replace(/^/gm, ' '))
68
if (!s) return
69
downloadFileWithContent('config_no_passwords.yaml', s)
70
}
admin/src/HomePage.ts
+3
-4
@@ -1,7 +1,7 @@
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 { createElement as h, ReactNode, useState } from 'react'
4
-import { Box, Card, CardContent, LinearProgress, Link } from '@mui/material'
4
+import { Box, Card, CardContent, Link } from '@mui/material'
5
import { apiCall, useApiEx, useApiList } from './api'
6
import {
7
dontBotherWithKeys, objSameKeys, onlyTruthy, prefix, REPO_URL, md,
@@ -14,7 +14,6 @@ import {
14
import { state, useSnapState } from './state'
15
import { alertDialog, confirmDialog, promptDialog, toast } from './dialog'
16
import { isCertError, isKeyError, suggestMakingCert } from './OptionsPage'
17
-import { Account } from './AccountsPage'
17
import _ from 'lodash'
18
import { subscribeKey } from 'valtio/utils'
19
import { SwitchThemeBtn } from './theme'
@@ -28,14 +27,14 @@ export default function HomePage() {
27
const SOLUTION_SEP = " — "
28
const { username } = useSnapState()
29
const { data: status, reload: reloadStatus, element: statusEl } = useApiEx<typeof adminApis.get_status>('get_status')
31
- const { data: account } = useApiEx<Account>(username && 'get_account')
30
+ const { data: account } = useApiEx<typeof adminApis.get_account>(username && 'get_account')
31
const cfg = useApiEx('get_config', { only: ['https_port', 'cert', 'private_key', 'proxies', 'ignore_proxies', 'vfs'] })
32
const { list: plugins } = useApiList('get_plugins')
33
const [checkPlugins, setCheckPlugins] = useState(false)
34
const { list: pluginUpdates} = useApiList(checkPlugins && 'get_plugin_updates')
35
const [updates, setUpdates] = useState<undefined | Release[]>()
36
const [otherVersions, setOtherVersions] = useState<undefined | Release[]>()
38
- if (statusEl || !status)
37
+ if (statusEl || !status) // !status here to shut up ts
38
return statusEl
39
const { http, https } = status
40
const goSecure = !http?.listening && https?.listening ? 's' : ''
admin/src/InstalledPlugins.ts
+2
-2
@@ -13,7 +13,7 @@ import {
13
} from './misc'
14
import { alertDialog, confirmDialog, formDialog, toast } from './dialog'
15
import _ from 'lodash'
16
-import { Account } from './AccountsPage'
16
+import { adminApis } from '../../src/adminApis'
17
import { BoolField, Field, FieldProps, MultiSelectField, NumberField, SelectField, StringField } from '@hfs/mui-grid-form'
18
import { ArrayField } from './ArrayField'
19
import FileField from './FileField'
@@ -310,7 +310,7 @@ export async function startPlugin(id: string) {
310
}
311
312
function UsernameField({ value, onChange, multiple, groups, ...rest }: FieldProps<string>) {
313
- const { data, element, loading } = useApiEx<{ list: Account[] }>('get_accounts')
313
+ const { data, element, loading } = useApiEx<typeof adminApis.get_accounts>('get_accounts')
314
return !loading && element || h((multiple ? MultiSelectField : SelectField) as Field<string>, {
315
value, onChange,
316
options: data?.list.filter(x => groups === undefined || groups === x.isGroup).map(x => x.username),
admin/src/InternetPage.ts
+3
-3
@@ -12,7 +12,7 @@ import { alertDialog, confirmDialog, formDialog, promptDialog, toast, waitDialog
12
import { BoolField, Form, MultiSelectField, NumberField, SelectField } from '@hfs/mui-grid-form'
13
import { suggestMakingCert } from './OptionsPage'
14
import { changeBaseUrl } from './FileForm'
15
-import apiNet from '../../src/api.net'
15
+import { adminApis } from '../../src/adminApis'
16
import { ALL, WITH_IP } from './countries'
17
import _ from 'lodash'
18
import { SvgIconProps } from '@mui/material/SvgIcon/SvgIcon'
@@ -37,8 +37,8 @@ export default function InternetPage({ setTitleSide }: PageProps) {
37
const baseUrl = config.data?.[CFG.base_url]
38
const localColor = with_([status.data?.http?.error, status.data?.https?.error], ([h, s]) =>
39
h && s ? 'error' : h || s ? 'warning' : 'success')
40
- const nat = useApiEx<typeof apiNet.get_nat>('get_nat', {}, { timeout: 20 })
41
- const { data: publicIps } = useApiEx('get_public_ips')
40
+ const nat = useApiEx<typeof adminApis.get_nat>('get_nat', {}, { timeout: 20 })
41
+ const { data: publicIps } = useApiEx<typeof adminApis.get_public_ips>('get_public_ips')
42
const { data } = nat
43
const port = data?.internalPort
44
const wrongMap = data?.mapped && data.mapped.private.port !== port && data.mapped.private.port
admin/src/MonitorPage.ts
+2
-1
@@ -21,6 +21,7 @@ import { BlockIpBtn } from './blockIp'
21
import { alertDialog, confirmDialog, toast } from './dialog'
22
import { useInterval } from 'usehooks-ts'
23
import { PageProps } from './App'
24
+import { adminApis } from '../../src/adminApis'
25
26
export default function MonitorPage({ setTitleSide }: PageProps) {
27
setTitleSide(useMemo(() =>
@@ -33,7 +34,7 @@ export default function MonitorPage({ setTitleSide }: PageProps) {
34
}
35
36
function MoreInfo() {
36
- const { data: status, element, reload } = useApiEx('get_status')
37
+ const { data: status, element, reload } = useApiEx<typeof adminApis.get_status>('get_status')
38
useInterval(reload, 10_000) // status hardly change, but it can
39
const { data: connections } = useApiEvents('get_connection_stats')
40
const [allInfo, setAllInfo] = useState(false)
admin/src/VfsMenuBar.ts
+2
-1
@@ -14,6 +14,7 @@ import VfsPathField from './VfsPathField'
14
import { alertDialog, promptDialog } from './dialog'
15
import { formatDiskSpace } from './FilePicker'
16
import { getDiskSpaces } from '../../src/util-os'
17
+import { adminApis } from '../../src/adminApis'
18
19
export default function VfsMenuBar({ statusApi, add }: { add: ReactNode, statusApi: ApiObject }) {
20
return h(Flex, {
@@ -59,7 +60,7 @@ export function AddVfsBtn(props: Partial<ButtonProps>) {
60
61
function SystemIntegrationButton({ platform }: { platform: string | undefined }) {
62
const isWindows = platform === 'win32'
62
- const { data: integrated, reload } = useApi(isWindows && 'windows_integrated')
63
+ const { data: integrated, reload } = useApi<typeof adminApis.windows_integrated>(isWindows && 'windows_integrated')
64
const sm = useBreakpoint('sm')
65
return !isWindows ? null : h(Btn, {
66
icon: osIcon('win'),
admin/src/VfsPage.ts
+4
-1
@@ -25,7 +25,10 @@ export default function VfsPage({ setTitleSide }: PageProps) {
25
const { vfs, selectedFiles, movingFile } = useSnapState()
26
const { data, reload, element, loading } = useApiEx('get_vfs')
27
exposeVfsLoading = loading
28
- useMemo(() => vfs || reload(), [vfs, reload])
28
+ useEffect(() => {
29
+ if (!vfs)
30
+ reload()
31
+ }, [vfs, reload])
32
const { data: config } = useApiEx('get_config', { only: [CFG.force_address, CFG.base_url] })
33
const sideBreakpoint = 'md'
34
const isSideBreakpoint = useBreakpoint(sideBreakpoint)
admin/src/api.ts
+3
-2
@@ -10,6 +10,7 @@ import { state } from './state'
10
import { Refresh } from '@mui/icons-material'
11
import { produce, Draft } from 'immer'
12
import { ApiError, apiEvents, setDefaultApiCallOptions, useApi } from '@hfs/shared/api'
13
+import { ApiHandler } from '../../src/apiMiddleware'
14
export * from '@hfs/shared/api'
15
16
setDefaultApiCallOptions({
@@ -23,8 +24,8 @@ setDefaultApiCallOptions({
24
25
const ERRORS = { timeout: "Operation timeout" }
26
// expand useApi with things that cannot be shared with Frontend
26
-export type ApiObject<T=any> = ReturnType<typeof useApiEx<T>>
27
-export function useApiEx<T=any>(...args: Parameters<typeof useApi>) {
27
+export type ApiObject<T extends ApiHandler=any> = ReturnType<typeof useApiEx<T>>
28
+export function useApiEx<T extends ApiHandler=any>(...args: Parameters<typeof useApi>) {
29
const res = useApi<T>(...args)
30
return {
31
...res,
frontend/src/fileMenu.ts
+4
-2
@@ -17,6 +17,7 @@ import { cut } from './clip'
17
import { loginDialog } from './login'
18
import { useInterval } from 'usehooks-ts'
19
import i18n from './i18n'
20
+import { frontEndApis } from '../../src/frontEndApis'
21
const { t, useI18N } = i18n
22
23
interface FileMenuEntry {
@@ -102,7 +103,8 @@ export async function openFileMenu(entry: DirEntry, ev: MouseEvent, addToMenu: (
103
restoreFocus: ev.screenY || ev.screenX ? false : undefined,
104
Content() {
105
const {t} = useI18N()
105
- const details = useApi('get_file_details', { uris: [entry.uri] }).data?.details?.[0]
106
+ const { data } = useApi<typeof frontEndApis.get_file_details>('get_file_details', { uris: [entry.uri] })
107
+ const details = data?.details?.[0]
108
const showProps = [ ...props,
109
with_(renderUploaderFromDetails(details), value =>
110
value && { id: 'uploader', label: t`Uploader`, value })
@@ -183,7 +185,7 @@ async function rename(entry: DirEntry) {
185
getHFS().navigate(uri + '../' + pathEncode(dest) + '/') )
186
// update state instead of re-getting the list
187
const newN = n.replace(/(.*?)[^/]+$/, (_,before) => before + dest)
186
- const newEntry = new DirEntry(newN, { key: n, ...entry }) // by keeping old key, we avoid unmounting the element, that's causing focus lost
188
+ const newEntry = new DirEntry(newN, { key: n, ...entry }) // by keeping the old key, we avoid unmounting the element, that's causing focus lost
189
const i = _.findIndex(state.list, { n })
190
state.list[i] = newEntry
191
// update filteredList too
shared/api.ts
+40
-20
@@ -6,6 +6,9 @@ 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
@@ -32,7 +35,10 @@ export function setDefaultApiCallOptions(options: Partial<ApiCallOptions>) {
35
Object.assign(defaultApiCallOptions, options)
36
}
37
35
-export function apiCall<T=any>(cmd: string, params?: Dict, options: ApiCallOptions={}) {
38
+// shortcut: if it's a function, consider its return type (without ApiError which is thrown instead, and others similarly)
39
+type ApiData<FT> = Jsonify<FT extends (...args: any[]) => infer R ? SanitizeReturn<R> : SanitizeReturn<FT>>
40
+type SanitizeReturn<R> = Exclude<Awaited<R>, BackendApiError | Readable | AsyncGenerator<any>>
41
+export function apiCall<FT=any>(cmd: string, params?: Dict, options: ApiCallOptions={}) {
42
_.defaults(options, defaultApiCallOptions)
43
const stop = options.modal?.(cmd, params)
44
const controller = window.AbortController ? new AbortController() : undefined
@@ -53,7 +59,7 @@ export function apiCall<T=any>(cmd: string, params?: Dict, options: ApiCallOptio
59
}).then(async res => {
60
stop?.()
61
let body: any = await res.text()
56
- let data: any
62
+ let data: ApiData<FT>
63
try { data = options.skipParse ? body : JSON.parse(body) }
64
catch { data = body }
65
if (!options?.skipLog)
@@ -61,7 +67,7 @@ export function apiCall<T=any>(cmd: string, params?: Dict, options: ApiCallOptio
67
await options.onResponse?.(res, data)
68
if (!res.ok)
69
throw new ApiError(res.status, data === body ? body : `Failed API ${cmd}: ${res.statusText}`, data)
64
- return data as Awaited<T extends (...args: any[]) => infer R ? Awaited<R> : T>
70
+ return data
71
}, err => {
72
stop?.()
73
if (err?.message?.includes('fetch')) {
@@ -86,32 +92,46 @@ export class ApiError extends Error {
92
}
93
}
94
89
-export type UseApi<T=unknown> = ReturnType<typeof useApi<T>>
90
-export function useApi<T=any>(cmd: string | Falsy, params?: object, options: ApiCallOptions={}) {
91
- const [data, setData, getData] = useStateMounted<Jsonify<Awaited<ReturnType<typeof apiCall<T>>>> | undefined>(undefined)
95
+
96
+export type UseApi<FT extends ApiHandler=ApiHandler> = ReturnType<typeof useApi<FT>>
97
+// FT is the type of the server-side function
98
+export function useApi<FT extends ApiHandler>(cmd: string | Falsy, params?: object, options: ApiCallOptions={}) {
99
+ type ApiReq = Promise<ApiData<FT>> & { abort(): void, aborted: () => boolean | undefined }
100
+ const [data, setData, getData] = useStateMounted<ApiData<FT> | undefined>(undefined)
101
const [error, setError] = useStateMounted<Error | undefined>(undefined)
102
const [forcer, setForcer] = useStateMounted(0)
94
- const [loading, setLoading, getLoading] = useStateMounted<undefined | ReturnType<typeof apiCall>>(undefined)
103
+ const [loading, setLoading, getLoading] = useStateMounted<undefined | ApiReq>(undefined)
104
const reloadPromise = useRef<any>()
105
useEffect(() => {
106
setError(undefined)
98
- const isAborted = () => getLoading()?.aborted()
107
+ let undone = false
108
+ let currentReq: ApiReq | undefined
109
+ const isAborted = () => undone || currentReq?.aborted()
110
const wholePromise = wait(0) // postpone a bit so that if it is aborted immediately, it is never really fired (happens mostly in dev mode)
111
.then(() => {
101
- const ret = !cmd || isAborted() ? undefined : apiCall<T>(cmd, params, options)
102
- setLoading(ret)
103
- return ret
112
+ if (undone) return
113
+ currentReq = !cmd || isAborted() ? undefined : apiCall<FT>(cmd, params, options)
114
+ setLoading(currentReq)
115
+ return currentReq
116
+ })
117
+ .then(res => {
118
+ setData(isAborted() ? undefined : res)
119
+ setError(undefined)
120
+ }, err => {
121
+ setError(isAborted() ? undefined : err)
122
+ setData(undefined)
123
+ })
124
+ .finally(() => {
125
+ if (currentReq === getLoading()) // update loading only if it's only if it's still the current request
126
+ setLoading(undefined)
127
+ reloadPromise.current = undefined
128
})
105
- .then(res => isAborted() || setData(res as any) || setError(undefined),
106
- err => {
107
- if (isAborted()) return
108
- setError(err)
109
- setData(undefined)
110
- })
111
- .finally(() => setLoading(reloadPromise.current = undefined))
129
reloadPromise.current?.resolve(wholePromise)
113
- return () => { wholePromise.finally(() => getLoading()?.abort()) }
114
- }, [cmd, JSON.stringify(params), forcer]) //eslint-disable-line -- json-ize to detect deep changes
130
+ return () => {
131
+ undone = true
132
+ currentReq?.abort()
133
+ }
134
+ }, [cmd, JSON.stringify(params), JSON.stringify(options), forcer]) //eslint-disable-line -- json-ize to detect deep changes
135
const reload = useCallback(() => {
136
if (reloadPromise.current) return
137
reloadPromise.current = pendingPromise()
src/api.vfs.ts
+2
-4
@@ -32,7 +32,7 @@ const ALLOWED_KEYS: (keyof VfsNodeStored)[] = ['name', 'source', 'masks', 'defau
32
33
export interface LsEntry { n:string, s?:number, m?:string, c?:string, k?:'d' }
34
35
-const apis: ApiHandlers = {
35
+export default {
36
37
async get_vfs() {
38
return { root: await recur() }
@@ -268,9 +268,7 @@ const apis: ApiHandlers = {
268
return {}
269
},
270
271
-}
272
-
273
-export default apis
271
+} satisfies ApiHandlers
272
273
// pick only selected props, and consider null and empty string as undefined, as it's the default value and we don't want to store it
274
export function pickProps(o: any, keys: string[]) {