Skip to content

Set App Hosting overrides for NextJS apps without a Next Config #323

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
Original file line number Diff line number Diff line change
Expand Up @@ -200,3 +200,4 @@ tests:

module.exports = nextConfig;
file: next.config.js
- name: without-a-next-config
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ const compiledFilesPath = posix.join(
const requiredServerFilePath = posix.join(compiledFilesPath, "required-server-files.json");

describe("next.config override", () => {
it("should have images optimization disabled", async function () {
it("should have image optimization disabled", async function () {
if (
scenario.includes("with-empty-config") ||
scenario.includes("with-images-unoptimized-false") ||
Expand All @@ -53,7 +53,7 @@ describe("next.config override", () => {
});

it("should preserve other user set next configs", async function () {
if (scenario.includes("with-empty-config")) {
if (scenario.includes("with-empty-config") || scenario.includes("without-a-next-config")) {
// eslint-disable-next-line @typescript-eslint/no-invalid-this
this.skip();
}
Expand Down
9 changes: 7 additions & 2 deletions packages/@apphosting/adapter-nextjs/e2e/run-local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,9 @@ const scenarios: Scenario[] = [
tests: ["middleware.spec.ts"], // Only run middleware-specific tests
},
...configOverrideTestScenarios.map(
(scenario: { name: string; config: string; file: string }) => ({
(scenario: { name: string; config?: string; file?: string }) => ({
name: scenario.name,
setup: async (cwd: string) => {
const configContent = scenario.config;
const files = await fsExtra.readdir(cwd);
const configFiles = files
.filter((file) => file.startsWith("next.config."))
Expand All @@ -67,6 +66,12 @@ const scenarios: Scenario[] = [
console.log(`Removed existing config file: ${file}`);
}

// skip creating the test config if data is not provided
if (!scenario.config || !scenario.file) {
return;
}

const configContent = scenario.config;
await fsExtra.writeFile(join(cwd, scenario.file), configContent);
console.log(`Created ${scenario.file} file with ${scenario.name} config`);
},
Expand Down
11 changes: 4 additions & 7 deletions packages/@apphosting/adapter-nextjs/src/bin/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import {
generateBuildOutput,
validateOutputDirectory,
getAdapterMetadata,
exists,
} from "../utils.js";
import { join } from "path";
import { getBuildOptions, runBuild } from "@apphosting/common";
Expand All @@ -30,16 +29,14 @@ const originalConfig = await loadConfig(root, opts.projectDirectory);
* load.
*
* If the app does not have a next.config.[js|mjs|ts] file in the first place,
* then can skip config override.
* then one is created with the overrides.
*
* 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 originalConfigPath = join(root, originalConfig.configFileName);
if (await exists(originalConfigPath)) {
await overrideNextConfig(root, originalConfig.configFileName);
await validateNextConfigOverride(root, opts.projectDirectory, originalConfig.configFileName);
}

await overrideNextConfig(root, originalConfig.configFileName);
await validateNextConfigOverride(root, opts.projectDirectory, originalConfig.configFileName);

await runBuild();

Expand Down
79 changes: 42 additions & 37 deletions packages/@apphosting/adapter-nextjs/src/overrides.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,20 +172,32 @@ describe("next config overrides", () => {
...config,
images: {
...(config.images || {}),
...(config.images?.unoptimized === undefined && config.images?.loader === undefined
? { unoptimized: true }
...(config.images?.unoptimized === undefined && config.images?.loader === undefined
? { unoptimized: true }
: {}),
},
});

const config = typeof originalConfig === 'function'
const config = typeof originalConfig === 'function'
? async (...args) => {
const resolvedConfig = await originalConfig(...args);
return fahOptimizedConfig(resolvedConfig);
}
: fahOptimizedConfig(originalConfig);
`;

const defaultNextConfig = `
// @ts-nocheck

/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
unoptimized: true,
}
}

module.exports = nextConfig
`;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "test-overrides"));
});
Expand All @@ -194,12 +206,12 @@ describe("next config overrides", () => {
const { overrideNextConfig } = await importOverrides;
const originalConfig = `
// @ts-check

/** @type {import('next').NextConfig} */
const nextConfig = {
/* config options here */
}

module.exports = nextConfig
`;

Expand All @@ -213,7 +225,7 @@ describe("next config overrides", () => {
normalizeWhitespace(`
// @ts-nocheck
const originalConfig = require('./next.config.original.js');

${nextConfigOverrideBody}

module.exports = config;
Expand All @@ -225,14 +237,14 @@ describe("next config overrides", () => {
const { overrideNextConfig } = await importOverrides;
const originalConfig = `
// @ts-check

/**
* @type {import('next').NextConfig}
*/
const nextConfig = {
/* config options here */
}

export default nextConfig
`;

Expand All @@ -257,7 +269,7 @@ describe("next config overrides", () => {
const { overrideNextConfig } = await importOverrides;
const originalConfig = `
// @ts-check

export default (phase, { defaultConfig }) => {
/**
* @type {import('next').NextConfig}
Expand All @@ -280,7 +292,7 @@ describe("next config overrides", () => {
import originalConfig from './next.config.original.mjs';

${nextConfigOverrideBody}

export default config;
`),
);
Expand All @@ -290,11 +302,11 @@ describe("next config overrides", () => {
const { overrideNextConfig } = await importOverrides;
const originalConfig = `
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
/* config options here */
}

export default nextConfig
`;

Expand All @@ -307,42 +319,37 @@ describe("next config overrides", () => {
normalizeWhitespace(`
// @ts-nocheck
import originalConfig from './next.config.original';

${nextConfigOverrideBody}

module.exports = config;
`),
);
});

it("should not do anything if no next.config.* file exists", async () => {
it("should create a default next.config.js file if one does not exist yet", async () => {
const { overrideNextConfig } = await importOverrides;
await overrideNextConfig(tmpDir, "next.config.js");

// Assert that no next.config* files were created
const files = fs.readdirSync(tmpDir);
const nextConfigFiles = files.filter((file) => file.startsWith("next.config"));
assert.strictEqual(nextConfigFiles.length, 0, "No next.config files should exist");
const updatedConfig = fs.readFileSync(path.join(tmpDir, "next.config.js"), "utf-8");
assert.equal(normalizeWhitespace(updatedConfig), normalizeWhitespace(defaultNextConfig));
});
});

describe("validateNextConfigOverride", () => {
let tmpDir: string;
let root: string;
let projectRoot: string;
let originalConfigFileName: string;
let newConfigFileName: string;
let originalConfigPath: string;
let newConfigPath: string;
let configFileName: string;
let preservedConfigFileName: string;
let preservedConfigFilePath: string;

beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "test-next-config-override"));
root = tmpDir;
projectRoot = tmpDir;
originalConfigFileName = "next.config.js";
newConfigFileName = "next.config.original.js";
originalConfigPath = path.join(root, originalConfigFileName);
newConfigPath = path.join(root, newConfigFileName);
configFileName = "next.config.js";
preservedConfigFileName = "next.config.original.js";
preservedConfigFilePath = path.join(root, preservedConfigFileName);

fs.mkdirSync(root, { recursive: true });
});
Expand All @@ -351,25 +358,23 @@ describe("validateNextConfigOverride", () => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});

it("should throw an error when new config file doesn't exist", async () => {
fs.writeFileSync(originalConfigPath, "module.exports = {}");

it("should throw an error if a next config file was not created because the user did not have one", async () => {
const { validateNextConfigOverride } = await importOverrides;

await assert.rejects(
async () => await validateNextConfigOverride(root, projectRoot, originalConfigFileName),
/New Next.js config file not found/,
async () => await validateNextConfigOverride(root, projectRoot, configFileName),
/Next.js config file not found/,
);
});

it("should throw an error when original config file doesn't exist", async () => {
fs.writeFileSync(newConfigPath, "module.exports = {}");
it("should throw an error when main config file doesn't exist", async () => {
fs.writeFileSync(preservedConfigFilePath, "module.exports = {}");

const { validateNextConfigOverride } = await importOverrides;

await assert.rejects(
async () => await validateNextConfigOverride(root, projectRoot, originalConfigFileName),
/Original Next.js config file not found/,
async () => await validateNextConfigOverride(root, projectRoot, configFileName),
/Next Config Override Failed: Next.js config file not found/,
);
});
});
Expand Down
71 changes: 50 additions & 21 deletions packages/@apphosting/adapter-nextjs/src/overrides.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,26 +10,38 @@ import {
import { join, extname } from "path";
import { rename as renamePromise } from "fs/promises";

const DEFAULT_NEXT_CONFIG_FILE = "next.config.js";

/**
* Overrides the user's Next Config file (next.config.[ts|js|mjs]) to add configs
* optimized for Firebase App Hosting.
*/
export async function overrideNextConfig(projectRoot: string, nextConfigFileName: string) {
console.log(`Overriding Next Config to add configs optmized for Firebase App Hosting`);
// Check if the file exists in the current working directory
const configPath = join(projectRoot, nextConfigFileName);

if (!(await exists(configPath))) {
console.log(`No Next.js config file found at ${configPath}`);
const userNextConfigExists = await exists(join(projectRoot, nextConfigFileName));
if (!userNextConfigExists) {
console.log(`No Next config file found, creating one with Firebase App Hosting overrides`);
try {
await writeFile(join(projectRoot, DEFAULT_NEXT_CONFIG_FILE), defaultNextConfigForFAH());
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does nextConfigFileName include the default filename? Then we can drop nextConfigFileName in favor.

console.log(
`Successfully created ${DEFAULT_NEXT_CONFIG_FILE} with Firebase App Hosting overrides`,
);
} catch (error) {
console.error(`Error creating ${DEFAULT_NEXT_CONFIG_FILE}: ${error}`);
throw error;
}
return;
}

// A Next Config already exists in the user's project, so it needs to be overriden

// Determine the file extension
const fileExtension = extname(nextConfigFileName);
const originalConfigName = `next.config.original${fileExtension}`;

// Rename the original config file
try {
const configPath = join(projectRoot, nextConfigFileName);
const originalPath = join(projectRoot, originalConfigName);
await renamePromise(configPath, originalPath);

Expand Down Expand Up @@ -104,30 +116,47 @@ function getCustomNextConfig(importStatement: string, fileExtension: string) {
}

/**
* This function is used to validate the state of an app after running the
* Returns the default Next Config file that is created in the user's project
* if one does not exist already. This config ensures the Next.Js app is optimized
* for Firebase App Hosting.
*/
function defaultNextConfigForFAH() {
return `
// @ts-nocheck

/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
unoptimized: true,
}
}

module.exports = nextConfig
`;
}

/**
* This function is used to validate the state of an app after running
* overrideNextConfig. It validates that:
* 1. original next config is preserved
* 2. a new next config is created
* 3. new next config can be loaded by NextJs without any issues.
* 1. if user has a next config it is preserved in a next.config.original.[js|ts|mjs] file
* 2. a next config exists (should be created with FAH overrides
* even if user did not create one)
* 3. next config can be loaded by NextJs without any issues.
*/
export async function validateNextConfigOverride(
root: string,
projectRoot: string,
originalConfigFileName: string,
configFileName: string,
) {
const originalConfigExtension = extname(originalConfigFileName);
const newConfigFileName = `next.config.original${originalConfigExtension}`;
const newConfigFilePath = join(root, newConfigFileName);
if (!(await exists(newConfigFilePath))) {
throw new Error(
`Next Config Override Failed: New Next.js config file not found at ${newConfigFilePath}`,
);
}
const userNextConfigExists = await exists(join(root, configFileName));
const configFilePath = join(
root,
userNextConfigExists ? configFileName : DEFAULT_NEXT_CONFIG_FILE,
);

const originalNextConfigFilePath = join(root, originalConfigFileName);
if (!(await exists(originalNextConfigFilePath))) {
if (!(await exists(configFilePath))) {
throw new Error(
`Next Config Override Failed: Original Next.js config file not found at ${originalNextConfigFilePath}`,
`Next Config Override Failed: Next.js config file not found at ${configFilePath}`,
);
}

Expand Down