Skip to content

Commit fb3edee

Browse files
Merge pull request #790 from nextcloud-libraries/chore/typescrip
refactor: resolve Typescript errors and stabilize tests
2 parents fc64f21 + 76eed60 commit fb3edee

File tree

15 files changed

+439
-374
lines changed

15 files changed

+439
-374
lines changed
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
# This workflow is provided via the organization template repository
2+
#
3+
# https://github.com/nextcloud/.github
4+
# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization
5+
#
6+
# SPDX-FileCopyrightText: 2023-2024 Nextcloud GmbH and Nextcloud contributors
7+
# SPDX-License-Identifier: MIT
8+
9+
name: Type checking
10+
11+
on:
12+
pull_request:
13+
push:
14+
branches:
15+
- main
16+
- master
17+
- stable*
18+
19+
permissions:
20+
contents: read
21+
22+
concurrency:
23+
group: lint-typescript-${{ github.head_ref || github.run_id }}
24+
cancel-in-progress: true
25+
26+
jobs:
27+
changes:
28+
runs-on: ubuntu-latest
29+
permissions:
30+
contents: read
31+
pull-requests: read
32+
33+
outputs:
34+
src: ${{ steps.changes.outputs.src}}
35+
36+
steps:
37+
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
38+
id: changes
39+
continue-on-error: true
40+
with:
41+
filters: |
42+
src:
43+
- '.github/workflows/lint-typescript.yml'
44+
- 'package.json'
45+
- 'package-lock.json'
46+
- 'tsconfig.json'
47+
- '**.ts'
48+
- '**.vue'
49+
50+
test:
51+
runs-on: ubuntu-latest
52+
53+
needs: changes
54+
if: needs.changes.outputs.src != 'false'
55+
56+
steps:
57+
- name: Checkout
58+
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
59+
with:
60+
persist-credentials: false
61+
62+
- name: Read package.json node and npm engines version
63+
uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3
64+
id: versions
65+
with:
66+
fallbackNode: '^20'
67+
fallbackNpm: '^10'
68+
69+
- name: Set up node ${{ steps.versions.outputs.nodeVersion }}
70+
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
71+
with:
72+
node-version: ${{ steps.versions.outputs.nodeVersion }}
73+
74+
- name: Set up npm ${{ steps.versions.outputs.npmVersion }}
75+
run: npm i -g 'npm@${{ steps.versions.outputs.npmVersion }}'
76+
77+
- name: Install dependencies
78+
env:
79+
CYPRESS_INSTALL_BINARY: 0
80+
run: |
81+
npm ci
82+
83+
- name: Check types
84+
run: |
85+
npm run --if-present check-types
86+
npm run --if-present ts:check
87+
88+
summary:
89+
permissions:
90+
contents: none
91+
runs-on: ubuntu-latest
92+
needs: [changes, test]
93+
94+
if: always()
95+
96+
name: typescript-summary
97+
98+
steps:
99+
- name: Summary status
100+
run: if ${{ needs.changes.outputs.src != 'false' && needs.test.result != 'success' }}; then exit 1; fi

lib/axios.d.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/*!
2+
* SPDX-License-Identifier: GPL-3.0-or-later
3+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
4+
*/
5+
6+
/**
7+
* THIS is not exposed to the public API, but is used internally in the project.
8+
*/
9+
declare module 'axios' {
10+
// eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any -- needed as we extend the interface only.
11+
interface AxiosRequestConfig<D = any> {
12+
[key: symbol]: unknown
13+
}
14+
}
15+
16+
export {}

lib/client.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/*!
2+
* SPDX-License-Identifier: GPL-3.0-or-later
3+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
4+
*/
5+
6+
import type { AxiosInstance, CancelTokenStatic } from 'axios'
7+
8+
import { getRequestToken, onRequestTokenUpdate } from '@nextcloud/auth'
9+
import Axios from 'axios'
10+
11+
export interface CancelableAxiosInstance extends AxiosInstance {
12+
CancelToken: CancelTokenStatic
13+
isCancel: typeof Axios.isCancel
14+
}
15+
16+
const client = Axios.create({
17+
headers: {
18+
requesttoken: getRequestToken() ?? '',
19+
'X-Requested-With': 'XMLHttpRequest',
20+
},
21+
})
22+
23+
onRequestTokenUpdate((token: string) => {
24+
client.defaults.headers.requesttoken = token
25+
})
26+
27+
export const cancelableClient: CancelableAxiosInstance = Object.assign(client, {
28+
CancelToken: Axios.CancelToken,
29+
isCancel: Axios.isCancel,
30+
})

lib/custom-config.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/*!
2+
* SPDX-License-Identifier: GPL-3.0-or-later
3+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
4+
*/
5+
6+
// public interface for custom configuration of the Axios client
7+
declare module 'axios' {
8+
// eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any -- needed as we extend the interface only.
9+
interface AxiosRequestConfig<D = any> {
10+
/**
11+
* Only available if the Axios instance from `@nextcloud/axios` is used.
12+
* If set to `true`, the interceptor will reload the page when a 401 response is received
13+
* and the error message indicates that the user is not logged in.
14+
*
15+
* @default false
16+
*/
17+
reloadExpiredSession?: boolean
18+
19+
/**
20+
* Only available if the Axios instance from `@nextcloud/axios` is used.
21+
* If set to `true`, the interceptor will retry the request if it failed
22+
* because the server is in maintenance mode.
23+
*
24+
* @default false
25+
*/
26+
retryIfMaintenanceMode?: boolean
27+
}
28+
}
29+
30+
export {}

lib/index.ts

Lines changed: 7 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -3,55 +3,16 @@
33
* SPDX-License-Identifier: GPL-3.0-or-later
44
*/
55

6-
import type { AxiosInstance, CancelTokenStatic } from 'axios'
7-
8-
import { getRequestToken, onRequestTokenUpdate } from '@nextcloud/auth'
9-
import Axios from 'axios'
10-
import { onError as onCsrfTokenError } from './interceptors/csrf-token.ts'
11-
import { onError as onMaintenanceModeError } from './interceptors/maintenance-mode.ts'
12-
import { onError as onNotLoggedInError } from './interceptors/not-logged-in.ts'
13-
14-
interface CancelableAxiosInstance extends AxiosInstance {
15-
CancelToken: CancelTokenStatic
16-
isCancel: typeof Axios.isCancel
17-
}
18-
19-
declare module 'axios' {
20-
// eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any -- needed as we extend the interface only.
21-
interface AxiosRequestConfig<D = any> {
22-
/**
23-
* Only available if the Axios instance from `@nextcloud/axios` is used.
24-
* If set to `true`, the interceptor will reload the page when a 401 response is received
25-
* and the error message indicates that the user is not logged in.
26-
*
27-
* @default false
28-
*/
29-
reloadExpiredSession?: boolean
30-
}
31-
}
32-
33-
const client = Axios.create({
34-
headers: {
35-
requesttoken: getRequestToken() ?? '',
36-
'X-Requested-With': 'XMLHttpRequest',
37-
},
38-
})
39-
40-
const cancelableClient: CancelableAxiosInstance = Object.assign(client, {
41-
CancelToken: Axios.CancelToken,
42-
isCancel: Axios.isCancel,
43-
})
6+
import { cancelableClient } from './client.ts'
7+
import { onCsrfTokenError } from './interceptors/csrf-token.ts'
8+
import { onMaintenanceModeError } from './interceptors/maintenance-mode.ts'
9+
import { onNotLoggedInError } from './interceptors/not-logged-in.ts'
4410

4511
cancelableClient.interceptors.response.use((r) => r, onCsrfTokenError(cancelableClient))
4612
cancelableClient.interceptors.response.use((r) => r, onMaintenanceModeError(cancelableClient))
4713
cancelableClient.interceptors.response.use((r) => r, onNotLoggedInError)
4814

49-
onRequestTokenUpdate((token) => {
50-
client.defaults.headers.requesttoken = token
51-
})
52-
53-
export default cancelableClient
54-
55-
export { isAxiosError, isCancel } from 'axios'
56-
5715
export type * from 'axios'
16+
export type * from './custom-config.ts'
17+
export { isAxiosError, isCancel } from 'axios'
18+
export default cancelableClient

lib/interceptors/csrf-token.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@
33
* SPDX-License-Identifier: GPL-3.0-or-later
44
*/
55

6+
import type { CancelableAxiosInstance } from '../client.ts'
7+
import type { InterceptorErrorHandler } from './index.ts'
8+
69
import { generateUrl } from '@nextcloud/router'
10+
import { isAxiosError } from 'axios'
711

812
const RETRY_KEY = Symbol('csrf-retry')
913

@@ -12,15 +16,20 @@ const RETRY_KEY = Symbol('csrf-retry')
1216
*
1317
* @param axios - The axios instance the interceptor is attached to
1418
*/
15-
export function onError(axios) {
16-
return async (error) => {
19+
export function onCsrfTokenError(axios: CancelableAxiosInstance): InterceptorErrorHandler {
20+
return async (error: unknown) => {
21+
if (!isAxiosError(error)) {
22+
throw error
23+
}
24+
1725
const { config, response, request } = error
1826
const responseURL = request?.responseURL
19-
const status = response?.status
2027

21-
if (status === 412
28+
if (config
29+
&& !config[RETRY_KEY]
30+
&& response?.status === 412
2231
&& response?.data?.message === 'CSRF check failed'
23-
&& config[RETRY_KEY] === undefined) {
32+
) {
2433
console.warn(`Request to ${responseURL} failed because of a CSRF mismatch. Fetching a new token`)
2534

2635
const { data: { token } } = await axios.get(generateUrl('/csrftoken'))
@@ -37,6 +46,6 @@ export function onError(axios) {
3746
})
3847
}
3948

40-
return Promise.reject(error)
49+
throw error
4150
}
4251
}

lib/interceptors/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/*!
2+
* SPDX-License-Identifier: GPL-3.0-or-later
3+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
4+
*/
5+
6+
/**
7+
* Axios interceptor onError callback
8+
*/
9+
export type InterceptorErrorHandler = (error: unknown) => Promise<unknown> | unknown

lib/interceptors/maintenance-mode.ts

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,31 @@
33
* SPDX-License-Identifier: GPL-3.0-or-later
44
*/
55

6-
const RETRY_DELAY_KEY = Symbol('retryDelay')
6+
import type { CancelableAxiosInstance } from '../client.ts'
7+
import type { InterceptorErrorHandler } from './index.ts'
8+
9+
import { isAxiosError } from 'axios'
10+
11+
export const RETRY_DELAY_KEY = Symbol('retryDelay')
712

813
/**
914
* Handles Nextcloud maintenance mode errors in Axios requests.
1015
*
1116
* @param axios - The current Axios instance
1217
*/
13-
export function onError(axios) {
14-
return async (error) => {
18+
export function onMaintenanceModeError(axios: CancelableAxiosInstance): InterceptorErrorHandler {
19+
return async (error: unknown) => {
20+
if (!isAxiosError(error)) {
21+
throw error
22+
}
23+
1524
const { config, response, request } = error
1625
const responseURL = request?.responseURL
1726
const status = response?.status
1827
const headers = response?.headers
28+
let retryDelay = typeof config?.[RETRY_DELAY_KEY] === 'number'
29+
? config?.[RETRY_DELAY_KEY]
30+
: 1
1931

2032
/**
2133
* Retry requests if they failed due to maintenance mode
@@ -26,10 +38,15 @@ export function onError(axios) {
2638
* the caller.
2739
*/
2840
if (status === 503
29-
&& headers['x-nextcloud-maintenance-mode'] === '1'
30-
&& config.retryIfMaintenanceMode
31-
&& (!config[RETRY_DELAY_KEY] || config[RETRY_DELAY_KEY] <= 32)) {
32-
const retryDelay = (config[RETRY_DELAY_KEY] ?? 1) * 2
41+
&& headers?.['x-nextcloud-maintenance-mode'] === '1'
42+
&& config?.retryIfMaintenanceMode
43+
) {
44+
retryDelay *= 2
45+
if (retryDelay > 32) {
46+
console.error('Retry delay exceeded one minute, giving up.', { responseURL })
47+
throw error
48+
}
49+
3350
console.warn(`Request to ${responseURL} failed because of maintenance mode. Retrying in ${retryDelay}s`)
3451
await new Promise((resolve) => {
3552
setTimeout(resolve, retryDelay * 1000)
@@ -41,6 +58,6 @@ export function onError(axios) {
4158
})
4259
}
4360

44-
return Promise.reject(error)
61+
throw error
4562
}
4663
}

lib/interceptors/not-logged-in.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { isAxiosError } from 'axios'
1212
*
1313
* @param error - The response error
1414
*/
15-
export async function onError(error: unknown) {
15+
export async function onNotLoggedInError(error: unknown) {
1616
if (isAxiosError(error)) {
1717
const { config, response, request } = error
1818
const responseURL = request?.responseURL
@@ -29,5 +29,5 @@ export async function onError(error: unknown) {
2929
}
3030
}
3131

32-
return Promise.reject(error)
32+
throw error
3333
}

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,8 @@
4040
"lint:fix": "eslint --fix",
4141
"test": "vitest run",
4242
"test:coverage": "vitest run --coverage",
43-
"test:types": "tsc --noEmit",
4443
"test:watch": "vitest run --watch",
44+
"ts:check": "tsc --noEmit",
4545
"watch": "vite --mode development build --watch"
4646
},
4747
"browserslist": [

0 commit comments

Comments
 (0)