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[]) {