Skip to content

Commit 00669d3

Browse files
authored
Restore Next config after build. (#373)
The next config should be restored in the root directory after build in the build output dir .next/standalone/ should include both the next config used for build and the user's original config for reference
1 parent cbeec0b commit 00669d3

File tree

4 files changed

+156
-29
lines changed

4 files changed

+156
-29
lines changed

packages/@apphosting/adapter-nextjs/e2e/config-override.spec.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,40 @@ const compiledFilesPath = posix.join(
2727
".next",
2828
);
2929

30+
const standalonePath = posix.join(process.cwd(), "e2e", "runs", runId, ".next", "standalone");
31+
32+
const appPath = posix.join(process.cwd(), "e2e", "runs", runId);
33+
3034
const requiredServerFilePath = posix.join(compiledFilesPath, "required-server-files.json");
3135

3236
describe("next.config override", () => {
37+
it("Should not overwrite original next config", async function () {
38+
if (
39+
scenario.includes("with-empty-config") ||
40+
scenario.includes("with-images-unoptimized-false") ||
41+
scenario.includes("with-custom-image-loader")
42+
) {
43+
// eslint-disable-next-line @typescript-eslint/no-invalid-this
44+
this.skip();
45+
}
46+
const files = await fsExtra.readdir(appPath);
47+
const configRegex = /^next\.config\..*$/g;
48+
const configOriginalRegex = /^next\.config\.(?!original).*$/g;
49+
const configFiles = files.filter((file) => file.match(configRegex));
50+
assert.strictEqual(configFiles.length, 1);
51+
assert.ok(
52+
configFiles[0].match(configOriginalRegex),
53+
"temporary original config not properly removed",
54+
);
55+
56+
const standaloneFiles = await fsExtra.readdir(standalonePath);
57+
const standaloneConfigFiles = standaloneFiles.filter((file) => file.match(configRegex));
58+
assert.strictEqual(standaloneConfigFiles.length, 2);
59+
assert.ok(
60+
standaloneConfigFiles.some((file) => file.match(configOriginalRegex)),
61+
"no original config found in standalone",
62+
);
63+
});
3364
it("should have images optimization disabled", async function () {
3465
if (
3566
scenario.includes("with-empty-config") ||

packages/@apphosting/adapter-nextjs/src/bin/build.ts

Lines changed: 41 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,12 @@ import {
99
} from "../utils.js";
1010
import { join } from "path";
1111
import { getBuildOptions, runBuild } from "@apphosting/common";
12-
import { addRouteOverrides, overrideNextConfig, validateNextConfigOverride } from "../overrides.js";
12+
import {
13+
addRouteOverrides,
14+
overrideNextConfig,
15+
restoreNextConfig,
16+
validateNextConfigOverride,
17+
} from "../overrides.js";
1318

1419
const root = process.cwd();
1520
const opts = getBuildOptions();
@@ -19,48 +24,55 @@ process.env.NEXT_PRIVATE_STANDALONE = "true";
1924
// Opt-out sending telemetry to Vercel
2025
process.env.NEXT_TELEMETRY_DISABLED = "1";
2126

22-
const originalConfig = await loadConfig(root, opts.projectDirectory);
27+
const nextConfig = await loadConfig(root, opts.projectDirectory);
2328

2429
/**
2530
* Override user's Next Config to optimize the app for Firebase App Hosting
2631
* and validate that the override resulted in a valid config that Next.js can
2732
* load.
2833
*
34+
* We restore the user's Next Config at the end of the build, after the config file has been
35+
* copied over to the output directory, so that the user's original code is not modified.
36+
*
2937
* If the app does not have a next.config.[js|mjs|ts] file in the first place,
3038
* then can skip config override.
3139
*
3240
* Note: loadConfig always returns a fileName (default: next.config.js) even if
3341
* one does not exist in the app's root: https://github.com/vercel/next.js/blob/23681508ca34b66a6ef55965c5eac57de20eb67f/packages/next/src/server/config.ts#L1115
3442
*/
35-
const originalConfigPath = join(root, originalConfig.configFileName);
36-
if (await exists(originalConfigPath)) {
37-
await overrideNextConfig(root, originalConfig.configFileName);
38-
await validateNextConfigOverride(root, opts.projectDirectory, originalConfig.configFileName);
43+
const nextConfigPath = join(root, nextConfig.configFileName);
44+
if (await exists(nextConfigPath)) {
45+
await overrideNextConfig(root, nextConfig.configFileName);
46+
await validateNextConfigOverride(root, opts.projectDirectory, nextConfig.configFileName);
3947
}
4048

41-
await runBuild();
49+
try {
50+
await runBuild();
4251

43-
const adapterMetadata = getAdapterMetadata();
44-
const nextBuildDirectory = join(opts.projectDirectory, originalConfig.distDir);
45-
const outputBundleOptions = populateOutputBundleOptions(
46-
root,
47-
opts.projectDirectory,
48-
nextBuildDirectory,
49-
);
52+
const adapterMetadata = getAdapterMetadata();
53+
const nextBuildDirectory = join(opts.projectDirectory, nextConfig.distDir);
54+
const outputBundleOptions = populateOutputBundleOptions(
55+
root,
56+
opts.projectDirectory,
57+
nextBuildDirectory,
58+
);
5059

51-
await addRouteOverrides(
52-
outputBundleOptions.outputDirectoryAppPath,
53-
originalConfig.distDir,
54-
adapterMetadata,
55-
);
60+
await addRouteOverrides(
61+
outputBundleOptions.outputDirectoryAppPath,
62+
nextConfig.distDir,
63+
adapterMetadata,
64+
);
5665

57-
const nextjsVersion = process.env.FRAMEWORK_VERSION || "unspecified";
58-
await generateBuildOutput(
59-
root,
60-
opts.projectDirectory,
61-
outputBundleOptions,
62-
nextBuildDirectory,
63-
nextjsVersion,
64-
adapterMetadata,
65-
);
66-
await validateOutputDirectory(outputBundleOptions, nextBuildDirectory);
66+
const nextjsVersion = process.env.FRAMEWORK_VERSION || "unspecified";
67+
await generateBuildOutput(
68+
root,
69+
opts.projectDirectory,
70+
outputBundleOptions,
71+
nextBuildDirectory,
72+
nextjsVersion,
73+
adapterMetadata,
74+
);
75+
await validateOutputDirectory(outputBundleOptions, nextBuildDirectory);
76+
} finally {
77+
await restoreNextConfig(root, nextConfig.configFileName);
78+
}

packages/@apphosting/adapter-nextjs/src/overrides.spec.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,67 @@ describe("validateNextConfigOverride", () => {
378378
});
379379
});
380380

381+
describe("next config restore", () => {
382+
let tmpDir: string;
383+
const nextConfigOriginalBody = `
384+
// @ts-check
385+
386+
/** @type {import('next').NextConfig} */
387+
const nextConfig = {
388+
/* config options here */
389+
}
390+
391+
module.exports = nextConfig
392+
`;
393+
const nextConfigBody = `
394+
// This file was automatically generated by Firebase App Hosting adapter
395+
const fahOptimizedConfig = (config) => ({
396+
...config,
397+
images: {
398+
...(config.images || {}),
399+
...(config.images?.unoptimized === undefined && config.images?.loader === undefined
400+
? { unoptimized: true }
401+
: {}),
402+
},
403+
});
404+
405+
const config = typeof originalConfig === 'function'
406+
? async (...args) => {
407+
const resolvedConfig = await originalConfig(...args);
408+
return fahOptimizedConfig(resolvedConfig);
409+
}
410+
: fahOptimizedConfig(originalConfig);
411+
`;
412+
413+
beforeEach(() => {
414+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "test-overrides"));
415+
});
416+
417+
it("handle no original config file found", async () => {
418+
const { restoreNextConfig } = await importOverrides;
419+
fs.writeFileSync(path.join(tmpDir, "next.config.mjs"), nextConfigBody);
420+
await restoreNextConfig(tmpDir, "next.config.mjs");
421+
422+
const restoredConfig = fs.readFileSync(path.join(tmpDir, "next.config.mjs"), "utf-8");
423+
assert.equal(restoredConfig, nextConfigBody);
424+
});
425+
426+
it("handle no config file found", async () => {
427+
const { restoreNextConfig } = await importOverrides;
428+
assert.doesNotReject(restoreNextConfig(tmpDir, "next.config.mjs"));
429+
});
430+
431+
it("original config file restored", async () => {
432+
const { restoreNextConfig } = await importOverrides;
433+
fs.writeFileSync(path.join(tmpDir, "next.config.mjs"), nextConfigBody);
434+
fs.writeFileSync(path.join(tmpDir, "next.config.original.mjs"), nextConfigOriginalBody);
435+
await restoreNextConfig(tmpDir, "next.config.mjs");
436+
437+
const restoredConfig = fs.readFileSync(path.join(tmpDir, "next.config.mjs"), "utf-8");
438+
assert.equal(restoredConfig, nextConfigOriginalBody);
439+
});
440+
});
441+
381442
// Normalize whitespace for comparison
382443
function normalizeWhitespace(str: string) {
383444
return str.replace(/\s+/g, " ").trim();

packages/@apphosting/adapter-nextjs/src/overrides.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,29 @@ export async function validateNextConfigOverride(
142142
}
143143
}
144144

145+
/**
146+
* Restores the user's original Next Config file (next.config.original.[ts|js|mjs])
147+
* to leave user code the way we found it.
148+
*/
149+
export async function restoreNextConfig(projectRoot: string, nextConfigFileName: string) {
150+
// Determine the file extension
151+
const fileExtension = extname(nextConfigFileName);
152+
const originalConfigPath = join(projectRoot, `next.config.original${fileExtension}`);
153+
154+
if (!(await exists(originalConfigPath))) {
155+
// No backup file found, nothing to restore.
156+
return;
157+
}
158+
console.log(`Restoring original next config in project root`);
159+
160+
const configPath = join(projectRoot, nextConfigFileName);
161+
try {
162+
await renamePromise(originalConfigPath, configPath);
163+
} catch (error) {
164+
console.error(`Error restoring Next config: ${error}`);
165+
}
166+
}
167+
145168
/**
146169
* Modifies the app's route manifest (routes-manifest.json) to add Firebase App Hosting
147170
* specific overrides (i.e headers).

0 commit comments

Comments
 (0)