Skip to content

gitignore .apphosting #374

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

Draft
wants to merge 7 commits into
base: main
Choose a base branch
from
Draft
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
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import * as assert from "assert";
import { posix } from "path";
import { posix, join } from "path";
import fsExtra from "fs-extra";


const host = process.env.HOST;
if (!host) {
throw new Error("HOST environment variable expected");
Expand All @@ -27,9 +28,47 @@ 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")
) {
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), "found original config file in root");

const standaloneFiles = await fsExtra.readdir(standalonePath);
const standaloneConfigFiles = standaloneFiles
.filter((file) => file.match(configRegex));
assert.strictEqual(standaloneConfigFiles.length, 2);
assert.ok(configFiles.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
66 changes: 37 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,7 @@ 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 +19,56 @@ 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
29 changes: 29 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,35 @@ 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) {
// Check if the file exists in the current working directory
const configPath = join(projectRoot, nextConfigFileName);
if (!(await exists(configPath))) {
return;
}

// Determine the file extension
const fileExtension = extname(nextConfigFileName);
const originalConfigPath = join(projectRoot, `next.config.original${fileExtension}`);
if (!(await exists(originalConfigPath))) {
console.warn(`next config may have been overwritten but original contents not found`);
return;
}
console.log(`Restoring original next config in project root`);

try {
await renamePromise(originalConfigPath, configPath);
} catch (error) {
console.error(`Error restoring Next config: ${error}`);
}
return;

}

/**
* Modifies the app's route manifest (routes-manifest.json) to add Firebase App Hosting
* specific overrides (i.e headers).
Expand Down
8 changes: 5 additions & 3 deletions packages/@apphosting/adapter-nextjs/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@
MiddlewareManifest,
} from "./interfaces.js";
import { NextConfigComplete } from "next/dist/server/config-shared.js";
import { OutputBundleConfig } from "@apphosting/common";
import { OutputBundleConfig, UpdateOrCreateGitignore } from "@apphosting/common";

// fs-extra is CJS, readJson can't be imported using shorthand
export const { copy, exists, writeFile, readJson, readdir, readFileSync, existsSync, mkdir } =
export const { copy, exists, writeFile, readJson, readdir, readFileSync, existsSync, ensureDir } =
fsExtra;

// Loads the user's next.config.js file.
Expand Down Expand Up @@ -181,7 +181,7 @@
nextVersion: string,
adapterMetadata: AdapterMetadata,
): Promise<void> {
await mkdir(opts.outputDirectoryBasePath);
await ensureDir(opts.outputDirectoryBasePath);
const outputBundle: OutputBundleConfig = {
version: "v1",
runConfig: {
Expand All @@ -203,6 +203,8 @@
}

await writeFile(opts.bundleYamlPath, yamlStringify(outputBundle));
const normalizedBundleDir = normalize(relative(cwd, opts.outputDirectoryBasePath));
UpdateOrCreateGitignore(cwd, [`/${normalizedBundleDir}/`]);

Check failure on line 207 in packages/@apphosting/adapter-nextjs/src/utils.ts

View workflow job for this annotation

GitHub Actions / Lint

A function with a name starting with an uppercase letter should only be used as a constructor
return;
}

Expand Down
25 changes: 25 additions & 0 deletions packages/@apphosting/common/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { spawn } from "child_process";
import * as path from "node:path";
import * as fs from "fs-extra";

// Output bundle metadata specifications to be written to bundle.yaml
export interface OutputBundleConfig {
Expand Down Expand Up @@ -139,3 +141,26 @@ export function getBuildOptions(): BuildOptions {
projectDirectory: process.cwd(),
};
}

/**
* Updates or creates a .gitignore file with the given entries in the given path
*/
export function UpdateOrCreateGitignore(dirPath: string, entries: string[]) {
const gitignorePath = path.join(dirPath, ".gitignore");

if (!fs.existsSync(gitignorePath)) {
console.log(`creating ${gitignorePath} with entries: ${entries.join("\n")}`);
fs.writeFileSync(gitignorePath, entries.join("\n"));
return;
}

let content = fs.readFileSync(gitignorePath, "utf-8");
for (const entry of entries) {
if (!content.includes(entry)) {
console.log(`adding ${entry} to ${gitignorePath}`);
content += `\n${entry}\n`;
}
}

fs.writeFileSync(gitignorePath, content);
}
Loading