diff --git a/package.json b/package.json index d2d99d7f..429563ce 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "packages/**/*.ts", "**/*.d.ts", "**/node_modules/**", + "packages/next/build/webpack/plugins/terser-webpack-plugin/**", "examples/with-ioc/**", "examples/with-kea/**", "examples/with-mobx/**" diff --git a/packages/next/build/webpack-config.js b/packages/next/build/webpack-config.js index f801a5f6..954d84e3 100644 --- a/packages/next/build/webpack-config.js +++ b/packages/next/build/webpack-config.js @@ -15,7 +15,7 @@ import { ReactLoadablePlugin } from './webpack/plugins/react-loadable-plugin' import {SERVER_DIRECTORY, REACT_LOADABLE_MANIFEST, CLIENT_STATIC_FILES_RUNTIME_WEBPACK, CLIENT_STATIC_FILES_RUNTIME_MAIN} from 'next-server/constants' import {NEXT_PROJECT_ROOT, NEXT_PROJECT_ROOT_NODE_MODULES, NEXT_PROJECT_ROOT_DIST_CLIENT, PAGES_DIR_ALIAS, DOT_NEXT_ALIAS} from '../lib/constants' import AutoDllPlugin from 'autodll-webpack-plugin' -import TerserPlugin from 'terser-webpack-plugin' +import TerserPlugin from './webpack/plugins/terser-webpack-plugin/src/cjs.js' import {ServerlessPlugin} from './webpack/plugins/serverless-plugin' // The externals config makes sure that diff --git a/packages/next/build/webpack/plugins/terser-webpack-plugin/LICENSE b/packages/next/build/webpack/plugins/terser-webpack-plugin/LICENSE new file mode 100644 index 00000000..8c11fc72 --- /dev/null +++ b/packages/next/build/webpack/plugins/terser-webpack-plugin/LICENSE @@ -0,0 +1,20 @@ +Copyright JS Foundation and other contributors + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/packages/next/build/webpack/plugins/terser-webpack-plugin/src/TaskRunner.js b/packages/next/build/webpack/plugins/terser-webpack-plugin/src/TaskRunner.js new file mode 100644 index 00000000..82e6664e --- /dev/null +++ b/packages/next/build/webpack/plugins/terser-webpack-plugin/src/TaskRunner.js @@ -0,0 +1,106 @@ +import os from 'os'; + +import cacache from 'cacache'; +import findCacheDir from 'find-cache-dir'; +import workerFarm from 'worker-farm'; +import serialize from 'serialize-javascript'; + +import minify from './minify'; + +const worker = require.resolve('./worker'); + +export default class TaskRunner { + constructor(options = {}) { + const { cache, parallel } = options; + this.cacheDir = + cache === true ? findCacheDir({ name: 'terser-webpack-plugin' }) : cache; + // In some cases cpus() returns undefined + // https://github.com/nodejs/node/issues/19022 + const cpus = os.cpus() || { length: 1 }; + this.maxConcurrentWorkers = + parallel === true + ? cpus.length - 1 + : Math.min(Number(parallel) || 0, cpus.length - 1); + } + + run(tasks, callback) { + /* istanbul ignore if */ + if (!tasks.length) { + callback(null, []); + return; + } + + if (this.maxConcurrentWorkers > 1) { + const workerOptions = + process.platform === 'win32' + ? { + maxConcurrentWorkers: this.maxConcurrentWorkers, + maxConcurrentCallsPerWorker: 1, + } + : { maxConcurrentWorkers: this.maxConcurrentWorkers }; + this.workers = workerFarm(workerOptions, worker); + this.boundWorkers = (options, cb) => { + try { + this.workers(serialize(options), cb); + } catch (error) { + // worker-farm can fail with ENOMEM or something else + cb(error); + } + }; + } else { + this.boundWorkers = (options, cb) => { + try { + cb(null, minify(options)); + } catch (error) { + cb(error); + } + }; + } + + let toRun = tasks.length; + const results = []; + const step = (index, data) => { + toRun -= 1; + results[index] = data; + + if (!toRun) { + callback(null, results); + } + }; + + tasks.forEach((task, index) => { + const enqueue = () => { + this.boundWorkers(task, (error, data) => { + const result = error ? { error } : data; + const done = () => step(index, result); + + if (this.cacheDir && !result.error) { + cacache + .put( + this.cacheDir, + serialize(task.cacheKeys), + JSON.stringify(data) + ) + .then(done, done); + } else { + done(); + } + }); + }; + + if (this.cacheDir) { + cacache + .get(this.cacheDir, serialize(task.cacheKeys)) + .then(({ data }) => step(index, JSON.parse(data)), enqueue); + } else { + enqueue(); + } + }); + } + + exit() { + if (this.workers) { + workerFarm.end(this.workers); + } + } +} diff --git a/packages/next/build/webpack/plugins/terser-webpack-plugin/src/cjs.js b/packages/next/build/webpack/plugins/terser-webpack-plugin/src/cjs.js new file mode 100644 index 00000000..baa7dacb --- /dev/null +++ b/packages/next/build/webpack/plugins/terser-webpack-plugin/src/cjs.js @@ -0,0 +1,3 @@ +const plugin = require('./index'); + +module.exports = plugin.default; diff --git a/packages/next/build/webpack/plugins/terser-webpack-plugin/src/index.js b/packages/next/build/webpack/plugins/terser-webpack-plugin/src/index.js new file mode 100644 index 00000000..672af77c --- /dev/null +++ b/packages/next/build/webpack/plugins/terser-webpack-plugin/src/index.js @@ -0,0 +1,423 @@ +/* eslint-disable + no-param-reassign +*/ +import crypto from 'crypto'; +import path from 'path'; + +import { SourceMapConsumer } from 'source-map'; +import { SourceMapSource, RawSource, ConcatSource } from 'webpack-sources'; +import RequestShortener from 'webpack/lib/RequestShortener'; +import ModuleFilenameHelpers from 'webpack/lib/ModuleFilenameHelpers'; +// import validateOptions from 'schema-utils'; +import serialize from 'serialize-javascript'; +import terserPackageJson from 'terser/package.json'; + +import schema from './options.json'; +import TaskRunner from './TaskRunner'; + +const warningRegex = /\[.+:([0-9]+),([0-9]+)\]/; + +class TerserPlugin { + constructor(options = {}) { + // validateOptions(schema, options, 'Terser Plugin'); + + const { + minify, + terserOptions = {}, + test = /\.m?js(\?.*)?$/i, + chunkFilter = () => true, + warningsFilter = () => true, + extractComments = false, + sourceMap = false, + cache = false, + cacheKeys = (defaultCacheKeys) => defaultCacheKeys, + parallel = false, + include, + exclude, + } = options; + + this.options = { + test, + chunkFilter, + warningsFilter, + extractComments, + sourceMap, + cache, + cacheKeys, + parallel, + include, + exclude, + minify, + terserOptions: { + output: { + comments: extractComments + ? false + : /^\**!|@preserve|@license|@cc_on/i, + }, + ...terserOptions, + }, + }; + } + + static isSourceMap(input) { + // All required options for `new SourceMapConsumer(...options)` + // https://github.com/mozilla/source-map#new-sourcemapconsumerrawsourcemap + return Boolean( + input && + input.version && + input.sources && + Array.isArray(input.sources) && + typeof input.mappings === 'string' + ); + } + + static buildSourceMap(inputSourceMap) { + if (!inputSourceMap || !TerserPlugin.isSourceMap(inputSourceMap)) { + return null; + } + + return new SourceMapConsumer(inputSourceMap); + } + + static buildError(err, file, sourceMap, requestShortener) { + // Handling error which should have line, col, filename and message + if (err.line) { + const original = + sourceMap && + sourceMap.originalPositionFor({ + line: err.line, + column: err.col, + }); + + if (original && original.source && requestShortener) { + return new Error( + `${file} from Terser\n${err.message} [${requestShortener.shorten( + original.source + )}:${original.line},${original.column}][${file}:${err.line},${ + err.col + }]` + ); + } + + return new Error( + `${file} from Terser\n${err.message} [${file}:${err.line},${err.col}]` + ); + } else if (err.stack) { + return new Error(`${file} from Terser\n${err.stack}`); + } + + return new Error(`${file} from Terser\n${err.message}`); + } + + static buildWarning( + warning, + file, + sourceMap, + requestShortener, + warningsFilter + ) { + let warningMessage = warning; + let locationMessage = ''; + let source = null; + + if (sourceMap) { + const match = warningRegex.exec(warning); + + if (match) { + const line = +match[1]; + const column = +match[2]; + const original = sourceMap.originalPositionFor({ + line, + column, + }); + + if ( + original && + original.source && + original.source !== file && + requestShortener + ) { + ({ source } = original); + warningMessage = `${warningMessage.replace(warningRegex, '')}`; + + locationMessage = `[${requestShortener.shorten(original.source)}:${ + original.line + },${original.column}]`; + } + } + } + + if (warningsFilter && !warningsFilter(warning, source)) { + return null; + } + + return `Terser Plugin: ${warningMessage}${locationMessage}`; + } + + apply(compiler) { + const buildModuleFn = (moduleArg) => { + // to get detailed location info about errors + moduleArg.useSourceMap = true; + }; + + const optimizeFn = (compilation, chunks, callback) => { + const taskRunner = new TaskRunner({ + cache: this.options.cache, + parallel: this.options.parallel, + }); + + const processedAssets = new WeakSet(); + const tasks = []; + + const { chunkFilter } = this.options; + + Array.from(chunks) + .filter((chunk) => chunkFilter && chunkFilter(chunk)) + .reduce((acc, chunk) => acc.concat(chunk.files || []), []) + .concat(compilation.additionalChunkAssets || []) + .filter(ModuleFilenameHelpers.matchObject.bind(null, this.options)) + .forEach((file) => { + let inputSourceMap; + + const asset = compilation.assets[file]; + + if (processedAssets.has(asset)) { + return; + } + + try { + let input; + + if (this.options.sourceMap && asset.sourceAndMap) { + const { source, map } = asset.sourceAndMap(); + + input = source; + + if (TerserPlugin.isSourceMap(map)) { + inputSourceMap = map; + } else { + inputSourceMap = map; + + compilation.warnings.push( + new Error(`${file} contains invalid source map`) + ); + } + } else { + input = asset.source(); + inputSourceMap = null; + } + + // Handling comment extraction + let commentsFile = false; + + if (this.options.extractComments) { + commentsFile = + this.options.extractComments.filename || `${file}.LICENSE`; + + if (typeof commentsFile === 'function') { + commentsFile = commentsFile(file); + } + } + + const task = { + file, + input, + inputSourceMap, + commentsFile, + extractComments: this.options.extractComments, + terserOptions: this.options.terserOptions, + minify: this.options.minify, + }; + + if (this.options.cache) { + const defaultCacheKeys = { + terser: terserPackageJson.version, + // eslint-disable-next-line global-require + 'terser-webpack-plugin': '1.2.2', + 'terser-webpack-plugin-options': this.options, + hash: crypto + .createHash('md4') + .update(input) + .digest('hex'), + }; + + task.cacheKeys = this.options.cacheKeys(defaultCacheKeys, file); + } + + tasks.push(task); + } catch (error) { + compilation.errors.push( + TerserPlugin.buildError( + error, + file, + TerserPlugin.buildSourceMap(inputSourceMap), + new RequestShortener(compiler.context) + ) + ); + } + }); + + taskRunner.run(tasks, (tasksError, results) => { + if (tasksError) { + compilation.errors.push(tasksError); + + return; + } + + results.forEach((data, index) => { + const { file, input, inputSourceMap, commentsFile } = tasks[index]; + const { error, map, code, warnings } = data; + let { extractedComments } = data; + + let sourceMap = null; + + if (error || (warnings && warnings.length > 0)) { + sourceMap = TerserPlugin.buildSourceMap(inputSourceMap); + } + + // Handling results + // Error case: add errors, and go to next file + if (error) { + compilation.errors.push( + TerserPlugin.buildError( + error, + file, + sourceMap, + new RequestShortener(compiler.context) + ) + ); + + return; + } + + let outputSource; + + if (map) { + outputSource = new SourceMapSource( + code, + file, + JSON.parse(map), + input, + inputSourceMap + ); + } else { + outputSource = new RawSource(code); + } + + // Write extracted comments to commentsFile + if ( + commentsFile && + extractedComments && + extractedComments.length > 0 + ) { + if (commentsFile in compilation.assets) { + const commentsFileSource = compilation.assets[ + commentsFile + ].source(); + + extractedComments = extractedComments.filter( + (comment) => !commentsFileSource.includes(comment) + ); + } + + if (extractedComments.length > 0) { + // Add a banner to the original file + if (this.options.extractComments.banner !== false) { + let banner = + this.options.extractComments.banner || + `For license information please see ${path.posix.basename( + commentsFile + )}`; + + if (typeof banner === 'function') { + banner = banner(commentsFile); + } + + if (banner) { + outputSource = new ConcatSource( + `/*! ${banner} */\n`, + outputSource + ); + } + } + + const commentsSource = new RawSource( + `${extractedComments.join('\n\n')}\n` + ); + + if (commentsFile in compilation.assets) { + // commentsFile already exists, append new comments... + if (compilation.assets[commentsFile] instanceof ConcatSource) { + compilation.assets[commentsFile].add('\n'); + compilation.assets[commentsFile].add(commentsSource); + } else { + compilation.assets[commentsFile] = new ConcatSource( + compilation.assets[commentsFile], + '\n', + commentsSource + ); + } + } else { + compilation.assets[commentsFile] = commentsSource; + } + } + } + + // Updating assets + processedAssets.add((compilation.assets[file] = outputSource)); + + // Handling warnings + if (warnings && warnings.length > 0) { + warnings.forEach((warning) => { + const builtWarning = TerserPlugin.buildWarning( + warning, + file, + sourceMap, + new RequestShortener(compiler.context), + this.options.warningsFilter + ); + + if (builtWarning) { + compilation.warnings.push(builtWarning); + } + }); + } + }); + + taskRunner.exit(); + + callback(); + }); + }; + + const plugin = { name: this.constructor.name }; + + compiler.hooks.compilation.tap(plugin, (compilation) => { + if (this.options.sourceMap) { + compilation.hooks.buildModule.tap(plugin, buildModuleFn); + } + + const { mainTemplate, chunkTemplate } = compilation; + + // Regenerate `contenthash` for minified assets + for (const template of [mainTemplate, chunkTemplate]) { + template.hooks.hashForChunk.tap(plugin, (hash) => { + const data = serialize({ + terser: terserPackageJson.version, + terserOptions: this.options.terserOptions, + }); + + hash.update('TerserPlugin'); + hash.update(data); + }); + } + + compilation.hooks.optimizeChunkAssets.tapAsync( + plugin, + optimizeFn.bind(this, compilation) + ); + }); + } +} + +export default TerserPlugin; diff --git a/packages/next/build/webpack/plugins/terser-webpack-plugin/src/minify.js b/packages/next/build/webpack/plugins/terser-webpack-plugin/src/minify.js new file mode 100644 index 00000000..077c5d5a --- /dev/null +++ b/packages/next/build/webpack/plugins/terser-webpack-plugin/src/minify.js @@ -0,0 +1,186 @@ +/* eslint-disable + arrow-body-style +*/ +import { minify as terserMinify } from 'terser'; + +const buildTerserOptions = ({ + ecma, + warnings, + parse = {}, + compress = {}, + mangle, + module, + output, + toplevel, + nameCache, + ie8, + /* eslint-disable camelcase */ + keep_classnames, + keep_fnames, + /* eslint-enable camelcase */ + safari10, +} = {}) => ({ + ecma, + warnings, + parse: { ...parse }, + compress: typeof compress === 'boolean' ? compress : { ...compress }, + // eslint-disable-next-line no-nested-ternary + mangle: + mangle == null + ? true + : typeof mangle === 'boolean' + ? mangle + : { ...mangle }, + output: { + shebang: true, + comments: false, + beautify: false, + semicolons: true, + ...output, + }, + module, + // Ignoring sourceMap from options + sourceMap: null, + toplevel, + nameCache, + ie8, + keep_classnames, + keep_fnames, + safari10, +}); + +const buildComments = (options, terserOptions, extractedComments) => { + const condition = {}; + const commentsOpts = terserOptions.output.comments; + + // Use /^\**!|@preserve|@license|@cc_on/i RegExp + if (typeof options.extractComments === 'boolean') { + condition.preserve = commentsOpts; + condition.extract = /^\**!|@preserve|@license|@cc_on/i; + } else if ( + typeof options.extractComments === 'string' || + options.extractComments instanceof RegExp + ) { + // extractComments specifies the extract condition and commentsOpts specifies the preserve condition + condition.preserve = commentsOpts; + condition.extract = options.extractComments; + } else if (typeof options.extractComments === 'function') { + condition.preserve = commentsOpts; + condition.extract = options.extractComments; + } else if ( + Object.prototype.hasOwnProperty.call(options.extractComments, 'condition') + ) { + // Extract condition is given in extractComments.condition + condition.preserve = commentsOpts; + condition.extract = options.extractComments.condition; + } else { + // No extract condition is given. Extract comments that match commentsOpts instead of preserving them + condition.preserve = false; + condition.extract = commentsOpts; + } + + // Ensure that both conditions are functions + ['preserve', 'extract'].forEach((key) => { + let regexStr; + let regex; + + switch (typeof condition[key]) { + case 'boolean': + condition[key] = condition[key] ? () => true : () => false; + + break; + case 'function': + break; + case 'string': + if (condition[key] === 'all') { + condition[key] = () => true; + + break; + } + + if (condition[key] === 'some') { + condition[key] = (astNode, comment) => { + return ( + comment.type === 'comment2' && + /^\**!|@preserve|@license|@cc_on/i.test(comment.value) + ); + }; + + break; + } + + regexStr = condition[key]; + + condition[key] = (astNode, comment) => { + return new RegExp(regexStr).test(comment.value); + }; + + break; + default: + regex = condition[key]; + + condition[key] = (astNode, comment) => regex.test(comment.value); + } + }); + + // Redefine the comments function to extract and preserve + // comments according to the two conditions + return (astNode, comment) => { + if (condition.extract(astNode, comment)) { + const commentText = + comment.type === 'comment2' + ? `/*${comment.value}*/` + : `//${comment.value}`; + + // Don't include duplicate comments + if (!extractedComments.includes(commentText)) { + extractedComments.push(commentText); + } + } + + return condition.preserve(astNode, comment); + }; +}; + +const minify = (options) => { + const { + file, + input, + inputSourceMap, + extractComments, + minify: minifyFn, + } = options; + + if (minifyFn) { + return minifyFn({ [file]: input }, inputSourceMap); + } + + // Copy terser options + const terserOptions = buildTerserOptions(options.terserOptions); + + // Add source map data + if (inputSourceMap) { + terserOptions.sourceMap = { + content: inputSourceMap, + }; + } + + const extractedComments = []; + + if (extractComments) { + terserOptions.output.comments = buildComments( + options, + terserOptions, + extractedComments + ); + } + + const { error, map, code, warnings } = terserMinify( + { [file]: input }, + terserOptions + ); + + return { error, map, code, warnings, extractedComments }; +}; + +export default minify; diff --git a/packages/next/build/webpack/plugins/terser-webpack-plugin/src/options.json b/packages/next/build/webpack/plugins/terser-webpack-plugin/src/options.json new file mode 100644 index 00000000..f937c2d1 --- /dev/null +++ b/packages/next/build/webpack/plugins/terser-webpack-plugin/src/options.json @@ -0,0 +1,169 @@ +{ + "additionalProperties": false, + "definitions": { + "file-conditions": { + "anyOf": [ + { + "instanceof": "RegExp" + }, + { + "type": "string" + } + ] + } + }, + "properties": { + "test": { + "anyOf": [ + { + "$ref": "#/definitions/file-conditions" + }, + { + "items": { + "anyOf": [ + { + "$ref": "#/definitions/file-conditions" + } + ] + }, + "type": "array" + } + ] + }, + "include": { + "anyOf": [ + { + "$ref": "#/definitions/file-conditions" + }, + { + "items": { + "anyOf": [ + { + "$ref": "#/definitions/file-conditions" + } + ] + }, + "type": "array" + } + ] + }, + "exclude": { + "anyOf": [ + { + "$ref": "#/definitions/file-conditions" + }, + { + "items": { + "anyOf": [ + { + "$ref": "#/definitions/file-conditions" + } + ] + }, + "type": "array" + } + ] + }, + "chunkFilter": { + "instanceof": "Function" + }, + "cache": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string" + } + ] + }, + "cacheKeys": { + "instanceof": "Function" + }, + "parallel": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "integer" + } + ] + }, + "sourceMap": { + "type": "boolean" + }, + "minify": { + "instanceof": "Function" + }, + "terserOptions": { + "additionalProperties": true, + "type": "object" + }, + "extractComments": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string" + }, + { + "instanceof": "RegExp" + }, + { + "instanceof": "Function" + }, + { + "additionalProperties": false, + "properties": { + "condition": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string" + }, + { + "instanceof": "RegExp" + }, + { + "instanceof": "Function" + } + ] + }, + "filename": { + "anyOf": [ + { + "type": "string" + }, + { + "instanceof": "Function" + } + ] + }, + "banner": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string" + }, + { + "instanceof": "Function" + } + ] + } + }, + "type": "object" + } + ] + }, + "warningsFilter": { + "instanceof": "Function" + } + }, + "type": "object" +} diff --git a/packages/next/build/webpack/plugins/terser-webpack-plugin/src/worker.js b/packages/next/build/webpack/plugins/terser-webpack-plugin/src/worker.js new file mode 100644 index 00000000..1d8b7aa8 --- /dev/null +++ b/packages/next/build/webpack/plugins/terser-webpack-plugin/src/worker.js @@ -0,0 +1,21 @@ +import minify from './minify'; + +module.exports = (options, callback) => { + try { + // 'use strict' => this === undefined (Clean Scope) + // Safer for possible security issues, albeit not critical at all here + // eslint-disable-next-line no-new-func, no-param-reassign + options = new Function( + 'exports', + 'require', + 'module', + '__filename', + '__dirname', + `'use strict'\nreturn ${options}` + )(exports, require, module, __filename, __dirname); + + callback(null, minify(options)); + } catch (errors) { + callback(errors); + } +}; diff --git a/packages/next/package.json b/packages/next/package.json index 3d76a75c..ef1c70eb 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -37,6 +37,13 @@ ] }, "dependencies": { + "cacache": "^11.0.2", + "find-cache-dir": "2.0.0", + "schema-utils": "1.0.0", + "serialize-javascript": "1.4.0", + "source-map": "0.6.1", + "terser": "3.16.1", + "worker-farm": "1.5.2", "@babel/core": "7.1.2", "@babel/plugin-proposal-class-properties": "7.1.0", "@babel/plugin-proposal-object-rest-spread": "7.0.0", @@ -79,7 +86,6 @@ "resolve": "1.5.0", "strip-ansi": "3.0.1", "styled-jsx": "3.2.0", - "terser-webpack-plugin": "1.1.0", "tty-aware-progress": "1.0.3", "unfetch": "3.0.0", "url": "0.11.0",