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
52 changes: 47 additions & 5 deletions packages/@apphosting/adapter-nextjs/src/bin/build.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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 = {
Expand All @@ -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 = {
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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 });
Expand Down
24 changes: 17 additions & 7 deletions packages/@apphosting/adapter-nextjs/src/bin/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
77 changes: 56 additions & 21 deletions packages/@apphosting/adapter-nextjs/src/utils.ts
Original file line number Diff line number Diff line change
@@ -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";
Copy link
Collaborator

Choose a reason for hiding this comment

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

Why npm?

Copy link
Collaborator Author

@blidd-google blidd-google Apr 15, 2024

Choose a reason for hiding this comment

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

@Yuangwang might be able to provide more context, but my understanding is that we always run npm run build in non-monorepo scenarios to run the custom build command that a user has defined as their build script. Monorepo tools can be configured to run custom build scripts if desired (https://nx.dev/nx-api/nx/executors/run-script).


// Loads the user's next.config.js file.
export async function loadConfig(root: string, projectRoot: string): Promise<NextConfigComplete> {
// 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);
Copy link
Collaborator

Choose a reason for hiding this comment

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

Why do we need this change? We might have discussed this but I forgot the reason

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yeah we discussed this a few weeks ago and my memory was also a bit fuzzy, but we need this in order to locate the package in the root node_modules folder (which is relevant for monorepo contexts).

Copy link
Collaborator

Choose a reason for hiding this comment

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

Is there any additional documentation we can link to in this line comment?

Copy link
Collaborator

Choose a reason for hiding this comment

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

Sorry not to be too pedantic but this comment explains what this does as opposed to why we need it.

Can we have a comment here explaining the motivation behind this statement? eg "Intentionally use ESM module resolution here to support blah blah blah"

See go/tott/563

Copy link
Collaborator

Choose a reason for hiding this comment

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

A good litmus test is: Would someone who's not you, a year from now, understand why we need this statement based on the line comment? Would they be able to gather sufficient context either from the code, the comment, or linked docs to own it going forward?

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<RoutesManifest> {
Expand All @@ -30,42 +45,62 @@ 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<void> {
const standaloneDirectory = join(nextBuildDirectory, "standalone");
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;
}
Expand Down