Skip to content

Commit e258429

Browse files
authored
Support monorepo builds in Next.js adapter (#160)
* support nx w nextjs * mv static files to correct location * fix nested non-monorepo builds & add docs * add comment on createRequire
1 parent 5fae47c commit e258429

File tree

3 files changed

+120
-33
lines changed

3 files changed

+120
-33
lines changed

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

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ describe("build commands", () => {
3131
}`,
3232
};
3333
generateTestFiles(tmpDir, files);
34-
await generateOutputDirectory(tmpDir, outputBundleOptions, path.join(tmpDir, ".next"));
34+
await generateOutputDirectory(tmpDir, tmpDir, outputBundleOptions, path.join(tmpDir, ".next"));
3535
await validateOutputDirectory(outputBundleOptions);
3636

3737
const expectedFiles = {
@@ -50,6 +50,48 @@ staticAssets:
5050
validateTestFiles(tmpDir, expectedFiles);
5151
});
5252

53+
it("moves files into correct location in a monorepo setup", async () => {
54+
const { generateOutputDirectory } = await importUtils;
55+
const files = {
56+
".next/standalone/apps/next-app/standalonefile": "",
57+
".next/static/staticfile": "",
58+
"public/publicfile": "",
59+
".next/routes-manifest.json": `{
60+
"headers":[],
61+
"rewrites":[],
62+
"redirects":[]
63+
}`,
64+
};
65+
generateTestFiles(tmpDir, files);
66+
await generateOutputDirectory(
67+
tmpDir,
68+
"apps/next-app",
69+
{
70+
bundleYamlPath: path.join(tmpDir, ".apphosting/bundle.yaml"),
71+
outputDirectory: path.join(tmpDir, ".apphosting"),
72+
outputPublicDirectory: path.join(tmpDir, ".apphosting/apps/next-app/public"),
73+
outputStaticDirectory: path.join(tmpDir, ".apphosting/apps/next-app/.next/static"),
74+
serverFilePath: path.join(tmpDir, ".apphosting/apps/next-app/server.js"),
75+
},
76+
path.join(tmpDir, ".next"),
77+
);
78+
79+
const expectedFiles = {
80+
".apphosting/apps/next-app/.next/static/staticfile": "",
81+
".apphosting/apps/next-app/standalonefile": "",
82+
".apphosting/bundle.yaml": `headers: []
83+
redirects: []
84+
rewrites: []
85+
runCommand: node .apphosting/apps/next-app/server.js
86+
neededDirs:
87+
- .apphosting
88+
staticAssets:
89+
- .apphosting/apps/next-app/public
90+
`,
91+
};
92+
validateTestFiles(tmpDir, expectedFiles);
93+
});
94+
5395
it("expects public directory to be copied over", async () => {
5496
const { generateOutputDirectory, validateOutputDirectory } = await importUtils;
5597
const files = {
@@ -63,7 +105,7 @@ staticAssets:
63105
}`,
64106
};
65107
generateTestFiles(tmpDir, files);
66-
await generateOutputDirectory(tmpDir, outputBundleOptions, path.join(tmpDir, ".next"));
108+
await generateOutputDirectory(tmpDir, tmpDir, outputBundleOptions, path.join(tmpDir, ".next"));
67109
await validateOutputDirectory(outputBundleOptions);
68110

69111
const expectedFiles = {
@@ -95,7 +137,7 @@ staticAssets:
95137
}`,
96138
};
97139
generateTestFiles(tmpDir, files);
98-
await generateOutputDirectory(tmpDir, outputBundleOptions, path.join(tmpDir, ".next"));
140+
await generateOutputDirectory(tmpDir, tmpDir, outputBundleOptions, path.join(tmpDir, ".next"));
99141
await validateOutputDirectory(outputBundleOptions);
100142

101143
const expectedFiles = {
@@ -132,7 +174,7 @@ staticAssets:
132174
}`,
133175
};
134176
generateTestFiles(tmpDir, files);
135-
await generateOutputDirectory(tmpDir, outputBundleOptions, path.join(tmpDir, ".next"));
177+
await generateOutputDirectory(tmpDir, tmpDir, outputBundleOptions, path.join(tmpDir, ".next"));
136178
assert.rejects(async () => await validateOutputDirectory(outputBundleOptions));
137179
});
138180
it("test populate output bundle options", async () => {
@@ -144,7 +186,7 @@ staticAssets:
144186
outputStaticDirectory: "test/.apphosting/.next/static",
145187
serverFilePath: "test/.apphosting/server.js",
146188
};
147-
assert.deepEqual(populateOutputBundleOptions("test"), expectedOutputBundleOptions);
189+
assert.deepEqual(populateOutputBundleOptions("test", "test"), expectedOutputBundleOptions);
148190
});
149191
afterEach(() => {
150192
fs.rmSync(tmpDir, { recursive: true, force: true });

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

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,29 @@ import {
44
build,
55
populateOutputBundleOptions,
66
generateOutputDirectory,
7+
DEFAULT_COMMAND,
78
validateOutputDirectory,
89
} from "../utils.js";
9-
1010
import { join } from "path";
1111

12-
const cwd = process.cwd();
12+
const root = process.cwd();
13+
14+
let projectRoot = root;
15+
if (process.env.FIREBASE_APP_DIRECTORY) {
16+
projectRoot = projectRoot.concat("/", process.env.FIREBASE_APP_DIRECTORY);
17+
}
1318

14-
build(cwd);
19+
// Determine which build runner to use
20+
let cmd = DEFAULT_COMMAND;
21+
if (process.env.MONOREPO_COMMAND) {
22+
cmd = process.env.MONOREPO_COMMAND;
23+
}
1524

16-
const outputBundleOptions = populateOutputBundleOptions(cwd);
17-
const { distDir } = await loadConfig(cwd);
18-
const nextBuildDirectory = join(cwd, distDir);
25+
build(projectRoot, cmd);
1926

20-
await generateOutputDirectory(cwd, outputBundleOptions, nextBuildDirectory);
27+
const outputBundleOptions = populateOutputBundleOptions(root, projectRoot);
28+
const { distDir } = await loadConfig(root, projectRoot);
29+
const nextBuildDirectory = join(projectRoot, distDir);
2130

31+
await generateOutputDirectory(root, projectRoot, outputBundleOptions, nextBuildDirectory);
2232
await validateOutputDirectory(outputBundleOptions);

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

Lines changed: 56 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,38 @@
1+
import { spawnSync } from "child_process";
12
import fsExtra from "fs-extra";
2-
import { PHASE_PRODUCTION_BUILD } from "./constants.js";
3-
import { ROUTES_MANIFEST } from "./constants.js";
3+
import { createRequire } from "node:module";
4+
import { join, relative, normalize } from "path";
45
import { fileURLToPath } from "url";
5-
import { OutputBundleOptions } from "./interfaces.js";
66
import { stringify as yamlStringify } from "yaml";
7-
import { spawnSync } from "child_process";
87

9-
import { join, relative, normalize } from "path";
8+
import { PHASE_PRODUCTION_BUILD } from "./constants.js";
9+
import { ROUTES_MANIFEST } from "./constants.js";
10+
import { OutputBundleOptions, RoutesManifest } from "./interfaces.js";
11+
import { NextConfigComplete } from "next/dist/server/config-shared.js";
1012

11-
import type { RoutesManifest } from "./interfaces.js";
1213
// fs-extra is CJS, readJson can't be imported using shorthand
1314
export const { move, exists, writeFile, readJson } = fsExtra;
1415

15-
export async function loadConfig(cwd: string) {
16+
// The default fallback command prefix to run a build.
17+
export const DEFAULT_COMMAND = "npm";
18+
19+
// Loads the user's next.config.js file.
20+
export async function loadConfig(root: string, projectRoot: string): Promise<NextConfigComplete> {
21+
// createRequire() gives us access to Node's CommonJS implementation of require.resolve()
22+
// (https://nodejs.org/api/module.html#modulecreaterequirefilename).
23+
// We use the require.resolve() resolution algorithm to get the path to the next config module,
24+
// which may reside in the node_modules folder at a higher level in the directory structure
25+
// (e.g. for monorepo projects).
26+
// Note that ESM has an equivalent (https://nodejs.org/api/esm.html#importmetaresolvespecifier),
27+
// but the feature is still experimental.
28+
const require = createRequire(import.meta.url);
29+
const configPath = require.resolve("next/dist/server/config.js", { paths: [projectRoot] });
1630
// dynamically load NextJS so this can be used in an NPX context
1731
const { default: nextServerConfig }: { default: typeof import("next/dist/server/config.js") } =
18-
await import(`${cwd}/node_modules/next/dist/server/config.js`);
32+
await import(configPath);
33+
1934
const loadConfig = nextServerConfig.default;
20-
return await loadConfig(PHASE_PRODUCTION_BUILD, cwd);
35+
return await loadConfig(PHASE_PRODUCTION_BUILD, root);
2136
}
2237

2338
export async function readRoutesManifest(distDir: string): Promise<RoutesManifest> {
@@ -30,42 +45,62 @@ export const isMain = (meta: ImportMeta) => {
3045
return process.argv[1] === fileURLToPath(meta.url);
3146
};
3247

33-
export function populateOutputBundleOptions(cwd: string): OutputBundleOptions {
34-
const outputBundleDir = join(cwd, ".apphosting");
48+
/**
49+
* Provides the paths in the output bundle for the built artifacts.
50+
* @param rootDir The root directory of the uploaded source code.
51+
* @param appDir The path to the application source code, relative to the root.
52+
* @return The output bundle paths.
53+
*/
54+
export function populateOutputBundleOptions(rootDir: string, appDir: string): OutputBundleOptions {
55+
const outputBundleDir = join(rootDir, ".apphosting");
56+
// In monorepo setups, the standalone directory structure will mirror the structure of the monorepo.
57+
// We find the relative path from the root to the app directory to correctly locate server.js.
58+
const outputBundleAppDir = join(
59+
outputBundleDir,
60+
process.env.MONOREPO_COMMAND ? relative(rootDir, appDir) : "",
61+
);
62+
3563
return {
3664
bundleYamlPath: join(outputBundleDir, "bundle.yaml"),
3765
outputDirectory: outputBundleDir,
38-
serverFilePath: join(outputBundleDir, "server.js"),
39-
outputPublicDirectory: join(outputBundleDir, "public"),
40-
outputStaticDirectory: join(outputBundleDir, ".next", "static"),
66+
serverFilePath: join(outputBundleAppDir, "server.js"),
67+
outputPublicDirectory: join(outputBundleAppDir, "public"),
68+
outputStaticDirectory: join(outputBundleAppDir, ".next", "static"),
4169
};
4270
}
4371

4472
// Run build command
45-
export function build(cwd: string): void {
73+
export function build(cwd: string, cmd = DEFAULT_COMMAND): void {
4674
// Set standalone mode
4775
process.env.NEXT_PRIVATE_STANDALONE = "true";
4876
// Opt-out sending telemetry to Vercel
4977
process.env.NEXT_TELEMETRY_DISABLED = "1";
50-
spawnSync("npm", ["run", "build"], { cwd, shell: true, stdio: "inherit" });
78+
spawnSync(cmd, ["run", "build"], { cwd, shell: true, stdio: "inherit" });
5179
}
5280

53-
// move the standalone directory, the static directory and the public directory to apphosting output directory
54-
// as well as generating bundle.yaml
81+
/**
82+
* Moves the standalone directory, the static directory and the public directory to apphosting output directory.
83+
* Also generates the bundle.yaml file.
84+
* @param rootDir The root directory of the uploaded source code.
85+
* @param appDir The path to the application source code, relative to the root.
86+
* @param outputBundleOptions The target location of built artifacts in the output bundle.
87+
* @param nextBuildDirectory The location of the .next directory.
88+
*/
5589
export async function generateOutputDirectory(
56-
cwd: string,
90+
rootDir: string,
91+
appDir: string,
5792
outputBundleOptions: OutputBundleOptions,
5893
nextBuildDirectory: string,
5994
): Promise<void> {
6095
const standaloneDirectory = join(nextBuildDirectory, "standalone");
6196
await move(standaloneDirectory, outputBundleOptions.outputDirectory, { overwrite: true });
6297

6398
const staticDirectory = join(nextBuildDirectory, "static");
64-
const publicDirectory = join(cwd, "public");
99+
const publicDirectory = join(appDir, "public");
65100
await Promise.all([
66101
move(staticDirectory, outputBundleOptions.outputStaticDirectory, { overwrite: true }),
67102
movePublicDirectory(publicDirectory, outputBundleOptions.outputPublicDirectory),
68-
generateBundleYaml(outputBundleOptions, nextBuildDirectory, cwd),
103+
generateBundleYaml(outputBundleOptions, nextBuildDirectory, rootDir),
69104
]);
70105
return;
71106
}

0 commit comments

Comments
 (0)