Skip to content

Commit 6a35de6

Browse files
authored
fix: fixes the usage of a custom distDir (#269)
* fix: fixes the usage of a custom distDir * chore: cleanups * chore: lint fix * chore: 🧹🧹🧹 * chore: fix paths from newly added changes * chore: fixes
1 parent fa41fce commit 6a35de6

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

51 files changed

+1316
-54
lines changed

README.md

+23
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,29 @@
33
Next.js is supported natively on Netlify, and in most cases you will not need to install or
44
configure anything. This repo includes the packages used to support Next.js on Netlify.
55

6+
## Lambda Folder structure:
7+
8+
For a simple next.js app
9+
10+
```
11+
/___netlify-server-handler
12+
├── .netlify
13+
│ ├── tags-manifest.json
14+
│ └── dist // the compiled runtime code
15+
│ └── run
16+
│ ├── handlers
17+
│ │ ├── server.js
18+
│ │ └── cache.cjs
19+
│ └── next.cjs
20+
├── .next // or distDir name from the next.config.js
21+
│ └── // content from standalone
22+
├── run-config.json // the config object from the required-server-files.json
23+
├── node_modules
24+
├── ___netlify-server-handler.json
25+
├── ___netlify-server-handler.mjs
26+
└── package.json
27+
```
28+
629
## Testing
730

831
### Integration testing

src/build/content/server.test.ts

+95
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { readFile } from 'node:fs/promises'
2+
import { join } from 'node:path'
3+
4+
import { NetlifyPluginOptions } from '@netlify/build'
5+
import { expect, test, vi } from 'vitest'
6+
7+
import { mockFileSystem } from '../../../tests/index.js'
8+
import { PluginContext } from '../plugin-context.js'
9+
10+
import { copyNextServerCode } from './server.js'
11+
12+
vi.mock('node:fs', async () => {
13+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, unicorn/no-await-expression-member
14+
const unionFs: any = (await import('unionfs')).default
15+
const fs = await vi.importActual('node:fs')
16+
unionFs.reset = () => {
17+
unionFs.fss = [fs]
18+
}
19+
20+
const united = unionFs.use(fs)
21+
return { default: united, ...united }
22+
})
23+
24+
vi.mock('node:fs/promises', async () => {
25+
const fs = await import('node:fs')
26+
return fs.promises
27+
})
28+
29+
test('should not modify the required-server-files.json distDir on simple next app', async () => {
30+
const reqServerFiles = JSON.stringify({ config: { distDir: '.next' } })
31+
const reqServerPath = '.next/required-server-files.json'
32+
const reqServerPathStandalone = join('.next/standalone', reqServerPath)
33+
const { cwd } = mockFileSystem({
34+
[reqServerPath]: reqServerFiles,
35+
[reqServerPathStandalone]: reqServerFiles,
36+
})
37+
const ctx = new PluginContext({ constants: {} } as NetlifyPluginOptions)
38+
await copyNextServerCode(ctx)
39+
expect(await readFile(join(cwd, reqServerPathStandalone), 'utf-8')).toBe(reqServerFiles)
40+
})
41+
42+
test('should not modify the required-server-files.json distDir on monorepo', async () => {
43+
const reqServerFiles = JSON.stringify({ config: { distDir: '.next' } })
44+
const reqServerPath = 'apps/my-app/.next/required-server-files.json'
45+
const reqServerPathStandalone = join('apps/my-app/.next/standalone', reqServerPath)
46+
const { cwd } = mockFileSystem({
47+
[reqServerPath]: reqServerFiles,
48+
[reqServerPathStandalone]: reqServerFiles,
49+
})
50+
const ctx = new PluginContext({
51+
constants: {
52+
PACKAGE_PATH: 'apps/my-app',
53+
},
54+
} as NetlifyPluginOptions)
55+
await copyNextServerCode(ctx)
56+
expect(await readFile(join(cwd, reqServerPathStandalone), 'utf-8')).toBe(reqServerFiles)
57+
})
58+
59+
test('should not modify the required-server-files.json distDir on monorepo', async () => {
60+
const reqServerFiles = JSON.stringify({ config: { distDir: '.next' } })
61+
const reqServerPath = 'apps/my-app/.next/required-server-files.json'
62+
const reqServerPathStandalone = join('apps/my-app/.next/standalone', reqServerPath)
63+
const { cwd } = mockFileSystem({
64+
[reqServerPath]: reqServerFiles,
65+
[reqServerPathStandalone]: reqServerFiles,
66+
})
67+
const ctx = new PluginContext({
68+
constants: {
69+
PACKAGE_PATH: 'apps/my-app',
70+
},
71+
} as NetlifyPluginOptions)
72+
await copyNextServerCode(ctx)
73+
expect(await readFile(join(cwd, reqServerPathStandalone), 'utf-8')).toBe(reqServerFiles)
74+
})
75+
76+
// case of nx-integrated
77+
test('should modify the required-server-files.json distDir on distDir outside of packagePath', async () => {
78+
const reqServerFiles = JSON.stringify({ config: { distDir: '../../dist/apps/my-app/.next' } })
79+
const reqServerPath = 'dist/apps/my-app/.next/required-server-files.json'
80+
const reqServerPathStandalone = join('dist/apps/my-app/.next/standalone', reqServerPath)
81+
const { cwd } = mockFileSystem({
82+
[reqServerPath]: reqServerFiles,
83+
[reqServerPathStandalone]: reqServerFiles,
84+
})
85+
const ctx = new PluginContext({
86+
constants: {
87+
PACKAGE_PATH: 'apps/my-app',
88+
PUBLISH_DIR: 'dist/apps/my-app/.next',
89+
},
90+
} as NetlifyPluginOptions)
91+
await copyNextServerCode(ctx)
92+
expect(await readFile(join(cwd, reqServerPathStandalone), 'utf-8')).toBe(
93+
'{"config":{"distDir":".next"}}',
94+
)
95+
})

src/build/content/server.ts

+30-8
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { dirname, join, resolve } from 'node:path'
66

77
import glob from 'fast-glob'
88

9+
import { RUN_CONFIG } from '../../run/constants.js'
910
import { PluginContext } from '../plugin-context.js'
1011

1112
/**
@@ -14,17 +15,38 @@ import { PluginContext } from '../plugin-context.js'
1415
export const copyNextServerCode = async (ctx: PluginContext): Promise<void> => {
1516
// update the dist directory inside the required-server-files.json to work with
1617
// nx monorepos and other setups where the dist directory is modified
17-
const reqServerFilesPath = join(ctx.standaloneDir, '.next/required-server-files.json')
18+
const reqServerFilesPath = join(
19+
ctx.standaloneRootDir,
20+
ctx.relPublishDir,
21+
'required-server-files.json',
22+
)
1823
const reqServerFiles = JSON.parse(await readFile(reqServerFilesPath, 'utf-8'))
1924

20-
// only override it if it was set before to a different value
21-
if (reqServerFiles.config.distDir) {
22-
reqServerFiles.config.distDir = '.next'
25+
// if the resolved dist folder does not match the distDir of the required-server-files.json
26+
// this means the path got altered by a plugin like nx and contained ../../ parts so we have to reset it
27+
// to point to the correct lambda destination
28+
if (
29+
ctx.distDir.replace(new RegExp(`^${ctx.packagePath}/?`), '') !== reqServerFiles.config.distDir
30+
) {
31+
// set the distDir to the latest path portion of the publish dir
32+
reqServerFiles.config.distDir = ctx.nextDistDir
2333
await writeFile(reqServerFilesPath, JSON.stringify(reqServerFiles))
2434
}
2535

26-
const srcDir = join(ctx.standaloneDir, '.next')
27-
const destDir = join(ctx.serverHandlerDir, '.next')
36+
// ensure the directory exists before writing to it
37+
await mkdir(ctx.serverHandlerDir, { recursive: true })
38+
// write our run-config.json to the root dir so that we can easily get the runtime config of the required-server-files.json
39+
// without the need to know about the monorepo or distDir configuration upfront.
40+
await writeFile(
41+
join(ctx.serverHandlerDir, RUN_CONFIG),
42+
JSON.stringify(reqServerFiles.config),
43+
'utf-8',
44+
)
45+
46+
const srcDir = join(ctx.standaloneDir, ctx.nextDistDir)
47+
// if the distDir got resolved and altered use the nextDistDir instead
48+
const nextFolder = ctx.distDir === ctx.buildConfig.distDir ? ctx.distDir : ctx.nextDistDir
49+
const destDir = join(ctx.serverHandlerDir, nextFolder)
2850

2951
const paths = await glob(
3052
[`*`, `server/*`, `server/chunks/*`, `server/edge-chunks/*`, `server/+(app|pages)/**/*.js`],
@@ -96,9 +118,9 @@ async function recreateNodeModuleSymlinks(src: string, dest: string, org?: strin
96118
export const copyNextDependencies = async (ctx: PluginContext): Promise<void> => {
97119
const entries = await readdir(ctx.standaloneDir)
98120
const promises: Promise<void>[] = entries.map(async (entry) => {
99-
// copy all except the package.json and .next folder as this is handled in a separate function
121+
// copy all except the package.json and distDir (.next) folder as this is handled in a separate function
100122
// this will include the node_modules folder as well
101-
if (entry === 'package.json' || entry === '.next') {
123+
if (entry === 'package.json' || entry === ctx.nextDistDir) {
102124
return
103125
}
104126
const src = join(ctx.standaloneDir, entry)

src/build/functions/edge.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ const copyRuntime = async (ctx: PluginContext, handlerDirectory: string): Promis
3838
}
3939

4040
const writeHandlerFile = async (ctx: PluginContext, { matchers, name }: NextDefinition) => {
41-
const nextConfig = await ctx.getBuildConfig()
41+
const nextConfig = ctx.buildConfig
4242
const handlerName = getHandlerName({ name })
4343
const handlerDirectory = join(ctx.edgeFunctionsDir, handlerName)
4444
const handlerRuntimeDirectory = join(handlerDirectory, 'edge-runtime')
@@ -81,7 +81,7 @@ const copyHandlerDependencies = async (
8181
ctx: PluginContext,
8282
{ name, files, wasm }: NextDefinition,
8383
) => {
84-
const srcDir = join(ctx.standaloneDir, '.next')
84+
const srcDir = join(ctx.standaloneDir, ctx.nextDistDir)
8585
const destDir = join(ctx.edgeFunctionsDir, getHandlerName({ name }))
8686

8787
await Promise.all(

src/build/functions/server.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,12 @@ import { glob } from 'fast-glob'
66
import { copyNextDependencies, copyNextServerCode, writeTagsManifest } from '../content/server.js'
77
import { PluginContext, SERVER_HANDLER_NAME } from '../plugin-context.js'
88

9+
/** Copies the runtime dist folder to the lambda */
910
const copyHandlerDependencies = async (ctx: PluginContext) => {
1011
const fileList = await glob('dist/**/*', { cwd: ctx.pluginDir })
1112
await Promise.all(
1213
[...fileList].map((path) =>
13-
cp(join(ctx.pluginDir, path), join(ctx.serverHandlerDir, path), {
14+
cp(join(ctx.pluginDir, path), join(ctx.serverHandlerDir, '.netlify', path), {
1415
recursive: true,
1516
force: true,
1617
}),

src/build/image-cdn.test.ts

+7-18
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,31 @@
11
/* eslint-disable id-length */
22
import type { NetlifyPluginOptions } from '@netlify/build'
33
import type { NextConfigComplete } from 'next/dist/server/config-shared.js'
4-
import { beforeEach, describe, expect, test, vi, TestContext } from 'vitest'
4+
import { TestContext, beforeEach, describe, expect, test } from 'vitest'
55

66
import { setImageConfig } from './image-cdn.js'
77
import { PluginContext } from './plugin-context.js'
88

9-
type DeepPartial<T> = T extends object
10-
? {
11-
[P in keyof T]?: DeepPartial<T[P]>
12-
}
13-
: T
14-
159
type ImageCDNTestContext = TestContext & {
1610
pluginContext: PluginContext
17-
mockNextConfig?: DeepPartial<NextConfigComplete>
1811
}
1912

2013
describe('Image CDN', () => {
2114
beforeEach<ImageCDNTestContext>((ctx) => {
22-
ctx.mockNextConfig = undefined
2315
ctx.pluginContext = new PluginContext({
2416
netlifyConfig: {
2517
redirects: [],
2618
},
2719
} as unknown as NetlifyPluginOptions)
28-
vi.spyOn(ctx.pluginContext, 'getBuildConfig').mockImplementation(() =>
29-
Promise.resolve((ctx.mockNextConfig ?? {}) as NextConfigComplete),
30-
)
3120
})
3221

3322
test<ImageCDNTestContext>('adds redirect to Netlify Image CDN when default image loader is used', async (ctx) => {
34-
ctx.mockNextConfig = {
23+
ctx.pluginContext._buildConfig = {
3524
images: {
3625
path: '/_next/image',
3726
loader: 'default',
3827
},
39-
}
28+
} as NextConfigComplete
4029

4130
await setImageConfig(ctx.pluginContext)
4231

@@ -57,13 +46,13 @@ describe('Image CDN', () => {
5746
})
5847

5948
test<ImageCDNTestContext>('does not add redirect to Netlify Image CDN when non-default loader is used', async (ctx) => {
60-
ctx.mockNextConfig = {
49+
ctx.pluginContext._buildConfig = {
6150
images: {
6251
path: '/_next/image',
6352
loader: 'custom',
6453
loaderFile: './custom-loader.js',
6554
},
66-
}
55+
} as NextConfigComplete
6756

6857
await setImageConfig(ctx.pluginContext)
6958

@@ -84,7 +73,7 @@ describe('Image CDN', () => {
8473
})
8574

8675
test<ImageCDNTestContext>('handles custom images.path', async (ctx) => {
87-
ctx.mockNextConfig = {
76+
ctx.pluginContext._buildConfig = {
8877
images: {
8978
// Next.js automatically adds basePath to images.path (when user does not set custom `images.path` in their config)
9079
// if user sets custom `images.path` - it will be used as-is (so user need to cover their basePath by themselves
@@ -94,7 +83,7 @@ describe('Image CDN', () => {
9483
path: '/base/path/_custom/image/endpoint',
9584
loader: 'default',
9685
},
97-
}
86+
} as NextConfigComplete
9887

9988
await setImageConfig(ctx.pluginContext)
10089

src/build/image-cdn.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { PluginContext } from './plugin-context.js'
66
export const setImageConfig = async (ctx: PluginContext): Promise<void> => {
77
const {
88
images: { path: imageEndpointPath, loader: imageLoader },
9-
} = await ctx.getBuildConfig()
9+
} = ctx.buildConfig
1010

1111
if (imageLoader === 'default') {
1212
ctx.netlifyConfig.redirects.push({

0 commit comments

Comments
 (0)