diff --git a/.cspell.json b/.cspell.json index eea1674..8a26b1a 100644 --- a/.cspell.json +++ b/.cspell.json @@ -2,6 +2,7 @@ "version": "0.2", "language": "en,en-gb", "words": [ + "eslintcache", "commitlint", "nestedfile", "directoryfile", @@ -34,7 +35,6 @@ "dottedfile", "tempfile" ], - "ignorePaths": [ "CHANGELOG.md", "package.json", diff --git a/.gitignore b/.gitignore index 5982f56..3762a52 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ npm-debug.log* /reports /node_modules /test/fixtures/\[special\$directory\] +/test/fixtures/watch/**/*.txt /test/outputs /test/bundled diff --git a/src/index.js b/src/index.js index 82c47e1..7cfd72c 100644 --- a/src/index.js +++ b/src/index.js @@ -22,6 +22,7 @@ const getTinyGlobby = memoize(() => require("tinyglobby")); /** @typedef {import("webpack").Compilation} Compilation */ /** @typedef {import("webpack").Asset} Asset */ /** @typedef {import("webpack").AssetInfo} AssetInfo */ +/** @typedef {import("webpack").InputFileSystem} InputFileSystem */ /** @typedef {import("tinyglobby").GlobOptions} GlobbyOptions */ /** @typedef {ReturnType} WebpackLogger */ /** @typedef {ReturnType} CacheFacade */ @@ -243,6 +244,63 @@ class CopyPlugin { return fullContentHash.toString().slice(0, hashDigestLength); } + /** + * @private + * @param {Compilation} compilation the compilation + * @param {"file" | "dir" | "glob"} typeOfFrom the type of from + * @param {string} absoluteFrom the source content to hash + * @param {InputFileSystem | null} inputFileSystem input file system + * @param {WebpackLogger} logger the logger to use for logging + * @returns {Promise} + */ + static async addCompilationDependency( + compilation, + typeOfFrom, + absoluteFrom, + inputFileSystem, + logger, + ) { + switch (typeOfFrom) { + case "dir": + compilation.contextDependencies.add(absoluteFrom); + logger.debug(`added '${absoluteFrom}' as a context dependency`); + break; + case "file": + compilation.fileDependencies.add(absoluteFrom); + logger.debug(`added '${absoluteFrom}' as a file dependency`); + break; + case "glob": + default: { + const contextDependency = getTinyGlobby().isDynamicPattern(absoluteFrom) + ? path.normalize(getGlobParent()(absoluteFrom)) + : path.normalize(absoluteFrom); + + let stats; + + // If we have `inputFileSystem` we should check the glob is existing or not + if (inputFileSystem) { + try { + stats = await stat(inputFileSystem, contextDependency); + } catch { + // Nothing + } + } + + // To prevent double compilation during aggregation (initial run) - https://github.com/webpack-contrib/copy-webpack-plugin/issues/806. + // On first run we don't know if the glob exists or not, adding the dependency to the context dependencies triggers the `removed` event during aggregation. + // To prevent this behavior we should add the glob to the missing dependencies if the glob doesn't exist, + // otherwise we should add the dependency to the context dependencies. + if (inputFileSystem && !stats) { + compilation.missingDependencies.add(contextDependency); + logger.debug(`added '${contextDependency}' as a missing dependency`); + } else { + compilation.contextDependencies.add(contextDependency); + logger.debug(`added '${contextDependency}' as a context dependency`); + } + } + } + } + /** * @private * @param {typeof import("tinyglobby").glob} globby the globby function to use for globbing @@ -277,12 +335,13 @@ class CopyPlugin { logger.debug(`getting stats for '${absoluteFrom}'...`); - const { inputFileSystem } = compiler; + const { inputFileSystem } = + /** @type {Compiler & { inputFileSystem: InputFileSystem }} */ + (compiler); let stats; try { - // @ts-expect-error - webpack types are incomplete stats = await stat(inputFileSystem, absoluteFrom); } catch { // Nothing @@ -291,22 +350,22 @@ class CopyPlugin { /** * @type {"file" | "dir" | "glob"} */ - let fromType; + let typeOfFrom; if (stats) { if (stats.isDirectory()) { - fromType = "dir"; + typeOfFrom = "dir"; logger.debug(`determined '${absoluteFrom}' is a directory`); } else if (stats.isFile()) { - fromType = "file"; + typeOfFrom = "file"; logger.debug(`determined '${absoluteFrom}' is a file`); } else { // Fallback - fromType = "glob"; + typeOfFrom = "glob"; logger.debug(`determined '${absoluteFrom}' is unknown`); } } else { - fromType = "glob"; + typeOfFrom = "glob"; logger.debug(`determined '${absoluteFrom}' is a glob`); } @@ -325,12 +384,8 @@ class CopyPlugin { let glob; - switch (fromType) { + switch (typeOfFrom) { case "dir": - compilation.contextDependencies.add(absoluteFrom); - - logger.debug(`added '${absoluteFrom}' as a context dependency`); - pattern.context = absoluteFrom; glob = path.posix.join( getTinyGlobby().escapePath(getNormalizePath()(absoluteFrom)), @@ -342,10 +397,6 @@ class CopyPlugin { } break; case "file": - compilation.fileDependencies.add(absoluteFrom); - - logger.debug(`added '${absoluteFrom}' as a file dependency`); - pattern.context = path.dirname(absoluteFrom); glob = getTinyGlobby().escapePath(getNormalizePath()(absoluteFrom)); @@ -355,14 +406,6 @@ class CopyPlugin { break; case "glob": default: { - const contextDependencies = path.normalize( - getGlobParent()(absoluteFrom), - ); - - compilation.contextDependencies.add(contextDependencies); - - logger.debug(`added '${contextDependencies}' as a context dependency`); - glob = path.isAbsolute(pattern.from) ? pattern.from : path.posix.join( @@ -388,6 +431,14 @@ class CopyPlugin { } if (globEntries.length === 0) { + await CopyPlugin.addCompilationDependency( + compilation, + typeOfFrom, + absoluteFrom, + inputFileSystem, + logger, + ); + if (pattern.noErrorOnMissing) { logger.log( `finished to process a pattern from '${pattern.from}' using '${pattern.context}' context to '${pattern.to}'`, @@ -401,6 +452,14 @@ class CopyPlugin { return; } + await CopyPlugin.addCompilationDependency( + compilation, + typeOfFrom, + absoluteFrom, + null, + logger, + ); + /** * @type {Array} */ @@ -475,7 +534,7 @@ class CopyPlugin { ); // If this came from a glob or dir, add it to the file dependencies - if (fromType === "dir" || fromType === "glob") { + if (typeOfFrom === "dir" || typeOfFrom === "glob") { compilation.fileDependencies.add(absoluteFilename); logger.debug(`added '${absoluteFilename}' as a file dependency`); @@ -540,7 +599,6 @@ class CopyPlugin { let data; try { - // @ts-expect-error - webpack types are incomplete data = await readFile(inputFileSystem, absoluteFilename); } catch (error) { compilation.errors.push(/** @type {Error} */ (error)); diff --git a/test/CopyPlugin.test.js b/test/CopyPlugin.test.js index bfc2453..9276428 100644 --- a/test/CopyPlugin.test.js +++ b/test/CopyPlugin.test.js @@ -442,7 +442,7 @@ describe("CopyPlugin", () => { }); describe("watch mode", () => { - it('should add the file to the watch list when "from" is a file', (done) => { + it('should add a file to the watch list when "from" is a file', (done) => { const expectedAssetKeys = ["file.txt"]; run({ @@ -461,6 +461,27 @@ describe("CopyPlugin", () => { .catch(done); }); + it('should add a file to the watch list when "from" is a file that does not exist.', (done) => { + run({ + patterns: [ + { + from: "directory-does-not-exist/file.txt", + noErrorOnMissing: true, + }, + ], + }) + .then(({ stats }) => { + const { missingDependencies } = stats.compilation; + const isIncludeDependency = missingDependencies.has( + path.join(FIXTURES_DIR, "directory-does-not-exist/file.txt"), + ); + + expect(isIncludeDependency).toBe(true); + }) + .then(done) + .catch(done); + }); + it('should add a directory to the watch list when "from" is a directory', (done) => { run({ patterns: [ @@ -481,6 +502,27 @@ describe("CopyPlugin", () => { .catch(done); }); + it('should add a directory to the watch list when "from" is a directory and that does not exist.', (done) => { + run({ + patterns: [ + { + from: "directory-does-not-exist/", + noErrorOnMissing: true, + }, + ], + }) + .then(({ stats }) => { + const { missingDependencies } = stats.compilation; + const isIncludeDependency = missingDependencies.has( + path.join(FIXTURES_DIR, "directory-does-not-exist"), + ); + + expect(isIncludeDependency).toBe(true); + }) + .then(done) + .catch(done); + }); + it('should add a directory to the watch list when "from" is a glob', (done) => { run({ patterns: [ @@ -501,6 +543,27 @@ describe("CopyPlugin", () => { .catch(done); }); + it('should add a directory to the watch list when "from" is a glob and that does not exist.', (done) => { + run({ + patterns: [ + { + from: "directory-does-not-exist/**/*", + noErrorOnMissing: true, + }, + ], + }) + .then(({ stats }) => { + const { missingDependencies } = stats.compilation; + const isIncludeDependency = missingDependencies.has( + path.join(FIXTURES_DIR, "directory-does-not-exist"), + ); + + expect(isIncludeDependency).toBe(true); + }) + .then(done) + .catch(done); + }); + it("should not add the directory to the watch list when glob is a file", (done) => { const expectedAssetKeys = ["directoryfile.txt"]; diff --git a/types/index.d.ts b/types/index.d.ts index 06bfa9a..2b351bf 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -23,6 +23,16 @@ declare class CopyPlugin { * @returns {string} returns the content hash of the source */ private static getContentHash; + /** + * @private + * @param {Compilation} compilation the compilation + * @param {"file" | "dir" | "glob"} typeOfFrom the type of from + * @param {string} absoluteFrom the source content to hash + * @param {InputFileSystem | null} inputFileSystem input file system + * @param {WebpackLogger} logger the logger to use for logging + * @returns {Promise} + */ + private static addCompilationDependency; /** * @private * @param {typeof import("tinyglobby").glob} globby the globby function to use for globbing @@ -62,6 +72,7 @@ declare namespace CopyPlugin { Compilation, Asset, AssetInfo, + InputFileSystem, GlobbyOptions, WebpackLogger, CacheFacade, @@ -94,6 +105,7 @@ type Compiler = import("webpack").Compiler; type Compilation = import("webpack").Compilation; type Asset = import("webpack").Asset; type AssetInfo = import("webpack").AssetInfo; +type InputFileSystem = import("webpack").InputFileSystem; type GlobbyOptions = import("tinyglobby").GlobOptions; type WebpackLogger = ReturnType; type CacheFacade = ReturnType;