diff --git a/package-lock.json b/package-lock.json index b0038705b3..13ac6cbd73 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51,6 +51,7 @@ "jest-extended": "^3.2.0", "jest-fetch-mock": "^3.0.3", "jest-junit": "^14.0.1", + "mock-fs": "^5.2.0", "netlify-plugin-cypress": "^2.2.1", "npm-run-all": "^4.1.5", "playwright-chromium": "^1.26.1", @@ -17995,6 +17996,15 @@ "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==" }, + "node_modules/mock-fs": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/mock-fs/-/mock-fs-5.2.0.tgz", + "integrity": "sha512-2dF2R6YMSZbpip1V1WHKGLNjr/k48uQClqMVb5H3MOvwc9qhYis3/IWbj02qIg/Y8MDXKFF4c5v0rxx2o6xTZw==", + "dev": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/module-definition": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/module-definition/-/module-definition-4.0.0.tgz", @@ -37527,6 +37537,12 @@ "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==" }, + "mock-fs": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/mock-fs/-/mock-fs-5.2.0.tgz", + "integrity": "sha512-2dF2R6YMSZbpip1V1WHKGLNjr/k48uQClqMVb5H3MOvwc9qhYis3/IWbj02qIg/Y8MDXKFF4c5v0rxx2o6xTZw==", + "dev": true + }, "module-definition": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/module-definition/-/module-definition-4.0.0.tgz", diff --git a/package.json b/package.json index d71d9b98d3..548200de7a 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "jest-extended": "^3.2.0", "jest-fetch-mock": "^3.0.3", "jest-junit": "^14.0.1", + "mock-fs": "^5.2.0", "netlify-plugin-cypress": "^2.2.1", "npm-run-all": "^4.1.5", "playwright-chromium": "^1.26.1", diff --git a/packages/runtime/src/constants.ts b/packages/runtime/src/constants.ts index 179ae16302..d26c5c8e8a 100644 --- a/packages/runtime/src/constants.ts +++ b/packages/runtime/src/constants.ts @@ -1,7 +1,11 @@ export const HANDLER_FUNCTION_NAME = '___netlify-handler' export const ODB_FUNCTION_NAME = '___netlify-odb-handler' export const IMAGE_FUNCTION_NAME = '_ipx' - +export const NEXT_PLUGIN_NAME = '@netlify/next-runtime' +export const NEXT_PLUGIN = '@netlify/plugin-nextjs' +export const HANDLER_FUNCTION_TITLE = 'Next.js SSR handler' +export const ODB_FUNCTION_TITLE = 'Next.js ISR handler' +export const IMAGE_FUNCTION_TITLE = 'next/image handler' // These are paths in .next that shouldn't be publicly accessible export const HIDDEN_PATHS = [ '/cache/*', diff --git a/packages/runtime/src/helpers/config.ts b/packages/runtime/src/helpers/config.ts index cb910c2e04..931c727f01 100644 --- a/packages/runtime/src/helpers/config.ts +++ b/packages/runtime/src/helpers/config.ts @@ -71,7 +71,7 @@ export const updateRequiredServerFiles = async (publish: string, modifiedConfig: await writeJSON(configFile, modifiedConfig) } -const resolveModuleRoot = (moduleName) => { +export const resolveModuleRoot = (moduleName) => { try { return dirname(relative(process.cwd(), require.resolve(`${moduleName}/package.json`, { paths: [process.cwd()] }))) } catch { diff --git a/packages/runtime/src/helpers/functions.ts b/packages/runtime/src/helpers/functions.ts index 8370650273..df8b5887a6 100644 --- a/packages/runtime/src/helpers/functions.ts +++ b/packages/runtime/src/helpers/functions.ts @@ -7,13 +7,22 @@ import type { ImageConfigComplete, RemotePattern } from 'next/dist/shared/lib/im import { outdent } from 'outdent' import { join, relative, resolve } from 'pathe' -import { HANDLER_FUNCTION_NAME, ODB_FUNCTION_NAME, IMAGE_FUNCTION_NAME, DEFAULT_FUNCTIONS_SRC } from '../constants' +import { + HANDLER_FUNCTION_NAME, + ODB_FUNCTION_NAME, + IMAGE_FUNCTION_NAME, + DEFAULT_FUNCTIONS_SRC, + HANDLER_FUNCTION_TITLE, + ODB_FUNCTION_TITLE, + IMAGE_FUNCTION_TITLE, +} from '../constants' import { getApiHandler } from '../templates/getApiHandler' import { getHandler } from '../templates/getHandler' import { getResolverForPages, getResolverForSourceFiles } from '../templates/getPageResolver' import { ApiConfig, ApiRouteType, extractConfigFromFile } from './analysis' import { getSourceFileForPage } from './files' +import { writeFunctionConfiguration } from './functionsMetaData' import { getFunctionNameForPage } from './utils' export interface ApiRouteConfig { @@ -70,7 +79,7 @@ export const generateFunctions = async ( await writeFile(join(functionsDir, functionName, 'pages.js'), resolverSource) } - const writeHandler = async (functionName: string, isODB: boolean) => { + const writeHandler = async (functionName: string, functionTitle: string, isODB: boolean) => { const handlerSource = await getHandler({ isODB, publishDir, appDir: relative(functionDir, appDir) }) await ensureDir(join(functionsDir, functionName)) @@ -87,10 +96,11 @@ export const generateFunctions = async ( join(__dirname, '..', '..', 'lib', 'templates', 'handlerUtils.js'), join(functionsDir, functionName, 'handlerUtils.js'), ) + writeFunctionConfiguration({ functionName, functionTitle, functionsDir }) } - await writeHandler(HANDLER_FUNCTION_NAME, false) - await writeHandler(ODB_FUNCTION_NAME, true) + await writeHandler(HANDLER_FUNCTION_NAME, HANDLER_FUNCTION_TITLE, false) + await writeHandler(ODB_FUNCTION_NAME, ODB_FUNCTION_TITLE, true) } /** @@ -154,6 +164,11 @@ export const setupImageFunction = async ({ }) await copyFile(join(__dirname, '..', '..', 'lib', 'templates', 'ipx.js'), join(functionDirectory, functionName)) + writeFunctionConfiguration({ + functionName: IMAGE_FUNCTION_NAME, + functionTitle: IMAGE_FUNCTION_TITLE, + functionsDir: functionsPath, + }) // If we have edge functions then the request will have already been rewritten // so this won't match. This is matched if edge is disabled or unavailable. diff --git a/packages/runtime/src/helpers/functionsMetaData.ts b/packages/runtime/src/helpers/functionsMetaData.ts new file mode 100644 index 0000000000..c8e0972ded --- /dev/null +++ b/packages/runtime/src/helpers/functionsMetaData.ts @@ -0,0 +1,56 @@ +import { existsSync, readJSON, writeFile } from 'fs-extra' +import { join } from 'pathe' + +import { NEXT_PLUGIN, NEXT_PLUGIN_NAME } from '../constants' + +import { resolveModuleRoot } from './config' + +const getNextRuntimeVersion = async (packageJsonPath: string, useNodeModulesPath: boolean) => { + if (!existsSync(packageJsonPath)) { + return + } + + const packagePlugin = await readJSON(packageJsonPath) + + return useNodeModulesPath ? packagePlugin.version : packagePlugin.dependencies[NEXT_PLUGIN] +} + +// The information needed to create a function configuration file +export interface FunctionInfo { + // The name of the function, e.g. `___netlify-handler` + functionName: string + + // The name of the function that will be displayed in logs, e.g. `Next.js SSR handler` + functionTitle: string + + // The directory where the function is located, e.g. `.netlify/functions` + functionsDir: string +} + +/** + * Creates a function configuration file for the given function. + * + * @param functionInfo The information needed to create a function configuration file + */ +export const writeFunctionConfiguration = async (functionInfo: FunctionInfo) => { + const { functionName, functionTitle, functionsDir } = functionInfo + const pluginPackagePath = '.netlify/plugins/package.json' + const moduleRoot = resolveModuleRoot(NEXT_PLUGIN) + const nodeModulesPath = moduleRoot ? join(moduleRoot, 'package.json') : null + + const nextPluginVersion = + (await getNextRuntimeVersion(nodeModulesPath, true)) || + (await getNextRuntimeVersion(pluginPackagePath, false)) || + // The runtime version should always be available, but if it's not, return 'unknown' + 'unknown' + + const metadata = { + config: { + name: functionTitle, + generator: `${NEXT_PLUGIN_NAME}@${nextPluginVersion}`, + }, + version: 1, + } + + await writeFile(join(functionsDir, functionName, `${functionName}.json`), JSON.stringify(metadata)) +} diff --git a/test/functionsMetaData.spec.ts b/test/functionsMetaData.spec.ts new file mode 100644 index 0000000000..9c130d454f --- /dev/null +++ b/test/functionsMetaData.spec.ts @@ -0,0 +1,105 @@ +import { readJSON } from 'fs-extra' +import mock from 'mock-fs' +import { join } from 'pathe' +import { NEXT_PLUGIN_NAME } from '../packages/runtime/src/constants' +import { writeFunctionConfiguration } from '../packages/runtime/src/helpers/functionsMetaData' + +describe('writeFunctionConfiguration', () => { + afterEach(() => { + mock.restore() + }) + + it('should write the configuration for a function using node modules version of @netlify/plugin-nextjs', async () => { + const nextRuntimeVersion = '23.4.5' + + mock({ + '.netlify/plugins/package.json': JSON.stringify({ + name: 'test', + version: '1.0.0', + dependencies: { + '@netlify/plugin-nextjs': '29.3.4', + }, + }), + 'node_modules/@netlify/plugin-nextjs/package.json': JSON.stringify({ + name: '@netlify/plugin-nextjs', + version: nextRuntimeVersion, + }), + '.netlify/functions/some-folder/someFunctionName': {}, + }) + + const functionName = 'someFunctionName' + const functionTitle = 'some function title' + const functionsDir = '.netlify/functions/some-folder' + + const expected = { + config: { + name: functionTitle, + generator: `${NEXT_PLUGIN_NAME}@${nextRuntimeVersion}`, + }, + version: 1, + } + + const filePathToSaveTo = join(functionsDir, functionName, `${functionName}.json`) + await writeFunctionConfiguration({ functionName, functionTitle, functionsDir }) + const actual = await readJSON(filePathToSaveTo) + + expect(actual).toEqual(expected) + }) + + it('should write the configuration for a function using version of @netlify/plugin-nextjs in package.json', async () => { + const nextRuntimeVersion = '23.4.5' + + mock({ + '.netlify/plugins/package.json': JSON.stringify({ + name: 'test', + version: '1.0.0', + dependencies: { + '@netlify/plugin-nextjs': nextRuntimeVersion, + }, + }), + '.netlify/functions/some-folder/someFunctionName': {}, + }) + + const functionName = 'someFunctionName' + const functionTitle = 'some function title' + const functionsDir = '.netlify/functions/some-folder' + + const expected = { + config: { + name: functionTitle, + generator: `${NEXT_PLUGIN_NAME}@${nextRuntimeVersion}`, + }, + version: 1, + } + + const filePathToSaveTo = join(functionsDir, functionName, `${functionName}.json`) + await writeFunctionConfiguration({ functionName, functionTitle, functionsDir }) + const actual = await readJSON(filePathToSaveTo) + + expect(actual).toEqual(expected) + }) + + it('should write the configuration for a function with runtime version not found', async () => { + mock({ + '.netlify/functions/some-folder/someFunctionName': {}, + }) + + const functionName = 'someFunctionName' + const functionTitle = 'some function title' + const functionsDir = '.netlify/functions/some-folder' + + const expected = { + config: { + name: functionTitle, + generator: '@netlify/next-runtime@unknown', + }, + version: 1, + } + + const filePathToSaveTo = join(functionsDir, functionName, `${functionName}.json`) + await writeFunctionConfiguration({ functionName, functionTitle, functionsDir }) + const actual = await readJSON(filePathToSaveTo) + + expect(actual).toEqual(expected) + }) +})