Skip to content
Open
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
11 changes: 6 additions & 5 deletions packages/@apphosting/adapter-nextjs/src/bin/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
47 changes: 47 additions & 0 deletions packages/@apphosting/adapter-nextjs/src/overrides.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
67 changes: 39 additions & 28 deletions packages/@apphosting/adapter-nextjs/src/overrides.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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;
}
}
Expand All @@ -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) {
Expand All @@ -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;"
}
`;
}

Expand Down Expand Up @@ -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) {
Expand Down