diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 17e43a72cb..f55222e5a5 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -25,7 +25,6 @@ updates: interval: "weekly" open-pull-requests-limit: 2 ignore: - - dependency-name: "ky" - dependency-name: "tailwind-merge" update-types: ["version-update:semver-major"] - dependency-name: "tailwindcss" diff --git a/.github/workflows/autofix.yml b/.github/workflows/autofix.yml index cfca882129..4b48e741df 100644 --- a/.github/workflows/autofix.yml +++ b/.github/workflows/autofix.yml @@ -23,6 +23,18 @@ jobs: docker/.env.example docker/docker-compose-template.yaml docker/docker-compose.yaml + - name: Check web inputs + id: web-changes + uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5 + with: + files: | + web/** + - name: Check api inputs + id: api-changes + uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5 + with: + files: | + api/** - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: "3.11" @@ -35,7 +47,8 @@ jobs: cd docker ./generate_docker_compose - - run: | + - if: steps.api-changes.outputs.any_changed == 'true' + run: | cd api uv sync --dev # fmt first to avoid line too long @@ -46,11 +59,13 @@ jobs: uv run ruff format .. - name: count migration progress + if: steps.api-changes.outputs.any_changed == 'true' run: | cd api ./cnt_base.sh - name: ast-grep + if: steps.api-changes.outputs.any_changed == 'true' run: | # ast-grep exits 1 if no matches are found; allow idempotent runs. uvx --from ast-grep-cli ast-grep --pattern 'db.session.query($WHATEVER).filter($HERE)' --rewrite 'db.session.query($WHATEVER).where($HERE)' -l py --update-all || true @@ -85,13 +100,15 @@ jobs: uvx --python 3.13 mdformat . --exclude ".agents/skills/**" - name: Setup web environment + if: steps.web-changes.outputs.any_changed == 'true' uses: ./.github/actions/setup-web with: node-version: "24" - name: ESLint autofix + if: steps.web-changes.outputs.any_changed == 'true' run: | cd web - pnpm eslint --concurrency=2 --prune-suppressions + pnpm eslint --concurrency=2 --prune-suppressions --quiet || true - uses: autofix-ci/action@7a166d7532b277f34e16238930461bf77f9d7ed8 # v1.3.3 diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json index dba3a08694..1c9e9d43f6 100644 --- a/web/eslint-suppressions.json +++ b/web/eslint-suppressions.json @@ -12961,9 +12961,6 @@ } }, "service/fetch.ts": { - "e18e/prefer-spread-syntax": { - "count": 1 - }, "e18e/prefer-static-regex": { "count": 1 }, diff --git a/web/package.json b/web/package.json index 0ea82b3f0f..c8d5360b15 100644 --- a/web/package.json +++ b/web/package.json @@ -121,7 +121,7 @@ "js-yaml": "4.1.1", "jsonschema": "1.5.0", "katex": "0.16.38", - "ky": "1.12.0", + "ky": "1.14.3", "lamejs": "1.2.1", "lexical": "0.41.0", "mermaid": "11.13.0", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 494851b823..7d1a1f80ce 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -234,8 +234,8 @@ importers: specifier: 0.16.38 version: 0.16.38 ky: - specifier: 1.12.0 - version: 1.12.0 + specifier: 1.14.3 + version: 1.14.3 lamejs: specifier: 1.2.1 version: 1.2.1 @@ -5515,8 +5515,8 @@ packages: kolorist@1.8.0: resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==} - ky@1.12.0: - resolution: {integrity: sha512-YRLmSUHCwOJRBMArtqMRLOmO7fewn3yOoui6aB8ERkRVXupa0UiaQaKbIXteMt4jUElhbdqTMsLFHs8APxxUoQ==} + ky@1.14.3: + resolution: {integrity: sha512-9zy9lkjac+TR1c2tG+mkNSVlyOpInnWdSMiue4F+kq8TwJSgv6o8jhLRg8Ho6SnZ9wOYUq/yozts9qQCfk7bIw==} engines: {node: '>=18'} lamejs@1.2.1: @@ -13268,7 +13268,7 @@ snapshots: kolorist@1.8.0: {} - ky@1.12.0: {} + ky@1.14.3: {} lamejs@1.2.1: dependencies: diff --git a/web/service/fetch.spec.ts b/web/service/fetch.spec.ts new file mode 100644 index 0000000000..ef38a4c510 --- /dev/null +++ b/web/service/fetch.spec.ts @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { base } from './fetch' + +vi.mock('@/app/components/base/toast', () => ({ + default: { + notify: vi.fn(), + }, +})) + +describe('base', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Error responses', () => { + it('should keep the response body readable when a 401 response is rejected', async () => { + // Arrange + const unauthorizedResponse = new Response( + JSON.stringify({ + code: 'unauthorized', + message: 'Unauthorized', + status: 401, + }), + { + status: 401, + headers: { + 'Content-Type': 'application/json', + }, + }, + ) + + vi.spyOn(globalThis, 'fetch').mockResolvedValue(unauthorizedResponse) + + // Act + let caughtError: unknown + try { + await base('/login') + } + catch (error) { + caughtError = error + } + + // Assert + expect(caughtError).toBeInstanceOf(Response) + await expect((caughtError as Response).json()).resolves.toEqual({ + code: 'unauthorized', + message: 'Unauthorized', + status: 401, + }) + }) + }) +}) diff --git a/web/service/fetch.ts b/web/service/fetch.ts index a8f29263d7..d5934d4a57 100644 --- a/web/service/fetch.ts +++ b/web/service/fetch.ts @@ -1,7 +1,7 @@ -import type { AfterResponseHook, BeforeErrorHook, BeforeRequestHook, Hooks } from 'ky' +import type { AfterResponseHook, BeforeRequestHook, Hooks } from 'ky' import type { IOtherOptions } from './base' import Cookies from 'js-cookie' -import ky from 'ky' +import ky, { HTTPError } from 'ky' import Toast from '@/app/components/base/toast' import { API_PREFIX, APP_VERSION, CSRF_COOKIE_NAME, CSRF_HEADER_NAME, IS_MARKETPLACE, MARKETPLACE_API_PREFIX, PASSPORT_HEADER_NAME, PUBLIC_API_PREFIX, WEB_APP_SHARE_CODE_HEADER_NAME } from '@/config' import { getWebAppAccessToken, getWebAppPassport } from './webapp-auth' @@ -40,37 +40,19 @@ export type ResponseError = { const afterResponseErrorCode = (otherOptions: IOtherOptions): AfterResponseHook => { return async (_request, _options, response) => { - const clonedResponse = response.clone() - if (!/^([23])\d{2}$/.test(String(clonedResponse.status))) { - const bodyJson = clonedResponse.json() as Promise - switch (clonedResponse.status) { - case 403: - bodyJson.then((data: ResponseError) => { - if (!otherOptions.silent) - Toast.notify({ type: 'error', message: data.message }) - if (data.code === 'already_setup') - globalThis.location.href = `${globalThis.location.origin}/signin` - }) - break - case 401: - return Promise.reject(response) - // fall through - default: - bodyJson.then((data: ResponseError) => { - if (!otherOptions.silent) - Toast.notify({ type: 'error', message: data.message }) - }) - return Promise.reject(response) - } - } - } -} + if (!/^([23])\d{2}$/.test(String(response.status))) { + const errorData = await response.clone() + .json() + .then(data => data as ResponseError) + .catch(() => null) + const shouldNotifyError = response.status !== 401 && errorData && !otherOptions.silent -const beforeErrorToast = (otherOptions: IOtherOptions): BeforeErrorHook => { - return (error) => { - if (!otherOptions.silent) - Toast.notify({ type: 'error', message: error.message }) - return error + if (shouldNotifyError) + Toast.notify({ type: 'error', message: errorData.message }) + + if (response.status === 403 && errorData?.code === 'already_setup') + globalThis.location.href = `${globalThis.location.origin}/signin` + } } } @@ -137,7 +119,7 @@ async function base(url: string, options: FetchOptionType = {}, otherOptions: mode: 'cors', credentials: 'include', // always send cookies、HTTP Basic authentication. redirect: 'follow', - } + } as const : { mode: 'cors', credentials: 'include', // always send cookies、HTTP Basic authentication. @@ -146,8 +128,8 @@ async function base(url: string, options: FetchOptionType = {}, otherOptions: }), method: 'GET', redirect: 'follow', - } - const { params, body, headers: headersFromProps, ...init } = Object.assign({}, baseOptions, options) + } as const + const { params, body, headers: headersFromProps, ...init } = { ...baseOptions, ...options } const headers = new Headers(headersFromProps || {}) const { @@ -189,10 +171,6 @@ async function base(url: string, options: FetchOptionType = {}, otherOptions: const client = baseClient.extend({ hooks: { ...baseHooks, - beforeError: [ - ...baseHooks.beforeError || [], - beforeErrorToast(otherOptions), - ], beforeRequest: [ ...baseHooks.beforeRequest || [], isPublicAPI && beforeRequestPublicWithCode, @@ -204,28 +182,36 @@ async function base(url: string, options: FetchOptionType = {}, otherOptions: }, }) - const res = await client(request || fetchPathname, { - ...init, - headers, - credentials: isMarketplaceAPI - ? 'omit' - : (options.credentials || 'include'), - retry: { - methods: [], - }, - ...(bodyStringify && !fetchCompat ? { json: body } : { body: body as BodyInit }), - searchParams: !fetchCompat ? params : undefined, - fetch(resource: RequestInfo | URL, options?: RequestInit) { - if (resource instanceof Request && options) { - const mergedHeaders = new Headers(options.headers || {}) - resource.headers.forEach((value, key) => { - mergedHeaders.append(key, value) - }) - options.headers = mergedHeaders - } - return globalThis.fetch(resource, options) - }, - }) + let res: Response + try { + res = await client(request || fetchPathname, { + ...init, + headers, + credentials: isMarketplaceAPI + ? 'omit' + : (options.credentials || 'include'), + retry: { + methods: [], + }, + ...(bodyStringify && !fetchCompat ? { json: body } : { body: body as BodyInit }), + searchParams: !fetchCompat ? params : undefined, + fetch(resource: RequestInfo | URL, options?: RequestInit) { + if (resource instanceof Request && options) { + const mergedHeaders = new Headers(options.headers || {}) + resource.headers.forEach((value, key) => { + mergedHeaders.append(key, value) + }) + options.headers = mergedHeaders + } + return globalThis.fetch(resource, options) + }, + }) + } + catch (error) { + if (error instanceof HTTPError) + throw error.response.clone() + throw error + } if (needAllResponseContent || fetchCompat) return res as T