Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/remove-node-fetch-from-cli-kit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/cli-kit': patch
---

Replace node-fetch and form-data with the standard fetch implementation; `@shopify/cli-kit/node/http` no longer exports `FetchError`
3 changes: 2 additions & 1 deletion packages/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,8 @@
"@types/react": "^19.0.0",
"@types/which": "3.0.4",
"@types/ws": "^8.5.13",
"@vitest/coverage-istanbul": "^3.2.7"
"@vitest/coverage-istanbul": "^3.2.7",
"undici": "8.10.0"
},
"engines": {
"node": ">=22.12.0"
Expand Down
4 changes: 2 additions & 2 deletions packages/app/src/cli/services/bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,13 +65,13 @@ export async function uploadToGCS(signedURL: string, filePath: string) {
let response: Response | undefined
for (let attempt = 1; attempt <= UPLOAD_MAX_ATTEMPTS; attempt++) {
// The signed URL only signs the `host` header, so no extra headers are
// required; node-fetch derives Content-Length from the buffer body.
// required; fetch derives Content-Length from the buffer body.
// eslint-disable-next-line no-await-in-loop
response = await fetch(signedURL, {method: 'put', body: buffer}, 'slow-request')
if (response.ok) return
const lastAttempt = attempt === UPLOAD_MAX_ATTEMPTS
const retryable = RETRYABLE_UPLOAD_STATUS_CODES.has(response.status)
// node-fetch keeps the socket open until the body is consumed. On the final
// fetch keeps the socket open until the body is consumed. On the final
// attempt we read it below for the error message; otherwise drain it here so
// the connection can be reused or released before the next attempt.
if (retryable && !lastAttempt) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ vi.mock('@shopify/cli-kit/node/fs')
vi.mock('@shopify/cli-kit/node/archiver')
vi.mock('@shopify/cli-kit/node/http')
vi.mock('../../../../utilities/app/app-url.js')
vi.mock('node-fetch')
vi.mock('../../../bundle.js')
describe('setupDevSessionProcess', () => {
test('returns a dev session process with correct configuration', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import {sendUninstallWebhookToAppServer} from './send-app-uninstalled-webhook.js
import {triggerLocalWebhook} from './trigger-local-webhook.js'
import {testDeveloperPlatformClient} from '../../models/app/app.test-data.js'
import {describe, expect, vi, test} from 'vitest'
import {FetchError} from '@shopify/cli-kit/node/http'
import {Writable} from 'stream'

vi.mock('./trigger-local-webhook.js')
Expand Down Expand Up @@ -59,8 +58,9 @@ describe('sendUninstallWebhookToAppServer', () => {
})

test("retries the webhook request if the app hasn't started yet", async () => {
const fakeError = new FetchError('Fake error for testing', 'network')
fakeError.code = 'ECONNREFUSED'
const fakeError = Object.assign(new TypeError('fetch failed'), {
cause: Object.assign(new Error('connect ECONNREFUSED'), {code: 'ECONNREFUSED'}),
})
vi.mocked(triggerLocalWebhook).mockRejectedValueOnce(fakeError).mockResolvedValueOnce(true)
const stdout = {write: vi.fn()} as unknown as Writable
const developerPlatformClient = testDeveloperPlatformClient()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,21 @@ import {getWebhookSample, SampleWebhook, SendSampleWebhookVariables} from './req
import {triggerLocalWebhook} from './trigger-local-webhook.js'
import {DELIVERY_METHOD} from './trigger-flags.js'
import {DeveloperPlatformClient} from '../../utilities/developer-platform-client.js'
import {FetchError} from '@shopify/cli-kit/node/http'
import {sleep} from '@shopify/cli-kit/node/system'
import {Writable} from 'stream'

// fetch wraps connection failures in a TypeError whose cause carries the
// system error code, possibly inside an AggregateError when several
// addresses were tried (e.g. IPv4 and IPv6 localhost).
function isConnectionRefusedError(error: unknown): boolean {
if (!(error instanceof Error)) return false
const cause = (error as {cause?: unknown}).cause
if (!cause || typeof cause !== 'object') return false
const causeWithCode = cause as {code?: string; errors?: {code?: string}[]}
if (causeWithCode.code === 'ECONNREFUSED') return true
return Array.isArray(causeWithCode.errors) && causeWithCode.errors.some((err) => err.code === 'ECONNREFUSED')
}

interface SendUninstallWebhookToAppServerOptions {
stdout: Writable
developerPlatformClient: DeveloperPlatformClient
Expand Down Expand Up @@ -60,7 +71,7 @@ async function triggerWebhook(

return result
} catch (error) {
if (error instanceof FetchError && error.code === 'ECONNREFUSED') {
if (isConnectionRefusedError(error)) {
if (tries < 3) {
options.stdout.write("App isn't responding yet, retrying in 5 seconds")
await sleep(5)
Expand Down
16 changes: 9 additions & 7 deletions packages/app/src/cli/utilities/app/http-reverse-proxy.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {getProxyingWebServer} from './http-reverse-proxy.js'
import {AbortController} from '@shopify/cli-kit/node/abort'
import {describe, test, expect} from 'vitest'
import fetch from 'node-fetch'
import {fetch, Agent} from 'undici'
import WebSocket, {WebSocketServer} from 'ws'
import http from 'http'
import https from 'https'
Expand All @@ -16,17 +16,19 @@ describe.sequential.each(each)('http-reverse-proxy for %s', (protocol) => {
protocol === 'http'
? new http.Agent({keepAlive: false})
: new https.Agent({ca: localhostCert.cert, keepAlive: false})
const dispatcher =
protocol === 'http' ? new Agent({pipelining: 0}) : new Agent({pipelining: 0, connect: {ca: localhostCert.cert}})

test('routes requests to the correct target based on path', {retry: 2}, async ({setup}) => {
const response1 = await fetch(`${protocol}://localhost:${setup.proxyPort}/path1/test`, {agent})
const response1 = await fetch(`${protocol}://localhost:${setup.proxyPort}/path1/test`, {dispatcher})
await expect(response1.text()).resolves.toBe('Response from target server 1')

const response2 = await fetch(`${protocol}://localhost:${setup.proxyPort}/path2/test`, {agent})
const response2 = await fetch(`${protocol}://localhost:${setup.proxyPort}/path2/test`, {dispatcher})
await expect(response2.text()).resolves.toBe('Response from target server 2')
})

test('routes requests to the default target when no matching path is found', {retry: 2}, async ({setup}) => {
const response = await fetch(`${protocol}://localhost:${setup.proxyPort}/unknown/path`, {agent})
const response = await fetch(`${protocol}://localhost:${setup.proxyPort}/unknown/path`, {dispatcher})
await expect(response.text()).resolves.toBe('Response from target server 1')
})

Expand Down Expand Up @@ -63,7 +65,7 @@ describe.sequential.each(each)('http-reverse-proxy for %s', (protocol) => {
'Access-Control-Request-Method': 'GET',
'Access-Control-Request-Headers': 'Authorization',
},
agent,
dispatcher,
})
expect(response.status).toBe(204)
expect(response.headers.get('access-control-allow-origin')).toBe('https://extensions.shopifycdn.com')
Expand All @@ -75,7 +77,7 @@ describe.sequential.each(each)('http-reverse-proxy for %s', (protocol) => {
test('responds to CORS preflight OPTIONS with defaults when no request headers', {retry: 2}, async ({setup}) => {
const response = await fetch(`${protocol}://localhost:${setup.proxyPort}/path1/test`, {
method: 'OPTIONS',
agent,
dispatcher,
})
expect(response.status).toBe(204)
expect(response.headers.get('access-control-allow-origin')).toBe('*')
Expand All @@ -89,7 +91,7 @@ describe.sequential.each(each)('http-reverse-proxy for %s', (protocol) => {
await expect
.poll(async () => {
try {
await fetch(`${protocol}://localhost:${setup.proxyPort}/path1`, {agent})
await fetch(`${protocol}://localhost:${setup.proxyPort}/path1`, {dispatcher})
return 'open'
// eslint-disable-next-line no-catch-all/no-catch-all
} catch {
Expand Down
3 changes: 1 addition & 2 deletions packages/cli-kit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,6 @@
"fast-glob": "3.3.3",
"figures": "5.0.0",
"find-up": "6.3.0",
"form-data": "4.0.6",
"fs-extra": "11.1.0",
"gradient-string": "2.0.2",
"graphql": "16.14.2",
Expand All @@ -147,7 +146,6 @@
"minimatch": "9.0.9",
"mrmime": "1.0.1",
"network-interfaces": "1.1.0",
"node-fetch": "3.3.2",
"open": "8.4.2",
"pathe": "1.1.2",
"react": "19.2.4",
Expand All @@ -156,6 +154,7 @@
"stacktracey": "2.2.0",
"strip-ansi": "7.2.0",
"supports-hyperlinks": "3.2.0",
"undici": "8.10.0",
"which": "4.0.0",
"zod": "3.25.76"
},
Expand Down
5 changes: 4 additions & 1 deletion packages/cli-kit/src/private/node/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,14 @@ import {sleepWithBackoffUntil} from './sleep-with-backoff.js'
import {outputDebug} from '../../public/node/output.js'
import {recordRetry} from '../../public/node/analytics.js'

import {Headers} from 'form-data'
import {ClientError} from 'graphql-request'

import {performance} from 'perf_hooks'

interface Headers {
forEach(callbackfn: (value: string, key: string) => void): void
}

export type API = 'admin' | 'storefront-renderer' | 'partners' | 'business-platform' | 'app-management'

export const allAPIs: API[] = ['admin', 'storefront-renderer', 'partners', 'business-platform', 'app-management']
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {isCI, openURL} from '../../../public/node/system.js'
import * as output from '../../../public/node/output.js'

import {beforeEach, describe, expect, test, vi} from 'vitest'
import {Response} from 'node-fetch'
import {Response} from 'undici'

vi.mock('../../../public/node/context/fqdn.js')
vi.mock('./identity')
Expand Down Expand Up @@ -151,7 +151,7 @@ describe('requestDeviceAuthorization', () => {
Object.defineProperty(response, 'status', {value: 200})
Object.defineProperty(response, 'statusText', {value: 'OK'})
// Mock text() to throw an error
response.text = vi.fn().mockRejectedValue(new Error('Network error'))
Object.defineProperty(response, 'text', {value: vi.fn().mockRejectedValue(new Error('Network error'))})
vi.mocked(shopifyFetch).mockResolvedValue(response)
vi.mocked(identityFqdn).mockResolvedValue('fqdn.com')
vi.mocked(clientId).mockReturnValue('clientId')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,11 @@ import {clientId} from './identity.js'
import {exchangeDeviceCodeForAccessToken} from './exchange.js'
import {IdentityToken} from './schema.js'
import {identityFqdn} from '../../../public/node/context/fqdn.js'
import {shopifyFetch} from '../../../public/node/http.js'
import {shopifyFetch, Response} from '../../../public/node/http.js'
import {outputContent, outputDebug, outputInfo, outputToken} from '../../../public/node/output.js'
import {AbortError, BugError} from '../../../public/node/error.js'
import {isCI, openURL} from '../../../public/node/system.js'

import {Response} from 'node-fetch'

export interface DeviceAuthorizationResponse {
deviceCode: string
userCode: string
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {outputDebug} from '../../../public/node/output.js'
import {err, ok} from '../../../public/node/result.js'

import {describe, test, expect, vi, afterAll, beforeEach} from 'vitest'
import {Response} from 'node-fetch'
import {Response} from 'undici'

const currentDate = new Date(2022, 1, 1, 10)
const expiredDate = new Date(2022, 1, 1, 11)
Expand Down
10 changes: 5 additions & 5 deletions packages/cli-kit/src/public/node/api/admin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,12 +119,12 @@ describe('admin-rest-api', () => {
// Given
const json = () => Promise.resolve({result: true})
const status = 200
const headers = {'some-header': 123}
const headers = {'some-header': '123'}

vi.spyOn(http, 'shopifyFetch').mockResolvedValue({
json,
status,
headers: {raw: () => headers},
headers: new Headers(headers),
} as any)

// When
Expand All @@ -133,7 +133,7 @@ describe('admin-rest-api', () => {
// Then
expect(result.json).toEqual({result: true})
expect(result.status).toEqual(200)
expect(result.headers).toEqual({'some-header': 123})
expect(result.headers).toEqual({'some-header': ['123']})
})

test('fetch is called with correct parameters', async () => {
Expand All @@ -146,7 +146,7 @@ describe('admin-rest-api', () => {
const spyFetch = vi.spyOn(http, 'shopifyFetch').mockResolvedValue({
json,
status,
headers: {raw: () => ({})},
headers: new Headers(),
} as any)

// When
Expand All @@ -173,7 +173,7 @@ describe('admin-rest-api', () => {
const spyFetch = vi.spyOn(http, 'shopifyFetch').mockResolvedValue({
json: () => Promise.resolve({result: true}),
status,
headers: {raw: () => ({})},
headers: new Headers(),
} as any)

// When
Expand Down
7 changes: 6 additions & 1 deletion packages/cli-kit/src/public/node/api/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,10 +267,15 @@ export async function restRequest<T>(

const json = await response.json().catch(() => ({}))

const responseHeaders: Record<string, string[]> = {}
response.headers.forEach((value, key) => {
responseHeaders[key] = key.toLowerCase() === 'set-cookie' ? response.headers.getSetCookie() : [value]
})

return {
json,
status: response.status,
headers: response.headers.raw(),
headers: responseHeaders,
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import {stageFile} from './stage-file.js'
import {adminRequestDoc} from '../admin.js'
import {fetch} from '../../http.js'
import {fetch, formData} from '../../http.js'
import {renderSingleTask, RenderSingleTaskOptions} from '../../ui.js'
import {describe, test, expect, vi, beforeEach} from 'vitest'
import {FormData} from 'undici'

vi.mock('../admin.js')
vi.mock('../../session.js')
Expand Down Expand Up @@ -37,6 +38,7 @@ describe('stageFile', () => {
}

beforeEach(() => {
vi.mocked(formData).mockImplementation(() => new FormData())
vi.mocked(renderSingleTask).mockImplementation(async (options: RenderSingleTaskOptions<unknown>) => {
return options.task(vi.fn())
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
} from '../../../../cli/api/graphql/bulk-operations/generated/staged-uploads-create.js'
import {adminRequestDoc} from '../admin.js'
import {AdminSession} from '../../session.js'
import {fetch} from '../../http.js'
import {fetch, formData} from '../../http.js'
import {AbortError} from '../../error.js'
import {outputContent} from '../../output.js'
import {renderSingleTask} from '../../ui.js'
Expand Down Expand Up @@ -103,7 +103,7 @@ async function uploadFileToStagedUrl(
parameters: {name: string; value: string}[],
filename: string,
): Promise<void> {
const form = new FormData()
const form = formData()

for (const param of parameters) {
form.append(param.name, param.value)
Expand Down
2 changes: 1 addition & 1 deletion packages/cli-kit/src/public/node/github.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {joinPath} from './path.js'
import {readFile} from './fs.js'
import {isExecutable} from 'is-executable'
import {describe, expect, test, vi} from 'vitest'
import {Response} from 'node-fetch'
import {Response} from 'undici'

vi.mock('./http.js')

Expand Down
Loading
Loading