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
2 changes: 1 addition & 1 deletion src/build/functions/edge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ const copyHandlerDependenciesForNodeMiddleware = async (ctx: PluginContext) => {

const entry = 'server/middleware.js'
const nft = `${entry}.nft.json`
const nftFilesPath = join(process.cwd(), ctx.distDir, nft)
const nftFilesPath = join(ctx.publishDir, nft)
Copy link
Contributor Author

@pieh pieh Nov 19, 2025

Choose a reason for hiding this comment

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

This is second attempt at fixing this path (previous one was in #3211 ).

This time using publishDir as it already should point to absolute path to .next.

We already use publishDir in middleware handling to find relevant manifest files:

/**
* Get Next.js middleware config from the build output
*/
async getMiddlewareManifest(): Promise<MiddlewareManifest> {
return JSON.parse(
await readFile(join(this.publishDir, 'server/middleware-manifest.json'), 'utf-8'),
)
}
/**
* Get Next.js Functions Config Manifest config if it exists from the build output
*/
async getFunctionsConfigManifest(): Promise<FunctionsConfigManifest | null> {
const functionsConfigManifestPath = join(
this.publishDir,
'server/functions-config-manifest.json',
)
if (existsSync(functionsConfigManifestPath)) {
return JSON.parse(await readFile(functionsConfigManifestPath, 'utf-8'))
}
// this file might not have been produced
return null
}

and since those work (we wouldn't get to code path trying to read .nft file if they didn't), it's good idea to just reuse this method.

Some explanation why current method is not working in some cases:

In case of monorepos that are configured to use base directory instead of packagePath, the CWD will be workspace and not root of monorepo. The ctx.distDir will be path from monorepo root to .next directory (so will be prefixed with base directory). Both combined means that base directory is repeated in produced final path. Using example with repo that has app workspace and Netlify setup using base directory set to app:

// before
cwd: '/opt/build/repo/app'
distDir: 'app/.next', // <- this is produced using some values from Next.js manifest files, in particular `relativeAppDir` from `required-server-files.json`, but this path is not `base` aware and shouldn't be used if we are reading files outside of `.next/standalone` directory
join(cwd, distDir): '/opt/build/repo/app/app/.next' // <-- duplicated `app` dir leading to fatal "Error: ENOENT: no such file or directory, open '/opt/build/repo/app/app/.next/server/middleware.js.nft.json'" error

// with this change
publishDir: '/opt/build/repo/app/.next' // <- correct path

Note: with last fix attempt we did add smoke fixture using packagePath. In this PR I add another smoke fixture replicating monorepo setup using base directory instead of packagePath. Both are passing with the changes

const nftManifest = JSON.parse(await readFile(nftFilesPath, 'utf8'))

const files: string[] = nftManifest.files.map((file: string) => join('server', file))
Expand Down
11 changes: 11 additions & 0 deletions tests/smoke/deploy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,17 @@ if (nextVersionSatisfies('>=16.0.0')) {
const body = await response.json()
expect(body).toEqual({ proxy: true })
})

test('pnpm monorepo with proxy / node middleware with setup using base directory instead of package path', async () => {
// proxy ~= node middleware
const fixture = await selfCleaningFixtureFactories.pnpmMonorepoBaseProxy()

const response = await fetch(fixture.url)
expect(response.status).toBe(200)

const body = await response.json()
expect(body).toEqual({ proxy: true })
})
}

test('yarn@3 monorepo with pnpm linker', async () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
eslint: {
ignoreDuringBuilds: true,
},
}

module.exports = nextConfig
13 changes: 13 additions & 0 deletions tests/smoke/fixtures/pnpm-monorepo-base-proxy/app/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"name": "pnpm-monorepo-base-proxy-app",
"version": "0.1.0",
"private": true,
"scripts": {
"build": "next build"
},
"dependencies": {
"next": "latest",
"react": "^18.2.0",
"react-dom": "^18.2.0"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export default function Home({ ssr }) {
return (
<main>
<div data-testid="smoke">SSR: {ssr ? 'yes' : 'no'}</div>
</main>
)
}

export const getServerSideProps = async () => {
return {
props: {
ssr: true,
},
}
}
6 changes: 6 additions & 0 deletions tests/smoke/fixtures/pnpm-monorepo-base-proxy/app/proxy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'

export async function proxy(request: NextRequest) {
return NextResponse.json({ proxy: true })
}
7 changes: 7 additions & 0 deletions tests/smoke/fixtures/pnpm-monorepo-base-proxy/netlify.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
[build]
base = "app"
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Most important part of this fixture is using base directory

command = "pnpm run build"
publish = ".next"

[[plugins]]
package = "@netlify/plugin-nextjs"
4 changes: 4 additions & 0 deletions tests/smoke/fixtures/pnpm-monorepo-base-proxy/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"name": "monorepo",
"private": true
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
packages:
- 'app'
45 changes: 33 additions & 12 deletions tests/utils/create-e2e-fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { exec } from 'node:child_process'
import { existsSync } from 'node:fs'
import { appendFile, copyFile, mkdir, mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { dirname, join, relative } from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
import { cpus } from 'os'
Expand Down Expand Up @@ -228,6 +228,14 @@ async function installRuntime(
await rm(join(isolatedFixtureRoot, 'package-lock.json'), { force: true })
}

let relativePathToPackage = relative(
join(isolatedFixtureRoot, siteRelDir),
join(isolatedFixtureRoot, packageName),
)
if (!relativePathToPackage.startsWith('.')) {
relativePathToPackage = `./${relativePathToPackage}`
}

switch (packageManger) {
case 'npm':
command = `npm install --ignore-scripts --no-audit --legacy-peer-deps ${packageName} ${
Expand All @@ -248,7 +256,7 @@ async function installRuntime(
env['YARN_ENABLE_SCRIPTS'] = 'false'
break
case 'pnpm':
command = `pnpm add file:${join(isolatedFixtureRoot, packageName)} ${
command = `pnpm add file:${relativePathToPackage} ${
workspaceRelPath ? `--filter ./${workspaceRelPath}` : ''
} --ignore-scripts`
break
Expand Down Expand Up @@ -349,24 +357,27 @@ export async function deploySiteWithBuildbot(
newZip.addLocalFolder(isolatedFixtureRoot, '', (entry) => {
if (
// don't include node_modules / .git / publish dir in zip
entry.startsWith('node_modules') ||
entry.startsWith('.git') ||
entry.includes('node_modules') ||
entry.includes('.git') ||
Comment on lines +360 to +361
Copy link
Contributor Author

Choose a reason for hiding this comment

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

new fixture has nested directory with node_modules, so this ensures we skip zipping those

entry.startsWith(publishDirectory)
) {
return false
}
return true
})

const result = await fetch(`https://api.netlify.com/api/v1/sites/${siteId}/builds`, {
method: 'POST',
headers: {
'Content-Type': 'application/zip',
Authorization: `Bearer ${process.env.NETLIFY_AUTH_TOKEN}`,
const result = await fetch(
`https://api.netlify.com/api/v1/sites/${siteId}/builds?clear_cache=true`,
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added clear_cache=true here. We used buildbot zip builds for skew protection only so far which creates its own temporary sites, so build cache was not a problem.

However with this test we use shared next-runtime-testing site, so we do potentially get a build cache which seems to prevent using npm-packed runtime and instead use auto-installed one without code changes from pull request

{
method: 'POST',
headers: {
'Content-Type': 'application/zip',
Authorization: `Bearer ${process.env.NETLIFY_AUTH_TOKEN}`,
},
// @ts-expect-error sigh, it works
body: newZip.toBuffer(),
},
// @ts-expect-error sigh, it works
body: newZip.toBuffer(),
})
)
const { deploy_id } = await result.json()

let didRunOnBuildStartCallback = false
Expand Down Expand Up @@ -636,6 +647,16 @@ export const fixtureFactories = {
publishDirectory: 'apps/site/.next',
smoke: true,
}),
pnpmMonorepoBaseProxy: () =>
createE2EFixture('pnpm-monorepo-base-proxy', {
buildCommand: 'pnpm run build',
generateNetlifyToml: false,
packageManger: 'pnpm',
publishDirectory: '.next',
runtimeInstallationPath: 'app',
smoke: true,
useBuildbot: true,
Copy link
Contributor Author

@pieh pieh Nov 19, 2025

Choose a reason for hiding this comment

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

This is annoying because we don't see build failure logs when using buildbot, but I couldn't replicate the exact conditions with CLI deploys.

https://app.netlify.com/projects/next-runtime-testing/deploys/691db94c23e26600e071528e (this might get automatically deleted soon!) is example build reproducing

1:34:41 PM:   Error message
1:34:41 PM:   Error: ENOENT: no such file or directory, open '/opt/build/repo/app/app/.next/server/middleware.js.nft.json'

kind of errors without any runtime code changes (1st commit) that is fixed by runtime changes (2nd commit)

}),
dynamicCms: () => createE2EFixture('dynamic-cms'),
after: () => createE2EFixture('after'),
}
Loading