misc: remove vendored node-fetch usages (#75741)

## What

This PR replaces the usage of node-fetch in the next/font/google loader
code, which runs as part of every next build. `node-fetch` relies on
`punycode`, which shows a deprecration warning from Node.js 22,
affecting every users on Next.js and Node 22.

This PR also removes any other references to the vendored node-fetch
used in other parts of the codebase.

~~Unfortunately, we can't remove it because other next.js dependencies
like the amp optimiser actually use node-fetch, so they would need to be
updated separately.~~

not a problem anymore because of @wowczarczyk suggestion of also
rewriting the external in the dependencies to point to the correct one

This PR does not remove the node-fetch usage in tests.

## How

This replaces the node-fetch call with a call to the native fetch
exposed in Node.js. However, this fetch does not support proxies without
tapping into Undici, so I've had to "polyfill" a usage because I did not
want to add a dependency to Undici for a minor use case.

<!-- Thanks for opening a PR! Your contribution is much appreciated.
To make sure your PR is handled as smoothly as possible we request that
you follow the checklist sections below.
Choose the right checklist for the change(s) that you're making:

## For Contributors

### Improving Documentation

- Run `pnpm prettier-fix` to fix formatting issues before opening the
PR.
- Read the Docs Contribution Guide to ensure your contribution follows
the docs guidelines:
https://nextjs.org/docs/community/contribution-guide

### Adding or Updating Examples

- The "examples guidelines" are followed from our contributing doc
https://github.com/vercel/next.js/blob/canary/contributing/examples/adding-examples.md
- Make sure the linting passes by running `pnpm build && pnpm lint`. See
https://github.com/vercel/next.js/blob/canary/contributing/repository/linting.md

### Fixing a bug

- Related issues linked using `fixes #number`
- Tests added. See:
https://github.com/vercel/next.js/blob/canary/contributing/core/testing.md#writing-tests-for-nextjs
- Errors have a helpful link attached, see
https://github.com/vercel/next.js/blob/canary/contributing.md

### Adding a feature

- Implements an existing feature request or RFC. Make sure the feature
request has been accepted for implementation before opening a PR. (A
discussion must be opened, see
https://github.com/vercel/next.js/discussions/new?category=ideas)
- Related issues/discussions are linked using `fixes #number`
- e2e tests added
(https://github.com/vercel/next.js/blob/canary/contributing/core/testing.md#writing-tests-for-nextjs)
- Documentation added
- Telemetry added. In case of a feature if it's used or not.
- Errors have a helpful link attached, see
https://github.com/vercel/next.js/blob/canary/contributing.md


## For Maintainers

- Minimal description (aim for explaining to someone not on the team to
understand the PR)
- When linking to a Slack thread, you might want to share details of the
conclusion
- Link both the Linear (Fixes NEXT-xxx) and the GitHub issues
- Add review comments if necessary to explain to the reviewer the logic
behind a change

### What?

### Why?

### How?

Closes NEXT-


-->
Fixes #66289
Fixes #75313
Closes NDX-730

---------

Co-authored-by: JJ Kasper <[email protected]>
This commit is contained in:
Jimmy Lai
2025-02-19 22:13:34 +00:00
committed by GitHub
co-authored by JJ Kasper
parent b76775a6cc
commit 52284fedb6
8 changed files with 92 additions and 84 deletions
@@ -1,7 +1,5 @@
// @ts-ignore
import fetch from 'next/dist/compiled/node-fetch'
import { nextFontError } from '../next-font-error'
import { getProxyAgent } from './get-proxy-agent'
import { fetchResource } from './fetch-resource'
import { retry } from './retry'
/**
@@ -16,56 +14,23 @@ export async function fetchCSSFromGoogleFonts(
fontFamily: string,
isDev: boolean
): Promise<string> {
// Check if mocked responses are defined, if so use them instead of fetching from Google Fonts
let mockedResponse: string | undefined
if (process.env.NEXT_FONT_GOOGLE_MOCKED_RESPONSES) {
const mockFile = require(process.env.NEXT_FONT_GOOGLE_MOCKED_RESPONSES)
mockedResponse = mockFile[url]
const mockedResponse = mockFile[url]
if (!mockedResponse) {
nextFontError('Missing mocked response for URL: ' + url)
}
return mockedResponse
}
let cssResponse: string
if (mockedResponse) {
// Just use the mocked CSS if it's set
cssResponse = mockedResponse
} else {
// Retry the fetch a few times in case of network issues as some font files
// are quite large:
// https://github.com/vercel/next.js/issues/45080
cssResponse = await retry(async () => {
const controller =
isDev && typeof AbortController !== 'undefined'
? new AbortController()
: undefined
const signal = controller?.signal
const timeoutId = controller
? setTimeout(() => controller.abort(), 3000)
: undefined
const buffer = await retry(async () => {
return fetchResource(
url,
isDev,
`Failed to fetch font \`${fontFamily}\`: ${url}\n` +
`Please check your network connection.`
)
}, 3)
const res = await fetch(url, {
agent: getProxyAgent(),
// Add a timeout in dev
signal,
headers: {
// The file format is based off of the user agent, make sure woff2 files are fetched
'user-agent':
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.0.0 Safari/537.36',
},
}).finally(() => {
timeoutId && clearTimeout(timeoutId)
})
if (!res.ok) {
nextFontError(
`Failed to fetch font \`${fontFamily}\`.\nURL: ${url}\n\nPlease check if the network is available.`
)
}
return res.text()
}, 3)
}
return cssResponse
return buffer.toString('utf8')
}
+10 -20
View File
@@ -1,34 +1,24 @@
// @ts-ignore
import fetch from 'next/dist/compiled/node-fetch'
import { getProxyAgent } from './get-proxy-agent'
import fs from 'node:fs'
import { retry } from './retry'
import { fetchResource } from './fetch-resource'
/**
* Fetch the url and return a buffer with the font file.
* Fetches a font file and returns its contents as a Buffer.
* If NEXT_FONT_GOOGLE_MOCKED_RESPONSES is set, we handle mock data logic.
*/
export async function fetchFontFile(url: string, isDev: boolean) {
// Check if we're using mocked data
if (process.env.NEXT_FONT_GOOGLE_MOCKED_RESPONSES) {
// If it's an absolute path, read the file from the filesystem
if (url.startsWith('/')) {
return require('fs').readFileSync(url)
return fs.readFileSync(url)
}
// Otherwise just return a unique buffer
return Buffer.from(url)
}
return await retry(async () => {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 3000)
const arrayBuffer = await fetch(url, {
agent: getProxyAgent(),
// Add a timeout in dev
signal: isDev ? controller.signal : undefined,
})
.then((r: any) => r.arrayBuffer())
.finally(() => {
clearTimeout(timeoutId)
})
return Buffer.from(arrayBuffer)
return fetchResource(
url,
isDev,
`Failed to fetch font file from \`${url}\`.`
)
}, 3)
}
@@ -0,0 +1,57 @@
import http from 'node:http'
import https from 'node:https'
import { getProxyAgent } from './get-proxy-agent'
/**
* Makes a simple GET request and returns the entire response as a Buffer.
* - Throws if the response status is not 200.
* - Applies a 3000 ms timeout when `isDev` is `true`.
*/
export function fetchResource(
url: string,
isDev: boolean,
errorMessage?: string
): Promise<Buffer> {
return new Promise((resolve, reject) => {
const { protocol } = new URL(url)
const client = protocol === 'https:' ? https : http
const timeout = isDev ? 3000 : undefined
const req = client.request(
url,
{
agent: getProxyAgent(),
headers: {
// The file format is based off of the user agent, make sure woff2 files are fetched
'User-Agent':
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ' +
'AppleWebKit/537.36 (KHTML, like Gecko) ' +
'Chrome/104.0.0.0 Safari/537.36',
},
},
(res) => {
if (res.statusCode !== 200) {
reject(
new Error(
errorMessage ||
`Request failed: ${url} (status: ${res.statusCode})`
)
)
return
}
const chunks: Buffer[] = []
res.on('data', (chunk) => chunks.push(Buffer.from(chunk)))
res.on('end', () => resolve(Buffer.concat(chunks)))
}
)
if (timeout) {
req.setTimeout(timeout, () => {
req.destroy(new Error(`Request timed out after ${timeout}ms`))
})
}
req.on('error', (err) => reject(err))
req.end()
})
}
+11 -9
View File
@@ -1,8 +1,9 @@
import nextFontGoogleFontLoader from './loader'
// @ts-ignore
import fetch from 'next/dist/compiled/node-fetch'
import { fetchResource } from './fetch-resource'
jest.mock('next/dist/compiled/node-fetch')
jest.mock('./fetch-resource')
const mockFetchResource = fetchResource as jest.Mock
describe('next/font/google loader', () => {
afterEach(() => {
@@ -120,10 +121,7 @@ describe('next/font/google loader', () => {
fontFunctionArguments: any,
expectedUrl: any
) => {
fetch.mockResolvedValue({
ok: true,
text: async () => 'OK',
})
mockFetchResource.mockResolvedValue(Buffer.from('OK'))
const { css } = await nextFontGoogleFontLoader({
functionName,
data: [
@@ -141,8 +139,12 @@ describe('next/font/google loader', () => {
variableName: 'myFont',
})
expect(css).toBe('OK')
expect(fetch).toHaveBeenCalledTimes(1)
expect(fetch).toHaveBeenCalledWith(expectedUrl, expect.any(Object))
expect(mockFetchResource).toHaveBeenCalledTimes(1)
expect(mockFetchResource).toHaveBeenCalledWith(
expectedUrl,
false,
expect.stringContaining('Failed to fetch font')
)
}
)
})
File diff suppressed because one or more lines are too long
@@ -2,7 +2,6 @@ import findUp from 'next/dist/compiled/find-up'
import fsPromise from 'fs/promises'
import child_process from 'child_process'
import assert from 'assert'
import fetch from 'next/dist/compiled/node-fetch'
import os from 'os'
import { createInterface } from 'readline'
import { createReadStream } from 'fs'
+1
View File
@@ -77,6 +77,7 @@ const externals = {
'terser-webpack-plugin':
'next/dist/build/webpack/plugins/terser-webpack-plugin/src',
punycode: 'punycode/',
// TODO: Add @swc/helpers to externals once @vercel/ncc switch to swc-loader
}
// eslint-disable-next-line camelcase
-6
View File
@@ -351,12 +351,6 @@ declare module 'next/dist/compiled/@next/react-refresh-utils/dist/ReactRefreshWe
export = m
}
declare module 'next/dist/compiled/node-fetch' {
import fetch from 'node-fetch'
export * from 'node-fetch'
export default fetch
}
declare module 'next/dist/compiled/commander' {
import commander from 'commander'
export * from 'commander'