diff --git a/packages/@apphosting/adapter-nextjs/src/bin/build.ts b/packages/@apphosting/adapter-nextjs/src/bin/build.ts index 3d46dbd03..c56de969a 100644 --- a/packages/@apphosting/adapter-nextjs/src/bin/build.ts +++ b/packages/@apphosting/adapter-nextjs/src/bin/build.ts @@ -36,19 +36,20 @@ const nextConfig = await loadConfig(root, opts.projectDirectory); * We restore the user's Next Config at the end of the build, after the config file has been * copied over to the output directory, so that the user's original code is not modified. * - * If the app does not have a next.config.[js|mjs|ts] file in the first place, + * If the app does not have a next.config.[js|mjs|ts|mts] file in the first place, * then can skip config override. * * Note: loadConfig always returns a fileName (default: next.config.js) even if * one does not exist in the app's root: https://github.com/vercel/next.js/blob/23681508ca34b66a6ef55965c5eac57de20eb67f/packages/next/src/server/config.ts#L1115 */ const nextConfigPath = join(root, nextConfig.configFileName); -if (await exists(nextConfigPath)) { - await overrideNextConfig(root, nextConfig.configFileName); - await validateNextConfigOverride(root, opts.projectDirectory, nextConfig.configFileName); -} try { + if (await exists(nextConfigPath)) { + await overrideNextConfig(root, nextConfig.configFileName); + await validateNextConfigOverride(root, opts.projectDirectory, nextConfig.configFileName); + } + await runBuild(); const adapterMetadata = getAdapterMetadata(); diff --git a/packages/@apphosting/adapter-nextjs/src/overrides.spec.ts b/packages/@apphosting/adapter-nextjs/src/overrides.spec.ts index e223037c7..68a41c6dc 100644 --- a/packages/@apphosting/adapter-nextjs/src/overrides.spec.ts +++ b/packages/@apphosting/adapter-nextjs/src/overrides.spec.ts @@ -319,6 +319,53 @@ describe("next config overrides", () => { ); }); + it("should set images.unoptimized to true - TypeScript ES Modules", async () => { + const { overrideNextConfig } = await importOverrides; + const originalConfig = ` + import type { NextConfig } from 'next' + + const nextConfig: NextConfig = { + /* config options here */ + } + + export default nextConfig + `; + + fs.writeFileSync(path.join(tmpDir, "next.config.mts"), originalConfig); + await overrideNextConfig(tmpDir, "next.config.mts"); + + const updatedConfig = fs.readFileSync(path.join(tmpDir, "next.config.mts"), "utf-8"); + assert.equal( + normalizeWhitespace(updatedConfig), + normalizeWhitespace(` + // @ts-nocheck + import originalConfig from './next.config.original.mts'; + + ${nextConfigOverrideBody} + + export default config; + `), + ); + }); + + it("should leave the original config in place when the override fails", async () => { + const { overrideNextConfig } = await importOverrides; + const originalConfig = `module.exports = { /* config options here */ }`; + + fs.writeFileSync(path.join(tmpDir, "next.config.cjs"), originalConfig); + + await assert.rejects( + async () => await overrideNextConfig(tmpDir, "next.config.cjs"), + /Unsupported file extension for Next Config/, + ); + + assert.equal(fs.readFileSync(path.join(tmpDir, "next.config.cjs"), "utf-8"), originalConfig); + assert.ok( + !fs.existsSync(path.join(tmpDir, "next.config.original.cjs")), + "No next.config.original.cjs backup should be left behind", + ); + }); + it("should not do anything if no next.config.* file exists", async () => { const { overrideNextConfig } = await importOverrides; await overrideNextConfig(tmpDir, "next.config.js"); diff --git a/packages/@apphosting/adapter-nextjs/src/overrides.ts b/packages/@apphosting/adapter-nextjs/src/overrides.ts index 5edd0d547..de6b0dc15 100644 --- a/packages/@apphosting/adapter-nextjs/src/overrides.ts +++ b/packages/@apphosting/adapter-nextjs/src/overrides.ts @@ -11,7 +11,7 @@ import { join, extname } from "path"; import { rename as renamePromise } from "fs/promises"; /** - * Overrides the user's Next Config file (next.config.[ts|js|mjs]) to add configs + * Overrides the user's Next Config file (next.config.[ts|mts|js|mjs]) to add configs * optimized for Firebase App Hosting. */ export async function overrideNextConfig(projectRoot: string, nextConfigFileName: string) { @@ -28,40 +28,49 @@ export async function overrideNextConfig(projectRoot: string, nextConfigFileName const fileExtension = extname(nextConfigFileName); const originalConfigName = `next.config.original${fileExtension}`; + // Create a new config file with the appropriate import. This is done before renaming + // the original config file so that an unsupported extension fails before we have + // modified anything in the user's project. + let importStatement; + switch (fileExtension) { + case ".js": + importStatement = `const originalConfig = require('./${originalConfigName}');`; + break; + case ".mjs": + case ".mts": + // Next.js loads next.config.mts with Node's type stripping, which resolves ES module + // specifiers as written, so ".mts" keeps its extension instead of dropping it as ".ts" does. + importStatement = `import originalConfig from './${originalConfigName}';`; + break; + case ".ts": + importStatement = `import originalConfig from './${originalConfigName.replace(".ts", "")}';`; + break; + default: + throw new Error( + `Unsupported file extension for Next Config: "${fileExtension}", please use ".js", ".mjs", ".ts", or ".mts"`, + ); + } + + // Create the new config content with our overrides + const newConfigContent = getCustomNextConfig(importStatement, fileExtension); + // Rename the original config file + let originalConfigRenamed = false; try { const originalPath = join(projectRoot, originalConfigName); await renamePromise(configPath, originalPath); - - // Create a new config file with the appropriate import - let importStatement; - switch (fileExtension) { - case ".js": - importStatement = `const originalConfig = require('./${originalConfigName}');`; - break; - case ".mjs": - importStatement = `import originalConfig from './${originalConfigName}';`; - break; - case ".ts": - importStatement = `import originalConfig from './${originalConfigName.replace( - ".ts", - "", - )}';`; - break; - default: - throw new Error( - `Unsupported file extension for Next Config: "${fileExtension}", please use ".js", ".mjs", or ".ts"`, - ); - } - - // Create the new config content with our overrides - const newConfigContent = getCustomNextConfig(importStatement, fileExtension); + originalConfigRenamed = true; // Write the new config file await writeFile(join(projectRoot, nextConfigFileName), newConfigContent); console.log(`Successfully created ${nextConfigFileName} with Firebase App Hosting overrides`); } catch (error) { console.error(`Error overriding Next.js config: ${error}`); + // Move the original config file back so that a failed override does not leave the + // app without a Next Config. + if (originalConfigRenamed) { + await restoreNextConfig(projectRoot, nextConfigFileName); + } throw error; } } @@ -73,7 +82,7 @@ export async function overrideNextConfig(projectRoot: string, nextConfigFileName * - images.unoptimized = true, unless user explicitly sets images.unoptimized to false or * is using a custom image loader. * @param importStatement The import statement for the original config. - * @param fileExtension The file extension of the original config. Use ".js", ".mjs", or ".ts" + * @param fileExtension The file extension of the original config. Use ".js", ".mjs", ".ts", or ".mts" * @return The custom Next.js config. */ function getCustomNextConfig(importStatement: string, fileExtension: string) { @@ -99,7 +108,9 @@ function getCustomNextConfig(importStatement: string, fileExtension: string) { } : fahOptimizedConfig(originalConfig); - ${fileExtension === ".mjs" ? "export default config;" : "module.exports = config;"} + ${ + [".mjs", ".mts"].includes(fileExtension) ? "export default config;" : "module.exports = config;" + } `; } @@ -143,7 +154,7 @@ export async function validateNextConfigOverride( } /** - * Restores the user's original Next Config file (next.config.original.[ts|js|mjs]) + * Restores the user's original Next Config file (next.config.original.[ts|mts|js|mjs]) * to leave user code the way we found it. */ export async function restoreNextConfig(projectRoot: string, nextConfigFileName: string) {