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