From 2a49c37ff3036466ba7a15c1a6d2c6b4f2bbcd37 Mon Sep 17 00:00:00 2001 From: yifancong Date: Wed, 1 Apr 2026 18:00:50 +0800 Subject: [PATCH 1/5] chore: migrate axios to fetch chore: migrate axios to fetch --- packages/ai/package.json | 1 - packages/cli/package.json | 1 - packages/cli/src/utils.test.ts | 59 ++++++++++ packages/cli/src/utils.ts | 34 ++++-- packages/client/rsbuild.config.ts | 20 +++- packages/components/package.json | 1 - .../src/components/Alert/change.tsx | 15 ++- packages/components/src/utils/request.test.ts | 61 ++++++++++ packages/components/src/utils/request.ts | 110 ++++++++++++------ packages/core/package.json | 1 - packages/core/prebundle.config.mjs | 2 +- .../core/src/inner-plugins/utils/loader.ts | 58 +++++---- packages/utils/package.json | 3 +- packages/utils/src/common/fetch.ts | 12 ++ packages/utils/src/common/index.ts | 1 + pnpm-lock.yaml | 21 ++-- 16 files changed, 307 insertions(+), 93 deletions(-) create mode 100644 packages/cli/src/utils.test.ts create mode 100644 packages/components/src/utils/request.test.ts create mode 100644 packages/utils/src/common/fetch.ts diff --git a/packages/ai/package.json b/packages/ai/package.json index 204de65dd..56dca6082 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -31,7 +31,6 @@ "@types/node": "^22.8.1", "prebundle": "1.6.4", "socket.io-client": "4.8.1", - "axios": "1.14.0", "typescript": "^5.9.2", "zod": "^3.25.76" }, diff --git a/packages/cli/package.json b/packages/cli/package.json index 22740cca8..e84b5d3a7 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -41,7 +41,6 @@ "@rsdoctor/client": "workspace:*", "cac": "^7.0.0", "typescript": "^5.9.2", - "axios": "1.14.0", "picocolors": "^1.1.1" }, "peerDependencies": { diff --git a/packages/cli/src/utils.test.ts b/packages/cli/src/utils.test.ts new file mode 100644 index 000000000..6944b2e4f --- /dev/null +++ b/packages/cli/src/utils.test.ts @@ -0,0 +1,59 @@ +import { afterEach, describe, expect, it, rs } from '@rstest/core'; +import { fetchText, loadJSON } from './utils'; + +describe('cli utils', () => { + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it('fetchText() returns response text for 2xx', async () => { + const fetchMock = rs.fn().mockResolvedValue({ + ok: true, + text: async () => 'plain text', + }); + globalThis.fetch = fetchMock as typeof fetch; + + const result = await fetchText('https://example.com/file.txt'); + + expect(result).toBe('plain text'); + expect(fetchMock).toBeCalledTimes(1); + expect(fetchMock).toBeCalledWith( + 'https://example.com/file.txt', + expect.objectContaining({ + headers: { + 'Content-Type': 'text/plain; charset=utf-8', + 'Accept-Encoding': 'gzip,deflate,compress', + }, + }), + ); + }); + + it('fetchText() throws when response is non-2xx', async () => { + const fetchMock = rs.fn().mockResolvedValue({ + ok: false, + status: 404, + }); + globalThis.fetch = fetchMock as typeof fetch; + + await expect(fetchText('https://example.com/missing.txt')).rejects.toThrow( + 'Request failed with status 404', + ); + }); + + it('loadJSON() parses remote json text', async () => { + const fetchMock = rs.fn().mockResolvedValue({ + ok: true, + text: async () => '{"id":7,"name":"remote"}', + }); + globalThis.fetch = fetchMock as typeof fetch; + + const data = await loadJSON<{ id: number; name: string }>( + 'https://example.com/data.json', + process.cwd(), + ); + + expect(data).toStrictEqual({ id: 7, name: 'remote' }); + }); +}); diff --git a/packages/cli/src/utils.ts b/packages/cli/src/utils.ts index d2d44fb0b..f0b3345f1 100644 --- a/packages/cli/src/utils.ts +++ b/packages/cli/src/utils.ts @@ -1,10 +1,9 @@ -import axios from 'axios'; import path from 'path'; import fs from 'node:fs'; import { Ora } from 'ora'; import { Command } from './types'; import { Common } from '@rsdoctor/types'; -import { Url } from '@rsdoctor/utils/common'; +import { Fetch, Url } from '@rsdoctor/utils/common'; export function enhanceCommand( fn: Command, @@ -16,14 +15,27 @@ export function enhanceCommand( } export async function fetchText(url: string) { - const { data } = await axios.get(url, { - timeout: 60000, - headers: { - 'Content-Type': 'text/plain; charset=utf-8', - 'Accept-Encoding': 'gzip,deflate,compress', - }, - }); - return data; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 60000); + const fetchImpl = await Fetch.getFetch(); + + try { + const res = await fetchImpl(url, { + signal: controller.signal, + headers: { + 'Content-Type': 'text/plain; charset=utf-8', + 'Accept-Encoding': 'gzip,deflate,compress', + }, + }); + + if (!res.ok) { + throw new Error(`Request failed with status ${res.status}`); + } + + return await res.text(); + } finally { + clearTimeout(timeoutId); + } } export async function readFile(url: string, cwd: string) { @@ -39,7 +51,7 @@ export async function loadJSON( if (Url.isUrl(uri)) { const data = await fetchText(uri); - return data; + return JSON.parse(data) as T; } const file = await readFile(uri, cwd); diff --git a/packages/client/rsbuild.config.ts b/packages/client/rsbuild.config.ts index f28f1b3a8..3552edaef 100644 --- a/packages/client/rsbuild.config.ts +++ b/packages/client/rsbuild.config.ts @@ -30,7 +30,23 @@ export default defineConfig(({ env }) => { const config: RsbuildConfig = { plugins: [ pluginReact(), - pluginNodePolyfill(), + pluginNodePolyfill({ + // Explicitly handle Node builtins referenced by transitive deps. + overrides: { + 'node:async_hooks': false, + async_hooks: false, + 'node:diagnostics_channel': false, + diagnostics_channel: false, + 'node:worker_threads': false, + worker_threads: false, + 'node:perf_hooks': false, + perf_hooks: false, + 'node:sqlite': false, + sqlite: false, + 'node:util/types': false, + 'util/types': false, + }, + }), pluginSass(), pluginTypeCheck({ enable: IS_PRODUCTION, @@ -127,7 +143,7 @@ export default defineConfig(({ env }) => { vender: { chunks: 'all', name: 'vender', - test: /node_modules\/(acorn|lodash|i18next|socket.io-*|axios|remark-*)/, + test: /node_modules\/(acorn|lodash|i18next|socket.io-*|remark-*)/, maxSize: 1000000, minSize: 200000, }, diff --git a/packages/components/package.json b/packages/components/package.json index 1745ae834..70bf92b24 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -64,7 +64,6 @@ "@rsdoctor/utils": "workspace:*", "ansi-to-react": "6.2.6", "antd": "5.19.1", - "axios": "1.14.0", "clsx": "^2.1.1", "dayjs": "1.11.20", "echarts": "^5.6.0", diff --git a/packages/components/src/components/Alert/change.tsx b/packages/components/src/components/Alert/change.tsx index d0960e368..3f79320d9 100644 --- a/packages/components/src/components/Alert/change.tsx +++ b/packages/components/src/components/Alert/change.tsx @@ -10,7 +10,6 @@ import { Space, Typography, } from 'antd'; -import axios from 'axios'; import React, { useState } from 'react'; import { useRuleIndexNavigate } from '../../utils'; import { DiffViewer } from '../base'; @@ -31,10 +30,18 @@ const CodeChangeDrawerContent: React.FC = ({ const { file, id } = data; const { path, line, isFixed, actual, expected } = file; // const [isFixed, setIsFixed] = useState(file.isFixed ?? false); - const applyFix = () => { - axios.post(SDK.ServerAPI.API.ApplyErrorFix, { id }).then(() => { - setIsFixed(true); + const applyFix = async () => { + const res = await fetch(SDK.ServerAPI.API.ApplyErrorFix, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ id }), }); + if (!res.ok) { + throw new Error(`Request failed with status ${res.status}`); + } + setIsFixed(true); }; const FixButton = () => { diff --git a/packages/components/src/utils/request.test.ts b/packages/components/src/utils/request.test.ts new file mode 100644 index 000000000..dff02b2e2 --- /dev/null +++ b/packages/components/src/utils/request.test.ts @@ -0,0 +1,61 @@ +import { afterEach, describe, expect, it, rs } from '@rstest/core'; +import { fetchJSONByUrl, postServerAPI } from './request'; + +describe('request utils', () => { + const originalFetch = globalThis.fetch; + const originalNodeEnv = process.env.NODE_ENV; + + afterEach(() => { + globalThis.fetch = originalFetch; + process.env.NODE_ENV = originalNodeEnv; + }); + + it('fetchJSONByUrl() parses json text payload', async () => { + const fetchMock = rs.fn().mockResolvedValue({ + ok: true, + text: async () => '{"name":"rsdoctor"}', + }); + globalThis.fetch = fetchMock as typeof fetch; + process.env.NODE_ENV = 'production'; + + const data = await fetchJSONByUrl('https://example.com/manifest.json'); + + expect(data).toStrictEqual({ name: 'rsdoctor' }); + expect(fetchMock).toBeCalledTimes(1); + }); + + it('fetchJSONByUrl() throws for non-2xx response', async () => { + const fetchMock = rs.fn().mockResolvedValue({ + ok: false, + status: 500, + }); + globalThis.fetch = fetchMock as typeof fetch; + process.env.NODE_ENV = 'production'; + + await expect( + fetchJSONByUrl('https://example.com/manifest.json'), + ).rejects.toThrow('Request failed with status 500'); + }); + + it('postServerAPI() sends json body and parses response json', async () => { + const fetchMock = rs.fn().mockResolvedValue({ + ok: true, + json: async () => ({ ok: true }), + }); + globalThis.fetch = fetchMock as typeof fetch; + process.env.NODE_ENV = 'production'; + + const result = await (postServerAPI as any)('/api/demo', { id: 1 }); + const [url, init] = fetchMock.mock.calls[0]; + + expect(url).toContain('/api/demo?_t='); + expect(init).toMatchObject({ + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ id: 1 }), + }); + expect(result).toStrictEqual({ ok: true }); + }); +}); diff --git a/packages/components/src/utils/request.ts b/packages/components/src/utils/request.ts index eccc1a1ca..3d09b7cf5 100644 --- a/packages/components/src/utils/request.ts +++ b/packages/components/src/utils/request.ts @@ -1,4 +1,3 @@ -import axios from 'axios'; import { Manifest, SDK } from '@rsdoctor/types'; import { Manifest as ManifestMethod, Url } from '@rsdoctor/utils/common'; import { APILoaderMode4Dev } from '../constants'; @@ -9,11 +8,58 @@ function random() { return `${Date.now()}${Math.floor(Math.random() * 10000)}`; } +function mergeAbortSignal(signal?: AbortSignal, timeout = 30000) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + + if (signal) { + if (signal.aborted) { + controller.abort(); + } else { + signal.addEventListener('abort', () => controller.abort(), { + once: true, + }); + } + } + + return { + signal: controller.signal, + clear: () => clearTimeout(timeoutId), + }; +} + +function resolveRequestUrl(url: string): string { + if ( + process.env.NODE_ENV === 'development' && + getAPILoaderModeFromStorage() === APILoaderMode4Dev.Local && + url.startsWith('/') + ) { + const nextUrl = + url === manifestUrlForDev ? SDK.ServerAPI.API.Manifest : url; + const currentUrl = new URL(location.href); + currentUrl.port = String(process.env.LOCAL_CLI_PORT!); + return `${currentUrl.origin}${nextUrl}`; + } + + return url; +} + +async function requestText(url: string, timeout: number) { + const { signal, clear } = mergeAbortSignal(undefined, timeout); + try { + const res = await fetch(resolveRequestUrl(url), { signal }); + if (!res.ok) { + throw new Error(`Request failed with status ${res.status}`); + } + return await res.text(); + } finally { + clear(); + } +} + export async function fetchShardingFile(url: string): Promise { if (Url.isUrl(url)) { - return axios - .get(url, { timeout: 999999, responseType: 'text' }) - .then((e) => e.data); + return requestText(url, 999999); } // json string return url; @@ -27,8 +73,7 @@ export async function loadManifestByUrl(url: string) { } export async function fetchJSONByUrl(url: string) { - const res = await axios.get(url, { timeout: 30000 }); - let json: unknown = res.data; + let json: unknown = await requestText(url, 30000); if (typeof json === 'string') { const trimmed = json.trim(); @@ -129,41 +174,30 @@ export async function fetchManifest(url = getManifestUrl()) { return res; } -// test for cli -if (process.env.NODE_ENV === 'development') { - if (getAPILoaderModeFromStorage() === APILoaderMode4Dev.Local) { - axios.interceptors.request.use((c) => { - c.withCredentials = false; - if (c.url?.startsWith('/')) { - if (c.url === manifestUrlForDev) { - c.url = SDK.ServerAPI.API.Manifest; - } - const url = new URL(location.href); - url.port = String(process.env.LOCAL_CLI_PORT!); - return { - ...c, - url: `${url.origin}${c.url}`, - }; - } - - return c; - }); - } -} - export async function postServerAPI< T extends SDK.ServerAPI.API, - B extends - SDK.ServerAPI.InferRequestBodyType = SDK.ServerAPI.InferRequestBodyType, - R extends - SDK.ServerAPI.InferResponseType = SDK.ServerAPI.InferResponseType, + B extends SDK.ServerAPI.InferRequestBodyType = + SDK.ServerAPI.InferRequestBodyType, + R extends SDK.ServerAPI.InferResponseType = + SDK.ServerAPI.InferResponseType, >(...args: B extends void ? [api: T] : [api: T, body: B]): Promise { const [api, body] = args; const timeout = process.env.NODE_ENV === 'development' ? 10000 : 60000; - const { data } = await axios.post>( - `${api}?_t=${random()}`, - body, - { timeout }, - ); - return data as R; + const { signal, clear } = mergeAbortSignal(undefined, timeout); + try { + const res = await fetch(resolveRequestUrl(`${api}?_t=${random()}`), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: body === undefined ? undefined : JSON.stringify(body), + signal, + }); + if (!res.ok) { + throw new Error(`Request failed with status ${res.status}`); + } + return (await res.json()) as R; + } finally { + clear(); + } } diff --git a/packages/core/package.json b/packages/core/package.json index 9e6916a52..46fb80c9b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -82,7 +82,6 @@ "source-map": "^0.7.6" }, "devDependencies": { - "axios": "1.14.0", "@rspack/core": "2.0.0-canary-20260116", "@scripts/test-helper": "workspace:*", "@types/fs-extra": "^11.0.4", diff --git a/packages/core/prebundle.config.mjs b/packages/core/prebundle.config.mjs index 06044ea19..7d24e2cae 100644 --- a/packages/core/prebundle.config.mjs +++ b/packages/core/prebundle.config.mjs @@ -1,6 +1,6 @@ /** @type {import('prebundle').Config} */ export default { - dependencies: ['axios'], + dependencies: [], exclude: [ '@rsdoctor/client', '@rsdoctor/graph', diff --git a/packages/core/src/inner-plugins/utils/loader.ts b/packages/core/src/inner-plugins/utils/loader.ts index 8af791853..c29982bbe 100644 --- a/packages/core/src/inner-plugins/utils/loader.ts +++ b/packages/core/src/inner-plugins/utils/loader.ts @@ -1,4 +1,3 @@ -import axios from 'axios'; import { ResolverFactory } from '@rspack/resolver'; import { omit } from 'es-toolkit/compat'; import path from 'path'; @@ -12,7 +11,29 @@ import { checkCirclePath } from './circleDetect'; import { ProxyLoaderInternalOptions, ProxyLoaderOptions } from '@/types'; import { Utils as BuildUtils } from '@/build-utils/build'; import { isESMLoader, parseQuery } from '@/build-utils/build/utils'; -import { Lodash } from '@rsdoctor/utils/common'; +import { Fetch, Lodash } from '@rsdoctor/utils/common'; + +async function postJSON(url: string, body: unknown, timeout: number) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + const fetchImpl = await Fetch.getFetch(); + try { + const res = await fetchImpl(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + signal: controller.signal, + }); + + if (!res.ok) { + throw new Error(`Request failed with status ${res.status}`); + } + } finally { + clearTimeout(timeoutId); + } +} export function getInternalLoaderOptions( loaderContext: Plugin.LoaderContext, @@ -99,7 +120,7 @@ export function interceptLoader( if (result.path) { return result.path; } - } catch (e) { + } catch { // .. } @@ -222,23 +243,20 @@ export async function reportLoader( // fallback to request the url to report loader data await Promise.all([ - axios - .post(`${host}${SDK.ServerAPI.API.ReportLoader}`, loaderData, { - timeout: 8888, - }) - .catch((err: Error) => { - logger.debug(`${err.message}`, '[WebpackPlugin.ReportLoader][error]'); - }), - axios - .post(`${host}${SDK.ServerAPI.API.ReportSourceMap}`, sourceMapData, { - timeout: 8888, - }) - .catch((err: Error) => { - logger.debug( - `${err.message}`, - '[WebpackPlugin.ReportSourceMap][error]', - ); - }), + postJSON( + `${host}${SDK.ServerAPI.API.ReportLoader}`, + loaderData, + 8888, + ).catch((err: Error) => { + logger.debug(`${err.message}`, '[WebpackPlugin.ReportLoader][error]'); + }), + postJSON( + `${host}${SDK.ServerAPI.API.ReportSourceMap}`, + sourceMapData, + 8888, + ).catch((err: Error) => { + logger.debug(`${err.message}`, '[WebpackPlugin.ReportSourceMap][error]'); + }), ]); return loaderData; diff --git a/packages/utils/package.json b/packages/utils/package.json index dfbfad566..155f47b7f 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -83,7 +83,8 @@ "lines-and-columns": "2.0.4", "picocolors": "^1.1.1", "rslog": "^1.3.2", - "strip-ansi": "^6.0.1" + "strip-ansi": "^6.0.1", + "undici": "^7.16.0" }, "devDependencies": { "@types/babel__code-frame": "7.27.0", diff --git a/packages/utils/src/common/fetch.ts b/packages/utils/src/common/fetch.ts new file mode 100644 index 000000000..419793dc0 --- /dev/null +++ b/packages/utils/src/common/fetch.ts @@ -0,0 +1,12 @@ +const dynamicImport = new Function('specifier', 'return import(specifier)') as ( + specifier: string, +) => Promise<{ fetch: typeof fetch }>; + +export async function getFetch(): Promise { + if (typeof globalThis.fetch === 'function') { + return globalThis.fetch.bind(globalThis); + } + + const mod = await dynamicImport('undici'); + return mod.fetch; +} diff --git a/packages/utils/src/common/index.ts b/packages/utils/src/common/index.ts index 68310a5de..c0628e3e6 100644 --- a/packages/utils/src/common/index.ts +++ b/packages/utils/src/common/index.ts @@ -11,6 +11,7 @@ export * as Url from './url'; export * as Plugin from './plugin'; export * as Data from './data'; export * as Alerts from './alerts'; +export * as Fetch from './fetch'; export * as Rspack from './rspack'; export * as Package from './package'; export * as Lodash from './lodash'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 05de5d547..f7732241d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -493,9 +493,6 @@ importers: '@types/node': specifier: ^22.8.1 version: 22.18.1 - axios: - specifier: 1.14.0 - version: 1.14.0 prebundle: specifier: 1.6.4 version: 1.6.4(typescript@5.9.2) @@ -530,9 +527,6 @@ importers: '@rsdoctor/client': specifier: workspace:* version: link:../client - axios: - specifier: 1.14.0 - version: 1.14.0 cac: specifier: ^7.0.0 version: 7.0.0 @@ -626,9 +620,6 @@ importers: antd: specifier: 5.19.1 version: 5.19.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - axios: - specifier: 1.14.0 - version: 1.14.0 clsx: specifier: ^2.1.1 version: 2.1.1 @@ -781,9 +772,6 @@ importers: '@types/tapable': specifier: 2.3.0 version: 2.3.0 - axios: - specifier: 1.14.0 - version: 1.14.0 babel-loader: specifier: 10.1.1 version: 10.1.1(@babel/core@7.26.0)(@rspack/core@2.0.0-canary-20260116(@module-federation/runtime-tools@0.22.0)(@swc/helpers@0.5.19))(webpack@5.105.4) @@ -1108,6 +1096,9 @@ importers: strip-ansi: specifier: ^6.0.1 version: 6.0.1 + undici: + specifier: ^7.16.0 + version: 7.24.6 devDependencies: '@types/babel__code-frame': specifier: 7.27.0 @@ -10526,6 +10517,10 @@ packages: resolution: {integrity: sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==} engines: {node: '>=14.0'} + undici@7.24.6: + resolution: {integrity: sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA==} + engines: {node: '>=20.18.1'} + unhead@2.1.12: resolution: {integrity: sha512-iTHdWD9ztTunOErtfUFk6Wr11BxvzumcYJ0CzaSCBUOEtg+DUZ9+gnE99i8QkLFT2q1rZD48BYYGXpOZVDLYkA==} @@ -22434,6 +22429,8 @@ snapshots: dependencies: '@fastify/busboy': 2.1.1 + undici@7.24.6: {} + unhead@2.1.12: dependencies: hookable: 6.0.1 From 5668acc449058fe17cecce0aeeaf0931d9e10a04 Mon Sep 17 00:00:00 2001 From: yifancong Date: Thu, 2 Apr 2026 13:47:37 +0800 Subject: [PATCH 2/5] chore: migrate axios to fetch --- packages/cli/src/fetch-http.ts | 9 +++ packages/cli/src/utils.test.ts | 38 ++++++------ packages/cli/src/utils.ts | 32 +++------- packages/client/rsbuild.config.ts | 10 --- .../src/components/Alert/change.tsx | 13 +--- packages/components/src/utils/request.ts | 57 ++++------------- .../core/src/inner-plugins/utils/loader.ts | 26 +------- packages/types/src/sdk/server/apis/index.ts | 7 ++- packages/utils/src/common/fetch.ts | 61 +++++++++++++++++-- 9 files changed, 117 insertions(+), 136 deletions(-) create mode 100644 packages/cli/src/fetch-http.ts diff --git a/packages/cli/src/fetch-http.ts b/packages/cli/src/fetch-http.ts new file mode 100644 index 000000000..c7893258d --- /dev/null +++ b/packages/cli/src/fetch-http.ts @@ -0,0 +1,9 @@ +import { Fetch } from '@rsdoctor/utils/common'; + +/** Thin wrapper so tests can `rs.mock('./fetch-http')` without depending on `Fetch.getFetch` internals. */ +export function fetchWithTimeout( + url: string, + options: Parameters[1], +) { + return Fetch.fetchWithTimeout(url, options); +} diff --git a/packages/cli/src/utils.test.ts b/packages/cli/src/utils.test.ts index 6944b2e4f..62adfe281 100644 --- a/packages/cli/src/utils.test.ts +++ b/packages/cli/src/utils.test.ts @@ -1,29 +1,36 @@ import { afterEach, describe, expect, it, rs } from '@rstest/core'; + +const { fetchWithTimeoutMock } = rs.hoisted(() => ({ + fetchWithTimeoutMock: rs.fn(), +})); + +rs.mock('./fetch-http', () => ({ + fetchWithTimeout: fetchWithTimeoutMock, +})); + import { fetchText, loadJSON } from './utils'; describe('cli utils', () => { - const originalFetch = globalThis.fetch; - afterEach(() => { - globalThis.fetch = originalFetch; + fetchWithTimeoutMock.mockReset(); }); it('fetchText() returns response text for 2xx', async () => { - const fetchMock = rs.fn().mockResolvedValue({ + fetchWithTimeoutMock.mockResolvedValue({ ok: true, text: async () => 'plain text', - }); - globalThis.fetch = fetchMock as typeof fetch; + } as Response); const result = await fetchText('https://example.com/file.txt'); expect(result).toBe('plain text'); - expect(fetchMock).toBeCalledTimes(1); - expect(fetchMock).toBeCalledWith( + expect(fetchWithTimeoutMock).toBeCalledTimes(1); + expect(fetchWithTimeoutMock).toBeCalledWith( 'https://example.com/file.txt', expect.objectContaining({ + timeout: 60000, headers: { - 'Content-Type': 'text/plain; charset=utf-8', + Accept: 'text/plain; charset=utf-8', 'Accept-Encoding': 'gzip,deflate,compress', }, }), @@ -31,11 +38,9 @@ describe('cli utils', () => { }); it('fetchText() throws when response is non-2xx', async () => { - const fetchMock = rs.fn().mockResolvedValue({ - ok: false, - status: 404, - }); - globalThis.fetch = fetchMock as typeof fetch; + fetchWithTimeoutMock.mockRejectedValue( + new Error('Request failed with status 404'), + ); await expect(fetchText('https://example.com/missing.txt')).rejects.toThrow( 'Request failed with status 404', @@ -43,11 +48,10 @@ describe('cli utils', () => { }); it('loadJSON() parses remote json text', async () => { - const fetchMock = rs.fn().mockResolvedValue({ + fetchWithTimeoutMock.mockResolvedValue({ ok: true, text: async () => '{"id":7,"name":"remote"}', - }); - globalThis.fetch = fetchMock as typeof fetch; + } as Response); const data = await loadJSON<{ id: number; name: string }>( 'https://example.com/data.json', diff --git a/packages/cli/src/utils.ts b/packages/cli/src/utils.ts index f0b3345f1..0088d114a 100644 --- a/packages/cli/src/utils.ts +++ b/packages/cli/src/utils.ts @@ -3,7 +3,8 @@ import fs from 'node:fs'; import { Ora } from 'ora'; import { Command } from './types'; import { Common } from '@rsdoctor/types'; -import { Fetch, Url } from '@rsdoctor/utils/common'; +import { Url } from '@rsdoctor/utils/common'; +import { fetchWithTimeout } from './fetch-http'; export function enhanceCommand( fn: Command, @@ -15,27 +16,14 @@ export function enhanceCommand( } export async function fetchText(url: string) { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 60000); - const fetchImpl = await Fetch.getFetch(); - - try { - const res = await fetchImpl(url, { - signal: controller.signal, - headers: { - 'Content-Type': 'text/plain; charset=utf-8', - 'Accept-Encoding': 'gzip,deflate,compress', - }, - }); - - if (!res.ok) { - throw new Error(`Request failed with status ${res.status}`); - } - - return await res.text(); - } finally { - clearTimeout(timeoutId); - } + const res = await fetchWithTimeout(url, { + timeout: 60000, + headers: { + Accept: 'text/plain; charset=utf-8', + 'Accept-Encoding': 'gzip,deflate,compress', + }, + }); + return res.text(); } export async function readFile(url: string, cwd: string) { diff --git a/packages/client/rsbuild.config.ts b/packages/client/rsbuild.config.ts index 3552edaef..09a94be09 100644 --- a/packages/client/rsbuild.config.ts +++ b/packages/client/rsbuild.config.ts @@ -35,16 +35,6 @@ export default defineConfig(({ env }) => { overrides: { 'node:async_hooks': false, async_hooks: false, - 'node:diagnostics_channel': false, - diagnostics_channel: false, - 'node:worker_threads': false, - worker_threads: false, - 'node:perf_hooks': false, - perf_hooks: false, - 'node:sqlite': false, - sqlite: false, - 'node:util/types': false, - 'util/types': false, }, }), pluginSass(), diff --git a/packages/components/src/components/Alert/change.tsx b/packages/components/src/components/Alert/change.tsx index 3f79320d9..842b67a83 100644 --- a/packages/components/src/components/Alert/change.tsx +++ b/packages/components/src/components/Alert/change.tsx @@ -11,7 +11,7 @@ import { Typography, } from 'antd'; import React, { useState } from 'react'; -import { useRuleIndexNavigate } from '../../utils'; +import { postServerAPI, useRuleIndexNavigate } from '../../utils'; import { DiffViewer } from '../base'; import { CodeOpener } from '../Opener'; import { TextDrawer } from '../TextDrawer'; @@ -31,16 +31,7 @@ const CodeChangeDrawerContent: React.FC = ({ const { path, line, isFixed, actual, expected } = file; // const [isFixed, setIsFixed] = useState(file.isFixed ?? false); const applyFix = async () => { - const res = await fetch(SDK.ServerAPI.API.ApplyErrorFix, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ id }), - }); - if (!res.ok) { - throw new Error(`Request failed with status ${res.status}`); - } + await postServerAPI(SDK.ServerAPI.API.ApplyErrorFix, { id: Number(id) }); setIsFixed(true); }; diff --git a/packages/components/src/utils/request.ts b/packages/components/src/utils/request.ts index 3d09b7cf5..ff164b41a 100644 --- a/packages/components/src/utils/request.ts +++ b/packages/components/src/utils/request.ts @@ -1,5 +1,5 @@ import { Manifest, SDK } from '@rsdoctor/types'; -import { Manifest as ManifestMethod, Url } from '@rsdoctor/utils/common'; +import { Fetch, Manifest as ManifestMethod, Url } from '@rsdoctor/utils/common'; import { APILoaderMode4Dev } from '../constants'; import { getManifestUrlFromUrlQuery } from './url'; import { getAPILoaderModeFromStorage } from './storage'; @@ -8,26 +8,6 @@ function random() { return `${Date.now()}${Math.floor(Math.random() * 10000)}`; } -function mergeAbortSignal(signal?: AbortSignal, timeout = 30000) { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), timeout); - - if (signal) { - if (signal.aborted) { - controller.abort(); - } else { - signal.addEventListener('abort', () => controller.abort(), { - once: true, - }); - } - } - - return { - signal: controller.signal, - clear: () => clearTimeout(timeoutId), - }; -} - function resolveRequestUrl(url: string): string { if ( process.env.NODE_ENV === 'development' && @@ -45,16 +25,8 @@ function resolveRequestUrl(url: string): string { } async function requestText(url: string, timeout: number) { - const { signal, clear } = mergeAbortSignal(undefined, timeout); - try { - const res = await fetch(resolveRequestUrl(url), { signal }); - if (!res.ok) { - throw new Error(`Request failed with status ${res.status}`); - } - return await res.text(); - } finally { - clear(); - } + const res = await Fetch.fetchWithTimeout(resolveRequestUrl(url), { timeout }); + return res.text(); } export async function fetchShardingFile(url: string): Promise { @@ -183,21 +155,14 @@ export async function postServerAPI< >(...args: B extends void ? [api: T] : [api: T, body: B]): Promise { const [api, body] = args; const timeout = process.env.NODE_ENV === 'development' ? 10000 : 60000; - const { signal, clear } = mergeAbortSignal(undefined, timeout); - try { - const res = await fetch(resolveRequestUrl(`${api}?_t=${random()}`), { + const res = await Fetch.fetchWithTimeout( + resolveRequestUrl(`${api}?_t=${random()}`), + { method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, + headers: { 'Content-Type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body), - signal, - }); - if (!res.ok) { - throw new Error(`Request failed with status ${res.status}`); - } - return (await res.json()) as R; - } finally { - clear(); - } + timeout, + }, + ); + return (await res.json()) as R; } diff --git a/packages/core/src/inner-plugins/utils/loader.ts b/packages/core/src/inner-plugins/utils/loader.ts index c29982bbe..a64e577aa 100644 --- a/packages/core/src/inner-plugins/utils/loader.ts +++ b/packages/core/src/inner-plugins/utils/loader.ts @@ -13,28 +13,6 @@ import { Utils as BuildUtils } from '@/build-utils/build'; import { isESMLoader, parseQuery } from '@/build-utils/build/utils'; import { Fetch, Lodash } from '@rsdoctor/utils/common'; -async function postJSON(url: string, body: unknown, timeout: number) { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), timeout); - const fetchImpl = await Fetch.getFetch(); - try { - const res = await fetchImpl(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(body), - signal: controller.signal, - }); - - if (!res.ok) { - throw new Error(`Request failed with status ${res.status}`); - } - } finally { - clearTimeout(timeoutId); - } -} - export function getInternalLoaderOptions( loaderContext: Plugin.LoaderContext, ): ProxyLoaderInternalOptions { @@ -243,14 +221,14 @@ export async function reportLoader( // fallback to request the url to report loader data await Promise.all([ - postJSON( + Fetch.postJSON( `${host}${SDK.ServerAPI.API.ReportLoader}`, loaderData, 8888, ).catch((err: Error) => { logger.debug(`${err.message}`, '[WebpackPlugin.ReportLoader][error]'); }), - postJSON( + Fetch.postJSON( `${host}${SDK.ServerAPI.API.ReportSourceMap}`, sourceMapData, 8888, diff --git a/packages/types/src/sdk/server/apis/index.ts b/packages/types/src/sdk/server/apis/index.ts index 8016ce79a..01883e424 100644 --- a/packages/types/src/sdk/server/apis/index.ts +++ b/packages/types/src/sdk/server/apis/index.ts @@ -112,7 +112,8 @@ export interface SocketResponseType { } export interface ResponseTypes - extends LoaderAPIResponse, + extends + LoaderAPIResponse, ResolverAPIResponse, PluginAPIResponse, GraphAPIResponse, @@ -160,13 +161,15 @@ export interface ResponseTypes } export interface RequestBodyTypes - extends LoaderAPIRequestBody, + extends + LoaderAPIRequestBody, ResolverAPIRequestBody, PluginAPIRequestBody, GraphAPIRequestBody, AlertsAPIRequestBody, ProjectAPIRequestBody { [API.ReportLoader]: LoaderData; + [API.ApplyErrorFix]: { id: number }; [API.SendAPIDataToClient]: { api: API; data: unknown; diff --git a/packages/utils/src/common/fetch.ts b/packages/utils/src/common/fetch.ts index 419793dc0..b56a50ff4 100644 --- a/packages/utils/src/common/fetch.ts +++ b/packages/utils/src/common/fetch.ts @@ -2,11 +2,64 @@ const dynamicImport = new Function('specifier', 'return import(specifier)') as ( specifier: string, ) => Promise<{ fetch: typeof fetch }>; +let _fetch: typeof fetch | undefined; + export async function getFetch(): Promise { - if (typeof globalThis.fetch === 'function') { - return globalThis.fetch.bind(globalThis); + if (_fetch) return _fetch; + _fetch = + typeof globalThis.fetch === 'function' + ? globalThis.fetch.bind(globalThis) + : (await dynamicImport('undici')).fetch; + return _fetch; +} + +export interface FetchOptions { + timeout?: number; + method?: string; + headers?: Record; + body?: string; + signal?: AbortSignal; +} + +export async function fetchWithTimeout( + url: string, + options: FetchOptions = {}, +): Promise { + const { timeout = 30000, signal: externalSignal, ...init } = options; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + + if (externalSignal) { + if (externalSignal.aborted) { + controller.abort(); + } else { + externalSignal.addEventListener('abort', () => controller.abort(), { + once: true, + }); + } + } + + const fetchImpl = await getFetch(); + try { + const res = await fetchImpl(url, { ...init, signal: controller.signal }); + if (!res.ok) { + throw new Error(`Request failed with status ${res.status}`); + } + return res; + } finally { + clearTimeout(timeoutId); } +} - const mod = await dynamicImport('undici'); - return mod.fetch; +export async function postJSON( + url: string, + body: unknown, + timeout = 30000, +): Promise { + return fetchWithTimeout(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + timeout, + }); } From 550d18aa0524281d54097b7518e4ae1eeb620cea Mon Sep 17 00:00:00 2001 From: yifancong Date: Thu, 2 Apr 2026 14:11:42 +0800 Subject: [PATCH 3/5] fix(utils): align fetch helper deps and implementation --- packages/utils/package.json | 3 +-- packages/utils/src/common/fetch.ts | 16 ++++++---------- pnpm-lock.yaml | 15 +++------------ 3 files changed, 10 insertions(+), 24 deletions(-) diff --git a/packages/utils/package.json b/packages/utils/package.json index 155f47b7f..dfbfad566 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -83,8 +83,7 @@ "lines-and-columns": "2.0.4", "picocolors": "^1.1.1", "rslog": "^1.3.2", - "strip-ansi": "^6.0.1", - "undici": "^7.16.0" + "strip-ansi": "^6.0.1" }, "devDependencies": { "@types/babel__code-frame": "7.27.0", diff --git a/packages/utils/src/common/fetch.ts b/packages/utils/src/common/fetch.ts index b56a50ff4..8978c9b29 100644 --- a/packages/utils/src/common/fetch.ts +++ b/packages/utils/src/common/fetch.ts @@ -1,15 +1,11 @@ -const dynamicImport = new Function('specifier', 'return import(specifier)') as ( - specifier: string, -) => Promise<{ fetch: typeof fetch }>; - let _fetch: typeof fetch | undefined; -export async function getFetch(): Promise { +export function getFetch(): typeof fetch { if (_fetch) return _fetch; - _fetch = - typeof globalThis.fetch === 'function' - ? globalThis.fetch.bind(globalThis) - : (await dynamicImport('undici')).fetch; + if (typeof globalThis.fetch !== 'function') { + throw new Error('fetch is not available in this environment'); + } + _fetch = globalThis.fetch.bind(globalThis); return _fetch; } @@ -39,7 +35,7 @@ export async function fetchWithTimeout( } } - const fetchImpl = await getFetch(); + const fetchImpl = getFetch(); try { const res = await fetchImpl(url, { ...init, signal: controller.signal }); if (!res.ok) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f7732241d..ef011aa79 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -677,7 +677,7 @@ importers: devDependencies: '@rsbuild/plugin-check-syntax': specifier: 1.6.1 - version: 1.6.1(@rsbuild/core@2.0.0-beta.9(@module-federation/runtime-tools@0.22.0)(core-js@3.47.0)) + version: 1.6.1(@rsbuild/core@2.0.0-beta.9(core-js@3.47.0)) '@rsbuild/plugin-react': specifier: ^1.4.6 version: 1.4.6(@rsbuild/core@2.0.0-beta.9(core-js@3.47.0)) @@ -716,7 +716,7 @@ importers: dependencies: '@rsbuild/plugin-check-syntax': specifier: 1.6.1 - version: 1.6.1(@rsbuild/core@2.0.0-beta.9(@module-federation/runtime-tools@0.22.0)(core-js@3.47.0)) + version: 1.6.1(@rsbuild/core@2.0.0-beta.9(core-js@3.47.0)) '@rsdoctor/graph': specifier: workspace:* version: link:../graph @@ -1096,9 +1096,6 @@ importers: strip-ansi: specifier: ^6.0.1 version: 6.0.1 - undici: - specifier: ^7.16.0 - version: 7.24.6 devDependencies: '@types/babel__code-frame': specifier: 7.27.0 @@ -10517,10 +10514,6 @@ packages: resolution: {integrity: sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==} engines: {node: '>=14.0'} - undici@7.24.6: - resolution: {integrity: sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA==} - engines: {node: '>=20.18.1'} - unhead@2.1.12: resolution: {integrity: sha512-iTHdWD9ztTunOErtfUFk6Wr11BxvzumcYJ0CzaSCBUOEtg+DUZ9+gnE99i8QkLFT2q1rZD48BYYGXpOZVDLYkA==} @@ -13698,7 +13691,7 @@ snapshots: optionalDependencies: '@rsbuild/core': 1.7.3 - '@rsbuild/plugin-check-syntax@1.6.1(@rsbuild/core@2.0.0-beta.9(@module-federation/runtime-tools@0.22.0)(core-js@3.47.0))': + '@rsbuild/plugin-check-syntax@1.6.1(@rsbuild/core@2.0.0-beta.9(core-js@3.47.0))': dependencies: acorn: 8.15.0 browserslist-to-es-version: 1.3.0 @@ -22429,8 +22422,6 @@ snapshots: dependencies: '@fastify/busboy': 2.1.1 - undici@7.24.6: {} - unhead@2.1.12: dependencies: hookable: 6.0.1 From fa9e83b67029d5e9277cd564d182a08c2a79f198 Mon Sep 17 00:00:00 2001 From: yifancong Date: Thu, 2 Apr 2026 14:31:08 +0800 Subject: [PATCH 4/5] chore: update fetch utility --- packages/utils/src/common/fetch.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/utils/src/common/fetch.ts b/packages/utils/src/common/fetch.ts index 8978c9b29..cdef5dbe9 100644 --- a/packages/utils/src/common/fetch.ts +++ b/packages/utils/src/common/fetch.ts @@ -1,12 +1,10 @@ -let _fetch: typeof fetch | undefined; - export function getFetch(): typeof fetch { - if (_fetch) return _fetch; - if (typeof globalThis.fetch !== 'function') { + const currentFetch = globalThis.fetch; + if (typeof currentFetch !== 'function') { throw new Error('fetch is not available in this environment'); } - _fetch = globalThis.fetch.bind(globalThis); - return _fetch; + + return currentFetch.bind(globalThis); } export interface FetchOptions { From 21275836b63138c680a9e029bc18d99f335f587d Mon Sep 17 00:00:00 2001 From: yifancong Date: Thu, 2 Apr 2026 14:43:22 +0800 Subject: [PATCH 5/5] chore: update rsbuild config --- packages/client/rsbuild.config.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/client/rsbuild.config.ts b/packages/client/rsbuild.config.ts index 09a94be09..ca7f356ca 100644 --- a/packages/client/rsbuild.config.ts +++ b/packages/client/rsbuild.config.ts @@ -30,13 +30,7 @@ export default defineConfig(({ env }) => { const config: RsbuildConfig = { plugins: [ pluginReact(), - pluginNodePolyfill({ - // Explicitly handle Node builtins referenced by transitive deps. - overrides: { - 'node:async_hooks': false, - async_hooks: false, - }, - }), + pluginNodePolyfill(), pluginSass(), pluginTypeCheck({ enable: IS_PRODUCTION,