UNPKG

9.94 kBJavaScriptView Raw
1/*
2 MIT License http://www.opensource.org/licenses/mit-license.php
3 Author Sergey Melyukov @smelukov
4*/
5
6"use strict";
7
8const asyncLib = require("neo-async");
9const { SyncBailHook } = require("tapable");
10const Compilation = require("../lib/Compilation");
11const createSchemaValidation = require("./util/create-schema-validation");
12const { join } = require("./util/fs");
13const processAsyncTree = require("./util/processAsyncTree");
14
15/** @typedef {import("../declarations/WebpackOptions").CleanOptions} CleanOptions */
16/** @typedef {import("./Compiler")} Compiler */
17/** @typedef {import("./logging/Logger").Logger} Logger */
18/** @typedef {import("./util/fs").OutputFileSystem} OutputFileSystem */
19
20/** @typedef {(function(string):boolean)|RegExp} IgnoreItem */
21/** @typedef {function(IgnoreItem): void} AddToIgnoreCallback */
22
23/**
24 * @typedef {Object} CleanPluginCompilationHooks
25 * @property {SyncBailHook<[string], boolean>} keep when returning true the file/directory will be kept during cleaning, returning false will clean it and ignore the following plugins and config
26 */
27
28const validate = createSchemaValidation(
29 undefined,
30 () => {
31 const { definitions } = require("../schemas/WebpackOptions.json");
32 return {
33 definitions,
34 oneOf: [{ $ref: "#/definitions/CleanOptions" }]
35 };
36 },
37 {
38 name: "Clean Plugin",
39 baseDataPath: "options"
40 }
41);
42
43/**
44 * @param {OutputFileSystem} fs filesystem
45 * @param {string} outputPath output path
46 * @param {Set<string>} currentAssets filename of the current assets (must not start with .. or ., must only use / as path separator)
47 * @param {function(Error=, Set<string>=): void} callback returns the filenames of the assets that shouldn't be there
48 * @returns {void}
49 */
50const getDiffToFs = (fs, outputPath, currentAssets, callback) => {
51 const directories = new Set();
52 // get directories of assets
53 for (const asset of currentAssets) {
54 directories.add(asset.replace(/(^|\/)[^/]*$/, ""));
55 }
56 // and all parent directories
57 for (const directory of directories) {
58 directories.add(directory.replace(/(^|\/)[^/]*$/, ""));
59 }
60 const diff = new Set();
61 asyncLib.forEachLimit(
62 directories,
63 10,
64 (directory, callback) => {
65 fs.readdir(join(fs, outputPath, directory), (err, entries) => {
66 if (err) {
67 if (err.code === "ENOENT") return callback();
68 if (err.code === "ENOTDIR") {
69 diff.add(directory);
70 return callback();
71 }
72 return callback(err);
73 }
74 for (const entry of entries) {
75 const file = /** @type {string} */ (entry);
76 const filename = directory ? `${directory}/${file}` : file;
77 if (!directories.has(filename) && !currentAssets.has(filename)) {
78 diff.add(filename);
79 }
80 }
81 callback();
82 });
83 },
84 err => {
85 if (err) return callback(err);
86
87 callback(null, diff);
88 }
89 );
90};
91
92/**
93 * @param {Set<string>} currentAssets assets list
94 * @param {Set<string>} oldAssets old assets list
95 * @returns {Set<string>} diff
96 */
97const getDiffToOldAssets = (currentAssets, oldAssets) => {
98 const diff = new Set();
99 for (const asset of oldAssets) {
100 if (!currentAssets.has(asset)) diff.add(asset);
101 }
102 return diff;
103};
104
105/**
106 * @param {OutputFileSystem} fs filesystem
107 * @param {string} outputPath output path
108 * @param {boolean} dry only log instead of fs modification
109 * @param {Logger} logger logger
110 * @param {Set<string>} diff filenames of the assets that shouldn't be there
111 * @param {function(string): boolean} isKept check if the entry is ignored
112 * @param {function(Error=): void} callback callback
113 * @returns {void}
114 */
115const applyDiff = (fs, outputPath, dry, logger, diff, isKept, callback) => {
116 const log = msg => {
117 if (dry) {
118 logger.info(msg);
119 } else {
120 logger.log(msg);
121 }
122 };
123 /** @typedef {{ type: "check" | "unlink" | "rmdir", filename: string, parent: { remaining: number, job: Job } | undefined }} Job */
124 /** @type {Job[]} */
125 const jobs = Array.from(diff, filename => ({
126 type: "check",
127 filename,
128 parent: undefined
129 }));
130 processAsyncTree(
131 jobs,
132 10,
133 ({ type, filename, parent }, push, callback) => {
134 const handleError = err => {
135 if (err.code === "ENOENT") {
136 log(`${filename} was removed during cleaning by something else`);
137 handleParent();
138 return callback();
139 }
140 return callback(err);
141 };
142 const handleParent = () => {
143 if (parent && --parent.remaining === 0) push(parent.job);
144 };
145 const path = join(fs, outputPath, filename);
146 switch (type) {
147 case "check":
148 if (isKept(filename)) {
149 // do not decrement parent entry as we don't want to delete the parent
150 log(`${filename} will be kept`);
151 return process.nextTick(callback);
152 }
153 fs.stat(path, (err, stats) => {
154 if (err) return handleError(err);
155 if (!stats.isDirectory()) {
156 push({
157 type: "unlink",
158 filename,
159 parent
160 });
161 return callback();
162 }
163 fs.readdir(path, (err, entries) => {
164 if (err) return handleError(err);
165 /** @type {Job} */
166 const deleteJob = {
167 type: "rmdir",
168 filename,
169 parent
170 };
171 if (entries.length === 0) {
172 push(deleteJob);
173 } else {
174 const parentToken = {
175 remaining: entries.length,
176 job: deleteJob
177 };
178 for (const entry of entries) {
179 const file = /** @type {string} */ (entry);
180 if (file.startsWith(".")) {
181 log(
182 `${filename} will be kept (dot-files will never be removed)`
183 );
184 continue;
185 }
186 push({
187 type: "check",
188 filename: `${filename}/${file}`,
189 parent: parentToken
190 });
191 }
192 }
193 return callback();
194 });
195 });
196 break;
197 case "rmdir":
198 log(`${filename} will be removed`);
199 if (dry) {
200 handleParent();
201 return process.nextTick(callback);
202 }
203 if (!fs.rmdir) {
204 logger.warn(
205 `${filename} can't be removed because output file system doesn't support removing directories (rmdir)`
206 );
207 return process.nextTick(callback);
208 }
209 fs.rmdir(path, err => {
210 if (err) return handleError(err);
211 handleParent();
212 callback();
213 });
214 break;
215 case "unlink":
216 log(`${filename} will be removed`);
217 if (dry) {
218 handleParent();
219 return process.nextTick(callback);
220 }
221 if (!fs.unlink) {
222 logger.warn(
223 `${filename} can't be removed because output file system doesn't support removing files (rmdir)`
224 );
225 return process.nextTick(callback);
226 }
227 fs.unlink(path, err => {
228 if (err) return handleError(err);
229 handleParent();
230 callback();
231 });
232 break;
233 }
234 },
235 callback
236 );
237};
238
239/** @type {WeakMap<Compilation, CleanPluginCompilationHooks>} */
240const compilationHooksMap = new WeakMap();
241
242class CleanPlugin {
243 /**
244 * @param {Compilation} compilation the compilation
245 * @returns {CleanPluginCompilationHooks} the attached hooks
246 */
247 static getCompilationHooks(compilation) {
248 if (!(compilation instanceof Compilation)) {
249 throw new TypeError(
250 "The 'compilation' argument must be an instance of Compilation"
251 );
252 }
253 let hooks = compilationHooksMap.get(compilation);
254 if (hooks === undefined) {
255 hooks = {
256 /** @type {SyncBailHook<[string], boolean>} */
257 keep: new SyncBailHook(["ignore"])
258 };
259 compilationHooksMap.set(compilation, hooks);
260 }
261 return hooks;
262 }
263
264 /** @param {CleanOptions} options options */
265 constructor(options = {}) {
266 validate(options);
267 this.options = { dry: false, ...options };
268 }
269
270 /**
271 * Apply the plugin
272 * @param {Compiler} compiler the compiler instance
273 * @returns {void}
274 */
275 apply(compiler) {
276 const { dry, keep } = this.options;
277
278 const keepFn =
279 typeof keep === "function"
280 ? keep
281 : typeof keep === "string"
282 ? path => path.startsWith(keep)
283 : typeof keep === "object" && keep.test
284 ? path => keep.test(path)
285 : () => false;
286
287 // We assume that no external modification happens while the compiler is active
288 // So we can store the old assets and only diff to them to avoid fs access on
289 // incremental builds
290 let oldAssets;
291
292 compiler.hooks.emit.tapAsync(
293 {
294 name: "CleanPlugin",
295 stage: 100
296 },
297 (compilation, callback) => {
298 const hooks = CleanPlugin.getCompilationHooks(compilation);
299 const logger = compilation.getLogger("webpack.CleanPlugin");
300 const fs = compiler.outputFileSystem;
301
302 if (!fs.readdir) {
303 return callback(
304 new Error(
305 "CleanPlugin: Output filesystem doesn't support listing directories (readdir)"
306 )
307 );
308 }
309
310 const currentAssets = new Set();
311 for (const asset of Object.keys(compilation.assets)) {
312 if (/^[A-Za-z]:\\|^\/|^\\\\/.test(asset)) continue;
313 let normalizedAsset;
314 let newNormalizedAsset = asset.replace(/\\/g, "/");
315 do {
316 normalizedAsset = newNormalizedAsset;
317 newNormalizedAsset = normalizedAsset.replace(
318 /(^|\/)(?!\.\.)[^/]+\/\.\.\//g,
319 "$1"
320 );
321 } while (newNormalizedAsset !== normalizedAsset);
322 if (normalizedAsset.startsWith("../")) continue;
323 currentAssets.add(normalizedAsset);
324 }
325
326 const outputPath = compilation.getPath(compiler.outputPath, {});
327
328 const isKept = path => {
329 const result = hooks.keep.call(path);
330 if (result !== undefined) return result;
331 return keepFn(path);
332 };
333
334 const diffCallback = (err, diff) => {
335 if (err) {
336 oldAssets = undefined;
337 return callback(err);
338 }
339 applyDiff(fs, outputPath, dry, logger, diff, isKept, err => {
340 if (err) {
341 oldAssets = undefined;
342 } else {
343 oldAssets = currentAssets;
344 }
345 callback(err);
346 });
347 };
348
349 if (oldAssets) {
350 diffCallback(null, getDiffToOldAssets(currentAssets, oldAssets));
351 } else {
352 getDiffToFs(fs, outputPath, currentAssets, diffCallback);
353 }
354 }
355 );
356 }
357}
358
359module.exports = CleanPlugin;