diff --git a/.changeset/warm-tips-sit.md b/.changeset/warm-tips-sit.md new file mode 100644 index 0000000000..1e7fbd5c99 --- /dev/null +++ b/.changeset/warm-tips-sit.md @@ -0,0 +1,6 @@ +--- +"@redocly/openapi-core": minor +"@redocly/cli": minor +--- + +Switched to using native `fetch` API instead of `node-fetch` dependency, improving performance and reducing bundle size. diff --git a/package-lock.json b/package-lock.json index 2f85404098..fb7c8ddb8d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,7 +36,7 @@ "webpack-cli": "^4.10.0" }, "engines": { - "node": ">=15.0.0", + "node": ">=18.17.0", "npm": ">=7.0.0" } }, @@ -3457,16 +3457,6 @@ "undici-types": "~5.26.4" } }, - "node_modules/@types/node-fetch": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.2.tgz", - "integrity": "sha512-DHqhlq5jeESLy19TYhLakJ07kNumXWjcDdxXsLUMJZ6ue8VZJj4kLPQVE/2mdHh3xZziNF1xppu5lwmS53HR+A==", - "dev": true, - "dependencies": { - "@types/node": "*", - "form-data": "^3.0.0" - } - }, "node_modules/@types/pluralize": { "version": "0.0.29", "resolved": "https://registry.npmjs.org/@types/pluralize/-/pluralize-0.0.29.tgz", @@ -6367,20 +6357,6 @@ "resolved": "https://registry.npmjs.org/foreach/-/foreach-2.0.6.tgz", "integrity": "sha512-k6GAGDyqLe9JaebCsFCoudPPWfihKu8pylYXRlqP1J7ms39iPoTtk2fviNglIeQEwdh0bQeKJ01ZPyuyQvKzwg==" }, - "node_modules/form-data": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz", - "integrity": "sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==", - "dev": true, - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/fs-extra": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", @@ -12588,7 +12564,6 @@ "glob": "^7.1.6", "handlebars": "^4.7.6", "mobx": "^6.0.4", - "node-fetch": "^2.6.1", "pluralize": "^8.0.0", "react": "^17.0.0 || ^18.2.0", "react-dom": "^17.0.0 || ^18.2.0", @@ -12613,7 +12588,7 @@ "typescript": "5.5.3" }, "engines": { - "node": ">=14.19.0", + "node": ">=18.17.0", "npm": ">=7.0.0" } }, @@ -12638,11 +12613,10 @@ "@redocly/ajv": "^8.11.2", "@redocly/config": "^0.20.1", "colorette": "^1.2.0", - "https-proxy-agent": "^7.0.4", + "https-proxy-agent": "^7.0.5", "js-levenshtein": "^1.1.6", "js-yaml": "^4.1.0", "minimatch": "^5.0.1", - "node-fetch": "^2.6.1", "pluralize": "^8.0.0", "yaml-ast-parser": "0.0.43" }, @@ -12650,34 +12624,31 @@ "@types/js-levenshtein": "^1.1.0", "@types/js-yaml": "^4.0.3", "@types/minimatch": "^3.0.5", - "@types/node": "^20.11.5", - "@types/node-fetch": "^2.5.7", "@types/pluralize": "^0.0.29", "json-schema-to-ts": "^3.1.0", "typescript": "5.5.3" }, "engines": { - "node": ">=14.19.0", + "node": ">=18.17.0", "npm": ">=7.0.0" } }, "packages/core/node_modules/agent-base": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.0.tgz", - "integrity": "sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg==", - "dependencies": { - "debug": "^4.3.4" - }, + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", + "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", + "license": "MIT", "engines": { "node": ">= 14" } }, "packages/core/node_modules/https-proxy-agent": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.4.tgz", - "integrity": "sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", "dependencies": { - "agent-base": "^7.0.2", + "agent-base": "^7.1.2", "debug": "4" }, "engines": { @@ -15064,7 +15035,6 @@ "glob": "^7.1.6", "handlebars": "^4.7.6", "mobx": "^6.0.4", - "node-fetch": "^2.6.1", "pluralize": "^8.0.0", "react": "^17.0.0 || ^18.2.0", "react-dom": "^17.0.0 || ^18.2.0", @@ -15101,35 +15071,29 @@ "@types/js-levenshtein": "^1.1.0", "@types/js-yaml": "^4.0.3", "@types/minimatch": "^3.0.5", - "@types/node": "^20.11.5", - "@types/node-fetch": "^2.5.7", "@types/pluralize": "^0.0.29", "colorette": "^1.2.0", - "https-proxy-agent": "^7.0.4", + "https-proxy-agent": "^7.0.5", "js-levenshtein": "^1.1.6", "js-yaml": "^4.1.0", "json-schema-to-ts": "^3.1.0", "minimatch": "^5.0.1", - "node-fetch": "^2.6.1", "pluralize": "^8.0.0", "typescript": "5.5.3", "yaml-ast-parser": "0.0.43" }, "dependencies": { "agent-base": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.0.tgz", - "integrity": "sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg==", - "requires": { - "debug": "^4.3.4" - } + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", + "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==" }, "https-proxy-agent": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.4.tgz", - "integrity": "sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "requires": { - "agent-base": "^7.0.2", + "agent-base": "^7.1.2", "debug": "4" } } @@ -15361,16 +15325,6 @@ "undici-types": "~5.26.4" } }, - "@types/node-fetch": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.2.tgz", - "integrity": "sha512-DHqhlq5jeESLy19TYhLakJ07kNumXWjcDdxXsLUMJZ6ue8VZJj4kLPQVE/2mdHh3xZziNF1xppu5lwmS53HR+A==", - "dev": true, - "requires": { - "@types/node": "*", - "form-data": "^3.0.0" - } - }, "@types/pluralize": { "version": "0.0.29", "resolved": "https://registry.npmjs.org/@types/pluralize/-/pluralize-0.0.29.tgz", @@ -17559,17 +17513,6 @@ "resolved": "https://registry.npmjs.org/foreach/-/foreach-2.0.6.tgz", "integrity": "sha512-k6GAGDyqLe9JaebCsFCoudPPWfihKu8pylYXRlqP1J7ms39iPoTtk2fviNglIeQEwdh0bQeKJ01ZPyuyQvKzwg==" }, - "form-data": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz", - "integrity": "sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==", - "dev": true, - "requires": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "mime-types": "^2.1.12" - } - }, "fs-extra": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", diff --git a/package.json b/package.json index e851ed374b..2040051d82 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "", "private": true, "engines": { - "node": ">=15.0.0", + "node": ">=18.17.0", "npm": ">=7.0.0" }, "engineStrict": true, diff --git a/packages/cli/package.json b/packages/cli/package.json index 3beb525310..4e30dd6b41 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -8,7 +8,7 @@ "redocly": "bin/cli.js" }, "engines": { - "node": ">=14.19.0", + "node": ">=18.17.0", "npm": ">=7.0.0" }, "engineStrict": true, @@ -46,7 +46,6 @@ "glob": "^7.1.6", "handlebars": "^4.7.6", "mobx": "^6.0.4", - "node-fetch": "^2.6.1", "pluralize": "^8.0.0", "react": "^17.0.0 || ^18.2.0", "react-dom": "^17.0.0 || ^18.2.0", diff --git a/packages/cli/src/__tests__/commands/push-region.test.ts b/packages/cli/src/__tests__/commands/push-region.test.ts index a0e4bb1881..b47db2670d 100644 --- a/packages/cli/src/__tests__/commands/push-region.test.ts +++ b/packages/cli/src/__tests__/commands/push-region.test.ts @@ -2,32 +2,90 @@ import { getMergedConfig } from '@redocly/openapi-core'; import { handlePush } from '../../commands/push'; import { promptClientToken } from '../../commands/login'; import { ConfigFixture } from '../fixtures/config'; +import { Readable } from 'node:stream'; -jest.mock('fs'); -jest.mock('node-fetch', () => ({ - default: jest.fn(() => ({ - ok: true, - json: jest.fn().mockResolvedValue({}), +// Mock fs operations +jest.mock('fs', () => ({ + ...jest.requireActual('fs'), + createReadStream: () => { + const readable = new Readable(); + readable.push('test data'); + readable.push(null); + return readable; + }, + statSync: () => ({ size: 9 }), + readFileSync: () => Buffer.from('test data'), + existsSync: () => false, + readdirSync: () => [], +})); + +// Mock OpenAPI core +jest.mock('@redocly/openapi-core', () => ({ + ...jest.requireActual('@redocly/openapi-core'), + getMergedConfig: jest.fn().mockReturnValue({ + styleguide: { + skipDecorators: jest.fn(), + extendPaths: [], + pluginPaths: [], + }, + }), + bundle: jest.fn().mockResolvedValue({ + bundle: { parsed: {} }, + problems: { + totals: { errors: 0, warnings: 0 }, + items: [], + [Symbol.iterator]: function* () { + yield* this.items; + }, + }, + }), + RedoclyClient: jest.fn().mockImplementation((region?: string) => ({ + domain: region === 'eu' ? 'eu.redocly.com' : 'redoc.ly', + isAuthorizedWithRedoclyByRegion: jest.fn().mockResolvedValue(false), + login: jest.fn().mockResolvedValue({}), + registryApi: { + prepareFileUpload: jest.fn().mockResolvedValue({ + signedUploadUrl: 'https://example.com', + filePath: 'test.yaml', + }), + pushApi: jest.fn().mockResolvedValue({}), + }, })), })); -jest.mock('@redocly/openapi-core'); + jest.mock('../../commands/login'); jest.mock('../../utils/miscellaneous'); -(getMergedConfig as jest.Mock).mockImplementation((config) => config); +// Mock global fetch +global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({}), + headers: new Headers(), + statusText: 'OK', + redirected: false, + type: 'default', + url: '', + clone: () => ({} as Response), + body: new ReadableStream(), + bodyUsed: false, + arrayBuffer: async () => new ArrayBuffer(0), + blob: async () => new Blob(), + formData: async () => new FormData(), + text: async () => '', + } as Response) +); const mockPromptClientToken = promptClientToken as jest.MockedFunction; describe('push-with-region', () => { - const redoclyClient = require('@redocly/openapi-core').__redoclyClient; - redoclyClient.isAuthorizedWithRedoclyByRegion = jest.fn().mockResolvedValue(false); - - beforeAll(() => { + beforeEach(() => { + jest.clearAllMocks(); jest.spyOn(process.stdout, 'write').mockImplementation(() => true); }); it('should call login with default domain when region is US', async () => { - redoclyClient.domain = 'redoc.ly'; await handlePush({ argv: { upsert: true, @@ -38,12 +96,15 @@ describe('push-with-region', () => { config: ConfigFixture as any, version: 'cli-version', }); + expect(mockPromptClientToken).toBeCalledTimes(1); - expect(mockPromptClientToken).toHaveBeenCalledWith(redoclyClient.domain); + expect(mockPromptClientToken).toHaveBeenCalledWith('redoc.ly'); }); it('should call login with EU domain when region is EU', async () => { - redoclyClient.domain = 'eu.redocly.com'; + // Update config for EU region + const euConfig = { ...ConfigFixture, region: 'eu' }; + await handlePush({ argv: { upsert: true, @@ -51,10 +112,11 @@ describe('push-with-region', () => { destination: '@org/my-api@1.0.0', branchName: 'test', }, - config: ConfigFixture as any, + config: euConfig as any, version: 'cli-version', }); + expect(mockPromptClientToken).toBeCalledTimes(1); - expect(mockPromptClientToken).toHaveBeenCalledWith(redoclyClient.domain); + expect(mockPromptClientToken).toHaveBeenCalledWith('eu.redocly.com'); }); }); diff --git a/packages/cli/src/__tests__/commands/push.test.ts b/packages/cli/src/__tests__/commands/push.test.ts index 2fe8c36d68..7179be583f 100644 --- a/packages/cli/src/__tests__/commands/push.test.ts +++ b/packages/cli/src/__tests__/commands/push.test.ts @@ -4,26 +4,64 @@ import { exitWithError } from '../../utils/miscellaneous'; import { getApiRoot, getDestinationProps, handlePush, transformPush } from '../../commands/push'; import { ConfigFixture } from '../fixtures/config'; import { yellow } from 'colorette'; +import { Readable } from 'node:stream'; jest.mock('fs'); -jest.mock('node-fetch', () => ({ - default: jest.fn(() => ({ - ok: true, - json: jest.fn().mockResolvedValue({}), - })), -})); jest.mock('@redocly/openapi-core'); jest.mock('../../utils/miscellaneous'); +// Mock fs operations +jest.mock('fs', () => ({ + ...jest.requireActual('fs'), + createReadStream: jest.fn(() => { + const readable = new Readable(); + readable.push('test data'); + readable.push(null); + return readable; + }), + statSync: jest.fn(() => ({ isDirectory: () => false, size: 10 })), + readFileSync: jest.fn(() => Buffer.from('test data')), + existsSync: jest.fn(() => false), + readdirSync: jest.fn(() => []), +})); + +// Mock fetch +const mockFetch = jest.fn(() => + Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({}), + headers: new Headers(), + statusText: 'OK', + redirected: false, + type: 'default', + url: '', + clone: () => ({} as Response), + body: null, + bodyUsed: false, + arrayBuffer: async () => new ArrayBuffer(0), + blob: async () => new Blob(), + formData: async () => new FormData(), + text: async () => '', + } as Response) +); + +global.fetch = mockFetch; + (getMergedConfig as jest.Mock).mockImplementation((config) => config); describe('push', () => { const redoclyClient = require('@redocly/openapi-core').__redoclyClient; beforeEach(() => { + jest.clearAllMocks(); jest.spyOn(process.stdout, 'write').mockImplementation(() => true); }); + afterEach(() => { + mockFetch.mockClear(); + }); + it('pushes definition', async () => { await handlePush({ argv: { diff --git a/packages/cli/src/__tests__/fetch-with-timeout.test.ts b/packages/cli/src/__tests__/fetch-with-timeout.test.ts index 20c5e84461..6902d0633d 100644 --- a/packages/cli/src/__tests__/fetch-with-timeout.test.ts +++ b/packages/cli/src/__tests__/fetch-with-timeout.test.ts @@ -1,10 +1,8 @@ import AbortController from 'abort-controller'; import fetchWithTimeout from '../utils/fetch-with-timeout'; -import nodeFetch from 'node-fetch'; import { getProxyAgent } from '@redocly/openapi-core'; import { HttpsProxyAgent } from 'https-proxy-agent'; -jest.mock('node-fetch'); jest.mock('@redocly/openapi-core'); describe('fetchWithTimeout', () => { @@ -12,6 +10,8 @@ describe('fetchWithTimeout', () => { // @ts-ignore global.setTimeout = jest.fn(); global.clearTimeout = jest.fn(); + // Add global fetch mock + global.fetch = jest.fn(); }); beforeEach(() => { @@ -22,32 +22,34 @@ describe('fetchWithTimeout', () => { jest.clearAllMocks(); }); - it('should call node-fetch with signal', async () => { + it('should call fetch with signal', async () => { await fetchWithTimeout('url', { timeout: 1000 }); expect(global.setTimeout).toHaveBeenCalledTimes(1); - expect(nodeFetch).toHaveBeenCalledWith('url', { - signal: new AbortController().signal, - agent: undefined, - }); + expect(global.fetch).toHaveBeenCalledWith( + 'url', + expect.objectContaining({ + signal: expect.any(AbortSignal), + }) + ); expect(global.clearTimeout).toHaveBeenCalledTimes(1); }); - it('should call node-fetch with proxy agent', async () => { + it('should call fetch with proxy agent', async () => { (getProxyAgent as jest.Mock).mockRestore(); const proxyAgent = new HttpsProxyAgent('http://localhost'); (getProxyAgent as jest.Mock).mockReturnValueOnce(proxyAgent); await fetchWithTimeout('url'); - expect(nodeFetch).toHaveBeenCalledWith('url', { agent: proxyAgent }); + expect(global.fetch).toHaveBeenCalledWith('url', { dispatcher: proxyAgent }); }); - it('should call node-fetch without signal when timeout is not passed', async () => { + it('should call fetch without signal when timeout is not passed', async () => { await fetchWithTimeout('url'); expect(global.setTimeout).not.toHaveBeenCalled(); - expect(nodeFetch).toHaveBeenCalledWith('url', { agent: undefined }); + expect(global.fetch).toHaveBeenCalledWith('url', { agent: undefined }); expect(global.clearTimeout).not.toHaveBeenCalled(); }); }); diff --git a/packages/cli/src/__tests__/wrapper.test.ts b/packages/cli/src/__tests__/wrapper.test.ts index e1f8577989..6f4b5a8e14 100644 --- a/packages/cli/src/__tests__/wrapper.test.ts +++ b/packages/cli/src/__tests__/wrapper.test.ts @@ -6,11 +6,19 @@ import { Arguments } from 'yargs'; import { handlePush, PushOptions } from '../commands/push'; import { detectSpec } from '@redocly/openapi-core'; -jest.mock('node-fetch'); jest.mock('../utils/miscellaneous', () => ({ sendTelemetry: jest.fn(), loadConfigAndHandleErrors: jest.fn(), })); + +beforeEach(() => { + global.fetch = jest.fn(); +}); + +afterEach(() => { + jest.resetAllMocks(); +}); + jest.mock('../commands/lint', () => ({ handleLint: jest.fn().mockImplementation(({ collectSpecData }) => { collectSpecData({ openapi: '3.1.0' }); diff --git a/packages/cli/src/cms/api/__tests__/api.client.test.ts b/packages/cli/src/cms/api/__tests__/api.client.test.ts index 77d4e5a3d7..2c00945bb3 100644 --- a/packages/cli/src/cms/api/__tests__/api.client.test.ts +++ b/packages/cli/src/cms/api/__tests__/api.client.test.ts @@ -1,15 +1,21 @@ -import fetch, { Response } from 'node-fetch'; -import * as FormData from 'form-data'; import { red, yellow } from 'colorette'; import { ReuniteApi, PushPayload, ReuniteApiError } from '../api-client'; -jest.mock('node-fetch', () => ({ - default: jest.fn(), -})); +const originalFetch = global.fetch; + +beforeEach(() => { + // Reset fetch mock before each test + global.fetch = jest.fn(); +}); + +afterEach(() => { + // Restore original fetch after each test + global.fetch = originalFetch; +}); function mockFetchResponse(response: any) { - (fetch as jest.MockedFunction).mockResolvedValue(response as unknown as Response); + (global.fetch as jest.Mock).mockResolvedValue(response); } describe('ApiClient', () => { @@ -38,7 +44,7 @@ describe('ApiClient', () => { const result = await apiClient.remotes.getDefaultBranch(testOrg, testProject); - expect(fetch).toHaveBeenCalledWith( + expect(global.fetch).toHaveBeenCalledWith( `${testDomain}/api/orgs/${testOrg}/projects/${testProject}/source`, { method: 'GET', @@ -115,7 +121,7 @@ describe('ApiClient', () => { const result = await apiClient.remotes.upsert(testOrg, testProject, remotePayload); - expect(fetch).toHaveBeenCalledWith( + expect(global.fetch).toHaveBeenCalledWith( `${testDomain}/api/orgs/${testOrg}/projects/${testProject}/remotes`, { method: 'POST', @@ -213,12 +219,11 @@ describe('ApiClient', () => { }); it('should push to remote', async () => { - let passedFormData = new FormData(); + let passedFormData: FormData = new FormData(); (fetch as jest.MockedFunction).mockImplementationOnce( async (_: any, options: any): Promise => { passedFormData = options.body as FormData; - return { ok: true, json: jest.fn().mockResolvedValue(responseMock), @@ -226,31 +231,18 @@ describe('ApiClient', () => { } ); - const formData = new FormData(); + const formData = new globalThis.FormData(); formData.append('remoteId', testRemoteId); formData.append('commit[message]', pushPayload.commit.message); formData.append('commit[author][name]', pushPayload.commit.author.name); formData.append('commit[author][email]', pushPayload.commit.author.email); formData.append('commit[branchName]', pushPayload.commit.branchName); - formData.append('files[some-file.yaml]', filesMock[0].stream); + formData.append('files[some-file.yaml]', new Blob([filesMock[0].stream])); const result = await apiClient.remotes.push(testOrg, testProject, pushPayload, filesMock); - expect(fetch).toHaveBeenCalledWith( - `${testDomain}/api/orgs/${testOrg}/projects/${testProject}/pushes`, - expect.objectContaining({ - method: 'POST', - headers: { - Authorization: `Bearer ${testToken}`, - 'user-agent': expectedUserAgent, - }, - }) - ); - - expect( - JSON.stringify(passedFormData).replace(new RegExp(passedFormData.getBoundary(), 'g'), '') - ).toEqual(JSON.stringify(formData).replace(new RegExp(formData.getBoundary(), 'g'), '')); + expect([...passedFormData.entries()]).toEqual([...formData.entries()]); expect(result).toEqual(responseMock); }); @@ -363,9 +355,10 @@ describe('ApiClient', () => { mockFetchResponse({ ok: true, json: jest.fn().mockResolvedValue(responseBody), - headers: new Headers({ - Sunset: sunsetDate.toISOString(), - }), + headers: { + get: (name: string) => + name.toLowerCase() === 'sunset' ? sunsetDate.toISOString() : null, + }, }); await requestFn(); @@ -388,9 +381,10 @@ describe('ApiClient', () => { mockFetchResponse({ ok: true, json: jest.fn().mockResolvedValue(responseBody), - headers: new Headers({ - Sunset: sunsetDate.toISOString(), - }), + headers: { + get: (name: string) => + name.toLowerCase() === 'sunset' ? sunsetDate.toISOString() : null, + }, }); await requestFn(); @@ -410,9 +404,12 @@ describe('ApiClient', () => { mockFetchResponse({ ok: true, json: jest.fn().mockResolvedValue(upsertRemoteMock.responseBody), - headers: new Headers({ - Sunset: new Date('2024-08-06T12:30:32.456Z').toISOString(), - }), + headers: { + get: (name: string) => + name.toLowerCase() === 'sunset' + ? new Date('2024-08-06T12:30:32.456Z').toISOString() + : null, + }, }); await upsertRemoteMock.requestFn(); @@ -420,9 +417,12 @@ describe('ApiClient', () => { mockFetchResponse({ ok: true, json: jest.fn().mockResolvedValue(getDefaultBranchMock.responseBody), - headers: new Headers({ - Sunset: new Date(Date.now() + 1000 * 60 * 60 * 24).toISOString(), - }), + headers: { + get: (name: string) => + name.toLowerCase() === 'sunset' + ? new Date(Date.now() + 1000 * 60 * 60 * 24).toISOString() + : null, + }, }); await getDefaultBranchMock.requestFn(); @@ -430,9 +430,12 @@ describe('ApiClient', () => { mockFetchResponse({ ok: true, json: jest.fn().mockResolvedValue(pushMock.responseBody), - headers: new Headers({ - Sunset: new Date('2024-08-06T12:30:32.456Z').toISOString(), - }), + headers: { + get: (name: string) => + name.toLowerCase() === 'sunset' + ? new Date('2024-08-06T12:30:32.456Z').toISOString() + : null, + }, }); await pushMock.requestFn(); diff --git a/packages/cli/src/cms/api/api-client.ts b/packages/cli/src/cms/api/api-client.ts index 3cd0fa5679..a65627252d 100644 --- a/packages/cli/src/cms/api/api-client.ts +++ b/packages/cli/src/cms/api/api-client.ts @@ -1,11 +1,9 @@ import { yellow, red } from 'colorette'; -import * as FormData from 'form-data'; import fetchWithTimeout, { type FetchWithTimeoutOptions, DEFAULT_FETCH_TIMEOUT, } from '../../utils/fetch-with-timeout'; -import type { Response } from 'node-fetch'; import type { ReadStream } from 'fs'; import type { ListRemotesResponse, @@ -178,7 +176,7 @@ class RemotesApi { payload: PushPayload, files: { path: string; stream: ReadStream | Buffer }[] ): Promise { - const formData = new FormData(); + const formData = new globalThis.FormData(); formData.append('remoteId', payload.remoteId); formData.append('commit[message]', payload.commit.message); @@ -192,7 +190,11 @@ class RemotesApi { payload.commit.createdAt && formData.append('commit[createdAt]', payload.commit.createdAt); for (const file of files) { - formData.append(`files[${file.path}]`, file.stream); + const blob = + file.stream instanceof Buffer + ? new Blob([file.stream]) + : new Blob([await streamToBuffer(file.stream)]); + formData.append(`files[${file.path}]`, blob, file.path); } payload.isMainBranch && formData.append('isMainBranch', 'true'); @@ -369,3 +371,11 @@ export type PushPayload = { }; isMainBranch?: boolean; }; + +async function streamToBuffer(stream: ReadStream): Promise { + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(chunk); + } + return Buffer.concat(chunks); +} diff --git a/packages/cli/src/commands/push.ts b/packages/cli/src/commands/push.ts index 3a6255bc8b..b336cfec24 100644 --- a/packages/cli/src/commands/push.ts +++ b/packages/cli/src/commands/push.ts @@ -1,6 +1,5 @@ import * as fs from 'fs'; import * as path from 'path'; -import fetch from 'node-fetch'; import { performance } from 'perf_hooks'; import { yellow, green, blue, red } from 'colorette'; import { createHash } from 'crypto'; @@ -26,6 +25,7 @@ import { handlePush as handleCMSPush } from '../cms/commands/push'; import type { Config, BundleOutputFormat, Region } from '@redocly/openapi-core'; import type { CommandArgs } from '../wrapper'; import type { VerifyConfigOptions } from '../types'; +import type { Readable } from 'node:stream'; const DEFAULT_VERSION = 'latest'; @@ -62,6 +62,7 @@ export function commonPushHandler({ export async function handlePush({ argv, config }: CommandArgs): Promise { const client = new RedoclyClient(config.region); + console.log('config.region', config.region); const isAuthorized = await client.isAuthorizedWithRedoclyByRegion(); if (!isAuthorized) { try { @@ -436,7 +437,7 @@ export function getApiRoot({ return api?.root; } -function uploadFileToS3(url: string, filePathOrBuffer: string | Buffer) { +async function uploadFileToS3(url: string, filePathOrBuffer: string | Buffer) { const fileSizeInBytes = typeof filePathOrBuffer === 'string' ? fs.statSync(filePathOrBuffer).size @@ -445,12 +446,29 @@ function uploadFileToS3(url: string, filePathOrBuffer: string | Buffer) { const readStream = typeof filePathOrBuffer === 'string' ? fs.createReadStream(filePathOrBuffer) : filePathOrBuffer; - return fetch(url, { + const requestOptions = { method: 'PUT', headers: { 'Content-Length': fileSizeInBytes.toString(), }, - body: readStream, - agent: getProxyAgent(), - }); + body: Buffer.isBuffer(readStream) + ? new Blob([readStream]) + : new Blob([await streamToBuffer(readStream as Readable)]), + } as RequestInit; + + const proxyAgent = getProxyAgent(); + if (proxyAgent) { + // @ts-expect-error Node.js fetch has different type for agent + requestOptions.dispatcher = proxyAgent; + } + + return fetch(url, requestOptions); +} + +async function streamToBuffer(stream: Readable): Promise { + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(chunk); + } + return Buffer.concat(chunks); } diff --git a/packages/cli/src/utils/fetch-with-timeout.ts b/packages/cli/src/utils/fetch-with-timeout.ts index 5bfcba4d41..6ba710fe3a 100644 --- a/packages/cli/src/utils/fetch-with-timeout.ts +++ b/packages/cli/src/utils/fetch-with-timeout.ts @@ -1,5 +1,3 @@ -import nodeFetch, { type RequestInit } from 'node-fetch'; -import AbortController from 'abort-controller'; import { getProxyAgent } from '@redocly/openapi-core'; export const DEFAULT_FETCH_TIMEOUT = 3000; @@ -9,25 +7,31 @@ export type FetchWithTimeoutOptions = RequestInit & { }; export default async (url: string, { timeout, ...options }: FetchWithTimeoutOptions = {}) => { + const requestOptions = { + ...options, + } as RequestInit; + + // Only set agent if proxy is configured + const proxyAgent = getProxyAgent(); + if (proxyAgent) { + // @ts-expect-error Node.js fetch has different type for agent + requestOptions.dispatcher = proxyAgent; + } + if (!timeout) { - return nodeFetch(url, { - ...options, - agent: getProxyAgent(), - }); + return fetch(url, requestOptions); } - const controller = new AbortController(); + const controller = new globalThis.AbortController(); const timeoutId = setTimeout(() => { controller.abort(); }, timeout); - const res = await nodeFetch(url, { + const res = await fetch(url, { + ...requestOptions, signal: controller.signal, - ...options, - agent: getProxyAgent(), }); clearTimeout(timeoutId); - return res; }; diff --git a/packages/core/package.json b/packages/core/package.json index f3cc99f103..f307753019 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -4,7 +4,7 @@ "description": "", "main": "lib/index.js", "engines": { - "node": ">=14.19.0", + "node": ">=18.17.0", "npm": ">=7.0.0" }, "engineStrict": true, @@ -17,7 +17,6 @@ "fs": false, "path": "path-browserify", "os": false, - "node-fetch": false, "colorette": false, "https-proxy-agent": false }, @@ -38,11 +37,10 @@ "@redocly/ajv": "^8.11.2", "@redocly/config": "^0.20.1", "colorette": "^1.2.0", - "https-proxy-agent": "^7.0.4", + "https-proxy-agent": "^7.0.5", "js-levenshtein": "^1.1.6", "js-yaml": "^4.1.0", "minimatch": "^5.0.1", - "node-fetch": "^2.6.1", "pluralize": "^8.0.0", "yaml-ast-parser": "0.0.43" }, @@ -50,8 +48,6 @@ "@types/js-levenshtein": "^1.1.0", "@types/js-yaml": "^4.0.3", "@types/minimatch": "^3.0.5", - "@types/node": "^20.11.5", - "@types/node-fetch": "^2.5.7", "@types/pluralize": "^0.0.29", "json-schema-to-ts": "^3.1.0", "typescript": "5.5.3" diff --git a/packages/core/src/redocly/__tests__/redocly-client.test.ts b/packages/core/src/redocly/__tests__/redocly-client.test.ts index 0e19752d6c..06973581c6 100644 --- a/packages/core/src/redocly/__tests__/redocly-client.test.ts +++ b/packages/core/src/redocly/__tests__/redocly-client.test.ts @@ -1,12 +1,12 @@ import { setRedoclyDomain } from '../domains'; import { RedoclyClient } from '../index'; -jest.mock('node-fetch', () => ({ - default: jest.fn(() => ({ +global.fetch = jest.fn(() => + Promise.resolve({ ok: true, json: jest.fn().mockResolvedValue({}), - })), -})); + } as any) +); describe('RedoclyClient', () => { const REDOCLY_DOMAIN_US = 'redocly.com'; diff --git a/packages/core/src/redocly/registry-api.ts b/packages/core/src/redocly/registry-api.ts index ff6abafde7..c3686a5116 100644 --- a/packages/core/src/redocly/registry-api.ts +++ b/packages/core/src/redocly/registry-api.ts @@ -1,8 +1,6 @@ -import fetch from 'node-fetch'; import { getProxyAgent, isNotEmptyObject } from '../utils'; import { getRedoclyDomain } from './domains'; -import type { RequestInit, HeadersInit } from 'node-fetch'; import type { NotFoundProblemResponse, PrepareFileuploadOKResponse, @@ -43,10 +41,13 @@ export class RegistryApi { throw new Error('Unauthorized'); } - const response = await fetch( - `${this.getBaseUrl()}${path}`, - Object.assign({}, options, { headers, agent: getProxyAgent() }) - ); + const requestOptions = { + ...options, + headers, + agent: getProxyAgent(), + }; + + const response = await fetch(`${this.getBaseUrl()}${path}`, requestOptions); if (response.status === 401) { throw new Error('Unauthorized'); diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts index 6b6d0476e3..0eba1a4e10 100644 --- a/packages/core/src/utils.ts +++ b/packages/core/src/utils.ts @@ -1,7 +1,6 @@ import * as fs from 'fs'; import { extname } from 'path'; import * as minimatch from 'minimatch'; -import fetch from 'node-fetch'; import { parseYaml } from './js-yaml'; import { env } from './env'; import { logger, colorize } from './logger';