Skip to content
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

feat: monorepo handling #434

Merged
merged 17 commits into from
Jun 28, 2021
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -150,3 +150,5 @@ Temporary Items

# End of https://www.toptal.com/developers/gitignore/api/osx,node
demo/package-lock.json

.netlify
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ Read more about [file-based plugin installation](https://docs.netlify.com/config
- [CLI Usage](https://github.com/netlify/netlify-plugin-nextjs/tree/main/docs/cli-usage.md)
- [Custom Netlify Functions](https://github.com/netlify/netlify-plugin-nextjs/tree/main/docs/custom-functions.md)
- [Image Handling](https://github.com/netlify/netlify-plugin-nextjs/tree/main/docs/image-handling.md)
- [Monorepos and Nx](https://github.com/netlify/netlify-plugin-nextjs/tree/main/docs/monorepos.md)
- [Custom Netlify Redirects](https://github.com/netlify/netlify-plugin-nextjs/tree/main/docs/custom-redirects.md)
- [Local Files in Runtime](https://github.com/netlify/netlify-plugin-nextjs/tree/main/docs/local-files-in-runtime.md)
- [FAQ](https://github.com/netlify/netlify-plugin-nextjs/tree/main/docs/faq.md)
Expand Down
11 changes: 0 additions & 11 deletions demo/netlify.toml

This file was deleted.

15 changes: 0 additions & 15 deletions demo/package.json

This file was deleted.

115 changes: 115 additions & 0 deletions docs/monorepos.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
## Using in a monorepo or subdirectory

The Essential Next.js plugin works in most monorepos, but may need some configuration changes. This depends on the type of monorepo and the tooling that you use.

### Self-contained subdirectory

If your Next.js site is in a subdirectory of the repo, but doesn't rely on installing or compiling anything outside of that directory, then the simplest arrangement is to set the `base` of the site to that directory. This can be done either in the Netlify dashboard or in the `netlify.toml`. If your site is in `/frontend`, you should set up your site with the following in the root of the repo:

```toml
# ./netlify.toml
[build]
base="frontend"
```
You can then place another `netlify.toml` file in `/frontend/` that configures the actual build:

```toml
# ./frontend/netlify.toml

[build]
command = "npm run build"
publish = "out"

[[plugins]]
package = "@netlify/plugin-nextjs"
```

### Yarn workspace

If your site is a yarn workspace - including one that uses lerna - you should keep the base as the root of the repo, but change the configuration as follows. Assuming the site is in `/packages/frontend/`:

```toml
# ./netlify.toml

[build]
command = "next build packages/frontend"
publish = "packages/frontend/out"

[dev]
command = "next dev packages/frontend"

[[plugins]]
package = "@netlify/plugin-nextjs"
```

Ensure that the `next.config.js` is in the site directory, i.e. `/packages/frontend/next.config.js`. You must ensure that there is either a `yarn.lock` in the root of the site, or the environment variable `NETLIFY_USE_YARN` is set to true.

### Lerna monorepo using npm

If your monorepo uses Yarn workspaces, then set it up as shown above in the Yarn workspace section. If it uses npm then it is a little more complicated. First, you need to ensure that the `next` package is installed as a top-level dependency, i.e. it is in `./package.json` rather than `packages/frontend/package.json`. This is because it needs to be installed before lerna is bootstrapped as the build plugin needs to use it. Generally, hoisting as many packages to the top level as possible is best, so that they are more efficiently cached. You then should change the build command, and make it similar to this:

```toml
# ./netlify.toml

[build]
command = "lerna bootstrap && next build packages/frontend"
publish = "packages/frontend/out"

[dev]
command = "next dev packages/frontend"

[[plugins]]
package = "@netlify/plugin-nextjs"
```

### Nx

[Nx](https://nx.dev/) is a build framework that handles scaffolding, building and deploying projects. It has support for Next.js via the `@nrwl/next` package. When building a Next.js site, it changes a lot of the configuraiton on the fly, and has quite a different directory structure to a normal Next.js site. The Essential Next.js plugin has full support for sites that use Nx, but there are a few required changes that you must make to the configuration.

First, you need to make the `publish` directory point at a dirctory called `out` inside the app directory, rather than the build directory. If your app is called `myapp`, your `netlify.toml` should look something like:

```toml
# ./netlify.toml

[build]
command = "npm run build"
publish = "apps/myapp/out"

[dev]
command = "npm run start"
targetPort = 4200

[[plugins]]
package = "@netlify/plugin-nextjs"
```

You also need to make a change to the `next.config.js` inside the app directory. By default, Nx changes the Next.js `distDir` on the fly, changing it to a directory in the root of the repo. The Essential Next.js plugin can't read this value, so has no way of determining where the build files can be found. However, if you change the `distDir` in the config to anything except `.next`, then `Nx` will leave it unchanged, and the Essential Next.js plugin can read the value from there. e.g.

```js
// ./apps/myapp/next.config.js

const withNx = require('@nrwl/next/plugins/with-nx');

module.exports = withNx({
distDir: '.dist',
target: 'serverless'
});

```

### Other monorepos

Other arrangements may work: for more details, see [the monorepo documentation](https://docs.netlify.com/configure-builds/common-configurations/monorepos/). The important points are:

1. The `next` package must be installed as part of the initial `npm install` or `yarn install`, not from the build command.
2. The `publish` directory must be called `out`, and should be in the same directory as the `next.config.js` file. e.g.

```
backend/
frontend/
|- next.config.js
|- out
netlify.toml
package.json
```
If you have another monorepo tool that you are using, we would welcome PRs to add instructions to this document.

Choose a reason for hiding this comment

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

🔥 this is so great!!!!!

55 changes: 55 additions & 0 deletions helpers/checkNxConfig.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
const { existsSync } = require('fs')
const { EOL } = require('os')
const path = require('path')

const checkNxConfig = ({ netlifyConfig, nextConfig, failBuild, constants: { PUBLISH_DIR } }) => {
const errors = []
if (nextConfig.distDir === '.next') {
errors.push(
"- When using Nx you must set a value for 'distDir' in your next.config.js, and the value cannot be '.next'",
)
}
// The PUBLISH_DIR constant is normalized, so no leading slash is needed
if (!PUBLISH_DIR.startsWith('apps/')) {
errors.push(
"Please set the 'publish' value in your Netlify build config to a folder inside your app directory. e.g. 'apps/myapp/out'",
)
}
// Look for the config file as a sibling of the publish dir
const expectedConfigFile = path.resolve(netlifyConfig.build.publish, '..', 'next.config.js')

Choose a reason for hiding this comment

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

🥴


if (expectedConfigFile !== nextConfig.configFile) {
const confName = path.relative(process.cwd(), nextConfig.configFile)
errors.push(
`- Using incorrect config file '${confName}'. Expected to use '${path.relative(
process.cwd(),
expectedConfigFile,
)}'`,
)

if (existsSync(expectedConfigFile)) {
errors.push(
`Please move or delete '${confName}'${confName === 'next.config.js' ? ' from the root of your site' : ''}.`,
)
} else {
errors.push(
`Please move or delete '${confName}'${
confName === 'next.config.js' ? ' from the root of your site' : ''
}, and create '${path.relative(process.cwd(), expectedConfigFile)}' instead.`,
)
}
}

if (errors.length !== 0) {
failBuild(
// TODO: Add ntl.fyi link to docs
[
'Invalid configuration',
...errors,
'See the docs on using Nx with Netlify for more information: https://ntl.fyi/nx-next',
].join(EOL),
)
}
}

module.exports = checkNxConfig
2 changes: 0 additions & 2 deletions helpers/doesNotNeedPlugin.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
const findUp = require('find-up')

// Checks all the cases for which the plugin should do nothing
const doesSiteUseNextOnNetlify = require('./doesSiteUseNextOnNetlify')
const isStaticExportProject = require('./isStaticExportProject')
Expand Down
8 changes: 6 additions & 2 deletions helpers/getNextConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,17 @@ const { cwd: getCwd } = require('process')

const moize = require('moize')

const resolveNextModule = require('./resolveNextModule')

// We used to cache nextConfig for any cwd. Now we pass process.cwd() to cache
// (or memoize) nextConfig per cwd.
const getNextConfig = async function (failBuild = defaultFailBuild, cwd = getCwd()) {
// We cannot load `next` at the top-level because we validate whether the
// site is using `next` inside `onPreBuild`.
const { PHASE_PRODUCTION_BUILD } = require('next/constants')
const loadConfig = require('next/dist/next-server/server/config').default
/* eslint-disable import/no-dynamic-require */
const { PHASE_PRODUCTION_BUILD } = require(resolveNextModule('next/constants', cwd))
const loadConfig = require(resolveNextModule('next/dist/next-server/server/config', cwd)).default
/* eslint-enable import/no-dynamic-require */

try {
return await loadConfig(PHASE_PRODUCTION_BUILD, cwd)
Expand Down
16 changes: 16 additions & 0 deletions helpers/getNextRoot.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
const { existsSync } = require('fs')
const path = require('path')

/**
* If we're in a monorepo then the Next root may not be the same as the base directory
* If there's no next.config.js in the root, we instead look for it as a sibling of the publish dir
*/
const getNextRoot = ({ netlifyConfig }) => {
let nextRoot = process.cwd()
if (!existsSync(path.join(nextRoot, 'next.config.js')) && netlifyConfig.build.publish) {
nextRoot = path.dirname(netlifyConfig.build.publish)
}
return nextRoot
}

module.exports = getNextRoot
13 changes: 13 additions & 0 deletions helpers/resolveNextModule.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* We can't require() these normally, because the "next" package might not be resolvable from the root of a monorepo
*/
const resolveNextModule = (module, nextRoot) => {
// Get the default list of require paths...
const paths = require.resolve.paths(module)
// ...add the root of the Next site to the beginning of that list so we try it first...
paths.unshift(nextRoot)
// ...then resolve the module using that list of paths.
return require.resolve(module, { paths })
}

Choose a reason for hiding this comment

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

oh my


module.exports = resolveNextModule
23 changes: 15 additions & 8 deletions helpers/validateNextUsage.js
Original file line number Diff line number Diff line change
@@ -1,23 +1,30 @@
const { lt: ltVersion, gte: gteVersion } = require('semver')
const { yellowBright } = require('chalk')
const { lt: ltVersion, gte: gteVersion } = require('semver')

const getNextRoot = require('./getNextRoot')
const resolveNextModule = require('./resolveNextModule')

// Ensure Next.js is available.
// We use `peerDependencies` instead of `dependencies` so that users can choose
// the Next.js version. However, this requires them to install "next" in their
// site.
const validateNextUsage = function (failBuild) {
if (!hasPackage('next')) {
const validateNextUsage = function ({ failBuild, netlifyConfig }) {
const nextRoot = getNextRoot({ netlifyConfig })
// Because we don't know the monorepo structure, we try to resolve next both locally and in the next root
if (!hasPackage('next', nextRoot)) {
return failBuild(
'This site does not seem to be using Next.js. Please run "npm install next" or "yarn next" in the repository.',
`This site does not seem to be using Next.js. Please run "npm install next" in the repository.
If you are using a monorepo, please see the docs on configuring your site: https://ntl.fyi/next-monorepos`,
)
}

// We cannot load `next` at the top-level because we validate whether the
// site is using `next` inside `onPreBuild`.
// Old Next.js versions are not supported
const { version } = require('next/package.json')
// eslint-disable-next-line import/no-dynamic-require
const { version } = require(resolveNextModule(`next/package.json`, nextRoot))
if (ltVersion(version, MIN_VERSION)) {
return failBuild(`Please upgrade to Next.js ${MIN_VERSION} or later`)
return failBuild(`Please upgrade to Next.js ${MIN_VERSION} or later. Found ${version}.`)
}

// Recent Next.js versions are sometimes unstable and we might not officially
Expand All @@ -31,9 +38,9 @@ const validateNextUsage = function (failBuild) {
const MIN_VERSION = '10.0.6'
const MIN_EXPERIMENTAL_VERSION = '11.0.0'

const hasPackage = function (packageName) {
const hasPackage = function (packageName, nextRoot) {
try {
require(`${packageName}/package.json`)
resolveNextModule(`${packageName}/package.json`, nextRoot)
return true
} catch (error) {
return false
Expand Down
32 changes: 17 additions & 15 deletions helpers/verifyBuildTarget.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
const getNextConfig = require('./getNextConfig')
const findUp = require('find-up')
const { writeFile, unlink } = require('fs-extra')
const path = require('path')

const { writeFile } = require('fs-extra')

const getNextConfig = require('./getNextConfig')
const getNextRoot = require('./getNextRoot')
const resolveNextModule = require('./resolveNextModule')

// Checks if site has the correct next.config.js
const verifyBuildTarget = async ({ failBuild }) => {
const { target } = await getNextConfig(failBuild)
const verifyBuildTarget = async ({ failBuild, netlifyConfig }) => {
const nextRoot = getNextRoot({ netlifyConfig })

const { target, configFile } = await getNextConfig(failBuild, nextRoot)

// If the next config exists, log warning if target isnt in acceptableTargets
const acceptableTargets = ['serverless', 'experimental-serverless-trace']
Expand All @@ -19,8 +24,6 @@ const verifyBuildTarget = async ({ failBuild }) => {
)}". Building with "serverless" target.`,
)

/* eslint-disable fp/no-delete, node/no-unpublished-require */

// We emulate Vercel so that we can set target to serverless if needed
process.env.NOW_BUILDER = true
// If no valid target is set, we use an internal Next env var to force it
Expand All @@ -30,29 +33,28 @@ const verifyBuildTarget = async ({ failBuild }) => {
// set as an import side effect so we need to clear the require cache first. 🐲
// https://github.com/vercel/next.js/blob/canary/packages/next/telemetry/ci-info.ts

delete require.cache[require.resolve('next/dist/telemetry/ci-info')]
delete require.cache[require.resolve('next/dist/next-server/server/config')]
delete require.cache[resolveNextModule('next/dist/telemetry/ci-info', nextRoot)]
delete require.cache[resolveNextModule('next/dist/next-server/server/config', nextRoot)]

// Clear memoized cache
getNextConfig.clear()

// Creating a config file, because otherwise Next won't reload the config and pick up the new target

if (!(await findUp('next.config.js'))) {
if (!configFile) {
await writeFile(
path.resolve('next.config.js'),
`module.exports = {
path.resolve(nextRoot, 'next.config.js'),
`
module.exports = {
// Supported targets are "serverless" and "experimental-serverless-trace"
target: "serverless"
}`,
)
}
// Force the new config to be generated
await getNextConfig(failBuild)

await getNextConfig(failBuild, nextRoot)
// Reset the value in case something else is looking for it
process.env.NOW_BUILDER = false
/* eslint-enable fp/no-delete, node/no-unpublished-require */
}

module.exports = verifyBuildTarget
Loading