diff --git a/packages/@apphosting/adapter-nextjs/src/bin/build.spec.ts b/packages/@apphosting/adapter-nextjs/src/bin/build.spec.ts index 9d06d95b..44111e05 100644 --- a/packages/@apphosting/adapter-nextjs/src/bin/build.spec.ts +++ b/packages/@apphosting/adapter-nextjs/src/bin/build.spec.ts @@ -31,7 +31,7 @@ describe("build commands", () => { }`, }; generateTestFiles(tmpDir, files); - await generateOutputDirectory(tmpDir, outputBundleOptions, path.join(tmpDir, ".next")); + await generateOutputDirectory(tmpDir, tmpDir, outputBundleOptions, path.join(tmpDir, ".next")); await validateOutputDirectory(outputBundleOptions); const expectedFiles = { @@ -50,6 +50,48 @@ staticAssets: validateTestFiles(tmpDir, expectedFiles); }); + it("moves files into correct location in a monorepo setup", async () => { + const { generateOutputDirectory } = await importUtils; + const files = { + ".next/standalone/apps/next-app/standalonefile": "", + ".next/static/staticfile": "", + "public/publicfile": "", + ".next/routes-manifest.json": `{ + "headers":[], + "rewrites":[], + "redirects":[] + }`, + }; + generateTestFiles(tmpDir, files); + await generateOutputDirectory( + tmpDir, + "apps/next-app", + { + bundleYamlPath: path.join(tmpDir, ".apphosting/bundle.yaml"), + outputDirectory: path.join(tmpDir, ".apphosting"), + outputPublicDirectory: path.join(tmpDir, ".apphosting/apps/next-app/public"), + outputStaticDirectory: path.join(tmpDir, ".apphosting/apps/next-app/.next/static"), + serverFilePath: path.join(tmpDir, ".apphosting/apps/next-app/server.js"), + }, + path.join(tmpDir, ".next"), + ); + + const expectedFiles = { + ".apphosting/apps/next-app/.next/static/staticfile": "", + ".apphosting/apps/next-app/standalonefile": "", + ".apphosting/bundle.yaml": `headers: [] +redirects: [] +rewrites: [] +runCommand: node .apphosting/apps/next-app/server.js +neededDirs: + - .apphosting +staticAssets: + - .apphosting/apps/next-app/public +`, + }; + validateTestFiles(tmpDir, expectedFiles); + }); + it("expects public directory to be copied over", async () => { const { generateOutputDirectory, validateOutputDirectory } = await importUtils; const files = { @@ -63,7 +105,7 @@ staticAssets: }`, }; generateTestFiles(tmpDir, files); - await generateOutputDirectory(tmpDir, outputBundleOptions, path.join(tmpDir, ".next")); + await generateOutputDirectory(tmpDir, tmpDir, outputBundleOptions, path.join(tmpDir, ".next")); await validateOutputDirectory(outputBundleOptions); const expectedFiles = { @@ -95,7 +137,7 @@ staticAssets: }`, }; generateTestFiles(tmpDir, files); - await generateOutputDirectory(tmpDir, outputBundleOptions, path.join(tmpDir, ".next")); + await generateOutputDirectory(tmpDir, tmpDir, outputBundleOptions, path.join(tmpDir, ".next")); await validateOutputDirectory(outputBundleOptions); const expectedFiles = { @@ -132,7 +174,7 @@ staticAssets: }`, }; generateTestFiles(tmpDir, files); - await generateOutputDirectory(tmpDir, outputBundleOptions, path.join(tmpDir, ".next")); + await generateOutputDirectory(tmpDir, tmpDir, outputBundleOptions, path.join(tmpDir, ".next")); assert.rejects(async () => await validateOutputDirectory(outputBundleOptions)); }); it("test populate output bundle options", async () => { @@ -144,7 +186,7 @@ staticAssets: outputStaticDirectory: "test/.apphosting/.next/static", serverFilePath: "test/.apphosting/server.js", }; - assert.deepEqual(populateOutputBundleOptions("test"), expectedOutputBundleOptions); + assert.deepEqual(populateOutputBundleOptions("test", "test"), expectedOutputBundleOptions); }); afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); diff --git a/packages/@apphosting/adapter-nextjs/src/bin/build.ts b/packages/@apphosting/adapter-nextjs/src/bin/build.ts index 69168fe4..cf5bc0d6 100644 --- a/packages/@apphosting/adapter-nextjs/src/bin/build.ts +++ b/packages/@apphosting/adapter-nextjs/src/bin/build.ts @@ -4,19 +4,29 @@ import { build, populateOutputBundleOptions, generateOutputDirectory, + DEFAULT_COMMAND, validateOutputDirectory, } from "../utils.js"; - import { join } from "path"; -const cwd = process.cwd(); +const root = process.cwd(); + +let projectRoot = root; +if (process.env.FIREBASE_APP_DIRECTORY) { + projectRoot = projectRoot.concat("/", process.env.FIREBASE_APP_DIRECTORY); +} -build(cwd); +// Determine which build runner to use +let cmd = DEFAULT_COMMAND; +if (process.env.MONOREPO_COMMAND) { + cmd = process.env.MONOREPO_COMMAND; +} -const outputBundleOptions = populateOutputBundleOptions(cwd); -const { distDir } = await loadConfig(cwd); -const nextBuildDirectory = join(cwd, distDir); +build(projectRoot, cmd); -await generateOutputDirectory(cwd, outputBundleOptions, nextBuildDirectory); +const outputBundleOptions = populateOutputBundleOptions(root, projectRoot); +const { distDir } = await loadConfig(root, projectRoot); +const nextBuildDirectory = join(projectRoot, distDir); +await generateOutputDirectory(root, projectRoot, outputBundleOptions, nextBuildDirectory); await validateOutputDirectory(outputBundleOptions); diff --git a/packages/@apphosting/adapter-nextjs/src/utils.ts b/packages/@apphosting/adapter-nextjs/src/utils.ts index 7f214644..14273e70 100644 --- a/packages/@apphosting/adapter-nextjs/src/utils.ts +++ b/packages/@apphosting/adapter-nextjs/src/utils.ts @@ -1,23 +1,38 @@ +import { spawnSync } from "child_process"; import fsExtra from "fs-extra"; -import { PHASE_PRODUCTION_BUILD } from "./constants.js"; -import { ROUTES_MANIFEST } from "./constants.js"; +import { createRequire } from "node:module"; +import { join, relative, normalize } from "path"; import { fileURLToPath } from "url"; -import { OutputBundleOptions } from "./interfaces.js"; import { stringify as yamlStringify } from "yaml"; -import { spawnSync } from "child_process"; -import { join, relative, normalize } from "path"; +import { PHASE_PRODUCTION_BUILD } from "./constants.js"; +import { ROUTES_MANIFEST } from "./constants.js"; +import { OutputBundleOptions, RoutesManifest } from "./interfaces.js"; +import { NextConfigComplete } from "next/dist/server/config-shared.js"; -import type { RoutesManifest } from "./interfaces.js"; // fs-extra is CJS, readJson can't be imported using shorthand export const { move, exists, writeFile, readJson } = fsExtra; -export async function loadConfig(cwd: string) { +// The default fallback command prefix to run a build. +export const DEFAULT_COMMAND = "npm"; + +// Loads the user's next.config.js file. +export async function loadConfig(root: string, projectRoot: string): Promise { + // createRequire() gives us access to Node's CommonJS implementation of require.resolve() + // (https://nodejs.org/api/module.html#modulecreaterequirefilename). + // We use the require.resolve() resolution algorithm to get the path to the next config module, + // which may reside in the node_modules folder at a higher level in the directory structure + // (e.g. for monorepo projects). + // Note that ESM has an equivalent (https://nodejs.org/api/esm.html#importmetaresolvespecifier), + // but the feature is still experimental. + const require = createRequire(import.meta.url); + const configPath = require.resolve("next/dist/server/config.js", { paths: [projectRoot] }); // dynamically load NextJS so this can be used in an NPX context const { default: nextServerConfig }: { default: typeof import("next/dist/server/config.js") } = - await import(`${cwd}/node_modules/next/dist/server/config.js`); + await import(configPath); + const loadConfig = nextServerConfig.default; - return await loadConfig(PHASE_PRODUCTION_BUILD, cwd); + return await loadConfig(PHASE_PRODUCTION_BUILD, root); } export async function readRoutesManifest(distDir: string): Promise { @@ -30,30 +45,50 @@ export const isMain = (meta: ImportMeta) => { return process.argv[1] === fileURLToPath(meta.url); }; -export function populateOutputBundleOptions(cwd: string): OutputBundleOptions { - const outputBundleDir = join(cwd, ".apphosting"); +/** + * Provides the paths in the output bundle for the built artifacts. + * @param rootDir The root directory of the uploaded source code. + * @param appDir The path to the application source code, relative to the root. + * @return The output bundle paths. + */ +export function populateOutputBundleOptions(rootDir: string, appDir: string): OutputBundleOptions { + const outputBundleDir = join(rootDir, ".apphosting"); + // In monorepo setups, the standalone directory structure will mirror the structure of the monorepo. + // We find the relative path from the root to the app directory to correctly locate server.js. + const outputBundleAppDir = join( + outputBundleDir, + process.env.MONOREPO_COMMAND ? relative(rootDir, appDir) : "", + ); + return { bundleYamlPath: join(outputBundleDir, "bundle.yaml"), outputDirectory: outputBundleDir, - serverFilePath: join(outputBundleDir, "server.js"), - outputPublicDirectory: join(outputBundleDir, "public"), - outputStaticDirectory: join(outputBundleDir, ".next", "static"), + serverFilePath: join(outputBundleAppDir, "server.js"), + outputPublicDirectory: join(outputBundleAppDir, "public"), + outputStaticDirectory: join(outputBundleAppDir, ".next", "static"), }; } // Run build command -export function build(cwd: string): void { +export function build(cwd: string, cmd = DEFAULT_COMMAND): void { // Set standalone mode process.env.NEXT_PRIVATE_STANDALONE = "true"; // Opt-out sending telemetry to Vercel process.env.NEXT_TELEMETRY_DISABLED = "1"; - spawnSync("npm", ["run", "build"], { cwd, shell: true, stdio: "inherit" }); + spawnSync(cmd, ["run", "build"], { cwd, shell: true, stdio: "inherit" }); } -// move the standalone directory, the static directory and the public directory to apphosting output directory -// as well as generating bundle.yaml +/** + * Moves the standalone directory, the static directory and the public directory to apphosting output directory. + * Also generates the bundle.yaml file. + * @param rootDir The root directory of the uploaded source code. + * @param appDir The path to the application source code, relative to the root. + * @param outputBundleOptions The target location of built artifacts in the output bundle. + * @param nextBuildDirectory The location of the .next directory. + */ export async function generateOutputDirectory( - cwd: string, + rootDir: string, + appDir: string, outputBundleOptions: OutputBundleOptions, nextBuildDirectory: string, ): Promise { @@ -61,11 +96,11 @@ export async function generateOutputDirectory( await move(standaloneDirectory, outputBundleOptions.outputDirectory, { overwrite: true }); const staticDirectory = join(nextBuildDirectory, "static"); - const publicDirectory = join(cwd, "public"); + const publicDirectory = join(appDir, "public"); await Promise.all([ move(staticDirectory, outputBundleOptions.outputStaticDirectory, { overwrite: true }), movePublicDirectory(publicDirectory, outputBundleOptions.outputPublicDirectory), - generateBundleYaml(outputBundleOptions, nextBuildDirectory, cwd), + generateBundleYaml(outputBundleOptions, nextBuildDirectory, rootDir), ]); return; }