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
132 changes: 74 additions & 58 deletions src/build/content/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
writeFile,
} from 'node:fs/promises'
import { createRequire } from 'node:module'
import { dirname, join, resolve, sep } from 'node:path'
import { dirname, join, relative, sep } from 'node:path'
import { join as posixJoin, sep as posixSep } from 'node:path/posix'

import { trace } from '@opentelemetry/api'
Expand Down Expand Up @@ -116,36 +116,53 @@ export const copyNextServerCode = async (ctx: PluginContext): Promise<void> => {
},
)

await Promise.all(
paths.map(async (path: string) => {
const srcPath = join(srcDir, path)
const destPath = join(destDir, path)

// If this is the middleware manifest file, replace it with an empty
// manifest to avoid running middleware again in the server handler.
if (path === 'server/middleware-manifest.json') {
try {
await replaceMiddlewareManifest(srcPath, destPath)
} catch (error) {
throw new Error('Could not patch middleware manifest file', { cause: error })
}
const promises = paths.map(async (path: string) => {
const srcPath = join(srcDir, path)
const destPath = join(destDir, path)

return
// If this is the middleware manifest file, replace it with an empty
// manifest to avoid running middleware again in the server handler.
if (path === 'server/middleware-manifest.json') {
try {
await replaceMiddlewareManifest(srcPath, destPath)
} catch (error) {
throw new Error('Could not patch middleware manifest file', { cause: error })
}

if (path === 'server/functions-config-manifest.json') {
try {
await replaceFunctionsConfigManifest(srcPath, destPath)
} catch (error) {
throw new Error('Could not patch functions config manifest file', { cause: error })
}
return
}

return
if (path === 'server/functions-config-manifest.json') {
try {
await replaceFunctionsConfigManifest(srcPath, destPath)
} catch (error) {
throw new Error('Could not patch functions config manifest file', { cause: error })
}

await cp(srcPath, destPath, { recursive: true, force: true })
}),
)
return
}

await cp(srcPath, destPath, { recursive: true, force: true })
})

// this is different node_modules than ones handled by `copyNextDependencies`
// this is under the standalone/.next folder (not standalone/node_modules or standalone/<some-workspace/node_modules)
// and started to be created by Next.js in some cases in next@16.1.0-canary.3
// this node_modules is artificially created and doesn't have equivalent in the repo
// so we only copy it, without additional symlinks handling
if (existsSync(join(srcDir, 'node_modules'))) {
const filter = ctx.constants.IS_LOCAL ? undefined : nodeModulesFilter
const src = join(srcDir, 'node_modules')
const dest = join(destDir, 'node_modules')
await cp(src, dest, {
recursive: true,
verbatimSymlinks: true,
force: true,
filter,
})
}
Comment on lines +148 to +163
Copy link
Contributor Author

@pieh pieh Nov 28, 2025

Choose a reason for hiding this comment

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

This part is fix for next@canary change to ensure the "hashed" artificial node_modules are included in NF (see first part of description for more details including first screenshot)


await Promise.all(promises)
})
}

Expand Down Expand Up @@ -290,42 +307,41 @@ async function patchNextModules(

export const copyNextDependencies = async (ctx: PluginContext): Promise<void> => {
await tracer.withActiveSpan('copyNextDependencies', async () => {
const entries = await readdir(ctx.standaloneDir)
const filter = ctx.constants.IS_LOCAL ? undefined : nodeModulesFilter
const promises: Promise<void>[] = []

const nodeModulesLocationsInStandalone = new Set<string>()
const commonFilter = ctx.constants.IS_LOCAL ? undefined : nodeModulesFilter

const dotNextDir = join(ctx.standaloneDir, ctx.nextDistDir)

await cp(ctx.standaloneRootDir, ctx.serverHandlerRootDir, {
recursive: true,
verbatimSymlinks: true,
force: true,
filter: async (sourcePath: string) => {
if (sourcePath === dotNextDir) {
// copy all except the distDir (.next) folder as this is handled in a separate function
// this will include the node_modules folder as well
return false
}

const promises: Promise<void>[] = entries.map(async (entry) => {
// copy all except the distDir (.next) folder as this is handled in a separate function
// this will include the node_modules folder as well
if (entry === ctx.nextDistDir) {
return
}
const src = join(ctx.standaloneDir, entry)
const dest = join(ctx.serverHandlerDir, entry)
await cp(src, dest, {
recursive: true,
verbatimSymlinks: true,
force: true,
filter,
})
if (sourcePath.endsWith('node_modules')) {
// keep track of node_modules as we might need to recreate symlinks
// we are still copying them
nodeModulesLocationsInStandalone.add(sourcePath)
}

if (entry === 'node_modules') {
await recreateNodeModuleSymlinks(ctx.resolveFromSiteDir('node_modules'), dest)
}
// finally apply common filter if defined
return commonFilter?.(sourcePath) ?? true
},
})

// inside a monorepo there is a root `node_modules` folder that contains all the dependencies
const rootSrcDir = join(ctx.standaloneRootDir, 'node_modules')
const rootDestDir = join(ctx.serverHandlerRootDir, 'node_modules')

// use the node_modules tree from the process.cwd() and not the one from the standalone output
// as the standalone node_modules are already wrongly assembled by Next.js.
// see: https://github.com/vercel/next.js/issues/50072
Copy link
Member

Choose a reason for hiding this comment

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

nit: might be worth still keeping this reference in a comment somewhere even though we aren't "explicitly" handling this anymore?

Copy link
Contributor Author

@pieh pieh Dec 1, 2025

Choose a reason for hiding this comment

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

I'll look to re-add this context in follow up ~chore to keep this PR mergeable

if (existsSync(rootSrcDir) && ctx.standaloneRootDir !== ctx.standaloneDir) {
promises.push(
cp(rootSrcDir, rootDestDir, { recursive: true, verbatimSymlinks: true, filter }).then(() =>
recreateNodeModuleSymlinks(resolve('node_modules'), rootDestDir),
),
)
Comment on lines -293 to -328
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This change is for the edge case encountered after I expanded test case (last paragraph + second screenshot). It starts with change of "traversing" from ctx.standaloneDir (which in monorepos will be standalone/path/to/workspace) to ctx.standaloneRootDir (just standalone).

and required adjustments to logic to still handle some special cases (like skipping ctx.nextDistDir here and calling recreateNodeModuleSymlinks, which now no longer have explicit special case for monorepo and instead have streamlined handling for it)

for (const nodeModulesLocationInStandalone of nodeModulesLocationsInStandalone) {
const relativeToRoot = relative(ctx.standaloneRootDir, nodeModulesLocationInStandalone)
const locationInProject = join(ctx.outputFileTracingRoot, relativeToRoot)
const locationInServerHandler = join(ctx.serverHandlerRootDir, relativeToRoot)

promises.push(recreateNodeModuleSymlinks(locationInProject, locationInServerHandler))
}

await Promise.all(promises)
Expand Down Expand Up @@ -451,7 +467,7 @@ export const verifyHandlerDirStructure = async (ctx: PluginContext) => {
// https://github.com/pnpm/pnpm/issues/9654
// https://github.com/pnpm/pnpm/issues/5928
// https://github.com/pnpm/pnpm/issues/7362 (persisting even though ticket is closed)
const nodeModulesFilter = async (sourcePath: string) => {
const nodeModulesFilter = (sourcePath: string) => {
// Filtering rule for the following packages:
// - @rspack+binding-linux-x64-musl
// - @swc+core-linux-x64-musl
Expand Down
11 changes: 11 additions & 0 deletions src/build/plugin-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,17 @@ export class PluginContext {
return this.requiredServerFiles.relativeAppDir ?? ''
}

/**
* The root directory for output file tracing. Paths inside standalone directory preserve paths of project, relative to this directory.
*/
get outputFileTracingRoot(): string {
return (
this.requiredServerFiles.config.outputFileTracingRoot ??
// fallback for older Next.js versions that don't have outputFileTracingRoot in the config, but had it in config.experimental
this.requiredServerFiles.config.experimental.outputFileTracingRoot
)
}

/**
* The working directory inside the lambda that is used for monorepos to execute the serverless function
*/
Expand Down
20 changes: 20 additions & 0 deletions tests/e2e/turborepo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,15 @@ test.describe('[PNPM] Package manager', () => {
const date3 = await page.getByTestId('date-now').textContent()
expect(date3).not.toBe(date2)
})

test('transitive external dependencies are supported', async ({ page, turborepo }) => {
Copy link
Member

Choose a reason for hiding this comment

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

🥇 Awesome tests

const pageResponse = await page.goto(new URL('/transitive-external-deps', turborepo.url).href)

expect(pageResponse?.status()).toBe(200)

await expect(page.getByTestId('dep-a-version')).toHaveText('3.10.1')
await expect(page.getByTestId('dep-b-version')).toHaveText('4.17.21')
})
})

test.describe('[NPM] Package manager', () => {
Expand Down Expand Up @@ -228,4 +237,15 @@ test.describe('[NPM] Package manager', () => {
'.env.production.local': 'defined in .env.production.local',
})
})

test('transitive external dependencies are supported', async ({ page, turborepoNPM }) => {
const pageResponse = await page.goto(
new URL('/transitive-external-deps', turborepoNPM.url).href,
)

expect(pageResponse?.status()).toBe(200)

await expect(page.getByTestId('dep-a-version')).toHaveText('3.10.1')
await expect(page.getByTestId('dep-b-version')).toHaveText('4.17.21')
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const nextConfig = {
},
transpilePackages: ['@repo/ui'],
outputFileTracingRoot: join(__dirname, '..', '..'),
serverExternalPackages: ['lodash'],
}

module.exports = nextConfig
2 changes: 2 additions & 0 deletions tests/fixtures/turborepo-npm/apps/page-router/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
},
"dependencies": {
"@netlify/functions": "^2.7.0",
"@repo/dep-a": "*",
"@repo/dep-b": "*",
"@repo/ui": "*",
"next": "latest",
"react": "^18.2.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import depA from '@repo/dep-a'
import depB from '@repo/dep-b'

export default function TransitiveDeps() {
return (
<body>
<ul>
<li>
dep-a uses lodash version 3.10.1 and we should see this version here:{' '}
<span data-testId="dep-a-version">{depA}</span>
</li>
<li>
dep-b uses lodash version 4.17.21 and we should see this version here:{' '}
<span data-testId="dep-b-version">{depB}</span>
</li>
</ul>
</body>
)
}

// just to ensure this is rendered in runtime and not prerendered
export async function getServerSideProps() {
return {
props: {},
}
}
3 changes: 3 additions & 0 deletions tests/fixtures/turborepo-npm/packages/dep-a/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import lodash from 'lodash'

export default lodash.VERSION
7 changes: 7 additions & 0 deletions tests/fixtures/turborepo-npm/packages/dep-a/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "@repo/dep-a",
"version": "1.0.0",
"dependencies": {
"lodash": "3.10.1"
}
}
3 changes: 3 additions & 0 deletions tests/fixtures/turborepo-npm/packages/dep-b/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import lodash from 'lodash'

export default lodash.VERSION
7 changes: 7 additions & 0 deletions tests/fixtures/turborepo-npm/packages/dep-b/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "@repo/dep-b",
"version": "1.0.0",
"dependencies": {
"lodash": "4.17.21"
}
}
1 change: 1 addition & 0 deletions tests/fixtures/turborepo/apps/page-router/next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const nextConfig = {
},
transpilePackages: ['@repo/ui'],
outputFileTracingRoot: join(__dirname, '..', '..'),
serverExternalPackages: ['lodash'],
}

module.exports = nextConfig
2 changes: 2 additions & 0 deletions tests/fixtures/turborepo/apps/page-router/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
},
"dependencies": {
"@netlify/functions": "^2.7.0",
"@repo/dep-a": "workspace:*",
"@repo/dep-b": "workspace:*",
"@repo/ui": "workspace:*",
"next": "latest",
"react": "^18.2.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import depA from '@repo/dep-a'
import depB from '@repo/dep-b'

export default function TransitiveDeps() {
return (
<body>
<ul>
<li>
dep-a uses lodash version 3.10.1 and we should see this version here:{' '}
<span data-testId="dep-a-version">{depA}</span>
</li>
<li>
dep-b uses lodash version 4.17.21 and we should see this version here:{' '}
<span data-testId="dep-b-version">{depB}</span>
</li>
</ul>
</body>
)
}

// just to ensure this is rendered in runtime and not prerendered
export async function getServerSideProps() {
return {
props: {},
}
}
3 changes: 3 additions & 0 deletions tests/fixtures/turborepo/packages/dep-a/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import lodash from 'lodash'

export default lodash.VERSION
7 changes: 7 additions & 0 deletions tests/fixtures/turborepo/packages/dep-a/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "@repo/dep-a",
"version": "1.0.0",
"dependencies": {
"lodash": "3.10.1"
}
}
3 changes: 3 additions & 0 deletions tests/fixtures/turborepo/packages/dep-b/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import lodash from 'lodash'

export default lodash.VERSION
7 changes: 7 additions & 0 deletions tests/fixtures/turborepo/packages/dep-b/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "@repo/dep-b",
"version": "1.0.0",
"dependencies": {
"lodash": "4.17.21"
}
}
Loading