diff --git a/packages/build-info/src/build-systems/nx.test.ts b/packages/build-info/src/build-systems/nx.test.ts index fee6d54999..85466e38a1 100644 --- a/packages/build-info/src/build-systems/nx.test.ts +++ b/packages/build-info/src/build-systems/nx.test.ts @@ -197,7 +197,7 @@ describe('nx-integrated project.json based', () => { frameworkPort: 4200, name: `Nx + Next.js ${join('packages/website')}`, packagePath: join('packages/website'), - plugins_recommended: ['@netlify/plugin-nextjs'], + plugins: [{ package: '@netlify/plugin-nextjs', autoInstall: true }], }), ]), ) @@ -212,7 +212,7 @@ describe('nx-integrated project.json based', () => { frameworkPort: 3000, name: `Nx + Astro ${join('packages/astro')}`, packagePath: join('packages/astro'), - plugins_recommended: [], + plugins: [], }), ]), ) @@ -250,7 +250,7 @@ describe('nx-integrated workspace.json based', () => { frameworkPort: 4200, name: `Nx + React Static ${join('apps/website')}`, packagePath: join('apps/website'), - plugins_recommended: [], + plugins: [], }), ]), ) @@ -264,7 +264,7 @@ describe('nx-integrated workspace.json based', () => { framework: { id: 'astro', name: 'Astro' }, name: `Nx + Astro ${join('apps/astro')}`, packagePath: join('apps/astro'), - plugins_recommended: [], + plugins: [], }), ]), ) diff --git a/packages/build-info/src/frameworks/angular.test.ts b/packages/build-info/src/frameworks/angular.test.ts index 874d782301..e85247a460 100644 --- a/packages/build-info/src/frameworks/angular.test.ts +++ b/packages/build-info/src/frameworks/angular.test.ts @@ -32,7 +32,7 @@ test('should detect Angular', async ({ fs }) => { expect(detected?.[0].build.command).toBe('ng build --prod') expect(detected?.[0].build.directory).toBe(fs.join('dist', 'demo', 'browser')) expect(detected?.[0].dev?.command).toBe('ng serve') - expect(detected?.[0].plugins).toEqual(['@netlify/angular-runtime']) + expect(detected?.[0].plugins).toEqual([{ package: '@netlify/angular-runtime' }]) }) test('should set publish directory based on builder', async ({ fs }) => { diff --git a/packages/build-info/src/frameworks/angular.ts b/packages/build-info/src/frameworks/angular.ts index 97c89eb4cf..33505951c5 100644 --- a/packages/build-info/src/frameworks/angular.ts +++ b/packages/build-info/src/frameworks/angular.ts @@ -31,7 +31,7 @@ export class Angular extends BaseFramework implements Framework { if (this.detected) { if (this.version && gte(this.version, '17.0.0-rc')) { - this.plugins.push('@netlify/angular-runtime') + this.plugins.push({ package: '@netlify/angular-runtime' }) const angularJson = await this.project.fs.gracefullyReadFile('angular.json') if (angularJson) { const { projects, defaultProject } = JSON.parse(angularJson) diff --git a/packages/build-info/src/frameworks/framework.ts b/packages/build-info/src/frameworks/framework.ts index c31311b900..099db454cc 100644 --- a/packages/build-info/src/frameworks/framework.ts +++ b/packages/build-info/src/frameworks/framework.ts @@ -39,6 +39,19 @@ export type Detection = { export type FrameworkInfo = ReturnType +export type BuildPlugin = { + package: string + /** + * This setting is for runtimes that are expected to be "automatically" + * installed. Even though they can be installed on package/toml, we always + * want them installed in the site settings. When installed our build will + * automatically install the latest version without the need of the user + * managing the version plugin. + */ + autoInstall?: boolean + source?: 'toml' +} + export interface Framework { project: Project @@ -67,7 +80,7 @@ export interface Framework { light?: string dark?: string } - plugins: string[] + plugins: BuildPlugin[] env: Record detect(): Promise @@ -93,7 +106,7 @@ export interface Framework { staticAssetsDirectory?: string env: Record logo?: Record - plugins: string[] + plugins: BuildPlugin[] } } @@ -147,7 +160,7 @@ export abstract class BaseFramework implements Framework { configFiles: string[] = [] npmDependencies: string[] = [] excludedNpmDependencies: string[] = [] - plugins: string[] = [] + plugins: BuildPlugin[] = [] staticAssetsDirectory?: string env = {} dev?: { diff --git a/packages/build-info/src/frameworks/gatsby.test.ts b/packages/build-info/src/frameworks/gatsby.test.ts index 1dc6f92cb3..ffd9020702 100644 --- a/packages/build-info/src/frameworks/gatsby.test.ts +++ b/packages/build-info/src/frameworks/gatsby.test.ts @@ -16,7 +16,7 @@ test('should not add the plugin if the node version is below 12.13.0', async ({ fs.cwd = cwd const detected = await new Project(fs, cwd).setNodeVersion('12.12.9').detectFrameworks() expect(detected?.[0].id).toBe('gatsby') - expect(detected?.[0].plugins).toMatchObject([]) + expect(detected?.[0].plugins).toHaveLength(0) }) test('should detect a simple Gatsby project and add the plugin if the node version is large enough', async ({ fs }) => { @@ -27,7 +27,7 @@ test('should detect a simple Gatsby project and add the plugin if the node versi fs.cwd = cwd const detected = await new Project(fs, cwd).setNodeVersion('12.13.0').detectFrameworks() expect(detected?.[0].id).toBe('gatsby') - expect(detected?.[0].plugins).toMatchObject(['@netlify/plugin-gatsby']) + expect(detected?.[0].plugins).toMatchObject([{ package: '@netlify/plugin-gatsby' }]) }) test('should detect a simple Gatsby 4 project', async ({ fs }) => { @@ -67,8 +67,7 @@ test('should detect a simple Gatsby 4 project', async ({ fs }) => { frameworkPort: 8000, name: 'Gatsby', packagePath: '', - plugins_from_config_file: [], - plugins_recommended: [], + plugins: [], pollingStrategies: ['TCP', 'HTTP'], }, ]) diff --git a/packages/build-info/src/frameworks/gatsby.ts b/packages/build-info/src/frameworks/gatsby.ts index a5eed74466..eca06c7b50 100644 --- a/packages/build-info/src/frameworks/gatsby.ts +++ b/packages/build-info/src/frameworks/gatsby.ts @@ -45,7 +45,7 @@ export class Gatsby extends BaseFramework implements Framework { const nodeVersion = await this.project.getCurrentNodeVersion() if (nodeVersion && gte(nodeVersion, '12.13.0')) { - this.plugins.push('@netlify/plugin-gatsby') + this.plugins.push({ package: '@netlify/plugin-gatsby' }) } return this as DetectedFramework } diff --git a/packages/build-info/src/frameworks/next.test.ts b/packages/build-info/src/frameworks/next.test.ts index 5a4dc4c8f6..7ccff9c64e 100644 --- a/packages/build-info/src/frameworks/next.test.ts +++ b/packages/build-info/src/frameworks/next.test.ts @@ -38,7 +38,15 @@ describe('Next.js Plugin', () => { const project = new Project(fs, cwd).setNodeVersion('v10.13.0') const frameworks = await project.detectFrameworks() expect(frameworks?.[0].id).toBe('next') - expect(frameworks?.[0].plugins).toEqual(['@netlify/plugin-nextjs']) + expect(frameworks?.[0].plugins).toEqual([{ package: '@netlify/plugin-nextjs', autoInstall: true }]) + }) + + test('Should use the old runtime if the next.js version is not >= 13.5.0', async ({ fs, cwd }) => { + const project = new Project(fs, cwd).setNodeVersion('v18.0.0') + project.featureFlags = { project_ceruledge_ui: '@netlify/next-runtime' } + const frameworks = await project.detectFrameworks() + expect(frameworks?.[0].id).toBe('next') + expect(frameworks?.[0].plugins).toEqual([{ package: '@netlify/plugin-nextjs', autoInstall: true }]) }) test('Should not detect Next.js plugin for Next.js if when Node version < 10.13.0', async ({ fs, cwd }) => { @@ -49,6 +57,44 @@ describe('Next.js Plugin', () => { }) }) +describe('New Next.js Runtime', () => { + beforeEach((ctx) => { + ctx.cwd = mockFileSystem({ + 'package.json': JSON.stringify({ + name: 'my-next-app', + version: '0.1.0', + private: true, + scripts: { + dev: 'next dev', + build: 'next build', + start: 'next start', + }, + dependencies: { + next: '13.5.0', + react: '17.0.1', + 'react-dom': '17.0.1', + }, + }), + }) + }) + + test('Should not use the new runtime if the node version is below 18', async ({ fs, cwd }) => { + const project = new Project(fs, cwd).setNodeVersion('v16.0.0') + project.featureFlags = { project_ceruledge_ui: '@netlify/next-runtime@latest' } + const frameworks = await project.detectFrameworks() + expect(frameworks?.[0].id).toBe('next') + expect(frameworks?.[0].plugins).toEqual([{ package: '@netlify/plugin-nextjs', autoInstall: true }]) + }) + + test('Should use the old runtime if the next.js version is not >= 13.5.0', async ({ fs, cwd }) => { + const project = new Project(fs, cwd).setNodeVersion('v18.0.0') + project.featureFlags = { project_ceruledge_ui: '@netlify/next-runtime@latest' } + const frameworks = await project.detectFrameworks() + expect(frameworks?.[0].id).toBe('next') + expect(frameworks?.[0].plugins).toEqual([{ package: '@netlify/next-runtime@latest', autoInstall: true }]) + }) +}) + describe('simple Next.js project', async () => { beforeEach((ctx) => { ctx.cwd = mockFileSystem({ @@ -90,7 +136,7 @@ describe('simple Next.js project', async () => { test('Should detect Next.js plugin for Next.js if when Node version >= 10.13.0', async ({ fs, cwd }) => { const detected = await new Project(fs, cwd).setEnvironment({ NODE_VERSION: '18.x' }).detectFrameworks() expect(detected?.[0].id).toBe('next') - expect(detected?.[0].plugins).toMatchObject(['@netlify/plugin-nextjs']) + expect(detected?.[0].plugins).toMatchObject([{ package: '@netlify/plugin-nextjs', autoInstall: true }]) }) }) @@ -134,7 +180,7 @@ describe('Nx monorepo', () => { devCommand: 'nx run website:serve', dist: join('dist/packages/website'), frameworkPort: 4200, - plugins_recommended: ['@netlify/plugin-nextjs'], + plugins: [{ package: '@netlify/plugin-nextjs', autoInstall: true }], }) }) }) @@ -152,7 +198,7 @@ describe('Nx turborepo', () => { devCommand: 'turbo run dev --filter web', dist: join('apps/web/.next'), frameworkPort: 3000, - plugins_recommended: ['@netlify/plugin-nextjs'], + plugins: [{ package: '@netlify/plugin-nextjs', autoInstall: true }], }) }) }) diff --git a/packages/build-info/src/frameworks/next.ts b/packages/build-info/src/frameworks/next.ts index be9c0bc2fc..eb668a8abb 100644 --- a/packages/build-info/src/frameworks/next.ts +++ b/packages/build-info/src/frameworks/next.ts @@ -32,8 +32,17 @@ export class Next extends BaseFramework implements Framework { if (this.detected) { const nodeVersion = await this.project.getCurrentNodeVersion() - if (nodeVersion && gte(nodeVersion, '10.13.0')) { - this.plugins.push('@netlify/plugin-nextjs') + const runtimeFromRollout = this.project.featureFlags['project_ceruledge_ui'] + if ( + nodeVersion && + gte(nodeVersion, '18.0.0') && + this.detected.package?.version && + gte(this.detected.package.version, '13.5.0') && + typeof runtimeFromRollout === 'string' + ) { + this.plugins.push({ package: runtimeFromRollout ?? '@netlify/plugin-nextjs', autoInstall: true }) + } else if (nodeVersion && gte(nodeVersion, '10.13.0')) { + this.plugins.push({ package: '@netlify/plugin-nextjs', autoInstall: true }) } return this as DetectedFramework } diff --git a/packages/build-info/src/node/__snapshots__/get-build-info.test.ts.snap b/packages/build-info/src/node/__snapshots__/get-build-info.test.ts.snap index 46739ecb9a..de97a07335 100644 --- a/packages/build-info/src/node/__snapshots__/get-build-info.test.ts.snap +++ b/packages/build-info/src/node/__snapshots__/get-build-info.test.ts.snap @@ -64,8 +64,7 @@ exports[`should retrieve the build info for providing a rootDir 1`] = ` "frameworkPort": 3000, "name": "PNPM + Astro packages/blog", "packagePath": "packages/blog", - "plugins_from_config_file": [], - "plugins_recommended": [], + "plugins": [], "pollingStrategies": [ "TCP", "HTTP", @@ -84,9 +83,11 @@ exports[`should retrieve the build info for providing a rootDir 1`] = ` "frameworkPort": 3000, "name": "PNPM + Next.js packages/website", "packagePath": "packages/website", - "plugins_from_config_file": [], - "plugins_recommended": [ - "@netlify/plugin-nextjs", + "plugins": [ + { + "autoInstall": true, + "package": "@netlify/plugin-nextjs", + }, ], "pollingStrategies": [ "TCP", @@ -199,8 +200,7 @@ exports[`should retrieve the build info for providing a rootDir and a nested pro "frameworkPort": 3000, "name": "PNPM + Astro packages/blog", "packagePath": "packages/blog", - "plugins_from_config_file": [], - "plugins_recommended": [], + "plugins": [], "pollingStrategies": [ "TCP", "HTTP", @@ -274,8 +274,7 @@ exports[`should retrieve the build info for providing a rootDir and the same pro "frameworkPort": 3000, "name": "PNPM + Astro packages/blog", "packagePath": "packages/blog", - "plugins_from_config_file": [], - "plugins_recommended": [], + "plugins": [], "pollingStrategies": [ "TCP", "HTTP", @@ -294,9 +293,11 @@ exports[`should retrieve the build info for providing a rootDir and the same pro "frameworkPort": 3000, "name": "PNPM + Next.js packages/website", "packagePath": "packages/website", - "plugins_from_config_file": [], - "plugins_recommended": [ - "@netlify/plugin-nextjs", + "plugins": [ + { + "autoInstall": true, + "package": "@netlify/plugin-nextjs", + }, ], "pollingStrategies": [ "TCP", diff --git a/packages/build-info/src/node/bin.ts b/packages/build-info/src/node/bin.ts index 320a1650ba..d7927333fa 100644 --- a/packages/build-info/src/node/bin.ts +++ b/packages/build-info/src/node/bin.ts @@ -8,7 +8,11 @@ import { report } from '../metrics.js' import { getBuildInfo } from './get-build-info.js' import { initializeMetrics } from './metrics.js' -type Args = Arguments<{ projectDir?: string; rootDir?: string; featureFlags: Record }> +type Args = Arguments<{ + projectDir?: string + rootDir?: string + featureFlags: Record +}> yargs(hideBin(argv)) .command( @@ -22,13 +26,9 @@ yargs(hideBin(argv)) }, featureFlags: { string: true, - describe: 'comma separated list of feature flags', - coerce: (value = '') => - value - .split(',') - .map((flag) => flag.trim()) - .filter((flag) => flag.length) - .reduce((prev, cur) => ({ ...prev, [cur]: true }), {}), + describe: 'JSON stringified list of feature flags with values', + alias: 'ff', + coerce: (value: '{}') => JSON.parse(value), }, }), async ({ projectDir, rootDir, featureFlags }: Args) => { @@ -37,7 +37,12 @@ yargs(hideBin(argv)) try { console.log( JSON.stringify( - await getBuildInfo({ projectDir, rootDir, featureFlags, bugsnagClient }), + await getBuildInfo({ + projectDir, + rootDir, + featureFlags, + bugsnagClient, + }), // hide null values from the string output as we use null to identify it has already run but did not detect anything // undefined marks that it was never run (_, value) => (value !== null ? value : undefined), diff --git a/packages/build-info/src/node/get-build-info.ts b/packages/build-info/src/node/get-build-info.ts index 85b3360c6c..79a2b7b9c2 100644 --- a/packages/build-info/src/node/get-build-info.ts +++ b/packages/build-info/src/node/get-build-info.ts @@ -45,7 +45,7 @@ export async function getBuildInfo( config: { projectDir?: string rootDir?: string - featureFlags?: Record + featureFlags?: Record bugsnagClient?: Client } = { featureFlags: {} }, ): Promise { @@ -54,6 +54,7 @@ export async function getBuildInfo( fs.logger = new NoopLogger() const project = new Project(fs, config.projectDir, config.rootDir) .setBugsnag(config.bugsnagClient) + .setFeatureFlags(config.featureFlags) .setEnvironment(process.env) .setNodeVersion(process.version) diff --git a/packages/build-info/src/project.ts b/packages/build-info/src/project.ts index a528c047b4..56080b1e77 100644 --- a/packages/build-info/src/project.ts +++ b/packages/build-info/src/project.ts @@ -62,6 +62,8 @@ export class Project { bugsnag: Client /** A logging instance */ logger: Logger + /** A list of enabled feature flags */ + featureFlags: Record = {} /** A function that is used to report errors */ reportFn: typeof report = report @@ -76,6 +78,11 @@ export class Project { return this } + setFeatureFlags(flags: Record = {}): this { + this.featureFlags = { ...this.featureFlags, ...flags } + return this + } + async getCurrentNodeVersion(): Promise { if (this._nodeVersion) { return this._nodeVersion diff --git a/packages/build-info/src/settings/get-build-settings.test.ts b/packages/build-info/src/settings/get-build-settings.test.ts index 14b223727f..51684d6b1b 100644 --- a/packages/build-info/src/settings/get-build-settings.test.ts +++ b/packages/build-info/src/settings/get-build-settings.test.ts @@ -17,7 +17,7 @@ beforeEach((ctx) => { test('get the settings for a next project', async (ctx) => { const fixture = await createFixture('next-project', ctx) - const project = new Project(ctx.fs, fixture.cwd) + const project = new Project(ctx.fs, fixture.cwd).setNodeVersion('18.0.0') const settings = await project.getBuildSettings() expect(settings).toEqual([ @@ -27,8 +27,7 @@ test('get the settings for a next project', async (ctx) => { dist: '.next', env: {}, frameworkPort: 3000, - plugins_recommended: [], - plugins_from_config_file: [], + plugins: [{ autoInstall: true, package: '@netlify/plugin-nextjs' }], pollingStrategies: ['TCP'], }), ]) @@ -36,7 +35,7 @@ test('get the settings for a next project', async (ctx) => { test('get the settings for a next project if a build system has no commands and overrides', async (ctx) => { const fixture = await createFixture('next-project', ctx) - const project = new Project(ctx.fs, fixture.cwd) + const project = new Project(ctx.fs, fixture.cwd).setNodeVersion('18.0.0') project.buildSystems = [new Bazel(project)] const settings = await project.getBuildSettings() @@ -47,8 +46,7 @@ test('get the settings for a next project if a build system has no commands and dist: '.next', env: {}, frameworkPort: 3000, - plugins_recommended: [], - plugins_from_config_file: [], + plugins: [{ autoInstall: true, package: '@netlify/plugin-nextjs' }], pollingStrategies: ['TCP'], }), ]) diff --git a/packages/build-info/src/settings/get-build-settings.ts b/packages/build-info/src/settings/get-build-settings.ts index 9b92638cf1..106875dca8 100644 --- a/packages/build-info/src/settings/get-build-settings.ts +++ b/packages/build-info/src/settings/get-build-settings.ts @@ -1,4 +1,4 @@ -import { type Framework } from '../frameworks/framework.js' +import { BuildPlugin, type Framework } from '../frameworks/framework.js' import { type Project } from '../project.js' export type Settings = { @@ -24,10 +24,8 @@ export type Settings = { /** The dist directory that contains the build output */ dist: string env: Record - /** Plugins installed via the netlify.toml */ - plugins_from_config_file: string[] /** Plugins that are detected via the framework detection and therefore recommended */ - plugins_recommended: string[] + plugins: BuildPlugin[] pollingStrategies: string[] /** The baseDirectory for the UI to configure (used to run the command in this working directory) */ baseDirectory?: string @@ -91,8 +89,7 @@ export async function getSettings(framework: Framework, project: Project, baseDi frameworkPort: framework.dev?.port, dist: project.fs.join(baseDirectory, framework.build.directory), env: framework.env || {}, - plugins_from_config_file: [], - plugins_recommended: framework.plugins || [], + plugins: framework.plugins || [], framework: { id: framework.id, name: framework.name, diff --git a/packages/build-info/src/settings/get-toml-settings.test.ts b/packages/build-info/src/settings/get-toml-settings.test.ts index 83dedb9aa8..ce07bd30fe 100644 --- a/packages/build-info/src/settings/get-toml-settings.test.ts +++ b/packages/build-info/src/settings/get-toml-settings.test.ts @@ -107,7 +107,7 @@ package = "@netlify/plugin-nextjs" dist: '.next', frameworkPort: 3000, functionsDir: 'api', - plugins_from_config_file: ['@netlify/plugin-nextjs'], + plugins: [{ package: '@netlify/plugin-nextjs', source: 'toml' }], }), ) }) diff --git a/packages/build-info/src/settings/get-toml-settings.ts b/packages/build-info/src/settings/get-toml-settings.ts index 240e0f7063..b20d310e26 100644 --- a/packages/build-info/src/settings/get-toml-settings.ts +++ b/packages/build-info/src/settings/get-toml-settings.ts @@ -31,17 +31,22 @@ export async function getTomlSettingsFromPath( const tomlFilePath = fs.join(directory, 'netlify.toml') try { - const settings: Partial = {} + const settings: Partial & Pick = { + plugins: [], + } const { build, dev, functions, template, plugins } = gracefulParseToml(await fs.readFile(tomlFilePath)) settings.buildCommand = build?.command ?? settings.buildCommand settings.dist = build?.publish ?? settings.dist settings.devCommand = dev?.command ?? settings.devCommand settings.frameworkPort = dev?.port ?? settings.frameworkPort - settings.plugins_from_config_file = plugins?.map((p) => p.package) ?? settings.plugins_from_config_file settings.functionsDir = (build?.functions || functions?.directory) ?? settings.functionsDir settings.template = template ?? settings.template + for (const plugin of plugins || []) { + settings.plugins.push({ package: plugin.package, source: 'toml' }) + } + return settings } catch { // no toml found or issue while parsing it diff --git a/packages/build-info/tests/__snapshots__/bin.test.ts.snap b/packages/build-info/tests/__snapshots__/bin.test.ts.snap index 237e5a1453..e4f4f76d7c 100644 --- a/packages/build-info/tests/__snapshots__/bin.test.ts.snap +++ b/packages/build-info/tests/__snapshots__/bin.test.ts.snap @@ -6,9 +6,10 @@ exports[`CLI --help flag 1`] = ` Print relevant build information from a project. Options: - --help Show help [boolean] - --version Show version number [boolean] - --rootDir The root directory of the project if different from projectDir - [string] - --featureFlags comma separated list of feature flags [string]" + --help Show help [boolean] + --version Show version number [boolean] + --rootDir The root directory of the project if different from proj + ectDir [string] + --featureFlags, --ff JSON stringified list of feature flags with values + [string]" `;