Skip to content
Merged
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
31 changes: 31 additions & 0 deletions packages/@apphosting/adapter-nextjs/e2e/config-override.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,40 @@ const compiledFilesPath = posix.join(
".next",
);

const standalonePath = posix.join(process.cwd(), "e2e", "runs", runId, ".next", "standalone");

const appPath = posix.join(process.cwd(), "e2e", "runs", runId);

const requiredServerFilePath = posix.join(compiledFilesPath, "required-server-files.json");

describe("next.config override", () => {
it("Should not overwrite original next config", async function () {
if (
scenario.includes("with-empty-config") ||
scenario.includes("with-images-unoptimized-false") ||
scenario.includes("with-custom-image-loader")
) {
// eslint-disable-next-line @typescript-eslint/no-invalid-this
this.skip();
}
const files = await fsExtra.readdir(appPath);
const configRegex = /^next\.config\..*$/g;
const configOriginalRegex = /^next\.config\.(?!original).*$/g;
const configFiles = files.filter((file) => file.match(configRegex));
assert.strictEqual(configFiles.length, 1);
assert.ok(
configFiles[0].match(configOriginalRegex),
"temporary original config not properly removed",
);

const standaloneFiles = await fsExtra.readdir(standalonePath);
const standaloneConfigFiles = standaloneFiles.filter((file) => file.match(configRegex));
assert.strictEqual(standaloneConfigFiles.length, 2);
assert.ok(
standaloneConfigFiles.some((file) => file.match(configOriginalRegex)),
"no original config found in standalone",
);
});
it("should have images optimization disabled", async function () {
if (
scenario.includes("with-empty-config") ||
Expand Down
70 changes: 41 additions & 29 deletions packages/@apphosting/adapter-nextjs/src/bin/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@ import {
} from "../utils.js";
import { join } from "path";
import { getBuildOptions, runBuild } from "@apphosting/common";
import { addRouteOverrides, overrideNextConfig, validateNextConfigOverride } from "../overrides.js";
import {
addRouteOverrides,
overrideNextConfig,
restoreNextConfig,
validateNextConfigOverride,
} from "../overrides.js";

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

const originalConfig = await loadConfig(root, opts.projectDirectory);
const nextConfig = await loadConfig(root, opts.projectDirectory);

/**
* Override user's Next Config to optimize the app for Firebase App Hosting
* and validate that the override resulted in a valid config that Next.js can
* load.
*
* 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,
* 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 originalConfigPath = join(root, originalConfig.configFileName);
if (await exists(originalConfigPath)) {
await overrideNextConfig(root, originalConfig.configFileName);
await validateNextConfigOverride(root, opts.projectDirectory, originalConfig.configFileName);
const nextConfigPath = join(root, nextConfig.configFileName);
if (await exists(nextConfigPath)) {
await overrideNextConfig(root, nextConfig.configFileName);
await validateNextConfigOverride(root, opts.projectDirectory, nextConfig.configFileName);
}

await runBuild();
try {
await runBuild();

const adapterMetadata = getAdapterMetadata();
const nextBuildDirectory = join(opts.projectDirectory, originalConfig.distDir);
const outputBundleOptions = populateOutputBundleOptions(
root,
opts.projectDirectory,
nextBuildDirectory,
);
const adapterMetadata = getAdapterMetadata();
const nextBuildDirectory = join(opts.projectDirectory, nextConfig.distDir);
const outputBundleOptions = populateOutputBundleOptions(
root,
opts.projectDirectory,
nextBuildDirectory,
);

await addRouteOverrides(
outputBundleOptions.outputDirectoryAppPath,
originalConfig.distDir,
adapterMetadata,
);
await addRouteOverrides(
outputBundleOptions.outputDirectoryAppPath,
nextConfig.distDir,
adapterMetadata,
);

const nextjsVersion = process.env.FRAMEWORK_VERSION || "unspecified";
await generateBuildOutput(
root,
opts.projectDirectory,
outputBundleOptions,
nextBuildDirectory,
nextjsVersion,
adapterMetadata,
);
await validateOutputDirectory(outputBundleOptions, nextBuildDirectory);
const nextjsVersion = process.env.FRAMEWORK_VERSION || "unspecified";
await generateBuildOutput(
root,
opts.projectDirectory,
outputBundleOptions,
nextBuildDirectory,
nextjsVersion,
adapterMetadata,
);
await validateOutputDirectory(outputBundleOptions, nextBuildDirectory);
} finally {
await restoreNextConfig(root, nextConfig.configFileName);
}
61 changes: 61 additions & 0 deletions packages/@apphosting/adapter-nextjs/src/overrides.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,67 @@ describe("validateNextConfigOverride", () => {
});
});

describe("next config restore", () => {
let tmpDir: string;
const nextConfigOriginalBody = `
// @ts-check

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

module.exports = nextConfig
`;
const nextConfigBody = `
// This file was automatically generated by Firebase App Hosting adapter
const fahOptimizedConfig = (config) => ({
...config,
images: {
...(config.images || {}),
...(config.images?.unoptimized === undefined && config.images?.loader === undefined
? { unoptimized: true }
: {}),
},
});

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

beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "test-overrides"));
});

it("handle no original config file found", async () => {
const { restoreNextConfig } = await importOverrides;
fs.writeFileSync(path.join(tmpDir, "next.config.mjs"), nextConfigBody);
await restoreNextConfig(tmpDir, "next.config.mjs");

const restoredConfig = fs.readFileSync(path.join(tmpDir, "next.config.mjs"), "utf-8");
assert.equal(restoredConfig, nextConfigBody);
});

it("handle no config file found", async () => {
const { restoreNextConfig } = await importOverrides;
assert.doesNotReject(restoreNextConfig(tmpDir, "next.config.mjs"));
});

it("original config file restored", async () => {
const { restoreNextConfig } = await importOverrides;
fs.writeFileSync(path.join(tmpDir, "next.config.mjs"), nextConfigBody);
fs.writeFileSync(path.join(tmpDir, "next.config.original.mjs"), nextConfigOriginalBody);
await restoreNextConfig(tmpDir, "next.config.mjs");

const restoredConfig = fs.readFileSync(path.join(tmpDir, "next.config.mjs"), "utf-8");
assert.equal(restoredConfig, nextConfigOriginalBody);
});
});

// Normalize whitespace for comparison
function normalizeWhitespace(str: string) {
return str.replace(/\s+/g, " ").trim();
Expand Down
23 changes: 23 additions & 0 deletions packages/@apphosting/adapter-nextjs/src/overrides.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,29 @@ export async function validateNextConfigOverride(
}
}

/**
* Restores the user's original Next Config file (next.config.original.[ts|js|mjs])
* to leave user code the way we found it.
*/
export async function restoreNextConfig(projectRoot: string, nextConfigFileName: string) {
// Determine the file extension
const fileExtension = extname(nextConfigFileName);
const originalConfigPath = join(projectRoot, `next.config.original${fileExtension}`);

if (!(await exists(originalConfigPath))) {
// No backup file found, nothing to restore.
return;
}
console.log(`Restoring original next config in project root`);

const configPath = join(projectRoot, nextConfigFileName);
try {
await renamePromise(originalConfigPath, configPath);
} catch (error) {
console.error(`Error restoring Next config: ${error}`);
}
}

/**
* Modifies the app's route manifest (routes-manifest.json) to add Firebase App Hosting
* specific overrides (i.e headers).
Expand Down