Skip to content

Commit 5bd9d85

Browse files
authored
fix: handle node_modules in standalone's dist dir (#3282)
1 parent 71456f2 commit 5bd9d85

File tree

17 files changed

+203
-58
lines changed

17 files changed

+203
-58
lines changed

src/build/content/server.ts

Lines changed: 74 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
writeFile,
1111
} from 'node:fs/promises'
1212
import { createRequire } from 'node:module'
13-
import { dirname, join, resolve, sep } from 'node:path'
13+
import { dirname, join, relative, sep } from 'node:path'
1414
import { join as posixJoin, sep as posixSep } from 'node:path/posix'
1515

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

119-
await Promise.all(
120-
paths.map(async (path: string) => {
121-
const srcPath = join(srcDir, path)
122-
const destPath = join(destDir, path)
123-
124-
// If this is the middleware manifest file, replace it with an empty
125-
// manifest to avoid running middleware again in the server handler.
126-
if (path === 'server/middleware-manifest.json') {
127-
try {
128-
await replaceMiddlewareManifest(srcPath, destPath)
129-
} catch (error) {
130-
throw new Error('Could not patch middleware manifest file', { cause: error })
131-
}
119+
const promises = paths.map(async (path: string) => {
120+
const srcPath = join(srcDir, path)
121+
const destPath = join(destDir, path)
132122

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

136-
if (path === 'server/functions-config-manifest.json') {
137-
try {
138-
await replaceFunctionsConfigManifest(srcPath, destPath)
139-
} catch (error) {
140-
throw new Error('Could not patch functions config manifest file', { cause: error })
141-
}
132+
return
133+
}
142134

143-
return
135+
if (path === 'server/functions-config-manifest.json') {
136+
try {
137+
await replaceFunctionsConfigManifest(srcPath, destPath)
138+
} catch (error) {
139+
throw new Error('Could not patch functions config manifest file', { cause: error })
144140
}
145141

146-
await cp(srcPath, destPath, { recursive: true, force: true })
147-
}),
148-
)
142+
return
143+
}
144+
145+
await cp(srcPath, destPath, { recursive: true, force: true })
146+
})
147+
148+
// this is different node_modules than ones handled by `copyNextDependencies`
149+
// this is under the standalone/.next folder (not standalone/node_modules or standalone/<some-workspace/node_modules)
150+
// and started to be created by Next.js in some cases in [email protected]
151+
// this node_modules is artificially created and doesn't have equivalent in the repo
152+
// so we only copy it, without additional symlinks handling
153+
if (existsSync(join(srcDir, 'node_modules'))) {
154+
const filter = ctx.constants.IS_LOCAL ? undefined : nodeModulesFilter
155+
const src = join(srcDir, 'node_modules')
156+
const dest = join(destDir, 'node_modules')
157+
await cp(src, dest, {
158+
recursive: true,
159+
verbatimSymlinks: true,
160+
force: true,
161+
filter,
162+
})
163+
}
164+
165+
await Promise.all(promises)
149166
})
150167
}
151168

@@ -290,42 +307,41 @@ async function patchNextModules(
290307

291308
export const copyNextDependencies = async (ctx: PluginContext): Promise<void> => {
292309
await tracer.withActiveSpan('copyNextDependencies', async () => {
293-
const entries = await readdir(ctx.standaloneDir)
294-
const filter = ctx.constants.IS_LOCAL ? undefined : nodeModulesFilter
310+
const promises: Promise<void>[] = []
311+
312+
const nodeModulesLocationsInStandalone = new Set<string>()
313+
const commonFilter = ctx.constants.IS_LOCAL ? undefined : nodeModulesFilter
314+
315+
const dotNextDir = join(ctx.standaloneDir, ctx.nextDistDir)
316+
317+
await cp(ctx.standaloneRootDir, ctx.serverHandlerRootDir, {
318+
recursive: true,
319+
verbatimSymlinks: true,
320+
force: true,
321+
filter: async (sourcePath: string) => {
322+
if (sourcePath === dotNextDir) {
323+
// copy all except the distDir (.next) folder as this is handled in a separate function
324+
// this will include the node_modules folder as well
325+
return false
326+
}
295327

296-
const promises: Promise<void>[] = entries.map(async (entry) => {
297-
// copy all except the distDir (.next) folder as this is handled in a separate function
298-
// this will include the node_modules folder as well
299-
if (entry === ctx.nextDistDir) {
300-
return
301-
}
302-
const src = join(ctx.standaloneDir, entry)
303-
const dest = join(ctx.serverHandlerDir, entry)
304-
await cp(src, dest, {
305-
recursive: true,
306-
verbatimSymlinks: true,
307-
force: true,
308-
filter,
309-
})
328+
if (sourcePath.endsWith('node_modules')) {
329+
// keep track of node_modules as we might need to recreate symlinks
330+
// we are still copying them
331+
nodeModulesLocationsInStandalone.add(sourcePath)
332+
}
310333

311-
if (entry === 'node_modules') {
312-
await recreateNodeModuleSymlinks(ctx.resolveFromSiteDir('node_modules'), dest)
313-
}
334+
// finally apply common filter if defined
335+
return commonFilter?.(sourcePath) ?? true
336+
},
314337
})
315338

316-
// inside a monorepo there is a root `node_modules` folder that contains all the dependencies
317-
const rootSrcDir = join(ctx.standaloneRootDir, 'node_modules')
318-
const rootDestDir = join(ctx.serverHandlerRootDir, 'node_modules')
319-
320-
// use the node_modules tree from the process.cwd() and not the one from the standalone output
321-
// as the standalone node_modules are already wrongly assembled by Next.js.
322-
// see: https://github.com/vercel/next.js/issues/50072
323-
if (existsSync(rootSrcDir) && ctx.standaloneRootDir !== ctx.standaloneDir) {
324-
promises.push(
325-
cp(rootSrcDir, rootDestDir, { recursive: true, verbatimSymlinks: true, filter }).then(() =>
326-
recreateNodeModuleSymlinks(resolve('node_modules'), rootDestDir),
327-
),
328-
)
339+
for (const nodeModulesLocationInStandalone of nodeModulesLocationsInStandalone) {
340+
const relativeToRoot = relative(ctx.standaloneRootDir, nodeModulesLocationInStandalone)
341+
const locationInProject = join(ctx.outputFileTracingRoot, relativeToRoot)
342+
const locationInServerHandler = join(ctx.serverHandlerRootDir, relativeToRoot)
343+
344+
promises.push(recreateNodeModuleSymlinks(locationInProject, locationInServerHandler))
329345
}
330346

331347
await Promise.all(promises)
@@ -451,7 +467,7 @@ export const verifyHandlerDirStructure = async (ctx: PluginContext) => {
451467
// https://github.com/pnpm/pnpm/issues/9654
452468
// https://github.com/pnpm/pnpm/issues/5928
453469
// https://github.com/pnpm/pnpm/issues/7362 (persisting even though ticket is closed)
454-
const nodeModulesFilter = async (sourcePath: string) => {
470+
const nodeModulesFilter = (sourcePath: string) => {
455471
// Filtering rule for the following packages:
456472
// - @rspack+binding-linux-x64-musl
457473
// - @swc+core-linux-x64-musl

src/build/plugin-context.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,17 @@ export class PluginContext {
8484
return this.requiredServerFiles.relativeAppDir ?? ''
8585
}
8686

87+
/**
88+
* The root directory for output file tracing. Paths inside standalone directory preserve paths of project, relative to this directory.
89+
*/
90+
get outputFileTracingRoot(): string {
91+
return (
92+
this.requiredServerFiles.config.outputFileTracingRoot ??
93+
// fallback for older Next.js versions that don't have outputFileTracingRoot in the config, but had it in config.experimental
94+
this.requiredServerFiles.config.experimental.outputFileTracingRoot
95+
)
96+
}
97+
8798
/**
8899
* The working directory inside the lambda that is used for monorepos to execute the serverless function
89100
*/

tests/e2e/turborepo.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,15 @@ test.describe('[PNPM] Package manager', () => {
107107
const date3 = await page.getByTestId('date-now').textContent()
108108
expect(date3).not.toBe(date2)
109109
})
110+
111+
test('transitive external dependencies are supported', async ({ page, turborepo }) => {
112+
const pageResponse = await page.goto(new URL('/transitive-external-deps', turborepo.url).href)
113+
114+
expect(pageResponse?.status()).toBe(200)
115+
116+
await expect(page.getByTestId('dep-a-version')).toHaveText('3.10.1')
117+
await expect(page.getByTestId('dep-b-version')).toHaveText('4.17.21')
118+
})
110119
})
111120

112121
test.describe('[NPM] Package manager', () => {
@@ -228,4 +237,15 @@ test.describe('[NPM] Package manager', () => {
228237
'.env.production.local': 'defined in .env.production.local',
229238
})
230239
})
240+
241+
test('transitive external dependencies are supported', async ({ page, turborepoNPM }) => {
242+
const pageResponse = await page.goto(
243+
new URL('/transitive-external-deps', turborepoNPM.url).href,
244+
)
245+
246+
expect(pageResponse?.status()).toBe(200)
247+
248+
await expect(page.getByTestId('dep-a-version')).toHaveText('3.10.1')
249+
await expect(page.getByTestId('dep-b-version')).toHaveText('4.17.21')
250+
})
231251
})

tests/fixtures/turborepo-npm/apps/page-router/next.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const nextConfig = {
88
},
99
transpilePackages: ['@repo/ui'],
1010
outputFileTracingRoot: join(__dirname, '..', '..'),
11+
serverExternalPackages: ['lodash'],
1112
}
1213

1314
module.exports = nextConfig

tests/fixtures/turborepo-npm/apps/page-router/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
},
99
"dependencies": {
1010
"@netlify/functions": "^2.7.0",
11+
"@repo/dep-a": "*",
12+
"@repo/dep-b": "*",
1113
"@repo/ui": "*",
1214
"next": "latest",
1315
"react": "^18.2.0",
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import depA from '@repo/dep-a'
2+
import depB from '@repo/dep-b'
3+
4+
export default function TransitiveDeps() {
5+
return (
6+
<body>
7+
<ul>
8+
<li>
9+
dep-a uses lodash version 3.10.1 and we should see this version here:{' '}
10+
<span data-testId="dep-a-version">{depA}</span>
11+
</li>
12+
<li>
13+
dep-b uses lodash version 4.17.21 and we should see this version here:{' '}
14+
<span data-testId="dep-b-version">{depB}</span>
15+
</li>
16+
</ul>
17+
</body>
18+
)
19+
}
20+
21+
// just to ensure this is rendered in runtime and not prerendered
22+
export async function getServerSideProps() {
23+
return {
24+
props: {},
25+
}
26+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import lodash from 'lodash'
2+
3+
export default lodash.VERSION
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"name": "@repo/dep-a",
3+
"version": "1.0.0",
4+
"dependencies": {
5+
"lodash": "3.10.1"
6+
}
7+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import lodash from 'lodash'
2+
3+
export default lodash.VERSION
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"name": "@repo/dep-b",
3+
"version": "1.0.0",
4+
"dependencies": {
5+
"lodash": "4.17.21"
6+
}
7+
}

0 commit comments

Comments
 (0)